Свой артефактори для Android библиотек

Мне понадобилось публиковать библиотеки для Android (и iOS, но это потом). Очевидно, для этого нужен maven репозиторий.
Я не профи в maven, но обнаружил, что репозиторий устроен довольно просто. Самый легкий способ выложить библиотеку - опубликовать ее локально и скопировать в публичную папку на сервере. Тут нет никакой магии, все очевидно и в 90% случаев этого достаточно.
Но очень хочется иметь возможность запустить команду из грейдла и получить опубликованную библиотеку на сервере. Нужна возможность удалять артефакты, добавлять какое-нибудь описание для артефакта и версии. Желательно, красиво выводить список артефактов на веб-страничке
Конечно, задача не новая. Сейчас есть много инструментов, которые можно установить на свой сервер. Можно использовать доступные публичные сервисы и паблишить сколько угодно библиотек. Что я сходу нагуглил:
- JFrog - классика, которая используется в больших компаниях. Но вам такой комбайн вряд ли понадобится
- nexus - это самая универсальная штука.Тут есть вообще все и даже свой собственный docker hub
- gitflic - да, можно не заморачиваться с селфхостед, а выкладывать все в реджистри на gitflic
- github - тож самое как и c gitflic
- reposilite - вот это очень классный аналог. Простой и понятный сервис, который позволяет опубликовать библиотеки одной gradle командой. Есть подготовленный docker контейнер. Но! У него один критический недостаток - сделан не мной
В результате моего небольшого исследования я написал селфхотстед сервис - depot. Построен на базе PocketBase с несколькими дополнительными ручками и главной страницей со списком артефактов
Структура данных
Сначала определимся с терминами. В контексте статьи репозиторий и реджистри - это одинаковые штуки, источник откуда вы можете скачать нужную версию. Артефактом я называю саму библиотеку, а версией - конкретную сборку библиотеки. Те нельзя просто скачать артефакт, это некоторая абстракция. Скачать можно только конкретную версию
Для мой задачи нужно всего две коллекции - артефакты и версии.

Для артефакта нам нужно:
- name - название библиотеки
- description - описание библиотеки
- group - группа или неймспес для библиотеки, например ru.kovardin
- type - можно выбрать ios или android
- last - дата последнего обновления
- enabled - доступность библиотеки
Поля будут заполняться автоматически при публикации библиотеки. По умолчанию библиотеки будут доступны и enabled будет выставлен в true. Поле description нужно заполнять руками для каждого артефакта
С версиями тоже никаких сложностей

