From a37d3cd41de5f9de1164c59b29c754a352c2f7ad Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 06:47:22 +0000 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=B1=D1=80=D0=B0=D1=89=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BA=20GitHub=20API=20=D0=B1=D0=B5=D0=B7=20?= =?UTF-8?q?=D0=B1=D0=B8=D0=B1=D0=BB=D0=B8=D0=BE=D1=82=D0=B5=D0=BA=D0=B8=20?= =?UTF-8?q?github-api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Переводит GitHubReleaseClient на java.net.http.HttpClient и встроенный минимальный JSON-парсер (RFC 8259) вместо org.kohsuke:github-api. Из рантайм-замкнутости уходят github-api, jackson-databind/core/annotations (~4 МБ) — остаются только slf4j-api, commons-compress, semver4j и jspecify, что упрощает встраивание в OSGi (EDT) и убирает конфликт версий Jackson. Поведение сохранено: стабильный канал берётся из releases/latest (404 = нет подходящих релизов), pre-release — первый не-draft из списка releases с дочитыванием страниц. Ассеты читаются из того же ответа, так что вместо двух обращений к API выполняется одно. Добавлен конструктор с внешним HttpClient (настройка прокси, тесты). Fixes #81 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WwLcL3YofsPmKBJpcDoVor --- build.gradle.kts | 1 - .../BslLanguageServerDownloader.java | 4 +- .../utils/downloader/GitHubReleaseClient.java | 135 ++++++++-- .../_1c_syntax/utils/downloader/Json.java | 247 ++++++++++++++++++ .../downloader/GitHubReleaseClientTest.java | 234 +++++++++++++++++ .../_1c_syntax/utils/downloader/JsonTest.java | 126 +++++++++ 6 files changed, 715 insertions(+), 32 deletions(-) create mode 100644 src/main/java/com/github/_1c_syntax/utils/downloader/Json.java create mode 100644 src/test/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClientTest.java create mode 100644 src/test/java/com/github/_1c_syntax/utils/downloader/JsonTest.java diff --git a/build.gradle.kts b/build.gradle.kts index dc8d93d..d5e240d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -49,7 +49,6 @@ dependencies { // Загрузчик BSL Language Server implementation("org.slf4j:slf4j-api:2.0.18") - implementation("org.kohsuke:github-api:1.330") implementation("org.apache.commons:commons-compress:1.28.0") implementation("org.semver4j:semver4j:6.0.0") diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java b/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java index 82bca80..dfd674e 100644 --- a/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/BslLanguageServerDownloader.java @@ -88,8 +88,8 @@ public class BslLanguageServerDownloader { * @param installDir каталог установки сервера; в нём создаются подпапки с версиями * и файл {@code SERVER-INFO} * @param releaseClient источник сведений о последнем релизе - * @param httpClient клиент только для скачивания ассета (github-api эту загрузку не умеет); - * должен следовать редиректам — ассеты GitHub отдаются редиректом на CDN + * @param httpClient клиент для скачивания ассета; должен следовать редиректам — + * ассеты GitHub отдаются редиректом на CDN */ public BslLanguageServerDownloader(Path installDir, GitHubReleaseClient releaseClient, HttpClient httpClient) { diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java b/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java index 87eeee9..0c73238 100644 --- a/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClient.java @@ -22,21 +22,22 @@ package com.github._1c_syntax.utils.downloader; import org.jspecify.annotations.Nullable; -import org.kohsuke.github.GHAsset; -import org.kohsuke.github.GHRelease; -import org.kohsuke.github.GHRepository; -import org.kohsuke.github.GitHub; -import org.kohsuke.github.GitHubBuilder; -import org.kohsuke.github.extras.HttpClientGitHubConnector; import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; import java.util.HashMap; +import java.util.List; import java.util.Map; /** * Клиент GitHub-релизов BSL Language Server: находит последний релиз канала в репозитории - * {@value #REPOSITORY}. HTTP-доступ обеспечивает сама github-api (свой {@link HttpClientGitHubConnector} - * со встроенным клиентом) — отдельный HTTP-клиент здесь не нужен. + * {@value #REPOSITORY} через GitHub REST API. Работает на {@link java.net.http.HttpClient} + * и встроенном JSON-парсере — без клиентских библиотек GitHub и внешних JSON-библиотек, + * чтобы рантайм-замкнутость оставалась минимальной (важно для встраивания в OSGi). * *

