Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,36 +22,56 @@
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).
*
* <p>Отдельная зависимость загрузчика — чтобы в тестах его можно было замокать и прогнать поток
* скачивания без обращения к GitHub. Класс не {@code final} специально: так его мокает Mockito.
*/
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;
}

/**
Expand All @@ -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<String, String> assetDownloadUrls(Map<?, ?> release) {
var assetUrls = new HashMap<String, String>();
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<String, String>();
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<String> 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<String> 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();
}

/**
Expand Down
Loading
Loading