Для артефакта нам нужно:
- name - название библиотеки
- notes - описание что изменилось в этой версии
- version - версия
- enable - доступна ли версия для установки
Notes - нужно заполнять для каждой версии самостоятельно
Создав две описанные выше коллекции, мы сделали половину сервиса для публикации библиотек. Осталось добавить логику загрузки и красивый список артефактов
Загрузка артефактов
Перед тем как начать загружать артефакты, нужно разобраться как добавить репозиторий в настройки Android проекта
В файле build.gradle для модуля который хотим опубликовать(например, somelib) добавляем код
 1repositories {
 2    // ...
 3    maven {
 4        Properties properties = new Properties()
 5        properties.load(project.rootProject.file('local.properties').newDataInputStream())
 6
 7        def uploadToken = properties.getProperty('uploadToken')
 8
 9        name = "depot"
10        url = "https://depot.kovardin.ru/packages"
11        credentials(HttpHeaderCredentials) {
12            name = "Authorization"
13            value =  uploadToken
14        }
15        authentication {
16            header(HttpHeaderAuthentication)
17        }
18    }
19}
Конечно, мы не хотим дать всем возможность опубликовать что угодно в нашем реджестри, поэтому мы будем указывать токен для авторизации. Сам токен указан в файле local.properties, а тут мы получаем его из файла и передаем в заголовок Authorization. Чуть позже укажу как получить эту самую авторизацию
- depot.kovardin.ru/packages - это адрес моего реджистри
- depot - название репозитория, может быть любое. От этого название будет зависеть название gradle команды для публикации
Указав все необходимые настройки, в gradle появиться команда вида:
./gradlew somelib:publishAllPublicationsToDepotRepository
После запуска этой команды, gradle соберет необходимые артефакты и отправит их на сервер в PUT запросе
Пример такого запроса
1PUT /packages/ru/kovardin/somelib/0.1.1/somelib-0.1.1.aar
2PUT /packages/ru/kovardin/somelib/maven-metadata.xml
3...
Название файла будет доступно в path, а содержимое каждого файла прилетит в теле запроса. Для работы с такими запросами, создаем новую ручку artifacts.Publish и добавляем ее для обработки PUT запросов
1e.Router.PUT("/packages/*", artifacts.Publish, apis.RequireAdminAuth())
apis.RequireAdminAuth() - это миделваря, которая делает ручку доступной только для администратора.
В методе artifacts.Publish нужно получить название файла, его содержимое и сохранить все на сервере.
 1file := strings.Replace(c.Request().URL.Path, "/packages/", "", -1)
 2
 3defer c.Request().Body.Close()
 4
 5uploadFolder := path.Join(h.settings.UploadFolder(""), filepath.Dir(file))
 6uploadFile := path.Join(uploadFolder, filepath.Base(file))
 7
 8if err := os.MkdirAll(uploadFolder, os.ModePerm); err != nil {
 9    return err
10}
11
12dst, err := os.Create(uploadFile)
13if err != nil {
14    return err
15}
16
17defer dst.Close()
18
19if _, err := io.Copy(dst, c.Request().Body); err != nil {
20    return err
21}
Нужно создать новый файл на сервере в локации ru/kovardin/somelib/ и записать в него все содержимое из c.Request().Body
Теперь нужно создать сущности в базе. Сначала создаем запись о артефакте. Чтобы узнать название и группу артефакта - распарсим файл maven-metadata.xml. Кроме артефакта, в этом файле указаны названия доступных версий:
 1<?xml version="1.0" encoding="UTF-8"?>
 2<metadata>
 3  <groupId>ru.kovardin</groupId>
 4  <artifactId>boosty</artifactId>
 5  <versioning>
 6    <latest>0.1.1</latest>
 7    <release>0.1.1</release>
 8    <versions>
 9      <version>0.1.1</version>