Отдельная зависимость загрузчика — чтобы в тестах его можно было замокать и прогнать поток * скачивания без обращения к GitHub. Класс не {@code final} специально: так его мокает Mockito. @@ -44,14 +45,33 @@ public class GitHubReleaseClient { private static final String REPOSITORY = "1c-syntax/bsl-language-server"; + private static final String API_ROOT = "https://api.github.com"; + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10); + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30); + // Релизы отдаются newest-first: не-draft почти всегда на первой странице, поэтому страницы + // небольшие; пагинация ниже дочитает хвост в вырожденном случае «страница целиком из драфтов». + private static final int RELEASES_PER_PAGE = 30; private final @Nullable String token; + private final HttpClient httpClient; /** * @param token GitHub OAuth-токен для обхода лимитов анонимного API; может быть {@code null} */ public GitHubReleaseClient(@Nullable String token) { + this(token, HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .followRedirects(HttpClient.Redirect.NORMAL) + .build()); + } + + /** + * @param token GitHub OAuth-токен для обхода лимитов анонимного API; может быть {@code null} + * @param httpClient клиент для запросов к GitHub API — например, с настроенным прокси + */ + public GitHubReleaseClient(@Nullable String token, HttpClient httpClient) { this.token = token; + this.httpClient = httpClient; } /** @@ -62,37 +82,94 @@ public GitHubReleaseClient(@Nullable String token) { * @throws IOException если релизы недоступны или подходящего релиза нет */ public Release latestRelease(BslLanguageServerReleaseChannel channel) throws IOException { - var builder = new GitHubBuilder().withConnector(new HttpClientGitHubConnector()); - if (token != null && !token.isBlank()) { - builder.withOAuthToken(token); + Map release; + if (channel == BslLanguageServerReleaseChannel.PRERELEASE) { + release = latestNonDraftRelease(); + } else { + release = latestStableRelease(); } - GitHub github = builder.build(); - GHRepository repository = github.getRepository(REPOSITORY); + if (release == null || !(release.get("tag_name") instanceof String tagName)) { + throw new IOException( + "Repository " + REPOSITORY + " has no suitable releases for channel " + channel); + } + return new Release(tagName, assetDownloadUrls(release)); + } - GHRelease release; - if (channel == BslLanguageServerReleaseChannel.PRERELEASE) { - // GitHub отдаёт релизы newest-first — берём первый не-draft, не подгружая все страницы. - release = null; - for (GHRelease candidate : repository.listReleases()) { - if (!candidate.isDraft()) { - release = candidate; - break; + /** + * Последний стабильный релиз: эндпоинт {@code releases/latest} сам исключает draft + * и pre-release, а при полном отсутствии стабильных релизов отвечает 404. + */ + private @Nullable Map latestStableRelease() throws IOException { + var response = send("/repos/" + REPOSITORY + "/releases/latest"); + if (response.statusCode() == 404) { + return null; + } + return Json.parse(body(response)) instanceof Map release ? release : null; + } + + /** + * Последний релиз с учётом pre-release: список {@code releases} отдаётся newest-first, + * берём первый не-draft. Драфты видны только пользователям с push-доступом, но при вызове + * с таким токеном их нужно пропустить, дочитывая следующие страницы при необходимости. + */ + private @Nullable Map latestNonDraftRelease() throws IOException { + for (var page = 1; ; page++) { + var path = "/repos/" + REPOSITORY + "/releases?per_page=" + RELEASES_PER_PAGE + "&page=" + page; + if (!(Json.parse(get(path)) instanceof List releases) || releases.isEmpty()) { + return null; + } + for (Object candidate : releases) { + if (candidate instanceof Map release && !Boolean.TRUE.equals(release.get("draft"))) { + return release; } } - } else { - release = repository.getLatestRelease(); } + } - if (release == null) { - throw new IOException("Repository " + REPOSITORY + " has no suitable releases for channel " + channel); + private static Map assetDownloadUrls(Map release) { + var assetUrls = new HashMap(); + if (release.get("assets") instanceof List assets) { + for (Object candidate : assets) { + if (candidate instanceof Map asset + && asset.get("name") instanceof String name + && asset.get("browser_download_url") instanceof String url) { + assetUrls.putIfAbsent(name, url); + } + } } + return Map.copyOf(assetUrls); + } - var assetUrls = new HashMap(); - for (GHAsset asset : release.listAssets().toList()) { - assetUrls.putIfAbsent(asset.getName(), asset.getBrowserDownloadUrl()); + private String get(String path) throws IOException { + return body(send(path)); + } + + private HttpResponse send(String path) throws IOException { + var builder = HttpRequest.newBuilder(URI.create(API_ROOT + path)) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .header("User-Agent", "1c-syntax-utils") + .timeout(REQUEST_TIMEOUT) + .GET(); + if (token != null && !token.isBlank()) { + builder.header("Authorization", "Bearer " + token); + } + + try { + return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("GitHub API request " + path + " was interrupted", e); + } + } + + private static String body(HttpResponse response) throws IOException { + if (response.statusCode() != 200) { + throw new IOException( + "GitHub API request " + response.request().uri() + " failed: HTTP " + response.statusCode()); } - return new Release(release.getTagName(), Map.copyOf(assetUrls)); + return response.body(); } /** diff --git a/src/main/java/com/github/_1c_syntax/utils/downloader/Json.java b/src/main/java/com/github/_1c_syntax/utils/downloader/Json.java new file mode 100644 index 0000000..993e211 --- /dev/null +++ b/src/main/java/com/github/_1c_syntax/utils/downloader/Json.java @@ -0,0 +1,247 @@ +/* + * This file is a part of 1c-syntax utils. + * + * Copyright (c) 2018-2026 + * Alexey Sosnoviy , Nikita Fedkin and contributors + * + * SPDX-License-Identifier: LGPL-3.0-or-later + * + * 1c-syntax utils is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3.0 of the License, or (at your option) any later version. + * + * 1c-syntax utils is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with 1c-syntax utils. + */ +package com.github._1c_syntax.utils.downloader; + +import org.jspecify.annotations.Nullable; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Минимальный JSON-парсер для ответов GitHub REST API — чтобы не тянуть Jackson/gson + * в рантайм-замкнутость библиотеки (важно для встраивания в OSGi, см. issue #81). + * + *

