diff --git a/apps/stamp-verification/.env.example b/apps/stamp-verification/.env.example new file mode 100644 index 0000000..8aa1df0 --- /dev/null +++ b/apps/stamp-verification/.env.example @@ -0,0 +1,70 @@ +# ============================================================================= +# stamp-verification-frontend — пример переменных окружения +# ============================================================================= +# +# ВАЖНО: само приложение (статическая страница на nginx) НЕ читает переменные +# окружения во время выполнения. Конфигурация «зашита» в код: +# - базовый URL API выбирается по window.location.host (см. static/js/index.js); +# - nginx слушает порт 8080 (см. nginx.conf); +# - health-check отдаётся статически по пути /ping. +# +# Поэтому файл .env НЕ подхватывается образом. Перечисленные ниже переменные +# участвуют только в СБОРКЕ и ДЕПЛОЕ (GitLab CI + Helm/universal-chart) и +# приведены здесь как справочный шаблон значений для каждого окружения. +# ----------------------------------------------------------------------------- + + +# --- GitLab CI (.gitlab-ci.yml) --------------------------------------------- +# Задаются пайплайном/переменными проекта, переключаются по ветке/тегу. +SERVICE_NAME=stamp-verification-frontend +DOCKERFILE_PATH=Dockerfile +BUILD_ARGS= +CI_TRIGGER_SOURCE=app + +# STAND / NAMESPACE выставляются автоматически по правилам workflow: +# ветка stage -> STAND=stage NAMESPACE=documentations +# ветка master -> STAND=preprod NAMESPACE=stamp-verification-preprod +# тег (release) -> STAND=production NAMESPACE=stamp-verification-prod +STAND=stage +NAMESPACE=documentations +RELEASE_NAME=stamp-verification-frontend +CHART_NAME=stamp-verification-frontend +CHART_VERSION=0.0.1-stage +K8S_HUSTLER_BRANCH=universal-chart-stage + +# Имя собранного образа (подставляется в HELM_SET_ARGS как +# universal-chart.services.frontend.image.name.) +IMAGE_NAME=cr.yandex/crp3ccidau046kdj8g9q/stamp-verification-frontend:latest + + +# --- Helm / universal-chart (.helm/values.yaml) ----------------------------- +# Значения чарта universal-chart (dependency 0.1.7). env выбирает набор *.env. +UNIVERSAL_CHART__GLOBAL__ENV=stage # _default | stage | preprod | production +UNIVERSAL_CHART__SERVICES__FRONTEND__DEPLOYMENT__NAME=stamp-verification-frontend +UNIVERSAL_CHART__SERVICES__FRONTEND__DEPLOYMENT__PORT=8080 +UNIVERSAL_CHART__SERVICES__FRONTEND__DEPLOYMENT__REPLICA_COUNT=1 +UNIVERSAL_CHART__SERVICES__FRONTEND__IMAGE__NAME=cr.yandex/crp3ccidau046kdj8g9q/stamp-verification-frontend:latest +UNIVERSAL_CHART__SERVICES__FRONTEND__IMAGE__PULL_POLICY=IfNotPresent +UNIVERSAL_CHART__SERVICES__FRONTEND__SERVICE__NAME=frontend-service +UNIVERSAL_CHART__SERVICES__FRONTEND__SERVICE__PORT=8080 # production: 80 +UNIVERSAL_CHART__SERVICES__FRONTEND__SERVICE__TARGET_PORT=8080 +UNIVERSAL_CHART__SERVICES__FRONTEND__SERVICE__TYPE=ClusterIP +UNIVERSAL_CHART__SERVICES__FRONTEND__IMAGE_PULL_SECRETS__NAME=dockerhub + + +# --- Docker build (Dockerfile) ---------------------------------------------- +# Базовый образ фиксирован в Dockerfile: nginx:mainline-alpine-otel. +# BUILD_ARGS пуст — build-time аргументов у образа нет. + + +# --- «Runtime»-конфигурация приложения (справочно, НЕ через env) ------------- +# Значения ниже НЕ настраиваются переменными окружения — они захардкожены в +# static/js/index.js и приведены только для понимания поведения: +# +# window.location.host == "stamp-verification.sarex.io" -> API = api.sarex.io (prod) +# иначе (напр. stamp-verification.stage.sarex.io) -> API = stage-api.sarex.io (stage) +# +# ВНИМАНИЕ (безопасность): в static/js/index.js в заголовок Authorization +# захардкожен Bearer-JWT (истёк в 2023 г.). Это должно быть удалено — +# публичный эндпоинт QR не требует пользовательского токена. Секрета для env +# здесь нет и быть не должно. diff --git a/apps/stamp-verification/CONFIGURATION.md b/apps/stamp-verification/CONFIGURATION.md new file mode 100644 index 0000000..a963a08 --- /dev/null +++ b/apps/stamp-verification/CONFIGURATION.md @@ -0,0 +1,132 @@ +# Конфигурация проекта stamp-verification-frontend + +Документ описывает, как конфигурируется и разворачивается модуль **stamp-verification-frontend** — публичная статическая страница проверки штампа (QR) на документе. + +## Что это за сервис + +`stamp-verification-frontend` — это статический фронтенд (обычный HTML + ванильный JavaScript + `moment.js`), который раздаётся через **nginx**. По QR-коду со штампа на PDF пользователь попадает на страницу с параметрами `?id=&page_number=`, страница запрашивает данные документа у backend-сервиса `documentations` и отображает информацию о версии, авторе, дате загрузки, статусе согласования и ссылке на документ в Sarex. + +Ключевое отличие от backend-сервисов: **у приложения нет разбора переменных окружения**. Здесь нет `pydantic-settings`, нет `.env`, нет секций конфигурации. Всё «runtime»-поведение либо статично, либо определяется по хосту в браузере (`window.location.host`). Конфигурируется только **сборка и деплой** (Docker, Helm/`universal-chart`, GitLab CI). + +## Способы конфигурирования + +| Слой | Где задаётся | Что настраивает | +| --- | --- | --- | +| Runtime (браузер) | `static/js/index.js` | Выбор базового URL API по `window.location.host`; параметры запроса берутся из query-строки (`id`, `page_number`, `v`) | +| Веб-сервер | `nginx.conf` | Порт прослушивания `8080`, корень `/dist`, health-check `GET /ping` → `200 {"result": "ok"}`, gzip, логи в stdout/stderr | +| Образ | `Dockerfile` | Базовый образ `nginx:mainline-alpine-otel`, копирование `static/` и `index.html` в `/dist`, `EXPOSE 8080` | +| Helm | `.helm/values.yaml`, `.helm/Chart.yaml` | Значения чарта-зависимости `universal-chart` (deployment, image, service, imagePullSecrets) | +| CI/CD | `.gitlab-ci.yml` | Выбор окружения по ветке/тегу, имя релиза/чарта, `HELM_SET_ARGS` | +| IaC (kustomize) | `iac/apps/stamp-verification/**` | Namespace, Deployment, Service и оверлеи кластеров (этот репозиторий) | + +Отдельного конфиг-файла (yaml/toml/env) приложение не читает. + +## Runtime-поведение (`static/js/index.js`) + +Скрипт выполняется на `DOMContentLoaded`. Логика: + +- читает query-параметры: `id` (идентификатор QR), `page_number`, `v` (пока не используется); +- запрос выполняется только если заданы одновременно `id` и `page_number`; +- выбирает базовый URL API: + + | `window.location.host` | Базовый URL API | Окружение | + | --- | --- | --- | + | `stamp-verification.sarex.io` | `api.sarex.io` | prod | + | любой другой (напр. `stamp-verification.stage.sarex.io`) | `stage-api.sarex.io` | stage | + +- делает `fetch` `GET https:///documentations/api/v1/public/qr/{id}/document_info?number_page={page_number}` с `mode: cors`, `credentials: include`, `cache: no-cache`; +- по ответу заполняет поля страницы (название, версия, номер страницы, автор, дата загрузки), блок изменений (changelog), индикаторы актуальности/аннулирования версии, статус согласования и ссылку на документ. + +Локаль дат — `ru` (`moment.locale("ru")`), формат отображения даты — `DD.MM.YYYY / HH:mm`. + +Полный перечень внешних вызовов см. в `ENDPOINTS.md`, контракт эндпоинта — в `openapi.yaml`. + +## nginx (`nginx.conf`) + +| Параметр | Значение | Назначение | +| --- | --- | --- | +| `listen` | `8080` | Порт HTTP | +| `root` | `/dist` | Корень статики (туда Dockerfile кладёт `static/` и `index.html`) | +| `location = /ping` | `return 200 '{"result": "ok"}'` | Health-check для k8s-проб/балансировщика | +| `access_log` | `/dev/stdout` | Логи доступа в stdout | +| `error_log` | `stderr warn` | Логи ошибок в stderr | +| `gzip` | `on` | Сжатие ответов | +| `expires` | `off` | Без заголовков кеширования | + +## Docker (`Dockerfile`, `docker-compose.yml`) + +Образ на базе `nginx:mainline-alpine-otel`. Сборка копирует `nginx.conf` в `/etc/nginx/nginx.conf`, каталог `static` и `index.html` в `/dist`, выставляет права на `/var` и `/run`, объявляет `EXPOSE 8080` и запускает `nginx -g "daemon off;"`. Build-time аргументов нет (`BUILD_ARGS=""`). + +`docker-compose.yml` — только для локального прогона (`image: sarex/landing:latest`, публикует порт `8000:8000`; обратите внимание, что nginx внутри слушает `8080` — маппинг в compose оставлен историческим). + +## Helm / universal-chart (`.helm/`) + +`Chart.yaml` подключает зависимость `universal-chart` (`oci://cr.yandex/crp3ccidau046kdj8g9q/charts`, версия `0.1.7`). Значения задаются в `values.yaml` под ключом `universal-chart` и разбиты по окружениям через суффиксы (`_default`, `stage`, `preprod`, `production`). + +| Ключ (`universal-chart.services.frontend.*`) | Значение (`_default`) | Назначение | +| --- | --- | --- | +| `deployment.enabled` | `true` | Создавать Deployment | +| `deployment.name` | `stamp-verification-frontend` | Имя Deployment | +| `deployment.port` | `8080` | Порт контейнера | +| `deployment.replicaCount` | `1` | Число реплик | +| `image.name` | `cr.yandex/crp3ccidau046kdj8g9q/stamp-verification-frontend:latest` | Образ | +| `image.pullPolicy` | `IfNotPresent` | Политика загрузки образа | +| `service.enabled` | `true` | Создавать Service | +| `service.name` | `frontend-service` | Имя Service | +| `service.port` | `8080` (в `production` — `80`) | Порт сервиса | +| `service.targetPort` | `8080` | Целевой порт | +| `service.type` | `ClusterIP` | Тип сервиса | +| `imagePullSecrets.name` | `dockerhub` | Секрет для доступа к реестру | + +`global.env` (`_default`) выбирает активный набор значений по окружению. + +## CI/CD (`.gitlab-ci.yml`) + +Пайплайн подключает общие шаблоны `generic/common-ci` (`common-security-scan.yaml`, `universal-pipeline.yaml`, ref `apps-business`). Общие переменные: `SERVICE_NAME=stamp-verification-frontend`, `DOCKERFILE_PATH=Dockerfile`, `BUILD_ARGS=""`, `CI_TRIGGER_SOURCE=app`. + +Окружение переключается правилами `workflow.rules`: + +| Условие | STAND | NAMESPACE | CHART_VERSION | K8S_HUSTLER_BRANCH | +| --- | --- | --- | --- | --- | +| `merge_request_event` | — | — | — | `ENABLE_BUILD_IMAGE="false"` (только проверки, без сборки образа) | +| ветка `stage` | `stage` | `documentations` | `0.0.1-stage` | `universal-chart-stage` | +| ветка `master` | `preprod` | `stamp-verification-preprod` | `0.0.1-preprod` | `universal-chart-preprod` | +| тег (`CI_COMMIT_TAG`) | `production` | `stamp-verification-prod` | `0.0.1-prod` | `universal-chart-production` | +| иначе | `when: never` | | | | + +Для каждого деплой-окружения `HELM_SET_ARGS` передаёт: образ (`universal-chart.services.frontend.image.name.=${IMAGE_NAME}`), `universal-chart.global.env=` и метаданные коммита (`commitSha`, `gitlabUri`, `gitlabJobUrl`, `owner`). + +## Инфраструктура (kustomize, этот репозиторий) + +Каталог `iac/apps/stamp-verification/` содержит kustomize-манифесты (альтернатива/дополнение к Helm-деплою из CI): + +- `base/namespace.yaml` — namespace `stamp-verification` с `istio-injection: enabled`; +- `base/deployment.yaml` — Deployment `frontend` (образ `cr.yandex/crp3ccidau046kdj8g9q/stamp-verification-frontend:`, `imagePullPolicy: IfNotPresent`, `containerPort: 80`, requests `cpu: 25m`/`memory: 100Mi`, `imagePullSecrets: regcred`); +- `base/service.yaml` — Service `frontend-service` (`ClusterIP`, `port: 80` → `targetPort: 80`); +- `base/kustomization.yaml` — сборка base (namespace + deployment + service); +- `yc-k8s-test/kustomization.yaml` — оверлей кластера (`resources: ../base`, патчи закомментированы); +- `yc-k8s-test/replicas.yaml` — заготовка патча реплик (`replicas: 1`). + +## Замечания и потенциальные проблемы + +- **Захардкоженный JWT.** В `static/js/index.js` в заголовок `Authorization: Bearer …` вшит истёкший (2023 г.) токен. Публичный эндпоинт `/public/qr/...` не должен требовать пользовательский токен — строку следует удалить. Держать секреты в статике недопустимо. +- **Рассогласование портов.** nginx и Helm/`universal-chart` используют порт **8080**, а kustomize-манифесты в этом репозитории (`base/deployment.yaml`, `base/service.yaml`) — порт **80**. Нужно привести к одному значению (образ слушает 8080), иначе проброс/пробы могут не сходиться. +- **Дублирование деплоя.** Приложение может разворачиваться и через Helm (CI, чарт `universal-chart`), и через kustomize (этот репозиторий). Следует зафиксировать единый источник истины, чтобы образ/порт/namespace не расходились. +- **NAMESPACE для stage.** На ветке `stage` деплой идёт в namespace `documentations` (общий), тогда как preprod/prod — в выделенные `stamp-verification-*`. Это осознанное решение или наследие — стоит проверить. +- **Конфиг только по хосту.** Выбор prod/stage жёстко завязан на строку `stamp-verification.sarex.io`. Любой новый прод-домен потребует правки кода, а не конфигурации. +- **Mock-данные в бандле.** В `index.js` присутствуют объекты `mockData`/`mockChangeLog` — тестовые данные, оставшиеся в продовом бандле. На поведение не влияют (не используются), но их стоит убрать. + +## Минимальный набор для локального запуска + +Отдельная конфигурация не требуется — приложение статично: + +``` +# сборка и запуск образа +docker build -t stamp-verification-frontend . +docker run --rm -p 8080:8080 stamp-verification-frontend +# проверка +curl http://localhost:8080/ping # {"result": "ok"} +# страница: http://localhost:8080/?id=&page_number=1 +``` + +Для реальных данных нужен доступный backend `documentations` (по умолчанию для не-prod хоста используется `https://stage-api.sarex.io`). diff --git a/apps/stamp-verification/ENDPOINTS.md b/apps/stamp-verification/ENDPOINTS.md new file mode 100644 index 0000000..febeb76 --- /dev/null +++ b/apps/stamp-verification/ENDPOINTS.md @@ -0,0 +1,69 @@ +# Эндпоинты, с которыми взаимодействует stamp-verification-frontend + +Документ описывает все внешние HTTP-вызовы, которые выполняет статическая страница проверки штампа (`stamp-verification-frontend`). + +## Как устроено взаимодействие + +В отличие от микрофронтендов с декларативным реестром эндпоинтов, здесь весь сетевой код сосредоточен в одном файле — `static/js/index.js`. Страница обычным `fetch` обращается к **одному** публичному эндпоинту backend-сервиса `documentations`, а также подгружает сторонние ресурсы аналитики и статику. + +Логика вызова: + +1. из query-строки берутся параметры `id` (идентификатор QR) и `page_number`; запрос выполняется только если оба заданы; +2. базовый хост API выбирается по `window.location.host`; +3. выполняется `GET`-запрос с `mode: "cors"`, `credentials: "include"`, `cache: "no-cache"`; +4. ответ (JSON) маппится на поля страницы. + +## Базовые хосты по окружениям + +Значение определяется в `index.js` по хосту страницы. Итоговый URL = `https://<базовый хост>` + путь эндпоинта. + +| Хост страницы (`window.location.host`) | Базовый хост API | Окружение | +| --- | --- | --- | +| `stamp-verification.sarex.io` | `api.sarex.io` | prod | +| любой другой (напр. `stamp-verification.stage.sarex.io`) | `stage-api.sarex.io` | stage | + +> Промежуточных окружений (preprod/local) в коде не предусмотрено: всё, что не prod-домен, трактуется как stage. + +## Эндпоинты по сервисам + +### `documentations` — Сервис документации + +| Ключ | Метод | Путь | Назначение | +| --- | --- | --- | --- | +| `getQrDocumentInfo` | GET | `/documentations/api/v1/public/qr/{qrId}/document_info?number_page={pageNumber}` | Публичная информация о документе по QR-коду штампа: название, версия, автор, дата загрузки, актуальность/аннулирование версии, статус согласования, changelog и ссылка на документ в Sarex | + +Параметры запроса: + +| Параметр | Расположение | Обязателен | Назначение | +| --- | --- | --- | --- | +| `qrId` | path (`{qrId}`) | да | Идентификатор QR-кода (из query `?id=`) | +| `number_page` | query | да | Номер страницы документа (из query `?page_number=`) | + +Заголовки запроса (как в текущем коде): `Content-Type: application/json` и `Authorization: Bearer `. **Токен захардкожен и истёк** — см. раздел «Замечания». Запрос идёт с `credentials: "include"` (куки/учётные данные), `redirect: "follow"`, `referrerPolicy: "no-referrer"`. + +Используемые поля ответа (по `index.js`): `document_name`, `version_number`, `page_number`, `author`, `upload_date`, `is_actual_version`, `is_canceled`, `does_bundle_have_flows`, `is_actual_version_approved`, `document_review_status`, `document_url`, `is_latest_changelog`, `changelog` (`no`, `description`, `author` и др.). Полная схема — в `openapi.yaml`. + +Формирование ссылки на документ: при наличии `document_url` ссылка на странице собирается как `${data.document_url}&page_number=${data.page_number}` и открывается в Sarex. + +## Внешние (сторонние) ресурсы + +Не относятся к API Sarex, но выполняются страницей: + +| Ресурс | Метод | URL | Назначение | +| --- | --- | --- | --- | +| Яндекс.Метрика (tag.js) | GET | `https://mc.yandex.ru/metrika/tag.js` | Загрузка счётчика аналитики (id `93441026`) | +| Яндекс.Метрика (noscript) | GET | `https://mc.yandex.ru/watch/93441026` | Пиксель для окружения без JS | +| Статика приложения | GET | `static/css/*`, `static/js/*`, `static/icons/*`, `static/fonts/*`, `static/img/*` | CSS, `moment-with-locales.min.js`, иконки, шрифты, фон — раздаются самим nginx | + +Health-check (со стороны инфраструктуры, не самой страницы): `GET /ping` → `200 {"result": "ok"}` (отдаётся nginx). + +## Обработка ошибок + +Специализированного маппинга кодов ошибок в человекочитаемые сообщения (как в backend-сервисах) в коде нет. Ошибки обрабатываются минимально: `fetch` обёрнут в `.catch(...)`, ошибки логируются в консоль (`console.error`). При пустом/отсутствующем ответе поля страницы заполняются плейсхолдером `–––` (`PLACEHOLDER_STR`). Пользовательских уведомлений об ошибке нет. + +## Замечания + +- **Захардкоженный JWT.** В заголовке `Authorization` в `index.js` вшит истёкший (2023 г.) Bearer-токен. Эндпоинт публичный (`/public/qr/...`) — заголовок с токеном следует удалить; хранить токены в статике недопустимо. +- **`credentials: "include"`** при кросс-доменном запросе к `*-api.sarex.io` требует корректной CORS-конфигурации на стороне `documentations` (`Access-Control-Allow-Credentials` + конкретный `Access-Control-Allow-Origin`). +- **Параметр `v`** (версия) читается из query, но пока не используется. +- **Выбор окружения только по домену** — новый prod-домен потребует правки кода. diff --git a/apps/stamp-verification/openapi.yaml b/apps/stamp-verification/openapi.yaml new file mode 100644 index 0000000..123ff4e --- /dev/null +++ b/apps/stamp-verification/openapi.yaml @@ -0,0 +1,216 @@ +openapi: 3.0.3 + +info: + title: Stamp Verification — Public QR API (consumed) + version: "1.0.0" + description: | + Контракт публичного эндпоинта, который потребляет фронтенд + **stamp-verification-frontend** (`pdm/stamp-verification-frontend`). + + Сам фронтенд — статическая страница на nginx (ванильный JS, `static/js/index.js`) + и **не предоставляет** собственного HTTP API. Данная спецификация описывает + единственный внешний эндпоинт backend-сервиса **documentations**, к которому + страница обращается для получения информации о документе по QR-коду штампа. + Спецификация восстановлена по клиентскому коду (`index.js`) и служит + справочным контрактом (потребитель, а не поставщик). + + ### Базовый хост + Выбирается на клиенте по `window.location.host`: + - `stamp-verification.sarex.io` → `https://api.sarex.io` (prod); + - любой другой хост → `https://stage-api.sarex.io` (stage). + + ### Аутентификация + Эндпоинт публичный (сегмент пути `/public/`). Текущий клиент тем не менее + отправляет заголовок `Authorization: Bearer ` с **захардкоженным и + истёкшим** токеном — это дефект клиента, а не требование API (см. замечания + в `ENDPOINTS.md`). Запрос выполняется с `credentials: include` (CORS с + учётными данными). + + ### Формат + Ответ — `application/json`. Все поля опциональны/nullable: клиент подставляет + плейсхолдер `–––` при отсутствии значения. + +servers: + - url: https://api.sarex.io + description: prod + - url: https://stage-api.sarex.io + description: stage + +paths: + /documentations/api/v1/public/qr/{qr_id}/document_info: + get: + operationId: getQrDocumentInfo + summary: Информация о документе по QR-коду штампа + description: | + Возвращает публичную информацию о документе, на который указывает QR-код + штампа: название, версию, автора, дату загрузки, актуальность и статус + согласования версии, сведения об изменениях (changelog) и ссылку на + документ в Sarex. + tags: + - public-qr + parameters: + - name: qr_id + in: path + required: true + description: Идентификатор QR-кода (на клиенте — query-параметр `id`). + schema: + type: string + example: "b46d6b82-9e51-48e3-8dce-a38dfc6b2a26" + - name: number_page + in: query + required: true + description: Номер страницы документа (на клиенте — query-параметр `page_number`). + schema: + type: integer + minimum: 1 + example: 1 + responses: + "200": + description: Информация о документе + content: + application/json: + schema: + $ref: "#/components/schemas/QrDocumentInfo" + "404": + description: Документ/QR не найден + "422": + description: Некорректные параметры запроса + "500": + description: Внутренняя ошибка сервиса + +components: + schemas: + QrDocumentInfo: + type: object + description: | + Набор полей, используемых клиентом (`static/js/index.js`). Поля + опциональны — при отсутствии клиент отображает плейсхолдер `–––`. + properties: + document_name: + type: string + nullable: true + description: Имя файла документа + example: some-file-01.pdf + version_number: + type: string + nullable: true + description: Номер версии документа + example: v2 + page_number: + type: integer + nullable: true + description: Номер страницы (эхо параметра запроса); участвует в сборке ссылки на документ + example: 1 + author: + type: string + nullable: true + description: Автор версии (отображаемое имя) + example: Андрей Алешков + upload_date: + type: string + format: date-time + nullable: true + description: Дата и время загрузки версии (ISO 8601); на клиенте форматируется как `DD.MM.YYYY / HH:mm` + example: "2023-05-23T20:01:55.496768Z" + is_actual_version: + type: boolean + nullable: true + description: >- + `true` — последняя версия; `false` — не последняя; отсутствие/`null` — + актуальность неизвестна + example: true + is_canceled: + type: boolean + nullable: true + description: >- + `true` — QR-код/версия аннулированы (клиент показывает предупреждение + «QR-код аннулирован») + example: false + does_bundle_have_flows: + type: boolean + nullable: true + description: Есть ли у бандла процессы согласования (управляет показом блока статуса согласования) + example: false + is_actual_version_approved: + type: boolean + nullable: true + description: Согласована ли текущая версия (при `does_bundle_have_flows = true`) + example: true + document_review_status: + type: string + nullable: true + description: Человекочитаемый статус документа (показывается при согласованной актуальной версии) + example: Согласован + document_url: + type: string + format: uri + nullable: true + description: >- + Ссылка на документ в Sarex. Клиент открывает её как + `${document_url}&page_number=${page_number}` + example: https://stage.sarex.io/workspaces-v2/0244d9f9-8f32-4b65-80c7-7c403b85c81e?type=pdf + is_latest_changelog: + type: boolean + nullable: true + description: >- + Является ли `changelog` последним. Если `false` при наличии + `changelog` — клиент показывает предупреждение «Была выпущена новая + версия изменения» + example: true + changelog: + $ref: "#/components/schemas/Changelog" + + Changelog: + type: object + nullable: true + description: Сведения об изменении версии документа + properties: + bundle_id: + type: string + format: uuid + example: b46d6b82-9e51-48e3-8dce-a38dfc6b2a26 + "no": + type: string + description: Порядковый номер изменения (на клиенте выводится как `№{no}`) + example: "2" + description: + type: string + nullable: true + description: >- + Описание изменения. Если длиннее 20 символов — клиент включает + сворачивание текста; при отсутствии показывает «Нет описания изменений» + example: Внесены изменения в структуру документа. Добавлена подпись руководителя проекта. + created_at: + type: string + format: date-time + example: "2023-05-23T20:01:55.496768Z" + updated_at: + type: string + format: date-time + example: "2023-05-23T20:01:55.496768Z" + created_by: + type: integer + example: 2203 + author: + $ref: "#/components/schemas/ChangelogAuthor" + + ChangelogAuthor: + type: object + description: Автор изменения + properties: + id: + type: integer + example: 2203 + username: + type: string + example: a.aleshkov + email: + type: string + format: email + example: a.aleshkov@sarex.io + first_name: + type: string + example: Андрей + last_name: + type: string + example: Алешков diff --git a/apps/workspaces/openapi.yaml b/apps/workspaces/openapi.yaml new file mode 100644 index 0000000..918d08b --- /dev/null +++ b/apps/workspaces/openapi.yaml @@ -0,0 +1,906 @@ +openapi: 3.0.3 +info: + title: workspaces-api + description: >- + HTTP API сервиса workspaces-api (репозиторий `pdm/workspaces-api`). + Хранит рабочие области (workspaces), документы, состояния (states), + динамические состояния и приложения (apps). Схема составлена по + маршрутам `cmd/api/bootstrap.go` и структурам пакетов `workspace` и `apps`. + + Группы маршрутов: + * публичные (`/ping`, `/metrics`) — без middleware; + * `/api/v1` — внешний API, требует JWT (middleware `auth.JWTToCtx`); + * `/internal/v1`, `/internal/v2` — внутренний API без JWT + (доступ ограничивается сетевой политикой/ingress). + version: "1.0.0" + +servers: + - url: https://api.sarex.io/workspaces + description: production + - url: https://api.preprod.sarex.io/workspaces + description: preprod + - url: https://stage-api.sarex.io/workspaces + description: stage + - url: http://localhost:6666 + description: local + +tags: + - name: health + description: Liveness/readiness и метрики + - name: workspaces + description: Рабочие области + - name: documents + description: Документы рабочих областей + - name: states + description: Состояния рабочих областей + - name: dynamic-states + description: Динамические состояния + - name: apps + description: Приложения и их инстансы + - name: internal + description: Внутренние эндпоинты (без JWT) + +security: + - bearerAuth: [] + +paths: + # ------------------------------------------------------------------ health + /ping: + get: + tags: [health] + summary: Liveness/readiness проба + security: [] + responses: + "200": + description: Сервис жив + + /metrics: + get: + tags: [health] + summary: Метрики Prometheus + security: [] + responses: + "200": + description: Метрики в формате Prometheus + content: + text/plain: + schema: + type: string + + # -------------------------------------------------------------- api/v1 · ws + /api/v1/workspaces: + post: + tags: [workspaces] + summary: Создать рабочую область (v2) + description: Создаёт рабочую область по списку id документов (`CreateV2Workspace`). + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateV2WorkspaceRequest" + responses: + "200": + description: Созданная рабочая область + content: + application/json: + schema: + $ref: "#/components/schemas/Workspace" + "400": { $ref: "#/components/responses/BadRequest" } + "500": { $ref: "#/components/responses/InternalError" } + + /api/v1/workspaces/{ws_id}: + get: + tags: [workspaces] + summary: Получить рабочую область + parameters: + - $ref: "#/components/parameters/WsId" + - name: archived + in: query + description: Включать архивные сущности + schema: { type: integer, enum: [0, 1] } + - name: state + in: query + description: uuid состояния; при указании в ответ добавляется последнее состояние + schema: { type: string, format: uuid } + responses: + "200": + description: Рабочая область + content: + application/json: + schema: + $ref: "#/components/schemas/Workspace" + "404": { $ref: "#/components/responses/NotFound" } + "500": { $ref: "#/components/responses/InternalError" } + + /api/v1/workspaces/{id}/archive: + post: + tags: [workspaces] + summary: Архивировать рабочую область + parameters: [ { $ref: "#/components/parameters/Id" } ] + responses: + "200": { $ref: "#/components/responses/Ok" } + "404": { $ref: "#/components/responses/NotFound" } + "500": { $ref: "#/components/responses/InternalError" } + + /api/v1/workspaces/{id}/unarchive: + post: + tags: [workspaces] + summary: Разархивировать рабочую область + parameters: [ { $ref: "#/components/parameters/Id" } ] + responses: + "200": { $ref: "#/components/responses/Ok" } + "404": { $ref: "#/components/responses/NotFound" } + "500": { $ref: "#/components/responses/InternalError" } + + /api/v1/workspaces/{id}/cache: + delete: + tags: [workspaces] + summary: Очистить кеш бандлов рабочей области + parameters: [ { $ref: "#/components/parameters/Id" } ] + responses: + "200": { description: Кеш очищен } + "404": { $ref: "#/components/responses/NotFound" } + "500": { $ref: "#/components/responses/InternalError" } + + /api/v1/workspaces/{ws_id}/states: + get: + tags: [states] + summary: Список состояний рабочей области + parameters: + - $ref: "#/components/parameters/WsId" + - name: expand + in: query + description: "`state_json` — вернуть полные данные состояний (иначе — краткий список)" + schema: { type: string, enum: [state_json] } + responses: + "200": + description: Состояния + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/State" } + "500": { $ref: "#/components/responses/InternalError" } + post: + tags: [states] + summary: Создать состояние рабочей области + parameters: [ { $ref: "#/components/parameters/WsId" } ] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/CreateStateRequest" } + responses: + "200": + description: Созданное состояние + content: + application/json: + schema: { $ref: "#/components/schemas/State" } + "400": { $ref: "#/components/responses/BadRequest" } + "500": { $ref: "#/components/responses/InternalError" } + + /api/v1/workspaces/{ws_id}/documents: + post: + tags: [documents] + summary: Добавить документ в рабочую область + parameters: [ { $ref: "#/components/parameters/WsId" } ] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/AddDocumentRequest" } + responses: + "200": { description: Документ добавлен } + "400": { $ref: "#/components/responses/BadRequest" } + "500": { $ref: "#/components/responses/InternalError" } + + /api/v1/workspaces/{ws_id}/documents/{doc_id}: + delete: + tags: [documents] + summary: Удалить документ из рабочей области + parameters: + - $ref: "#/components/parameters/WsId" + - $ref: "#/components/parameters/DocId" + responses: + "200": { description: Документ удалён } + "404": { $ref: "#/components/responses/NotFound" } + "500": { $ref: "#/components/responses/InternalError" } + + /api/v1/workspaces/{ws_id}/dynamic_states: + get: + tags: [dynamic-states] + summary: Список динамических состояний рабочей области + parameters: [ { $ref: "#/components/parameters/WsId" } ] + responses: + "200": + description: Динамические состояния + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/DynamicState" } + "500": { $ref: "#/components/responses/InternalError" } + post: + tags: [dynamic-states] + summary: Создать динамическое состояние + parameters: [ { $ref: "#/components/parameters/WsId" } ] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/CreateDynamicStateRequest" } + responses: + "200": + description: Созданное динамическое состояние + content: + application/json: + schema: { $ref: "#/components/schemas/DynamicState" } + "400": { $ref: "#/components/responses/BadRequest" } + "500": { $ref: "#/components/responses/InternalError" } + + /api/v1/workspaces/{workspace_id}/apps/{app_id}/instances: + post: + tags: [apps] + summary: Создать инстанс приложения в рабочей области + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/AppId" + responses: + "200": + description: Созданный инстанс + content: + application/json: + schema: { $ref: "#/components/schemas/AppInstance" } + "400": + description: Некорректный запрос или дубликат инстанса + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorResponse" } + "404": { $ref: "#/components/responses/NotFound" } + "500": { $ref: "#/components/responses/InternalError" } + + # -------------------------------------------------------- api/v1 · documents + /api/v1/documents/{doc_id}: + patch: + tags: [documents] + summary: Обновить документ + description: "Не реализовано в текущем коде — всегда возвращает 400 `Implement me`." + parameters: [ { $ref: "#/components/parameters/DocId" } ] + responses: + "400": { $ref: "#/components/responses/BadRequest" } + + /api/v1/documents/{id}/archive: + post: + tags: [documents] + summary: Архивировать документ + parameters: [ { $ref: "#/components/parameters/Id" } ] + responses: + "200": { $ref: "#/components/responses/Ok" } + "404": { $ref: "#/components/responses/NotFound" } + "500": { $ref: "#/components/responses/InternalError" } + + /api/v1/documents/{id}/unarchive: + post: + tags: [documents] + summary: Разархивировать документ + parameters: [ { $ref: "#/components/parameters/Id" } ] + responses: + "200": { $ref: "#/components/responses/Ok" } + "404": { $ref: "#/components/responses/NotFound" } + "500": { $ref: "#/components/responses/InternalError" } + + # ----------------------------------------------------------- api/v1 · states + /api/v1/states/{id}: + get: + tags: [states] + summary: Получить состояние + parameters: [ { $ref: "#/components/parameters/Id" } ] + responses: + "200": + description: Состояние + content: + application/json: + schema: { $ref: "#/components/schemas/State" } + "404": { $ref: "#/components/responses/NotFound" } + "500": { $ref: "#/components/responses/InternalError" } + patch: + tags: [states] + summary: Изменить состояние + parameters: [ { $ref: "#/components/parameters/Id" } ] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/SetStateRequest" } + responses: + "200": { $ref: "#/components/responses/Ok" } + "400": { $ref: "#/components/responses/BadRequest" } + "500": { $ref: "#/components/responses/InternalError" } + delete: + tags: [states] + summary: Удалить (архивировать) состояние + parameters: [ { $ref: "#/components/parameters/Id" } ] + responses: + "200": { $ref: "#/components/responses/Ok" } + "404": { $ref: "#/components/responses/NotFound" } + "500": { $ref: "#/components/responses/InternalError" } + + /api/v1/states/{id}/archive: + post: + tags: [states] + summary: Архивировать состояние + parameters: [ { $ref: "#/components/parameters/Id" } ] + responses: + "200": { $ref: "#/components/responses/Ok" } + "404": { $ref: "#/components/responses/NotFound" } + "500": { $ref: "#/components/responses/InternalError" } + + /api/v1/states/{id}/unarchive: + post: + tags: [states] + summary: Разархивировать состояние + parameters: [ { $ref: "#/components/parameters/Id" } ] + responses: + "200": { $ref: "#/components/responses/Ok" } + "404": { $ref: "#/components/responses/NotFound" } + "500": { $ref: "#/components/responses/InternalError" } + + # -------------------------------------------------- api/v1 · dynamic states + /api/v1/dynamic_states/{id}: + get: + tags: [dynamic-states] + summary: Получить динамическое состояние + parameters: [ { $ref: "#/components/parameters/Id" } ] + responses: + "200": + description: Динамическое состояние + content: + application/json: + schema: { $ref: "#/components/schemas/DynamicState" } + "404": { $ref: "#/components/responses/NotFound" } + "500": { $ref: "#/components/responses/InternalError" } + patch: + tags: [dynamic-states] + summary: Изменить динамическое состояние + parameters: [ { $ref: "#/components/parameters/Id" } ] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/SetDynamicStateRequest" } + responses: + "200": { $ref: "#/components/responses/Ok" } + "400": { $ref: "#/components/responses/BadRequest" } + "500": { $ref: "#/components/responses/InternalError" } + delete: + tags: [dynamic-states] + summary: Удалить динамическое состояние + parameters: [ { $ref: "#/components/parameters/Id" } ] + responses: + "200": { description: Удалено } + "404": { $ref: "#/components/responses/NotFound" } + "500": { $ref: "#/components/responses/InternalError" } + + # ------------------------------------------------------------ api/v1 · apps + /api/v1/company/{company_id}/apps: + get: + tags: [apps] + summary: Приложения компании + parameters: [ { $ref: "#/components/parameters/CompanyId" } ] + responses: + "200": + description: Список приложений + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/App" } + "400": { $ref: "#/components/responses/BadRequest" } + "500": { $ref: "#/components/responses/InternalError" } + post: + tags: [apps] + summary: Создать приложение + parameters: [ { $ref: "#/components/parameters/CompanyId" } ] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/AppAttributes" } + responses: + "200": + description: Созданное приложение + content: + application/json: + schema: { $ref: "#/components/schemas/App" } + "400": { $ref: "#/components/responses/BadRequest" } + "500": { $ref: "#/components/responses/InternalError" } + + /api/v1/apps/{app_id}: + patch: + tags: [apps] + summary: Изменить приложение + parameters: [ { $ref: "#/components/parameters/AppId" } ] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ChangeAppRequest" } + responses: + "200": + description: Обновлённое приложение + content: + application/json: + schema: { $ref: "#/components/schemas/App" } + "400": { $ref: "#/components/responses/BadRequest" } + "500": { $ref: "#/components/responses/InternalError" } + + /api/v1/apps/{app_id}/archive: + post: + tags: [apps] + summary: Архивировать приложение + parameters: [ { $ref: "#/components/parameters/AppId" } ] + responses: + "200": { description: Приложение архивировано } + "404": { $ref: "#/components/responses/NotFound" } + "500": { $ref: "#/components/responses/InternalError" } + + /api/v1/apps/{app_id}/unarchive: + post: + tags: [apps] + summary: Разархивировать приложение + parameters: [ { $ref: "#/components/parameters/AppId" } ] + responses: + "200": { description: Приложение разархивировано } + "404": { $ref: "#/components/responses/NotFound" } + "500": { $ref: "#/components/responses/InternalError" } + + /api/v1/app-instances/{instance_id}/archive: + post: + tags: [apps] + summary: Архивировать инстанс приложения + parameters: [ { $ref: "#/components/parameters/InstanceId" } ] + responses: + "200": { description: Инстанс архивирован } + "404": { $ref: "#/components/responses/NotFound" } + "500": { $ref: "#/components/responses/InternalError" } + + /api/v1/app-instances/{instance_id}/unarchive: + post: + tags: [apps] + summary: Разархивировать инстанс приложения + parameters: [ { $ref: "#/components/parameters/InstanceId" } ] + responses: + "200": { description: Инстанс разархивирован } + "404": { $ref: "#/components/responses/NotFound" } + "500": { $ref: "#/components/responses/InternalError" } + + # -------------------------------------------------------------- internal/v1 + /internal/v1/workspaces: + post: + tags: [internal] + summary: Создать рабочую область (v1, по ссылкам на бандлы) + description: Внутренний эндпоинт без JWT. Создаёт рабочую область по списку URL бандлов. + security: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/CreateWorkspaceRequest" } + responses: + "200": + description: Созданная рабочая область + content: + application/json: + schema: { $ref: "#/components/schemas/Workspace" } + "400": { $ref: "#/components/responses/BadRequest" } + "500": { $ref: "#/components/responses/InternalError" } + + /internal/v1/workspaces/{ws_id}/documents: + post: + tags: [internal] + summary: Добавить документ в рабочую область (без проверки прав) + security: [] + parameters: [ { $ref: "#/components/parameters/WsId" } ] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/AddDocumentRequest" } + responses: + "200": { description: Документ добавлен } + "400": { $ref: "#/components/responses/BadRequest" } + "500": { $ref: "#/components/responses/InternalError" } + + /internal/v1/workspaces/{ws_id}/states: + get: + tags: [internal] + summary: Список состояний рабочей области (internal) + security: [] + parameters: [ { $ref: "#/components/parameters/WsId" } ] + responses: + "200": + description: Состояния + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/State" } + "500": { $ref: "#/components/responses/InternalError" } + + # -------------------------------------------------------------- internal/v2 + /internal/v2/workspaces: + post: + tags: [internal] + summary: Создать рабочую область (v2, internal) + security: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/CreateV2WorkspaceRequest" } + responses: + "200": + description: Созданная рабочая область + content: + application/json: + schema: { $ref: "#/components/schemas/Workspace" } + "400": { $ref: "#/components/responses/BadRequest" } + "500": { $ref: "#/components/responses/InternalError" } + + /internal/v2/workspaces/is_workspaces_v2: + post: + tags: [internal] + summary: Проверить, являются ли рабочие области v2 + security: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/IsWorkspaceV2Request" } + responses: + "200": + description: Карта id → признак v2 + content: + application/json: + schema: { $ref: "#/components/schemas/IsWorkspaceV2Response" } + "400": { $ref: "#/components/responses/BadRequest" } + "500": { $ref: "#/components/responses/InternalError" } + + /internal/v2/documents/{docs_id}: + delete: + tags: [internal] + summary: Удалить документ (internal) + security: [] + parameters: + - name: docs_id + in: path + required: true + schema: { type: string } + responses: + "200": { description: Документ удалён } + "404": { $ref: "#/components/responses/NotFound" } + "500": { $ref: "#/components/responses/InternalError" } + + /internal/v2/documents/restore: + patch: + tags: [internal] + summary: Восстановить документы и рабочие области (internal) + security: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/RestoreDocumentsRequest" } + responses: + "200": { description: Восстановлено } + "400": { $ref: "#/components/responses/BadRequest" } + "500": { $ref: "#/components/responses/InternalError" } + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: >- + JWT в заголовке `Authorization: Bearer `. Токен также может + передаваться query-параметром `jwt` (удаляется middleware после разбора). + + parameters: + Id: + name: id + in: path + required: true + schema: { type: string, format: uuid } + WsId: + name: ws_id + in: path + required: true + schema: { type: string, format: uuid } + WorkspaceId: + name: workspace_id + in: path + required: true + schema: { type: string, format: uuid } + DocId: + name: doc_id + in: path + required: true + schema: { type: string } + AppId: + name: app_id + in: path + required: true + schema: { type: string, format: uuid } + InstanceId: + name: instance_id + in: path + required: true + schema: { type: string, format: uuid } + CompanyId: + name: company_id + in: path + required: true + schema: { type: integer, format: int64 } + + responses: + Ok: + description: Успех + content: + application/json: + schema: { $ref: "#/components/schemas/OkResponse" } + BadRequest: + description: Некорректный запрос + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorResponse" } + NotFound: + description: Ресурс не найден + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorResponse" } + InternalError: + description: Внутренняя ошибка сервера + content: + application/json: + schema: { $ref: "#/components/schemas/ErrorResponse" } + + schemas: + ErrorResponse: + type: object + properties: + error: { type: string } + required: [error] + + OkResponse: + type: object + properties: + ok: { type: boolean } + required: [ok] + + # ---- requests ---- + CreateWorkspaceRequest: + type: object + description: "`workspace/api/workspace/create.go` (v1)" + properties: + title: { type: string, maxLength: 254 } + subtitle: { type: string, maxLength: 254 } + target: { type: integer, nullable: true } + files: + type: array + description: Ссылки на бандлы документов + items: { type: string } + features: + type: array + items: { type: string } + required: [title, files] + + CreateV2WorkspaceRequest: + type: object + description: "`workspace/api/workspace/createV2.go` (v2)" + properties: + title: { type: string, maxLength: 254 } + subtitle: { type: string, maxLength: 254 } + target: { type: integer, nullable: true } + documents: + type: array + description: id документов сервиса documentation + items: { type: integer, format: int64 } + features: + type: array + items: { type: string } + auto_created: { type: boolean } + required: [documents] + + IsWorkspaceV2Request: + type: object + properties: + ids: + type: array + items: { type: string } + required: [ids] + + IsWorkspaceV2Response: + type: object + properties: + result: + type: object + additionalProperties: { type: boolean } + description: Карта uuid рабочей области → является ли она v2 + + AddDocumentRequest: + type: object + properties: + document_id: { type: integer, format: int64 } + required: [document_id] + + RestoreDocumentsRequest: + type: object + properties: + document_ids: + type: array + minItems: 1 + items: { type: integer, format: int64 } + required: [document_ids] + + CreateStateRequest: + type: object + properties: + name: { type: string, maxLength: 254 } + data: { type: string } + dynamic_state_id: { type: string, format: uuid, nullable: true } + required: [name, data] + + SetStateRequest: + type: object + description: Частичное обновление; все поля опциональны + properties: + name: { type: string, nullable: true } + is_default: { type: boolean, nullable: true } + data: { type: string, nullable: true } + + CreateDynamicStateRequest: + type: object + properties: + name: { type: string, maxLength: 254 } + autoload: { type: boolean } + required: [name] + + SetDynamicStateRequest: + type: object + properties: + name: { type: string, nullable: true } + autoload: { type: boolean, nullable: true } + + AppAttributes: + type: object + description: "`apps/models.go`" + properties: + name: { type: string, minLength: 1, maxLength: 100 } + entrypoint: { type: string, minLength: 1, maxLength: 1024, format: uri } + devices: + type: array + items: { type: string, enum: [mobile, tablet, desktop] } + module_name: { type: string, minLength: 1, maxLength: 100 } + required: [name, entrypoint, devices, module_name] + + ChangeAppRequest: + type: object + description: >- + Частичное обновление приложения; должно быть задано хотя бы одно поле + (`apps/api/changeapp.go`). + properties: + name: { type: string, maxLength: 100 } + entrypoint: { type: string, maxLength: 1024, format: uri } + devices: + type: array + items: { type: string, enum: [mobile, tablet, desktop] } + module_name: { type: string, maxLength: 100 } + + # ---- models ---- + Workspace: + type: object + description: "Агрегат рабочей области (`workspace.Model`)" + properties: + id: { type: string, format: uuid } + title: { type: string } + subtitle: { type: string } + features: + type: array + items: { type: string } + target: { type: integer, nullable: true } + auto_created: { type: boolean, nullable: true } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + documents: + type: array + items: { $ref: "#/components/schemas/Document" } + documents_v2: + type: array + items: { $ref: "#/components/schemas/DocumentV2" } + state: + allOf: [ { $ref: "#/components/schemas/State" } ] + nullable: true + app_instances: + type: array + items: { $ref: "#/components/schemas/AppInstance" } + + Document: + type: object + description: "`workspace.ModelDocument`" + properties: + id: { type: string, format: uuid } + name: { type: string } + workspace_id: { type: string, format: uuid } + is_archived: { type: boolean } + kind: { type: string } + url: { type: string } + bundle: { type: string } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + + DocumentV2: + type: object + description: "`workspace.ModelDocumentV2`" + properties: + id: { type: string, format: uuid } + document_id: { type: integer, format: int64 } + created_at: { type: string, format: date-time } + + State: + type: object + description: "`workspace.ModelState`" + properties: + id: { type: string, format: uuid } + name: { type: string } + workspace_id: { type: string, format: uuid } + created_at: { type: string, format: date-time } + data: { type: string } + author_id: { type: integer, format: int64, nullable: true } + is_default: { type: boolean } + indestructible: { type: boolean } + dynamic_state_id: { type: string, format: uuid, nullable: true } + + DynamicState: + type: object + description: "`workspace.ModelDynamicState`" + properties: + id: { type: string, format: uuid } + name: { type: string } + author_id: { type: integer, format: int64, nullable: true } + autoload: { type: boolean } + workspace_id: { type: string, format: uuid } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + states: + type: array + items: { $ref: "#/components/schemas/State" } + + App: + type: object + description: "`apps.App`" + properties: + id: { type: string, format: uuid } + company_id: { type: integer, format: int64 } + name: { type: string } + entrypoint: { type: string } + devices: + type: array + items: { type: string, enum: [mobile, tablet, desktop] } + module_name: { type: string } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + AppInstance: + type: object + description: "`apps.AppInstance`" + properties: + id: { type: string, format: uuid } + app_id: { type: string, format: uuid } + workspace_id: { type: string, format: uuid } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + app: + allOf: [ { $ref: "#/components/schemas/App" } ] + nullable: true