10    </versions>
11    <lastUpdated>20240818151425</lastUpdated>
12  </versioning>
13</metadata>
Парсим файл и сохраняем все в базу:
 1if filepath.Base(uploadFile) == "maven-metadata.xml" {
 2    metaFile := uploadFile
 3    data, err := os.ReadFile(metaFile)
 4    if err != nil {
 5        return err
 6    }
 7
 8    metadata := Metadata{}
 9    if err := xml.Unmarshal(data, &metadata); err != nil {
10        return err
11    }
12
13    artifact, _ := h.app.Dao().FindFirstRecordByFilter(
14        "artifacts",
15        "group = {:group} && name = {:name}",
16        dbx.Params{"group": metadata.GroupId, "name": metadata.ArtifactId},
17    )
18    if artifact == nil {
19        collection, err := h.app.Dao().FindCollectionByNameOrId("artifacts")
20        if err != nil {
21            return err
22        }
23
24        artifact = models.NewRecord(collection)
25    }
26
27    artifact.Set("name", metadata.ArtifactId)
28    artifact.Set("group", metadata.GroupId)
29    artifact.Set("type", "android")
30    artifact.Set("enabled", true)
31
32    if err := h.app.Dao().SaveRecord(artifact); err != nil {
33        return err
34    }
35// ...
36}
Осталось добавить в базу версии, указанные в файле maven-metadata.xml
 1for _, ver := range metadata.Versioning.Versions.Version {
 2    version, _ := h.app.Dao().FindFirstRecordByFilter("versions",
 3        "name = {:name} && version = {:version} && artifact = {:artifact}",
 4        dbx.Params{"name": metadata.ArtifactId, "version": ver, "artifact": artifact.Id},
 5    )
 6    if version == nil {
 7        collection, err := h.app.Dao().FindCollectionByNameOrId("versions")
 8        if err != nil {
 9            return err
10        }
11
12        version = models.NewRecord(collection)
13    }
14
15    version.Set("name", metadata.ArtifactId)
16    version.Set("version", ver)
17    version.Set("artifact", artifact.Id)
18    version.Set("enabled", true)
19
20    if err := h.app.Dao().SaveRecord(version); err != nil {
21        return err
22    }
23}
Отлично, новая версия библиотеки загружена на сервер. Нужно реализовать возможность скачивать эти версии. Gradle для скачивания отправляет два запроса GET и HEAD. Реализуем очень простые ручки, которые позволяют отдать любой файл по запросу
1e.Router.GET("/packages/*", apis.StaticDirectoryHandler(os.DirFS(settings.UploadFolder("")), true))
2e.Router.HEAD("/packages/*", apis.StaticDirectoryHandler(os.DirFS(settings.UploadFolder("")), true))
Скачивать и устанавливать библиотеки могут все пользователи, поэтому тут нет миделвари apis.RequireAdminAuth()
Авторизация
Теперь о том, как получить токен администратора для загрузки библиотеки. В PocketBase уже есть API для работы с авторизацией для администратор. Чтобы получить токен, нужно оправить POST запрос на ручку /api/admins/auth-with-password
1POST https://127.0.0.1:8080/api/admins/auth-with-password
2Content-Type: application/json
3
4{
5    "identity": "example@yandex.ru",
6    "password": "password"
7}
В ответ вернется JSON с полем token
 1{
 2  "admin": {
 3    "id": "zf8ehix5ljg2fu5",
 4    "created": "2024-08-04 23:10:15.851Z",
 5    "updated": "2024-08-04 23:10:15.851Z",
 6    "avatar": 0,
 7    "email": "example@yandex.ru"
 8  },
 9  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MjUyMDU1NTEsImlkIjoiemY4ZWhpeDVsamcyZnU1IiwidHlwZSI6ImFkbWluIn0.j7utkMnSHvd9jNLKCZJIoZ9SKhPt2Tcj1DuuIZCeUOo"
10}
Этот token нужно использовать для авторизации при загрузке библиотеки
Список артефактов
Уже сейчас достаточно функциональности, чтобы использовать сервис для публикации и использования библиотек в ваших Android проектах. Но добавим чуть-чуть красоты и выведем список библиотек на главной странице сервиса.
Для этого получаем список артефактов и последнюю версию:
 1records, err := h.app.Dao().FindRecordsByFilter(
 2    "artifacts",
 3    "enabled = true",
 4    "-created",
 5    100,
 6    0,
 7)
 8
 9if err != nil {
10    h.app.Logger().Error("failed fetch artifacts", "err", err)
11}
12
13aa := []Artifact{}
14for _, record := range records {
15    versions, err := h.app.Dao().FindRecordsByFilter(
16        "versions",
17        "enabled = true && artifact = {:artifact}",
18        "-created",
19        1,
20        0,
21        dbx.Params{"artifact": record.Id},
22    )
23
24    if err != nil {
25        h.app.Logger().Error("failed fetch versions", "err", err)
26
27        continue
28    }
29
30    v := ""
31    if len(versions) > 0 {
32        v = versions[0].GetString("version")
33    }
34
35    aa = append(aa, Artifact{
36        Name:        record.GetString("name"),
37        Group:       record.GetString("group"),
38        Description: record.GetString("description"),
39        LastVersion: v,
40    })
41}
42
43html, err := h.registry.LoadFS(views.FS,
44    "layout.html",
45    "home/home.html",
46).Render(map[string]any{
47    "artifacts": aa,
48})
В результате получаем красивую страничку со списком всех доступных библиотек

Конечно, еще много всего нужно улучшить и добавить:
- Сделать докер образ для быстрой установки
- Добавить список всех доступных версий
- Доработать удаление артефактов
- Добавить возможность закрывать доступ к артефактам по тоглу enabled
- Переключать репозиторий в приватный публичный режим
- Добавить регистрацию пользователей
- Написать подробную документацию
Надеюсь, у меня хватит времени добраться до всех этих задач. А сейчас проект доступен по на gitflic - depot