Поддерживает весь синтаксис RFC 8259. Значения отображаются в {@link Map} + * (объект, порядок ключей сохраняется), {@link List} (массив), {@link String}, + * {@link Long}/{@link Double} (число), {@link Boolean} и {@code null}. + * + *

Некорректный JSON приводит к {@link IOException}: парсер применяется только к сетевым + * ответам, где повреждённые данные — та же ошибка обмена, что и обрыв соединения. + */ +final class Json { + + private final String text; + private int pos; + + private Json(String text) { + this.text = text; + } + + /** + * Разбирает строку с одним JSON-значением. + * + * @param text текст JSON + * @return значение: {@code Map}, {@code List}, {@link String}, + * {@link Long}, {@link Double}, {@link Boolean} или {@code null} + * @throws IOException если текст не является корректным JSON + */ + static @Nullable Object parse(String text) throws IOException { + var parser = new Json(text); + parser.skipWhitespace(); + var value = parser.readValue(); + parser.skipWhitespace(); + if (parser.pos < text.length()) { + throw parser.error("Unexpected trailing characters"); + } + return value; + } + + private @Nullable Object readValue() throws IOException { + return switch (peek()) { + case '{' -> readObject(); + case '[' -> readArray(); + case '"' -> readString(); + case 't' -> readLiteral("true", Boolean.TRUE); + case 'f' -> readLiteral("false", Boolean.FALSE); + case 'n' -> readLiteral("null", null); + default -> readNumber(); + }; + } + + private Map readObject() throws IOException { + expect('{'); + var object = new LinkedHashMap(); + skipWhitespace(); + if (peek() == '}') { + pos++; + return object; + } + while (true) { + skipWhitespace(); + var key = readString(); + skipWhitespace(); + expect(':'); + skipWhitespace(); + object.put(key, readValue()); + skipWhitespace(); + char next = peek(); + pos++; + if (next == '}') { + return object; + } + if (next != ',') { + throw error("Expected ',' or '}' in object"); + } + } + } + + private List<@Nullable Object> readArray() throws IOException { + expect('['); + var array = new ArrayList<@Nullable Object>(); + skipWhitespace(); + if (peek() == ']') { + pos++; + return array; + } + while (true) { + skipWhitespace(); + array.add(readValue()); + skipWhitespace(); + char next = peek(); + pos++; + if (next == ']') { + return array; + } + if (next != ',') { + throw error("Expected ',' or ']' in array"); + } + } + } + + private String readString() throws IOException { + expect('"'); + var builder = new StringBuilder(); + while (true) { + char c = next(); + if (c == '"') { + return builder.toString(); + } + if (c == '\\') { + builder.append(readEscape()); + } else if (c < 0x20) { + throw error("Unescaped control character in string"); + } else { + builder.append(c); + } + } + } + + private char readEscape() throws IOException { + char c = next(); + return switch (c) { + case '"', '\\', '/' -> c; + case 'b' -> '\b'; + case 'f' -> '\f'; + case 'n' -> '\n'; + case 'r' -> '\r'; + case 't' -> '\t'; + case 'u' -> readUnicodeEscape(); + default -> throw error("Invalid escape sequence '\\" + c + "'"); + }; + } + + private char readUnicodeEscape() throws IOException { + if (pos + 4 > text.length()) { + throw error("Unexpected end of unicode escape"); + } + var hex = text.substring(pos, pos + 4); + try { + var code = Integer.parseInt(hex, 16); + pos += 4; + return (char) code; + } catch (NumberFormatException e) { + throw error("Invalid unicode escape '\\u" + hex + "'"); + } + } + + private Number readNumber() throws IOException { + var start = pos; + if (peek() == '-') { + pos++; + } + while (pos < text.length() && isNumberChar(text.charAt(pos))) { + pos++; + } + var literal = text.substring(start, pos); + try { + if (literal.indexOf('.') < 0 && literal.indexOf('e') < 0 && literal.indexOf('E') < 0) { + try { + return Long.parseLong(literal); + } catch (NumberFormatException outOfLongRange) { + return Double.parseDouble(literal); + } + } + return Double.parseDouble(literal); + } catch (NumberFormatException e) { + throw error("Invalid number '" + literal + "'"); + } + } + + private static boolean isNumberChar(char c) { + return (c >= '0' && c <= '9') || c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-'; + } + + private @Nullable Object readLiteral(String literal, @Nullable Object value) throws IOException { + if (!text.startsWith(literal, pos)) { + throw error("Invalid literal"); + } + pos += literal.length(); + return value; + } + + private void skipWhitespace() { + while (pos < text.length()) { + char c = text.charAt(pos); + if (c != ' ' && c != '\t' && c != '\n' && c != '\r') { + return; + } + pos++; + } + } + + private char peek() throws IOException { + if (pos >= text.length()) { + throw error("Unexpected end of JSON"); + } + return text.charAt(pos); + } + + private char next() throws IOException { + char c = peek(); + pos++; + return c; + } + + private void expect(char expected) throws IOException { + if (next() != expected) { + pos--; + throw error("Expected '" + expected + "'"); + } + } + + private IOException error(String message) { + return new IOException("Malformed JSON at position " + pos + ": " + message); + } +} diff --git a/src/test/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClientTest.java b/src/test/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClientTest.java new file mode 100644 index 0000000..22f0d19 --- /dev/null +++ b/src/test/java/com/github/_1c_syntax/utils/downloader/GitHubReleaseClientTest.java @@ -0,0 +1,234 @@ +/* + * This file is a part of 1c-syntax utils. + * + * Copyright (c) 2018-2026 + * Alexey Sosnoviy , Nikita Fedkin and contributors + * + * SPDX-License-Identifier: LGPL-3.0-or-later + * + * 1c-syntax utils is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3.0 of the License, or (at your option) any later version. + * + * 1c-syntax utils is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with 1c-syntax utils. + */ +package com.github._1c_syntax.utils.downloader; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.entry; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class GitHubReleaseClientTest { + + private static final String LATEST_RELEASE_JSON = """ + { + "tag_name": "v1.2.3", + "draft": false, + "prerelease": false, + "assets": [ + {"name": "bsl-language-server_nix.zip", + "browser_download_url": "https://example.invalid/nix.zip"}, + {"name": "bsl-language-server_win.zip", + "browser_download_url": "https://example.invalid/win.zip"} + ] + } + """; + + private final List requests = new ArrayList<>(); + + @Test + void stableChannelUsesLatestReleaseEndpoint() throws IOException { + var client = new GitHubReleaseClient("token", httpClient(200, LATEST_RELEASE_JSON)); + + var release = client.latestRelease(BslLanguageServerReleaseChannel.STABLE); + + assertThat(release.version()).isEqualTo("v1.2.3"); + assertThat(release.assetDownloadUrls()).containsOnly( + entry("bsl-language-server_nix.zip", "https://example.invalid/nix.zip"), + entry("bsl-language-server_win.zip", "https://example.invalid/win.zip")); + + assertThat(requests).hasSize(1); + var request = requests.get(0); + assertThat(request.uri().toString()) + .isEqualTo("https://api.github.com/repos/1c-syntax/bsl-language-server/releases/latest"); + assertThat(request.headers().firstValue("Authorization")).contains("Bearer token"); + assertThat(request.headers().firstValue("Accept")).contains("application/vnd.github+json"); + assertThat(request.headers().firstValue("User-Agent")).contains("1c-syntax-utils"); + } + + @Test + void anonymousClientSendsNoAuthorizationHeader() throws IOException { + var client = new GitHubReleaseClient(null, httpClient(200, LATEST_RELEASE_JSON)); + + client.latestRelease(BslLanguageServerReleaseChannel.STABLE); + + assertThat(requests.get(0).headers().firstValue("Authorization")).isEmpty(); + } + + @Test + void blankTokenSendsNoAuthorizationHeader() throws IOException { + var client = new GitHubReleaseClient(" ", httpClient(200, LATEST_RELEASE_JSON)); + + client.latestRelease(BslLanguageServerReleaseChannel.STABLE); + + assertThat(requests.get(0).headers().firstValue("Authorization")).isEmpty(); + } + + @Test + void prereleaseChannelPicksFirstNonDraftFromReleasesList() throws IOException { + var releasesJson = """ + [ + {"tag_name": "v9.9.9", "draft": true, "prerelease": true, "assets": []}, + {"tag_name": "v1.3.0-rc.1", "draft": false, "prerelease": true, + "assets": [{"name": "bsl-language-server_nix.zip", + "browser_download_url": "https://example.invalid/rc.zip"}]}, + {"tag_name": "v1.2.3", "draft": false, "prerelease": false, "assets": []} + ] + """; + var client = new GitHubReleaseClient(null, httpClient(200, releasesJson)); + + var release = client.latestRelease(BslLanguageServerReleaseChannel.PRERELEASE); + + assertThat(release.version()).isEqualTo("v1.3.0-rc.1"); + assertThat(release.assetDownloadUrls()) + .containsOnly(entry("bsl-language-server_nix.zip", "https://example.invalid/rc.zip")); + assertThat(requests).hasSize(1); + assertThat(requests.get(0).uri().toString()) + .startsWith("https://api.github.com/repos/1c-syntax/bsl-language-server/releases?"); + } + + @Test + void prereleaseChannelReadsNextPageWhenFirstPageContainsOnlyDrafts() throws IOException { + var draftsOnlyPage = """ + [{"tag_name": "v9.9.9", "draft": true, "prerelease": true, "assets": []}] + """; + var secondPage = """ + [{"tag_name": "v1.3.0-rc.1", "draft": false, "prerelease": true, "assets": []}] + """; + var client = new GitHubReleaseClient(null, httpClient(request -> + response(request, 200, request.uri().toString().endsWith("page=1") ? draftsOnlyPage : secondPage))); + + var release = client.latestRelease(BslLanguageServerReleaseChannel.PRERELEASE); + + assertThat(release.version()).isEqualTo("v1.3.0-rc.1"); + assertThat(requests).hasSize(2); + assertThat(requests.get(0).uri().toString()).endsWith("page=1"); + assertThat(requests.get(1).uri().toString()).endsWith("page=2"); + } + + @Test + void prereleaseChannelFailsWhenThereAreNoReleases() { + var client = new GitHubReleaseClient(null, httpClient(200, "[]")); + + assertThatThrownBy(() -> client.latestRelease(BslLanguageServerReleaseChannel.PRERELEASE)) + .isInstanceOf(IOException.class) + .hasMessageContaining("no suitable releases"); + } + + @Test + void stableChannelFailsWhenThereAreNoStableReleases() { + var client = new GitHubReleaseClient(null, httpClient(404, "{\"message\": \"Not Found\"}")); + + assertThatThrownBy(() -> client.latestRelease(BslLanguageServerReleaseChannel.STABLE)) + .isInstanceOf(IOException.class) + .hasMessageContaining("no suitable releases"); + } + + @Test + void failsOnHttpError() { + var client = new GitHubReleaseClient(null, httpClient(403, "{\"message\": \"rate limit\"}")); + + assertThatThrownBy(() -> client.latestRelease(BslLanguageServerReleaseChannel.STABLE)) + .isInstanceOf(IOException.class) + .hasMessageContaining("HTTP 403"); + } + + @Test + void failsOnMalformedResponse() { + var client = new GitHubReleaseClient(null, httpClient(200, "not a json")); + + assertThatThrownBy(() -> client.latestRelease(BslLanguageServerReleaseChannel.STABLE)) + .isInstanceOf(IOException.class) + .hasMessageContaining("Malformed JSON"); + } + + @Test + void failsWhenReleaseHasNoTagName() { + var client = new GitHubReleaseClient(null, httpClient(200, "{\"assets\": []}")); + + assertThatThrownBy(() -> client.latestRelease(BslLanguageServerReleaseChannel.STABLE)) + .isInstanceOf(IOException.class) + .hasMessageContaining("no suitable releases"); + } + + @Test + void interruptedRequestRestoresInterruptFlagAndFails() throws Exception { + var httpClient = mock(HttpClient.class); + doAnswer(invocation -> { + throw new InterruptedException("interrupted"); + }).when(httpClient).send(any(), any()); + var client = new GitHubReleaseClient(null, httpClient); + + try { + assertThatThrownBy(() -> client.latestRelease(BslLanguageServerReleaseChannel.STABLE)) + .isInstanceOf(IOException.class) + .hasMessageContaining("interrupted"); + assertThat(Thread.currentThread().isInterrupted()).isTrue(); + } finally { + Thread.interrupted(); // сбрасываем флаг, чтобы не влиять на другие тесты + } + } + + private HttpClient httpClient(int status, String body) { + return httpClient(request -> response(request, status, body)); + } + + /** + * Мок {@link HttpClient}, отвечающий на каждый {@code send} через {@code responses} + * и записывающий запросы в {@link #requests}. + */ + private HttpClient httpClient(Function> responses) { + var client = mock(HttpClient.class); + try { + when(client.send(any(), any())).thenAnswer(invocation -> { + HttpRequest request = invocation.getArgument(0); + requests.add(request); + return responses.apply(request); + }); + } catch (IOException | InterruptedException e) { + throw new IllegalStateException(e); // не бывает: это настройка мока, а не реальный вызов + } + return client; + } + + @SuppressWarnings("unchecked") + private static HttpResponse response(HttpRequest request, int status, String body) { + HttpResponse response = mock(HttpResponse.class); + when(response.statusCode()).thenReturn(status); + when(response.body()).thenReturn(body); + when(response.request()).thenReturn(request); + return response; + } +} diff --git a/src/test/java/com/github/_1c_syntax/utils/downloader/JsonTest.java b/src/test/java/com/github/_1c_syntax/utils/downloader/JsonTest.java new file mode 100644 index 0000000..6628ca1 --- /dev/null +++ b/src/test/java/com/github/_1c_syntax/utils/downloader/JsonTest.java @@ -0,0 +1,126 @@ +/* + * This file is a part of 1c-syntax utils. + * + * Copyright (c) 2018-2026 + * Alexey Sosnoviy , Nikita Fedkin and contributors + * + * SPDX-License-Identifier: LGPL-3.0-or-later + * + * 1c-syntax utils is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3.0 of the License, or (at your option) any later version. + * + * 1c-syntax utils is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with 1c-syntax utils. + */ +package com.github._1c_syntax.utils.downloader; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class JsonTest { + + @Test + void parsesScalars() throws IOException { + assertThat(Json.parse("\"text\"")).isEqualTo("text"); + assertThat(Json.parse("42")).isEqualTo(42L); + assertThat(Json.parse("-7")).isEqualTo(-7L); + assertThat(Json.parse("3.5")).isEqualTo(3.5d); + assertThat(Json.parse("1e3")).isEqualTo(1000d); + assertThat(Json.parse("true")).isEqualTo(Boolean.TRUE); + assertThat(Json.parse("false")).isEqualTo(Boolean.FALSE); + assertThat(Json.parse("null")).isNull(); + } + + @Test + void parsesNumberOutOfLongRangeAsDouble() throws IOException { + assertThat(Json.parse("123456789012345678901234567890")) + .isEqualTo(123456789012345678901234567890d); + } + + @Test + void parsesStringEscapes() throws IOException { + assertThat(Json.parse("\"a\\\"b\\\\c\\/d\\b\\f\\n\\r\\t\"")).isEqualTo("a\"b\\c/d\b\f\n\r\t"); + assertThat(Json.parse("\"\\u0416\\u045E\"")).isEqualTo("Жў"); + // суррогатная пара + assertThat(Json.parse("\"\\uD83D\\uDE00\"")).isEqualTo("😀"); + } + + @Test + void parsesObjectsAndArrays() throws IOException { + var value = Json.parse(""" + { + "tag_name": "v1.2.3", + "draft": false, + "assets": [ + {"name": "a.zip", "size": 100}, + {"name": "b.zip", "size": 200.5} + ], + "empty_object": {}, + "empty_array": [] + } + """); + + assertThat(value).isEqualTo(Map.of( + "tag_name", "v1.2.3", + "draft", false, + "assets", List.of( + Map.of("name", "a.zip", "size", 100L), + Map.of("name", "b.zip", "size", 200.5d)), + "empty_object", Map.of(), + "empty_array", List.of())); + } + + @Test + void parsesNestedArrays() throws IOException { + assertThat(Json.parse("[[1, 2], [], [null, true]]")) + .isEqualTo(List.of(List.of(1L, 2L), List.of(), java.util.Arrays.asList(null, true))); + } + + @Test + void rejectsMalformedJson() { + var samples = List.of( + "", + " ", + "{", + "[1, 2", + "{\"a\" 1}", + "{\"a\": 1,}", + "{\"a\": 1 \"b\": 2}", + "[1 2]", + "\"unterminated", + "\"bad escape \\x\"", + "\"bad unicode \\u12GX\"", + "\"truncated unicode \\u12", + "tru", + "nul", + "01a", + "--1", + "1.2.3", + "{} extra", + "42 43"); + + for (var malformed : samples) { + assertThatThrownBy(() -> Json.parse(malformed)) + .as("Json.parse(%s)", malformed) + .isInstanceOf(IOException.class); + } + } + + @Test + void rejectsUnescapedControlCharacterInString() { + assertThatThrownBy(() -> Json.parse("\"line\nbreak\"")).isInstanceOf(IOException.class); + } +}