diff --git a/REPORT.md b/REPORT.md new file mode 100644 index 0000000..b6f58cd --- /dev/null +++ b/REPORT.md @@ -0,0 +1,124 @@ +# REPORT + +## 1. Какой API выбран и почему + +Я выбрал **GitHub REST API** (`https://api.github.com`), потому что этот API нужен мне для курсового проекта. Подробнее я описал в `api-notes.md` + +Основной стек для реализации: REST Assured + JUnit 5 + AssertJ. + +--- + +## 2. Покрытые сценарии + +| Категория | Сценарий | Реализация | +|-----------|----------|------------| +| Happy path | Получение существующего пользователя `/users/octocat` | Реализовано | +| Happy path | Получение существующего репозитория `/repos/{owner}/{repo}` | Реализовано | +| Контракт ответа | Проверка обязательных полей и типов у профиля пользователя | Реализовано | +| Контракт ответа | Проверка полей репозитория (`id`, `name`, `full_name`, `owner.login`, `private`, `html_url`) | Реализовано | +| Границы/особенности | Проверка логина без учёта регистра (`/users/OCTOCAT`) | Реализовано | +| Параметры запроса | Проверка `per_page=2` и пагинации для `/users/{username}/repos` | Реализовано | +| Контракт ответа | Проверка заголовков `Cache-Control`, `ETag`, `X-GitHub-Api-Version-Selected`, `X-RateLimit-Limit` | Реализовано | +| Happy path / контракт | Проверка структуры `/repos/{owner}/{repo}/languages` | Реализовано | +| Ошибки клиента | Несуществующий пользователь возвращает `404` и тело ошибки | Реализовано | +| Ошибки клиента | Несуществующий репозиторий возвращает `404` и тело ошибки | Реализовано | +| Перформанс | Базовый запрос к пользователю укладывается в порог времени | Реализовано | + +Итого в проекте: + +- **9 тестовых методов** +- **11 выполнений тестов** с учётом 3 запусков параметризованного сценария +- покрыто **4 эндпоинта**: + - `GET /users/{username}` + - `GET /users/{username}/repos` + - `GET /repos/{owner}/{repo}` + - `GET /repos/{owner}/{repo}/languages` +- есть **параметризованный тест** +- есть **негативные сценарии** на `404` + +--- + +## 3. Что удалось узнать об API + +### 3.1. Полезные особенности GitHub API + +- Для большинства запросов рекомендуется передавать: + - `Accept: application/vnd.github+json` + - `User-Agent` + - `X-GitHub-Api-Version` +- Публичные данные можно читать **без аутентификации**. +- Для неаутентифицированных запросов действует лимит **60 запросов в час**. +- Для search-эндпоинтов лимиты строже, поэтому их лучше не перегружать тестами. + +### 3.2. Неожиданные или важные наблюдения + +- `username` на практике обрабатывается **без учёта регистра**: `/users/OCTOCAT` возвращает `octocat`. +- Ошибка `404` имеет достаточно стабильный формат: + +```json +{ + "message": "Not Found", + "documentation_url": "https://docs.github.com/rest", + "status": "404" +} +``` + +- Ответы содержат полезные служебные заголовки: + - `ETag` + - `Cache-Control` + - `X-RateLimit-*` + - `X-GitHub-Api-Version-Selected` +- Эндпоинт `/languages` возвращает не массив, а JSON-чик, где ключ - язык, значение - число байтов. +- У некоторых репозиториев `/languages` может вернуть пустой объект `{}`. + +### 3.3. Неоднозначности документации + +- Документация GitHub очень подробная, но она огромная, и в ней легко потеряться. +- На практике некоторые поведенческие детали удобнее подтвердить экспериментом через `curl`, чем искать в справке, например: + - чувствительность к регистру, + - реальный формат ошибки, + - конкретные заголовки кэширования и rate limit. + +--- + +## 4. Инструмент: что использовал, что понравилось и не понравилось + +### Использованный инструмент + +Я использовал: + +- **REST Assured** - для HTTP-запросов и извлечения ответа +- **JUnit 5** - для организации тестов +- **AssertJ** - для читаемых и гибких проверок + +### Что понравилось + +- У REST Assured короткий и понятный DSL для API-тестов. +- Удобно задавать базовые заголовки через `RequestSpecification`. +- AssertJ делает проверки читаемыми: легко сравнивать поля, коллекции, заголовки и типы значений. +- `@ParameterizedTest` хорошо подходит для проверки нескольких репозиториев без дублирования кода. + +### Что не понравилось / ограничения + +- Тесты к публичному интернет-API всегда немного более хрупкие, чем тесты к локально замоканному сервису: + - возможны сетевые задержки, + - меняются реальные данные, + - есть rate limit + +--- + +## 5. Запуск + +Команда для прогона всех тестов: + +```bash +mvn test +``` + +Или вот так лучше будет: + +```bash +mvn -Dtest=GitHubApiTest test +``` + +Но это надо как-то обойти, что в репозитории есть файлы, которые не компилируются, поэтому проще ручками сходить в директорию с тестом и из IDE его запустить \ No newline at end of file diff --git a/pom.xml b/pom.xml index de78097..216c7d0 100644 --- a/pom.xml +++ b/pom.xml @@ -79,6 +79,13 @@ jackson-databind 2.17.2 + + + io.rest-assured + rest-assured + 5.5.0 + test + com.google.code.gson @@ -111,4 +118,4 @@ - + \ No newline at end of file diff --git a/src/test/java/hse/java/githubapi/GitHubApiTest.java b/src/test/java/hse/java/githubapi/GitHubApiTest.java new file mode 100644 index 0000000..fc621ad --- /dev/null +++ b/src/test/java/hse/java/githubapi/GitHubApiTest.java @@ -0,0 +1,184 @@ +package hse.java.githubapi; + +import io.restassured.RestAssured; +import io.restassured.builder.RequestSpecBuilder; +import io.restassured.path.json.JsonPath; +import io.restassured.response.Response; +import io.restassured.specification.RequestSpecification; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +class GitHubApiTest { + + private static final String BASE_URI = "https://api.github.com"; + private static final String API_VERSION = "2026-03-10"; + private static RequestSpecification requestSpecification; + + @BeforeAll + static void setUp() { + RestAssured.baseURI = BASE_URI; + requestSpecification = new RequestSpecBuilder() + .setBaseUri(BASE_URI) + .addHeader("Accept", "application/vnd.github+json") + .addHeader("User-Agent", "course-project-github-api-tests") + .addHeader("X-GitHub-Api-Version", API_VERSION) + .build(); + } + + @ParameterizedTest(name = "GET /repos/{0}/{1} возвращает публичный репозиторий") + @MethodSource("knownRepositories") + void knownRepositoriesShouldBeAvailable(String owner, String repo) { + Response response = get("/repos/{owner}/{repo}", owner, repo); + JsonPath json = response.jsonPath(); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(json.getInt("id")).isPositive(); + assertThat(json.getString("name")).isEqualTo(repo); + assertThat(json.getString("owner.login")).isEqualToIgnoringCase(owner); + assertThat(json.getString("full_name")).isEqualTo(owner + "/" + repo); + assertThat(json.getBoolean("private")).isFalse(); + assertThat(json.getString("html_url")).isEqualTo("https://github.com/" + owner + "/" + repo); + } + + @Test + void getUserShouldReturnRequiredPublicProfileFields() { + Response response = get("/users/{username}", "octocat"); + JsonPath json = response.jsonPath(); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(json.getString("login")).isEqualTo("octocat"); + assertThat(json.getInt("id")).isPositive(); + assertThat(json.getString("type")).isEqualTo("User"); + assertThat(json.getBoolean("site_admin")).isFalse(); + assertThat(json.getString("html_url")).isEqualTo("https://github.com/octocat"); + assertThat(json.getString("followers_url")).contains("/users/octocat/followers"); + assertThat(json.getInt("public_repos")).isGreaterThanOrEqualTo(0); + assertThatCode(() -> Instant.parse(json.getString("created_at"))).doesNotThrowAnyException(); + assertThatCode(() -> Instant.parse(json.getString("updated_at"))).doesNotThrowAnyException(); + } + + @Test + @DisplayName("Логин пользователя обрабатывается без учёта регистра") + void getUserShouldBeCaseInsensitiveForLogin() { + Response response = get("/users/{username}", "OCTOCAT"); + JsonPath json = response.jsonPath(); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(json.getString("login")).isEqualTo("octocat"); + assertThat(json.getString("html_url")).isEqualTo("https://github.com/octocat"); + } + + @Test + void listUserRepositoriesShouldHonorPerPageParameter() { + Response response = given() + .spec(requestSpecification) + .queryParam("per_page", 2) + .queryParam("sort", "updated") + .when() + .get("/users/{username}/repos", "octocat"); + + List> repositories = response.jsonPath().getList("$"); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(repositories) + .isNotNull() + .hasSizeLessThanOrEqualTo(2) + .isNotEmpty(); + assertThat(repositories) + .allSatisfy(repository -> { + assertThat(repository).containsKeys("id", "name", "full_name", "owner"); + Map owner = (Map) repository.get("owner"); + assertThat(owner.get("login")).isEqualTo("octocat"); + }); + assertThat(response.getHeader("Link")) + .as("При per_page=2 у octocat ожидается пагинация") + .contains("rel=\"next\""); + } + + @Test + void getRepositoryLanguagesShouldReturnLanguageByteMap() { + Response response = get("/repos/{owner}/{repo}/languages", "torvalds", "linux"); + Map languages = response.jsonPath().getMap("$"); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(languages).isNotNull().isNotEmpty().containsKey("C"); + assertThat(languages.values()) + .allSatisfy(value -> { + assertThat(value).isInstanceOf(Number.class); + assertThat(((Number) value).longValue()).isPositive(); + }); + } + + @Test + void repositoryResponseShouldContainCachingAndVersionHeaders() { + Response response = get("/repos/{owner}/{repo}", "octocat", "Hello-World"); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.getHeader("Content-Type")).contains("application/json"); + assertThat(response.getHeader("Cache-Control")).contains("max-age"); + assertThat(response.getHeader("ETag")).isNotBlank(); + assertThat(response.getHeader("X-GitHub-Api-Version-Selected")).isEqualTo(API_VERSION); + assertThat(response.getHeader("X-RateLimit-Limit")).isNotBlank(); + } + + @Test + void getUnknownUserShouldReturn404AndStandardErrorBody() { + Response response = get("/users/{username}", "this-user-name-is-definitely-longer-than-thirty-nine-characters"); + JsonPath json = response.jsonPath(); + + assertThat(response.statusCode()).isEqualTo(404); + assertThat(json.getString("message")).isEqualTo("Not Found"); + assertThat(json.getString("documentation_url")).contains("docs.github.com/rest"); + assertThat(json.getString("status")).isEqualTo("404"); + } + + @Test + void getUnknownRepositoryShouldReturn404AndStandardErrorBody() { + Response response = get("/repos/{owner}/{repo}", "octocat", "repo-that-should-not-exist-1234567890"); + JsonPath json = response.jsonPath(); + + assertThat(response.statusCode()).isEqualTo(404); + assertThat(json.getString("message")).isEqualTo("Not Found"); + assertThat(json.getString("documentation_url")).contains("docs.github.com/rest"); + assertThat(json.getString("status")).isEqualTo("404"); + } + + @Test + void getUserShouldRespondWithinReasonableTime() { + Response response = get("/users/{username}", "octocat"); + + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.time()).isLessThan(10_000L); + } + + private static Stream knownRepositories() { + return Stream.of( + Arguments.of("octocat", "Hello-World"), + Arguments.of("torvalds", "linux"), + Arguments.of("github", "docs") + ); + } + + private Response get(String path, Object... pathParams) { + return given() + .spec(requestSpecification) + .when() + .get(path, pathParams) + .then() + .extract() + .response(); + } +} diff --git a/tasks/classwork/api-tests/api-notes.md b/tasks/classwork/api-tests/api-notes.md new file mode 100644 index 0000000..bb66d78 --- /dev/null +++ b/tasks/classwork/api-tests/api-notes.md @@ -0,0 +1,195 @@ +# Конспект по GitHub REST API + +## Почему выбран именно GitHub API + +Для практического задания я выбрал **GitHub REST API**, потому что он нужен мне для курсового проекта: будет телеграм-бот и веб-сервис scrapper, который подписывается на события в репозиториях (issue, pr), при обновлении будет сообщать bot, а bot будет отправлять сообщение в тг + +Базовый URL: `https://api.github.com` + +Рекомендуемые заголовки по документации GitHub: + +- `Accept: application/vnd.github+json` +- `User-Agent: <имя приложения>` +- `X-GitHub-Api-Version: 2026-03-10` + +## Изученные эндпоинты + +### 1. Получить публичный профиль пользователя + +- **Метод:** `GET` +- **Путь:** `/users/{username}` +- **Пример:** `/users/octocat` + +В `200 OK`-ответе мы имеем: + +- JSON-объект +- основные поля: + - `login` - строка + - `id` - число + - `type` - строка (`User` / `Organization`) + - `site_admin` - boolean + - `html_url` - строка URL + - `followers_url`, `repos_url` - строки URL + - `public_repos`, `followers`, `following` - числа + - `created_at`, `updated_at` - строки в ISO-8601 +- часть полей может быть `null` (`email`, `hireable`, `bio` и др.) + +Ошибка для несуществующего пользователя: + +- **Статус:** `404 Not Found` +- **Формат тела:** + +```json +{ + "message": "Not Found", + "documentation_url": "https://docs.github.com/rest", + "status": "404" +} +``` + +Забавное наблюдение: логин обрабатывается без учёта регистра - запрос `/users/OCTOCAT` успешно возвращает пользователя `octocat`. + +--- + +### 2. Получить репозиторий + +- **Метод:** `GET` +- **Путь:** `/repos/{owner}/{repo}` +- **Пример:** `/repos/octocat/Hello-World` + +Что есть в успешном ответе (`200 OK`): + +- JSON-объект +- важные поля: + - `id` - число + - `name` - строка + - `full_name` - строка + - `private` - boolean + - `owner.login` - строка + - `html_url` - строка URL + - `description` - строка или `null` + - `fork`, `archived`, `disabled` - boolean + - `language` - строка или `null` + - `stargazers_count`, `forks_count`, `watchers_count` - числа + - `default_branch` - строка + - `created_at`, `updated_at`, `pushed_at` - ISO-8601 строки + +Также в ответе есть полезные (или не очень) заголовки: + +- `Cache-Control` +- `ETag` +- `X-GitHub-Api-Version-Selected` +- `X-RateLimit-Limit` +- `X-RateLimit-Remaining` + +Ошибка для несуществующего репозитория: + +- **Статус:** `404 Not Found` +- **Тело ошибки:** такое же по структуре, как и у `/users/{username}` + +--- + +### 3. Получить языки репозитория + +- **Метод:** `GET` +- **Путь:** `/repos/{owner}/{repo}/languages` +- **Пример:** `/repos/torvalds/linux/languages` + +Что возвращается: + +- JSON-объект формата `"Язык" -> число` +- число - это объём кода в байтах, отнесённый GitHub к данному языку + +Пример структурки: + +```json +{ + "C": 1404444259, + "Assembly": 9720015, + "Shell": 5921052 +} +``` + +Ещё одна забавная особенность: для некоторых репозиториев ответ может быть **пустым объектом `{}`**, если GitHub по какой-то причине не смог определить языки или репозиторий почти пустой + +--- + +### 4. Получить список репозиториев пользователя + +- **Метод:** `GET` +- **Путь:** `/users/{username}/repos` +- **Пример:** `/users/octocat/repos?per_page=2&sort=updated` + +Изученные query-параметры: + +- `per_page` - размер страницы, максимум `100`, по умолчанию `30` +- `page` - номер страницы +- `sort` - сортировка (`created`, `updated`, `pushed`, `full_name`) +- `direction` - направление (`asc`, `desc`) + +Что видно на практике: + +- тело ответа - JSON-массив репозиториев +- количество элементов действительно ограничивается `per_page` +- при пагинации GitHub возвращает заголовок `Link` с `rel="next"`, `rel="last"` и т.д. + +## Коды ответов и ошибки + +Для изученных публичных `GET`-эндпоинтов зафиксированы: + +- `200 OK` - успешный запрос +- `404 Not Found` - пользователь или репозиторий не найден +- `304 Not Modified` - возможен при условных запросах с кэшем +- `403 Forbidden` - возможен при проблемах с заголовками, лимитами или доступом +- `401 Unauthorized` - для эндпоинтов, где требуется аутентификация + +ВАЖНО: GitHub требует корректный `User-Agent`. По документации запросы без него могут быть отклонены. + +## Лимиты и особенности поведения + +### Rate limit + +- Для **неаутентифицированных** запросов к публичным данным - **60 запросов в час**. +- Для **аутентифицированных** запросов - обычно **5000 запросов в час**. +- У некоторых эндпоинтов (например, `search`) лимиты строже. + +Это видно и по заголовкам ответа: + +- `X-RateLimit-Limit` +- `X-RateLimit-Remaining` +- `X-RateLimit-Reset` +- `X-RateLimit-Resource` + +### Кэширование + +Для публичных ресурсов GitHub часто возвращает: + +- `Cache-Control: public, max-age=60, s-maxage=60` +- `ETag` +- `Last-Modified` + +Это значит, что API явно поддерживает кэширование и условные запросы. + +### Регистрозависимость + +- На практике `username` в пути обрабатывается **case-insensitive**. +- В документации некоторые имена владельцев/организаций тоже описаны как нечувствительные к регистру. + +### Кодировка и формат + +- Ответы приходят как `application/json; charset=utf-8` +- Даты возвращаются в UTC в формате ISO-8601, например `2011-01-25T18:44:36Z` + +## Набор сценариев перед написанием кода + +| Категория | Сценарий | Что должен поймать тест | +|-----------|----------|--------------------------| +| Happy path | Получение существующего пользователя | API доступен, контракт профиля не сломан | +| Happy path | Получение существующего репозитория | Основной ресурс repo отдаётся корректно | +| Контракт ответа | Проверка обязательных полей и типов | Регрессии в JSON-структуре | +| Параметры запроса | `per_page=2` для списка репозиториев | Параметры реально влияют на ответ | +| Границы/особенности | Логин в верхнем регистре (`OCTOCAT`) | Нечувствительность к регистру не сломалась | +| Ошибки клиента | Несуществующий пользователь | Корректный `404` и стандартное тело ошибки | +| Ошибки клиента | Несуществующий репозиторий | Корректный `404` и формат ошибки | +| Контракт ответа | Заголовки `ETag`, `Cache-Control`, версия API | Не исчезли служебные заголовки | +| Перформанс | Ответ профиля пользователя быстрее разумного порога | Грубая деградация производительности |