Files
tech-log-frontend/src/features/tech-log/contracts/studio/studio-api.openapi.yaml
T
DongHyeonka 197b2c7e72 chore: pick up the regenerated Question input contract
`resolution` leaves the input's required list, so the generated type makes it
optional. The editor already sent null for an unresolved question; nothing in
the frontend changes behaviour.
2026-08-21 14:32:14 +09:00

1842 lines
90 KiB
YAML

openapi: 3.1.0
info:
title: Tech Log Studio API
version: 3.0.0
description: |
Tech Log Studio orchestration 계약이다.
이 파일이 Studio HTTP 계약의 canonical source다. Frontend의
`contracts/studio-api.openapi.yaml`은 이 계약에서 생성되어야 하며
독립적인 두 번째 canonical source가 되어서는 안 된다. (ADR-004)
## 이 계약의 성격
현재 Studio UI의 단일 문서 제작 흐름을 그대로 계약으로 승격한 것이다.
```text
작업본 → 편집 → 저장 → 검증 → Public Preview → 게시/재게시 → 게시 기록/Snapshot
```
유형별 CMS 메뉴(Case 관리 / Reference 관리 / Question 관리)는 이 계약의
primary mental model이 아니다. 유형별 specialized management capability는
`studio-management-v1.yaml`에 secondary API로 보존한다.
## WorkingCopy는 API projection이다
`WorkingCopy`는 Domain Aggregate가 아니다. Studio API가 여러 도메인을
동일한 편집 경험으로 보여주기 위한 API/Application projection이며,
Backend는 이를 기존 유형별 use case로 dispatch한다.
```text
createStudioDocument(kind=CASE) → CreateCaseDraft
saveStudioDocument(kind=CASE) → UpdateCaseDraft
validateStudioDocument(kind=CASE) → CasePublicationValidator
publishStudioDocument(kind=CASE) → CasePublicationHandler
kind=REFERENCE → Reference capability
kind=QUESTION → Inquiry capability
kind=PROJECT_DECISION → ProjectDecision capability
```
`documentId`는 source aggregate id를 그대로 사용한다. 별도 surrogate
Studio id를 만들지 않으며 `working_copy` 범용 테이블도 만들지 않는다.
## API 용어 ↔ Domain 용어 mapper 경계
API enum은 UI 용어이고 Domain enum은 그대로 보존한다. 변환은 mapper가 한다.
| API (이 계약) | Domain | 비고 |
|---|---|---|
| `questionStatus=OPEN` | `OPEN`, `INVESTIGATING`, `PAUSED` | 축약 view. 저장이 Domain 상태를 덮어쓰지 않는다 |
| `questionStatus=RESOLVED` | `RESOLVED` | Resolve command로 해석하며 기존 resolve invariant를 통과해야 한다 |
| `decisionStatus=ADOPTED` | `ACCEPTED`/`ADOPTED` | Domain enum을 UI 용어 때문에 변경하지 않는다 |
| `documentId` | `source_kind` + `source_id` | Publication 계열 테이블은 두 컬럼으로 저장한다 |
| `nextAction` | (없음) | Domain state가 아니라 Studio projection이다. `workflow_status`에 저장하지 않는다 |
`saveStudioDocument`는 편집 가능한 content field만 저장한다. lifecycle
전이는 명시적인 Domain Action이 담당한다. Frontend가 `OPEN`을 보냈다고 해서
`INVESTIGATING → OPEN`으로 자동 전이하면 안 된다.
## Frontend 계약과의 현재 차이 (2026-08-17 실측)
`tech-log-frontend`의 `src/features/tech-log/contracts/studio/studio-api.openapi.yaml`과
대조한 결과 다음이 이미 일치한다.
```text
operation 13개 전부 operationId · method · path 일치
RecordKind 양쪽 4종 동일 (CASE / REFERENCE / QUESTION / PROJECT_DECISION)
오류 코드 Frontend 15종이 이 계약의 23종에 모두 포함
```
Backend가 추가로 제공하는 것은 두 가지이며, Frontend가 이 계약에서 재생성할
때 흡수된다.
1. Asset operation 5개와 `getStudioSession`.
Frontend는 아직 Asset capability를 구현하지 않았다.
2. mutating operation의 `X-CSRF-TOKEN`.
Frontend 계약은 `security: []`이며 이 header를 선언하지 않는다.
## 게시 경로는 하나다
`studio-management-v1.yaml`의 publish/unpublish 계열 operation도 이 계약과
동일한 Publication Event/Snapshot 경로를 거쳐야 한다. 서로 다른 두 개의
게시 경로를 허용하지 않는다.
servers:
- url: /
security:
- sessionCookie: []
tags:
- name: Session
description: Studio 접근 제어
- name: Dashboard
description: Workspace 대시보드
- name: Documents
description: 통합 Working Copy 편집
- name: Validation
description: Validation Artifact
- name: Preview
description: 인증된 Public Preview Artifact
- name: Publication
description: Publication Aggregate / Event / Snapshot
- name: Catalog
description: Editor picker 통합 조회
- name: Assets
description: Asset Library / Picker / Upload
paths:
/api/v1/studio/session:
get:
operationId: getStudioSession
tags: [Session]
summary: 현재 Studio 세션과 CSRF 토큰을 조회한다
responses:
"200": { description: 인증된 Studio 세션, content: { application/json: { schema: { $ref: "#/components/schemas/StudioSessionEnvelope" } } } }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
"503": { $ref: "#/components/responses/StudioUnavailable" }
/api/v1/studio/dashboard:
get:
operationId: getStudioDashboard
tags: [Dashboard]
summary: Get the Studio dashboard
description: |
`nextAction`을 포함한 모든 workflow 상태는 서버가 계산한다.
Frontend는 여러 endpoint를 조합해 workflow 상태를 재추론하지 않는다.
responses:
"200": { description: Dashboard lists and totals, content: { application/json: { schema: { $ref: "#/components/schemas/StudioDashboardEnvelope" } } } }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
"503": { $ref: "#/components/responses/StudioUnavailable" }
/api/v1/studio/documents:
get:
operationId: listStudioDocuments
tags: [Documents]
summary: List working copies
description: |
Case/Reference/OpenQuestion/ProjectDecision은 서로 다른 table/aggregate에
존재한다. 공통 CRUD repository를 만들지 않고 query side에서 union projection을
구성한다(`StudioDocumentQueryService`).
`cursor`는 normalized filter/sort와 결합된 opaque 값이다. 필터가 달라진
cursor 재사용은 거절한다.
parameters:
- { $ref: "#/components/parameters/Query" }
- { $ref: "#/components/parameters/DocumentKind" }
- { $ref: "#/components/parameters/PublicationStatus" }
- { $ref: "#/components/parameters/NextAction" }
- { $ref: "#/components/parameters/ProjectId" }
- { $ref: "#/components/parameters/DocumentSort" }
- { $ref: "#/components/parameters/Cursor" }
- { $ref: "#/components/parameters/Limit" }
responses:
"200": { description: Working-copy cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/DocumentPageEnvelope" } } } }
"400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
"422": { $ref: "#/components/responses/RequestValidationFailed" }
"503": { $ref: "#/components/responses/StudioUnavailable" }
post:
operationId: createStudioDocument
tags: [Documents]
summary: Create a working copy
description: 불완전한 초안도 생성할 수 있다. 생성은 Public Projection을 변경하지 않는다.
parameters:
- { $ref: "#/components/parameters/IdempotencyKey" }
- { $ref: "#/components/parameters/CsrfToken" }
requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/CreateDocumentInput" } } } }
responses:
"201":
description: Created working copy
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyEnvelope" } } }
"400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
"409": { $ref: "#/components/responses/CommandConflict" }
"422": { $ref: "#/components/responses/RequestValidationFailed" }
"503": { $ref: "#/components/responses/StudioUnavailable" }
/api/v1/studio/documents/{documentId}:
parameters: [{ $ref: "#/components/parameters/DocumentId" }]
get:
operationId: getStudioDocument
tags: [Documents]
summary: Get a working copy and its current state
responses:
"200": { description: Working-copy detail, content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyDetailEnvelope" } } } }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
"404": { $ref: "#/components/responses/DocumentNotFound" }
"503": { $ref: "#/components/responses/StudioUnavailable" }
put:
operationId: saveStudioDocument
tags: [Documents]
summary: Save a full working copy
description: |
편집 가능한 content field만 저장한다. lifecycle 전이는 명시적인 Domain
Action이 담당한다. 저장은 Public Projection을 변경하지 않는다.
`expectedVersion` 불일치는 `VERSION_CONFLICT`이며 응답 `error.details`
(`VersionConflictDetails`)의 `latestDocument`로 현재 상태를 함께 제공한다.
parameters:
- { $ref: "#/components/parameters/IdempotencyKey" }
- { $ref: "#/components/parameters/CsrfToken" }
requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/SaveDocumentCommand" } } } }
responses:
"200":
description: Saved working-copy detail
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
content: { application/json: { schema: { $ref: "#/components/schemas/WorkingCopyDetailEnvelope" } } }
"400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
"404": { $ref: "#/components/responses/DocumentNotFound" }
"409": { $ref: "#/components/responses/CommandConflict" }
"422": { $ref: "#/components/responses/RequestValidationFailed" }
"503": { $ref: "#/components/responses/StudioUnavailable" }
/api/v1/studio/documents/{documentId}/validate:
parameters: [{ $ref: "#/components/parameters/DocumentId" }]
post:
operationId: validateStudioDocument
tags: [Validation]
summary: Validate a saved working copy
description: |
단순 request validation이 아니다. 다음 체인을 모두 수행한다.
```text
Schema/Input Validation
→ 유형별 Domain Validation
→ 관계/Project/Topic 존재 검증
→ Asset READY 검증
→ Slug/Route 충돌 검증
→ Publication Validation
```
결과는 일급 artifact인 `ValidationReport`로 영속되며 `validationId`로
`createStudioPreview`와 `publishStudioDocument`가 이를 참조한다.
parameters:
- { $ref: "#/components/parameters/IdempotencyKey" }
- { $ref: "#/components/parameters/CsrfToken" }
requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/ValidateDocumentCommand" } } } }
responses:
"200":
description: Validation report
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
content: { application/json: { schema: { $ref: "#/components/schemas/ValidationReportEnvelope" } } }
"400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
"404": { $ref: "#/components/responses/DocumentNotFound" }
"409": { $ref: "#/components/responses/CommandConflict" }
"422": { $ref: "#/components/responses/RequestValidationFailed" }
"503": { $ref: "#/components/responses/StudioUnavailable" }
/api/v1/studio/documents/{documentId}/preview:
parameters: [{ $ref: "#/components/parameters/DocumentId" }]
get:
operationId: getCurrentStudioPreview
tags: [Preview]
summary: Get the latest preview and its computed state
description: |
Preview 상태(`CURRENT`/`STALE`/`EXPIRED`)는 서버가 계산한다.
anonymous `/preview/{token}` 계약은 폐기되었다. Preview는 인증된
Studio API로만 조회한다.
responses:
"200": { description: Preview detail, content: { application/json: { schema: { $ref: "#/components/schemas/PreviewDetailEnvelope" } } } }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
"404": { $ref: "#/components/responses/PreviewNotFound" }
"503": { $ref: "#/components/responses/StudioUnavailable" }
post:
operationId: createStudioPreview
tags: [Preview]
summary: Create a public-layout preview
description: |
저장된 working version + validationId + dependency revision을 묶어
`PublicRenderModel` snapshot을 만든다. Preview는 Public과 동일한
semantic renderer와 Asset resolver를 사용한다.
parameters:
- { $ref: "#/components/parameters/IdempotencyKey" }
- { $ref: "#/components/parameters/CsrfToken" }
requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/CreatePreviewCommand" } } } }
responses:
"201":
description: Created preview
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
content: { application/json: { schema: { $ref: "#/components/schemas/PublicPreviewEnvelope" } } }
"400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
"404": { $ref: "#/components/responses/DocumentNotFound" }
"409": { $ref: "#/components/responses/CommandConflict" }
"422": { $ref: "#/components/responses/PreviewRejected" }
"503": { $ref: "#/components/responses/StudioUnavailable" }
/api/v1/studio/documents/{documentId}/publish:
parameters: [{ $ref: "#/components/parameters/DocumentId" }]
post:
operationId: publishStudioDocument
tags: [Publication]
summary: Publish or republish a validated preview
description: |
하나의 transaction으로 다음을 수행한다.
```text
1. source version lock/check
2. validationId / current dependency revision 검증
3. previewId / current dependency revision 검증
4. warning acknowledgement 검증
5. 유형별 publication validation
6. Publication Event 생성 (PUBLISHED | REPUBLISHED)
7. Publication Snapshot 생성 (immutable)
8. public_resource_projection 교체
9. public_route 교체 / alias 처리
10. public relation/tag/project projection 교체
11. published Asset reference 교체
12. Publication Aggregate 갱신
13. commit
```
재시도가 중복 Publication Event를 만들면 안 된다. `Idempotency-Key`로
최초 결과를 재생한다.
parameters:
- { $ref: "#/components/parameters/IdempotencyKey" }
- { $ref: "#/components/parameters/CsrfToken" }
requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/PublishDocumentCommand" } } } }
responses:
"200":
description: Publication aggregate and immutable event
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
content: { application/json: { schema: { $ref: "#/components/schemas/PublishResultEnvelope" } } }
"400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
"404": { $ref: "#/components/responses/DocumentNotFound" }
"409": { $ref: "#/components/responses/CommandConflict" }
"422": { $ref: "#/components/responses/PublishRejected" }
"503": { $ref: "#/components/responses/StudioUnavailable" }
/api/v1/studio/publications:
get:
operationId: listStudioPublications
tags: [Publication]
summary: List immutable publication events
description: Events are ordered by occurredAt DESC, then publicationEventId.
parameters:
- { $ref: "#/components/parameters/Query" }
- { $ref: "#/components/parameters/PublicationEventType" }
- { $ref: "#/components/parameters/Cursor" }
- { $ref: "#/components/parameters/Limit" }
responses:
"200": { description: Publication cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/PublicationPageEnvelope" } } } }
"400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
"422": { $ref: "#/components/responses/RequestValidationFailed" }
"503": { $ref: "#/components/responses/StudioUnavailable" }
/api/v1/studio/publications/{publicationId}/unpublish:
parameters: [{ $ref: "#/components/parameters/PublicationId" }]
post:
operationId: unpublishStudioPublication
tags: [Publication]
summary: Unpublish the current publication
description: |
`UNPUBLISHED` Event를 생성하고 current projection을 `WITHDRAWN`으로 바꾼다.
canonical route ownership/history는 보존한다.
과거 Snapshot은 삭제하거나 재계산하지 않는다.
Backend 내부에서 working copy가 `DRAFT`로 되돌아가는 lifecycle 전이는
유지하되 이 API로 직접 노출하지 않는다. 응답은
`publicationStatus=UNPUBLISHED`와 다음 `nextAction`으로 표현한다.
parameters:
- { $ref: "#/components/parameters/IdempotencyKey" }
- { $ref: "#/components/parameters/CsrfToken" }
requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/UnpublishCommand" } } } }
responses:
"200":
description: Updated publication aggregate and event
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
content: { application/json: { schema: { $ref: "#/components/schemas/PublishResultEnvelope" } } }
"400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
"404": { $ref: "#/components/responses/PublicationNotFound" }
"409": { $ref: "#/components/responses/CommandConflict" }
"422": { $ref: "#/components/responses/RequestValidationFailed" }
"503": { $ref: "#/components/responses/StudioUnavailable" }
/api/v1/studio/publications/{publicationEventId}/preview:
parameters: [{ $ref: "#/components/parameters/PublicationEventId" }]
get:
operationId: getStudioPublicationSnapshot
tags: [Publication]
summary: Get an immutable publication snapshot
description: |
`PUBLISHED`/`REPUBLISHED` Event 시점에 저장된 불변 `PublicRenderModel`을
반환한다. 현재 Working Copy나 현재 Projection에서 재계산하지 않는다.
`UNPUBLISHED` Event는 자체 snapshot을 갖지 않는다. 이 경우
`sourcePublishedEventId`가 가리키는 마지막 공개 Snapshot을 사용한다.
responses:
"200": { description: Publication snapshot, content: { application/json: { schema: { $ref: "#/components/schemas/PublicationSnapshotEnvelope" } } } }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
"404": { $ref: "#/components/responses/PublicationSnapshotNotFound" }
"503": { $ref: "#/components/responses/StudioUnavailable" }
/api/v1/studio/catalog:
get:
operationId: listStudioCatalog
tags: [Catalog]
summary: List catalog entries for editor pickers
description: |
Editor picker가 사용하는 통합 read API다. Backend source는 다음과 같다.
```text
TOPIC → topic capability
PROJECT → project capability
RELATION → Case/Reference/Question/ProjectDecision 중 연결 가능한 대상
EVIDENCE → resolution/decision 근거로 사용할 수 있는 공개 기록
```
Asset 검색/업로드/metadata는 Catalog가 아니라 `/api/v1/studio/assets`가
소유한다.
parameters:
- { $ref: "#/components/parameters/CatalogType" }
- { $ref: "#/components/parameters/Query" }
- { $ref: "#/components/parameters/Cursor" }
- { $ref: "#/components/parameters/Limit" }
responses:
"200": { description: Catalog cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/CatalogPageEnvelope" } } } }
"400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
"422": { $ref: "#/components/responses/RequestValidationFailed" }
"503": { $ref: "#/components/responses/StudioUnavailable" }
/api/v1/studio/assets:
get:
operationId: listStudioAssets
tags: [Assets]
summary: List assets for the library and the editor picker
parameters:
- { $ref: "#/components/parameters/Query" }
- { $ref: "#/components/parameters/AssetKindFilter" }
- { $ref: "#/components/parameters/AssetStatusFilter" }
- { $ref: "#/components/parameters/Cursor" }
- { $ref: "#/components/parameters/Limit" }
responses:
"200": { description: Asset cursor page, content: { application/json: { schema: { $ref: "#/components/schemas/AssetPageEnvelope" } } } }
"400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
"422": { $ref: "#/components/responses/RequestValidationFailed" }
"503": { $ref: "#/components/responses/StudioUnavailable" }
post:
operationId: uploadStudioAsset
tags: [Assets]
summary: Upload an image, diagram, or attachment
description: |
MVP 업로드는 `multipart/form-data`를 사용한다. 향후 presigned/resumable로
교체하더라도 같은 Asset port 뒤에서 처리한다.
Backend는 확장자를 신뢰하지 않는다. MIME/type 검증과 크기 제한을 적용하고
검사에 실패하면 `REJECTED` 또는 `QUARANTINED`로 저장한다. `READY` 전환은
검증 완료 후에만 일어난다.
`image/svg+xml`을 지원하지만 업로드 원문을 HTML에 inline하지 않는다.
Public/Preview는 검증된 delivery URL을 `<img src>`로 렌더링한다.
parameters:
- { $ref: "#/components/parameters/IdempotencyKey" }
- { $ref: "#/components/parameters/CsrfToken" }
requestBody:
required: true
content:
multipart/form-data:
schema: { $ref: "#/components/schemas/AssetUploadForm" }
encoding:
file: { contentType: "image/png, image/jpeg, image/webp, image/gif, image/svg+xml, application/pdf" }
responses:
"201":
description: Stored asset
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
content: { application/json: { schema: { $ref: "#/components/schemas/AssetEnvelope" } } }
"400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
"409": { $ref: "#/components/responses/CommandConflict" }
"413": { $ref: "#/components/responses/PayloadTooLarge" }
"415": { $ref: "#/components/responses/UnsupportedMediaType" }
"422": { $ref: "#/components/responses/RequestValidationFailed" }
"503": { $ref: "#/components/responses/StudioUnavailable" }
/api/v1/studio/assets/{assetId}:
parameters: [{ $ref: "#/components/parameters/AssetId" }]
get:
operationId: getStudioAsset
tags: [Assets]
summary: Get an asset with its usage
responses:
"200": { description: Asset detail, content: { application/json: { schema: { $ref: "#/components/schemas/AssetDetailEnvelope" } } } }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
"404": { $ref: "#/components/responses/AssetNotFound" }
"503": { $ref: "#/components/responses/StudioUnavailable" }
put:
operationId: updateStudioAsset
tags: [Assets]
summary: Update asset metadata
description: |
`altText`, `decorative`, `kind`와 `READY ↔ ARCHIVED` 전환만 허용한다.
`REJECTED`/`QUARANTINED`는 서버 검증 결과이며 클라이언트가 지정할 수 없다.
parameters:
- { $ref: "#/components/parameters/IdempotencyKey" }
- { $ref: "#/components/parameters/CsrfToken" }
requestBody: { required: true, content: { application/json: { schema: { $ref: "#/components/schemas/UpdateAssetCommand" } } } }
responses:
"200":
description: Updated asset
headers: { Idempotency-Replayed: { $ref: "#/components/headers/IdempotencyReplayed" } }
content: { application/json: { schema: { $ref: "#/components/schemas/AssetEnvelope" } } }
"400": { $ref: "#/components/responses/MalformedRequest" }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
"404": { $ref: "#/components/responses/AssetNotFound" }
"409": { $ref: "#/components/responses/CommandConflict" }
"422": { $ref: "#/components/responses/AssetRejected" }
"503": { $ref: "#/components/responses/StudioUnavailable" }
delete:
operationId: deleteStudioAsset
tags: [Assets]
summary: Delete an unused asset
description: |
공개 이력이 있는 Asset과 사용 중인 Asset은 hard delete하지 않는다.
두 경우 모두 `ASSET_IN_USE`로 거절하고 `ARCHIVED` 전환을 사용한다.
parameters:
- { $ref: "#/components/parameters/IdempotencyKey" }
- { $ref: "#/components/parameters/CsrfToken" }
responses:
"204": { description: Deleted }
"401": { $ref: "#/components/responses/AuthenticationRequired" }
"403": { $ref: "#/components/responses/AccessDenied" }
"404": { $ref: "#/components/responses/AssetNotFound" }
"409": { $ref: "#/components/responses/CommandConflict" }
"503": { $ref: "#/components/responses/StudioUnavailable" }
components:
securitySchemes:
sessionCookie:
type: apiKey
in: cookie
name: TECHLOG_SESSION
description: |
Backend Session Cookie. `TECH_LOG_ADMIN` 권한이 필요하다.
mutating operation은 추가로 `X-CSRF-TOKEN` header를 요구한다.
parameters:
DocumentId: { name: documentId, in: path, required: true, schema: { type: string, format: uuid } }
PublicationId: { name: publicationId, in: path, required: true, schema: { type: string, format: uuid } }
PublicationEventId: { name: publicationEventId, in: path, required: true, schema: { type: string, format: uuid } }
AssetId: { name: assetId, in: path, required: true, schema: { type: string, format: uuid } }
IdempotencyKey:
name: Idempotency-Key
in: header
required: true
description: |
동일 key + 동일 normalized request는 최초 결과를 재생한다.
동일 key + 다른 request는 `IDEMPOTENCY_KEY_REUSED` conflict다.
key 원문을 로그에 남길 때 요청 본문의 민감정보가 함께 기록되지 않게 한다.
schema: { type: string, minLength: 1, maxLength: 200 }
CsrfToken:
name: X-CSRF-TOKEN
in: header
required: true
description: "`getStudioSession`이 발급한 CSRF 토큰."
schema: { type: string, minLength: 1, maxLength: 200 }
Query: { name: q, in: query, description: Free-text query normalized as part of the opaque cursor, schema: { type: string, maxLength: 100 } }
Cursor: { name: cursor, in: query, description: Opaque cursor bound to normalized filters and sort, schema: { type: string, minLength: 1, maxLength: 2000 } }
Limit: { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } }
DocumentKind: { name: kind, in: query, schema: { $ref: "#/components/schemas/RecordKind" } }
PublicationStatus: { name: publicationStatus, in: query, schema: { $ref: "#/components/schemas/PublicationStatus" } }
NextAction: { name: nextAction, in: query, schema: { $ref: "#/components/schemas/NextAction" } }
ProjectId: { name: projectId, in: query, schema: { type: string, format: uuid } }
DocumentSort: { name: sort, in: query, schema: { type: string, enum: [UPDATED_DESC, UPDATED_ASC, TITLE_ASC], default: UPDATED_DESC } }
PublicationEventType: { name: type, in: query, schema: { $ref: "#/components/schemas/PublicationEventType" } }
CatalogType: { name: type, in: query, required: true, schema: { $ref: "#/components/schemas/CatalogEntryType" } }
AssetKindFilter: { name: kind, in: query, schema: { $ref: "#/components/schemas/AssetKind" } }
AssetStatusFilter: { name: managementStatus, in: query, schema: { $ref: "#/components/schemas/AssetManagementStatus" } }
headers:
IdempotencyReplayed: { description: True when the original result was replayed, schema: { type: boolean } }
responses:
MalformedRequest: { description: Malformed request, x-error-codes: [REQUEST_VALIDATION_FAILED], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
AuthenticationRequired: { description: Authentication required, x-error-codes: [AUTHENTICATION_REQUIRED], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
AccessDenied: { description: Studio access denied, x-error-codes: [STUDIO_ACCESS_DENIED], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
DocumentNotFound: { description: Document not found, x-error-codes: [DOCUMENT_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
PreviewNotFound: { description: Document or preview not found, x-error-codes: [DOCUMENT_NOT_FOUND, PREVIEW_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
PublicationNotFound: { description: Publication not found, x-error-codes: [PUBLICATION_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
PublicationSnapshotNotFound: { description: Publication event or snapshot not found, x-error-codes: [PUBLICATION_EVENT_NOT_FOUND, PUBLICATION_SNAPSHOT_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
AssetNotFound: { description: Asset not found, x-error-codes: [ASSET_NOT_FOUND], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
CommandConflict:
description: |
Command conflicts with current state, freshness, or idempotency.
`ASSET_IN_USE`는 사용 중이거나 공개 이력이 있는 Asset의 hard delete 시도다.
x-error-codes: [VERSION_CONFLICT, PUBLICATION_CONFLICT, VALIDATION_STALE, PREVIEW_STALE, PREVIEW_EXPIRED, IDEMPOTENCY_KEY_REUSED, ASSET_IN_USE]
content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
RequestValidationFailed: { description: Request validation failed, x-error-codes: [REQUEST_VALIDATION_FAILED], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
PreviewRejected:
description: Preview 생성이 도메인 규칙으로 거절되었다
x-error-codes: [REQUEST_VALIDATION_FAILED, DOCUMENT_VALIDATION_FAILED, ASSET_NOT_READY, ASSET_QUARANTINED]
content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
PublishRejected:
description: |
Publication validation이 실패했다.
`WARNING_ACKNOWLEDGEMENT_REQUIRED`는 `acknowledgedWarningCodes`가
현재 Validation의 WARNING 집합을 덮지 못한 경우다.
x-error-codes: [REQUEST_VALIDATION_FAILED, DOCUMENT_VALIDATION_FAILED, WARNING_ACKNOWLEDGEMENT_REQUIRED, ASSET_NOT_READY, ASSET_QUARANTINED]
content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
AssetRejected:
description: Asset metadata 변경이 거절되었다
x-error-codes: [REQUEST_VALIDATION_FAILED, ASSET_NOT_READY, ASSET_QUARANTINED]
content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } }
PayloadTooLarge: { description: Upload exceeds the configured size limit, x-error-codes: [PAYLOAD_TOO_LARGE], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
UnsupportedMediaType: { description: Unsupported media type, x-error-codes: [UNSUPPORTED_MEDIA_TYPE], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
StudioUnavailable: { description: Studio unavailable, x-error-codes: [STUDIO_UNAVAILABLE], content: { application/json: { schema: { $ref: "#/components/schemas/ErrorEnvelope" } } } }
schemas:
# ------------------------------------------------------------- envelope
# wire format은 봉투다 (ADR-006). payload 스키마는 그대로 두고
# 응답만 <Payload>Envelope으로 감싼다.
ResponseMeta:
type: object
additionalProperties: false
required: [requestId, traceId]
properties:
requestId: { type: string, minLength: 1, maxLength: 200 }
traceId: { type: string, minLength: 1, maxLength: 200 }
correlationId: { type: [string, "null"], maxLength: 200 }
page: { type: ["object", "null"], additionalProperties: true, description: "Studio는 body 안 cursor 페이지네이션을 쓰므로 항상 null이다. 백엔드 템플릿의 ResponseMeta record가 이 필드를 직렬화한다." }
ApiError:
type: object
additionalProperties: false
required: [code, category, message, retryable]
properties:
code:
type: string
enum: [AUTHENTICATION_REQUIRED, STUDIO_ACCESS_DENIED, DOCUMENT_NOT_FOUND, VERSION_CONFLICT,
REQUEST_VALIDATION_FAILED, DOCUMENT_VALIDATION_FAILED, VALIDATION_STALE, PREVIEW_NOT_FOUND,
PREVIEW_STALE, PREVIEW_EXPIRED, PUBLICATION_NOT_FOUND, PUBLICATION_CONFLICT,
PUBLICATION_EVENT_NOT_FOUND, PUBLICATION_SNAPSHOT_NOT_FOUND,
WARNING_ACKNOWLEDGEMENT_REQUIRED, IDEMPOTENCY_KEY_REUSED, ASSET_NOT_FOUND,
ASSET_NOT_READY, ASSET_IN_USE, ASSET_QUARANTINED, PAYLOAD_TOO_LARGE,
UNSUPPORTED_MEDIA_TYPE, STUDIO_UNAVAILABLE]
category:
type: string
enum: [VALIDATION, AUTH, AUTHZ, NOT_FOUND, CONFLICT, RATE_LIMIT,
TRANSIENT_DEPENDENCY, PERMANENT_DEPENDENCY, DATA_INTEGRITY, INTERNAL]
message: { type: string, minLength: 1, maxLength: 5000 }
retryable: { type: boolean }
details:
oneOf:
- $ref: "#/components/schemas/ValidationErrorDetails"
- $ref: "#/components/schemas/VersionConflictDetails"
- $ref: "#/components/schemas/PublicationConflictDetails"
- type: "null"
ErrorEnvelope:
type: object
additionalProperties: false
required: [success, error, meta]
properties:
success: { type: boolean, const: false }
error: { $ref: "#/components/schemas/ApiError" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
ValidationErrorDetails:
type: object
additionalProperties: false
required: [fieldErrors]
properties:
fieldErrors: { type: array, maxItems: 200, items: { $ref: "#/components/schemas/FieldError" } }
VersionConflictDetails:
type: object
additionalProperties: false
required: [latestDocument]
properties:
latestDocument: { $ref: "#/components/schemas/WorkingCopyDetail" }
conflictingFields:
type: array
uniqueItems: true
maxItems: 200
items: { type: string, pattern: "^(?:/(?:[^~/]|~0|~1)*)*$" }
PublicationConflictDetails:
type: object
additionalProperties: false
required: [latestPublication]
properties:
latestPublication: { $ref: "#/components/schemas/PublicationAggregate" }
StudioSessionEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/StudioSession" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
StudioDashboardEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/StudioDashboard" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
DocumentPageEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/DocumentPage" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
WorkingCopyDetailEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/WorkingCopyDetail" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
WorkingCopyEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/WorkingCopy" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
ValidationReportEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/ValidationReport" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
PreviewDetailEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/PreviewDetail" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
PublicPreviewEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/PublicPreview" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
PublishResultEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/PublishResult" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
PublicationPageEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/PublicationPage" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
PublicationSnapshotEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/PublicationSnapshot" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
CatalogPageEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/CatalogPage" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
AssetPageEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/AssetPage" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
AssetDetailEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/AssetDetail" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
AssetEnvelope:
type: object
additionalProperties: false
required: [success, data, meta]
properties:
success: { type: boolean, const: true }
data: { $ref: "#/components/schemas/Asset" }
meta: { $ref: "#/components/schemas/ResponseMeta" }
# ---------------------------------------------------------------- session
StudioSession:
type: object
additionalProperties: false
required: [authenticated, displayName, roles, csrfToken, csrfHeaderName]
properties:
authenticated: { type: boolean }
displayName: { type: string, minLength: 1, maxLength: 120 }
roles: { type: array, uniqueItems: true, maxItems: 20, items: { type: string, minLength: 1, maxLength: 60 } }
csrfToken: { type: string, minLength: 1, maxLength: 200 }
csrfHeaderName: { type: string, const: X-CSRF-TOKEN }
# ------------------------------------------------------------ enumerations
RecordKind:
type: string
enum: [CASE, REFERENCE, QUESTION, PROJECT_DECISION]
description: |
Studio 편집 대상 유형. API projection discriminator이며 Domain Aggregate가
아니다. `PROJECT_DECISION`은 `ProjectDecision` capability로 dispatch된다.
PublicationStatus: { type: string, enum: [NEVER_PUBLISHED, PUBLISHED, UNPUBLISHED] }
NextAction:
type: string
enum: [CONTINUE_EDITING, VALIDATE, FIX_VALIDATION, CREATE_PREVIEW, PUBLISH, NONE]
description: |
서버가 계산하는 Studio projection이다. Domain state machine이 아니며
`workflow_status` 같은 domain 컬럼에 저장하지 않는다.
# --------------------------------------------------------- shared fragments
RelationInput:
type: object
additionalProperties: false
required: [id, targetId, reason, order]
properties:
id: { type: [string, "null"], format: uuid }
targetId: { type: [string, "null"], format: uuid }
reason: { type: string, maxLength: 100000 }
order: { type: integer, minimum: 0 }
Relation:
type: object
additionalProperties: false
required: [id, targetId, reason, order]
properties:
id: { type: string, format: uuid }
targetId: { type: string, format: uuid }
reason: { type: string, maxLength: 100000 }
order: { type: integer, minimum: 0 }
OrderedText:
type: object
additionalProperties: false
required: [id, text, order]
properties:
id: { type: string, format: uuid }
text: { type: string, minLength: 1, maxLength: 100000 }
order: { type: integer, minimum: 0 }
ReferenceRule:
type: object
additionalProperties: false
required: [id, title, body, order]
properties:
id: { type: string, format: uuid }
title: { type: string, minLength: 1, maxLength: 120 }
body: { type: string, minLength: 1, maxLength: 100000 }
order: { type: integer, minimum: 0 }
QuestionOption:
type: object
additionalProperties: false
required: [id, title, description, order]
properties:
id: { type: string, format: uuid }
title: { type: string, minLength: 1, maxLength: 120 }
description: { type: string, maxLength: 100000 }
order: { type: integer, minimum: 0 }
QuestionResolution:
type: object
additionalProperties: false
required: [summary, evidenceTargetId, linkLabel]
properties:
summary: { type: string, maxLength: 100000 }
evidenceTargetId: { type: [string, "null"], format: uuid }
linkLabel: { type: string, maxLength: 120 }
# -------------------------------------------------------- working copy input
WorkingCopyInputBase:
type: object
description: |
불완전한 초안도 저장할 수 있어야 하므로 필드는 required이되 빈 값과 null을
허용한다. 게시 가능 여부는 `validateStudioDocument`가 판단한다.
required: [kind, title, slug, summary, topicId, projectId, relations]
properties:
kind: { $ref: "#/components/schemas/RecordKind" }
title: { type: string, maxLength: 120 }
slug:
oneOf:
- { type: string, const: "" }
- { type: string, minLength: 3, maxLength: 100, pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$" }
summary: { type: string, maxLength: 300 }
topicId: { type: [string, "null"], format: uuid }
projectId:
type: [string, "null"]
format: uuid
description: "`kind=PROJECT_DECISION`은 게시 시점에 non-null이어야 한다. 저장 시점에는 강제하지 않는다."
relations: { type: array, maxItems: 20, items: { $ref: "#/components/schemas/RelationInput" } }
CaseInput:
unevaluatedProperties: false
allOf:
- { $ref: "#/components/schemas/WorkingCopyInputBase" }
- type: object
required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyMarkdown]
properties:
kind: { type: string, enum: [CASE] }
problem: { type: string, maxLength: 100000 }
conclusion: { type: string, maxLength: 100000 }
environment: { type: string, maxLength: 100000 }
reproduction: { type: string, maxLength: 100000 }
lastVerifiedOn: { type: [string, "null"], format: date }
bodyMarkdown:
type: string
maxLength: 100000
description: |
Markdown 원문. Asset은 `:::evidence key="<assetKey>"` directive로
참조한다. object storage URL을 원문에 직접 저장하지 않는다.
ReferenceInput:
unevaluatedProperties: false
allOf:
- { $ref: "#/components/schemas/WorkingCopyInputBase" }
- type: object
required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn]
properties:
kind: { type: string, enum: [REFERENCE] }
purpose: { type: string, maxLength: 100000 }
rules: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } }
applyWhen: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
exceptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
examples: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
verifiedOn: { type: [string, "null"], format: date }
QuestionInput:
unevaluatedProperties: false
allOf:
- { $ref: "#/components/schemas/WorkingCopyInputBase" }
- type: object
# `resolution` 은 여기 없다. 미해결 질문에는 해결 내용이 없고, 그것을 required 로 두면
# Java 생성기가 nullable 여부와 무관하게 @NotNull 을 찍는다 — oneOf 로 적은 null 을 그
# 생성기는 읽지 못한다. 실제로 그래서 Question 작업본을 만들 수 없었다: 프론트가 계약대로
# resolution: null 을 보냈고 백엔드가 422 로 거절했다. 같은 목록의 `questionStatus` 가
# 통과하는 것은 그쪽이 nullability 를 `type: [string, "null"]` 로 적었기 때문이다.
required: [kind, questionStatus, facts, assumptions, unknowns, constraints, options, nextValidation]
properties:
kind: { type: string, enum: [QUESTION] }
questionStatus:
type: [string, "null"]
enum: [OPEN, RESOLVED, null]
description: |
Backend Inquiry lifecycle의 축약 view다.
`OPEN`은 Domain의 `OPEN`/`INVESTIGATING`/`PAUSED`를 모두 대표하므로
저장 시 Domain 상태를 `OPEN`으로 덮어쓰지 않는다.
`RESOLVED`로의 변경만 Resolve command로 해석하며 기존 resolve
invariant를 통과해야 한다.
facts: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
assumptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
unknowns: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
constraints: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
options: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/QuestionOption" } }
nextValidation: { type: string, maxLength: 100000 }
resolution:
oneOf:
- { $ref: "#/components/schemas/QuestionResolution" }
- { type: "null" }
ProjectDecisionInput:
unevaluatedProperties: false
allOf:
- { $ref: "#/components/schemas/WorkingCopyInputBase" }
- type: object
required: [kind, decisionStatus, decidedOn, statement, rationale, consequences]
properties:
kind: { type: string, enum: [PROJECT_DECISION] }
decisionStatus:
type: [string, "null"]
enum: [PROPOSED, ADOPTED, null]
description: |
UI 용어다. Backend Domain의 `ACCEPTED`/`ADOPTED` 명칭이 다르면
mapper에서 변환하고 Domain enum을 UI 용어 때문에 변경하지 않는다.
`supersede`/`reject`는 secondary management 계약이 소유한다.
decidedOn: { type: [string, "null"], format: date }
statement: { type: string, maxLength: 100000 }
rationale: { type: string, maxLength: 100000 }
consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
WorkingCopyInput:
oneOf:
- { $ref: "#/components/schemas/CaseInput" }
- { $ref: "#/components/schemas/ReferenceInput" }
- { $ref: "#/components/schemas/QuestionInput" }
- { $ref: "#/components/schemas/ProjectDecisionInput" }
discriminator:
propertyName: kind
mapping:
CASE: "#/components/schemas/CaseInput"
REFERENCE: "#/components/schemas/ReferenceInput"
QUESTION: "#/components/schemas/QuestionInput"
PROJECT_DECISION: "#/components/schemas/ProjectDecisionInput"
# ------------------------------------------------------- working copy output
WorkingCopyBase:
allOf:
- { $ref: "#/components/schemas/WorkingCopyInputBase" }
- type: object
required: [id, version, relations, updatedAt]
properties:
id:
type: string
format: uuid
description: source aggregate id를 그대로 사용한다. 별도 Studio surrogate id를 만들지 않는다.
version: { type: integer, minimum: 1 }
relations: { type: array, maxItems: 20, items: { $ref: "#/components/schemas/Relation" } }
updatedAt: { type: string, format: date-time }
CaseWorkingCopy:
unevaluatedProperties: false
allOf:
- { $ref: "#/components/schemas/WorkingCopyBase" }
- type: object
required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyMarkdown]
properties:
kind: { type: string, enum: [CASE] }
problem: { type: string, maxLength: 100000 }
conclusion: { type: string, maxLength: 100000 }
environment: { type: string, maxLength: 100000 }
reproduction: { type: string, maxLength: 100000 }
lastVerifiedOn: { type: [string, "null"], format: date }
bodyMarkdown: { type: string, maxLength: 100000 }
ReferenceWorkingCopy:
unevaluatedProperties: false
allOf:
- { $ref: "#/components/schemas/WorkingCopyBase" }
- type: object
required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn]
properties:
kind: { type: string, enum: [REFERENCE] }
purpose: { type: string, maxLength: 100000 }
rules: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } }
applyWhen: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
exceptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
examples: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
verifiedOn: { type: [string, "null"], format: date }
QuestionWorkingCopy:
unevaluatedProperties: false
allOf:
- { $ref: "#/components/schemas/WorkingCopyBase" }
- type: object
required: [kind, questionStatus, facts, assumptions, unknowns, constraints, options, nextValidation, resolution]
properties:
kind: { type: string, enum: [QUESTION] }
questionStatus: { type: [string, "null"], enum: [OPEN, RESOLVED, null] }
facts: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
assumptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
unknowns: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
constraints: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
options: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/QuestionOption" } }
nextValidation: { type: string, maxLength: 100000 }
resolution:
oneOf:
- { $ref: "#/components/schemas/QuestionResolution" }
- { type: "null" }
ProjectDecisionWorkingCopy:
unevaluatedProperties: false
allOf:
- { $ref: "#/components/schemas/WorkingCopyBase" }
- type: object
required: [kind, decisionStatus, decidedOn, statement, rationale, consequences]
properties:
kind: { type: string, enum: [PROJECT_DECISION] }
decisionStatus: { type: [string, "null"], enum: [PROPOSED, ADOPTED, null] }
decidedOn: { type: [string, "null"], format: date }
statement: { type: string, maxLength: 100000 }
rationale: { type: string, maxLength: 100000 }
consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
WorkingCopy:
description: |
API union이다. DB에 `working_copy` 범용 테이블을 만들지 않는다.
```text
WorkingCopy
= CaseWorkingCopy
| ReferenceWorkingCopy
| QuestionWorkingCopy
| ProjectDecisionWorkingCopy
```
oneOf:
- { $ref: "#/components/schemas/CaseWorkingCopy" }
- { $ref: "#/components/schemas/ReferenceWorkingCopy" }
- { $ref: "#/components/schemas/QuestionWorkingCopy" }
- { $ref: "#/components/schemas/ProjectDecisionWorkingCopy" }
discriminator:
propertyName: kind
mapping:
CASE: "#/components/schemas/CaseWorkingCopy"
REFERENCE: "#/components/schemas/ReferenceWorkingCopy"
QUESTION: "#/components/schemas/QuestionWorkingCopy"
PROJECT_DECISION: "#/components/schemas/ProjectDecisionWorkingCopy"
# -------------------------------------------------------------- commands
CreateDocumentInput: { $ref: "#/components/schemas/WorkingCopyInput" }
SaveDocumentCommand:
type: object
additionalProperties: false
required: [expectedVersion, document]
properties:
expectedVersion: { type: integer, minimum: 1 }
document: { $ref: "#/components/schemas/WorkingCopyInput" }
ValidateDocumentCommand:
type: object
additionalProperties: false
required: [expectedVersion]
properties: { expectedVersion: { type: integer, minimum: 1 } }
CreatePreviewCommand:
type: object
additionalProperties: false
required: [expectedVersion, validationId]
properties:
expectedVersion: { type: integer, minimum: 1 }
validationId: { type: string, format: uuid }
PublishDocumentCommand:
type: object
additionalProperties: false
required: [expectedVersion, validationId, previewId, acknowledgedWarningCodes]
properties:
expectedVersion: { type: integer, minimum: 1 }
validationId: { type: string, format: uuid }
previewId: { type: string, format: uuid }
acknowledgedWarningCodes:
type: array
uniqueItems: true
maxItems: 200
items: { type: string, minLength: 1, maxLength: 100 }
description: 현재 Validation의 WARNING code 집합을 모두 덮지 못하면 `WARNING_ACKNOWLEDGEMENT_REQUIRED`로 거절한다.
UnpublishCommand:
type: object
additionalProperties: false
required: [expectedPublicationRevision]
properties: { expectedPublicationRevision: { type: integer, minimum: 1 } }
# ------------------------------------------------------------- validation
ValidationIssue:
type: object
additionalProperties: false
required: [code, severity, path, message]
properties:
code: { type: string, minLength: 1, maxLength: 100 }
severity: { type: string, enum: [ERROR, WARNING] }
path:
type: string
pattern: "^(?:/(?:[^~/]|~0|~1)*)*$"
description: JSON Pointer to the affected field
message: { type: string, minLength: 1, maxLength: 1000 }
ValidationReport:
type: object
additionalProperties: false
description: |
일급 application artifact다. 실행 결과를 그때그때 반환하고 버리지 않고
`studio_validation`에 영속한다.
required: [validationId, documentId, validatedVersion, status, issues, validatedAt, validUntil, dependencyRevision]
properties:
validationId: { type: string, format: uuid }
documentId: { type: string, format: uuid }
validatedVersion: { type: integer, minimum: 1 }
status: { type: string, enum: [INVALID, WARNINGS, VALID] }
issues: { type: array, maxItems: 200, items: { $ref: "#/components/schemas/ValidationIssue" } }
validatedAt: { type: string, format: date-time }
validUntil: { type: string, format: date-time }
dependencyRevision: { $ref: "#/components/schemas/DependencyRevision" }
DependencyRevision:
type: string
minLength: 1
maxLength: 200
description: |
검증 결과에 영향을 주는 외부 의존 상태를 대표하는 값이다.
```text
Topic/Project 존재와 publishability
relation target 상태
Asset READY/QUARANTINED 상태
slug/route ownership
catalog revision
필요 시 renderer/content-format version
```
모든 테이블의 global counter일 필요는 없다. 검증에 사용한 dependency
identity/version을 정규화해 hash로 만들 수 있다. Publish 시 동일
dependency set을 다시 계산해 값이 다르면 `VALIDATION_STALE`로 거절한다.
# ------------------------------------------------------- public render model
DisplayTarget:
type: object
additionalProperties: false
required: [id, label, publicPath]
properties:
id: { type: string, format: uuid }
label: { type: string, minLength: 1, maxLength: 200 }
publicPath: { type: [string, "null"], maxLength: 500 }
ResolvedRelation:
type: object
additionalProperties: false
required: [id, targetId, targetKind, title, publicPath, reason, order]
properties:
id: { type: string, format: uuid }
targetId: { type: string, format: uuid }
targetKind: { type: string, enum: [CASE, REFERENCE, QUESTION, PROJECT, PROJECT_DECISION] }
title: { type: string, minLength: 1, maxLength: 200 }
publicPath: { type: [string, "null"], maxLength: 500 }
reason: { type: string, maxLength: 100000 }
order: { type: integer, minimum: 0 }
RenderContext:
type: object
additionalProperties: false
required: [generatedAt, dependencyRevision]
properties:
generatedAt: { type: string, format: date-time }
dependencyRevision: { $ref: "#/components/schemas/DependencyRevision" }
PublicRenderModelBase:
type: object
required: [kind, slug, title, summary, publicPath, topic, project, relations, renderContext]
properties:
kind: { $ref: "#/components/schemas/RecordKind" }
slug: { type: string, minLength: 3, maxLength: 100, pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$" }
title: { type: string, minLength: 1, maxLength: 120 }
summary: { type: string, minLength: 1, maxLength: 300 }
publicPath: { type: string, minLength: 1, maxLength: 500 }
topic: { $ref: "#/components/schemas/DisplayTarget" }
project:
oneOf:
- { $ref: "#/components/schemas/DisplayTarget" }
- { type: "null" }
relations: { type: array, maxItems: 20, items: { $ref: "#/components/schemas/ResolvedRelation" } }
renderContext: { $ref: "#/components/schemas/RenderContext" }
InlineText:
type: object
additionalProperties: false
required: [type, text]
properties:
type: { type: string, enum: [TEXT] }
text: { type: string, minLength: 1, maxLength: 100000 }
InlineContainer:
type: object
required: [type, children]
properties:
type: { type: string, enum: [EMPHASIS, STRONG] }
children: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
InlineCode:
type: object
additionalProperties: false
required: [type, code]
properties:
type: { type: string, enum: [INLINE_CODE] }
code: { type: string, minLength: 1, maxLength: 100000 }
InlineLink:
type: object
additionalProperties: false
required: [type, label, href]
properties:
type: { type: string, enum: [LINK] }
label: { type: string, minLength: 1, maxLength: 100000 }
href: { type: string, format: uri, maxLength: 2000 }
InlineStatus:
type: object
additionalProperties: false
required: [type, label, tone]
properties:
type: { type: string, enum: [STATUS] }
label: { type: string, minLength: 1, maxLength: 120 }
tone: { type: string, enum: [warning, evidence, neutral] }
Inline:
oneOf:
- { $ref: "#/components/schemas/InlineText" }
- { $ref: "#/components/schemas/InlineEmphasis" }
- { $ref: "#/components/schemas/InlineStrong" }
- { $ref: "#/components/schemas/InlineCode" }
- { $ref: "#/components/schemas/InlineLink" }
- { $ref: "#/components/schemas/InlineStatus" }
discriminator:
propertyName: type
mapping:
TEXT: "#/components/schemas/InlineText"
EMPHASIS: "#/components/schemas/InlineEmphasis"
STRONG: "#/components/schemas/InlineStrong"
INLINE_CODE: "#/components/schemas/InlineCode"
LINK: "#/components/schemas/InlineLink"
STATUS: "#/components/schemas/InlineStatus"
InlineEmphasis:
unevaluatedProperties: false
allOf:
- { $ref: "#/components/schemas/InlineContainer" }
- { type: object, properties: { type: { type: string, enum: [EMPHASIS] } } }
InlineStrong:
unevaluatedProperties: false
allOf:
- { $ref: "#/components/schemas/InlineContainer" }
- { type: object, properties: { type: { type: string, enum: [STRONG] } } }
HeadingBlock:
type: object
additionalProperties: false
required: [type, id, level, content]
properties:
type: { type: string, enum: [HEADING] }
id: { type: string, minLength: 1, maxLength: 200 }
level: { type: integer, minimum: 2, maximum: 4 }
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
ParagraphBlock:
type: object
additionalProperties: false
required: [type, content]
properties:
type: { type: string, enum: [PARAGRAPH] }
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
BlockquoteBlock:
type: object
additionalProperties: false
required: [type, content]
properties:
type: { type: string, enum: [BLOCKQUOTE] }
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
ListItem:
type: object
additionalProperties: false
required: [id, content]
properties:
id: { type: string, minLength: 1, maxLength: 200 }
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
ListBlockBase:
type: object
required: [type, items]
properties:
type: { type: string, enum: [UNORDERED_LIST, ORDERED_LIST] }
items: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/ListItem" } }
UnorderedListBlock:
unevaluatedProperties: false
allOf:
- { $ref: "#/components/schemas/ListBlockBase" }
- { type: object, properties: { type: { type: string, enum: [UNORDERED_LIST] } } }
OrderedListBlock:
unevaluatedProperties: false
allOf:
- { $ref: "#/components/schemas/ListBlockBase" }
- { type: object, properties: { type: { type: string, enum: [ORDERED_LIST] } } }
CodeBlock:
type: object
additionalProperties: false
required: [type, code, language, label]
properties:
type: { type: string, enum: [CODE_BLOCK] }
code: { type: string, maxLength: 100000 }
language: { type: [string, "null"], maxLength: 100 }
label: { type: [string, "null"], maxLength: 200 }
DataTableColumn:
type: object
additionalProperties: false
required: [id, label, alignment]
properties:
id: { type: string, minLength: 1, maxLength: 200 }
label: { type: string, minLength: 1, maxLength: 500 }
alignment: { type: string, enum: [LEFT, CENTER, RIGHT] }
DataTableCell:
type: object
additionalProperties: false
required: [columnId, content]
properties:
columnId: { type: string, minLength: 1, maxLength: 200 }
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
DataTableRow:
type: object
additionalProperties: false
required: [id, cells]
properties:
id: { type: string, minLength: 1, maxLength: 200 }
cells: { type: array, maxItems: 100, items: { $ref: "#/components/schemas/DataTableCell" } }
DataTableBlock:
type: object
additionalProperties: false
required: [type, id, caption, rowHeaderColumn, columns, rows]
properties:
type: { type: string, enum: [DATA_TABLE] }
id: { type: string, minLength: 1, maxLength: 200 }
caption: { type: string, maxLength: 1000 }
rowHeaderColumn: { type: [integer, "null"], minimum: 1 }
columns: { type: array, minItems: 1, maxItems: 100, items: { $ref: "#/components/schemas/DataTableColumn" } }
rows: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/DataTableRow" } }
CalloutBlock:
type: object
additionalProperties: false
required: [type, tone, label, content]
properties:
type: { type: string, enum: [CALLOUT] }
tone: { type: string, enum: [warning, info] }
label: { type: string, maxLength: 200 }
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
EvidenceFigureBlock:
type: object
additionalProperties: false
required: [type, key, alt, caption, zoom, asset]
description: |
`key`는 managed `asset_key`다. object storage key나 raw URL이 아니다.
```text
asset_key → Asset lookup → current approved delivery path
```
`alt`는 빈 문자열을 허용한다. 빈 alt 자체를 syntax error로 차단하지 않고
Publication Validation이 Asset metadata와 함께 의미 검증한다.
```text
Asset decorative=false + 사용 위치 alt 비어 있음 → PublishValidationFailed
Asset decorative=true → alt="" 허용
```
properties:
type: { type: string, enum: [EVIDENCE_FIGURE] }
key: { type: string, minLength: 1, maxLength: 200 }
alt: { type: string, maxLength: 1000 }
caption: { type: string, maxLength: 1000 }
zoom: { type: boolean }
asset: { $ref: "#/components/schemas/ResolvedAsset" }
ResolvedAsset:
type: object
additionalProperties: false
description: |
renderer가 사용하는 Asset descriptor다. Preview/Public/Snapshot이 동일한
resolver를 통해 동일 semantic output을 만들어야 한다.
SVG도 URL 기반 `<img>`로 렌더링하며 원문을 inline하지 않는다.
required: [assetId, assetKey, mediaType, publicPath, width, height, decorative]
properties:
assetId: { type: string, format: uuid }
assetKey: { type: string, minLength: 1, maxLength: 200 }
mediaType: { type: string, minLength: 1, maxLength: 200 }
publicPath: { type: string, minLength: 1, maxLength: 500 }
width: { type: [integer, "null"], minimum: 1 }
height: { type: [integer, "null"], minimum: 1 }
decorative: { type: boolean }
CaseRenderBlock:
oneOf:
- { $ref: "#/components/schemas/HeadingBlock" }
- { $ref: "#/components/schemas/ParagraphBlock" }
- { $ref: "#/components/schemas/BlockquoteBlock" }
- { $ref: "#/components/schemas/UnorderedListBlock" }
- { $ref: "#/components/schemas/OrderedListBlock" }
- { $ref: "#/components/schemas/CodeBlock" }
- { $ref: "#/components/schemas/DataTableBlock" }
- { $ref: "#/components/schemas/CalloutBlock" }
- { $ref: "#/components/schemas/EvidenceFigureBlock" }
discriminator:
propertyName: type
mapping:
HEADING: "#/components/schemas/HeadingBlock"
PARAGRAPH: "#/components/schemas/ParagraphBlock"
BLOCKQUOTE: "#/components/schemas/BlockquoteBlock"
UNORDERED_LIST: "#/components/schemas/UnorderedListBlock"
ORDERED_LIST: "#/components/schemas/OrderedListBlock"
CODE_BLOCK: "#/components/schemas/CodeBlock"
DATA_TABLE: "#/components/schemas/DataTableBlock"
CALLOUT: "#/components/schemas/CalloutBlock"
EVIDENCE_FIGURE: "#/components/schemas/EvidenceFigureBlock"
CasePublicRenderModel:
unevaluatedProperties: false
allOf:
- { $ref: "#/components/schemas/PublicRenderModelBase" }
- type: object
required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyBlocks]
properties:
kind: { type: string, enum: [CASE] }
problem: { type: string, minLength: 1, maxLength: 100000 }
conclusion: { type: string, minLength: 1, maxLength: 100000 }
environment: { type: string, maxLength: 100000 }
reproduction: { type: string, maxLength: 100000 }
lastVerifiedOn: { type: string, format: date }
bodyBlocks: { type: array, maxItems: 10000, items: { $ref: "#/components/schemas/CaseRenderBlock" } }
ReferencePublicRenderModel:
unevaluatedProperties: false
allOf:
- { $ref: "#/components/schemas/PublicRenderModelBase" }
- type: object
required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn]
properties:
kind: { type: string, enum: [REFERENCE] }
purpose: { type: string, minLength: 1, maxLength: 100000 }
rules: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } }
applyWhen: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
exceptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
examples: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
verifiedOn: { type: string, format: date }
ResolvedQuestionResolution:
type: object
additionalProperties: false
required: [summary, evidenceTarget, linkLabel]
properties:
summary: { type: string, minLength: 1, maxLength: 100000 }
evidenceTarget: { $ref: "#/components/schemas/DisplayTarget" }
linkLabel: { type: string, minLength: 1, maxLength: 120 }
QuestionPublicRenderModel:
unevaluatedProperties: false
allOf:
- { $ref: "#/components/schemas/PublicRenderModelBase" }
- type: object
required: [kind, status, facts, assumptions, unknowns, constraints, options, nextValidation, resolution]
properties:
kind: { type: string, enum: [QUESTION] }
status:
type: string
enum: [OPEN, RESOLVED]
description: 공개 표현용 축약 상태다. Domain의 `INVESTIGATING`/`PAUSED`는 `OPEN`으로 표현된다.
facts: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
assumptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
unknowns: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
constraints: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
options: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/QuestionOption" } }
nextValidation: { type: string, minLength: 1, maxLength: 100000 }
resolution:
oneOf:
- { $ref: "#/components/schemas/ResolvedQuestionResolution" }
- { type: "null" }
ProjectDecisionPublicRenderModel:
unevaluatedProperties: false
allOf:
- { $ref: "#/components/schemas/PublicRenderModelBase" }
- type: object
required: [kind, status, decidedOn, statement, rationale, consequences]
properties:
kind: { type: string, enum: [PROJECT_DECISION] }
status: { type: string, enum: [PROPOSED, ADOPTED] }
decidedOn: { type: string, format: date }
statement: { type: string, minLength: 1, maxLength: 100000 }
rationale: { type: string, minLength: 1, maxLength: 100000 }
consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
PublicRenderModel:
oneOf:
- { $ref: "#/components/schemas/CasePublicRenderModel" }
- { $ref: "#/components/schemas/ReferencePublicRenderModel" }
- { $ref: "#/components/schemas/QuestionPublicRenderModel" }
- { $ref: "#/components/schemas/ProjectDecisionPublicRenderModel" }
discriminator:
propertyName: kind
mapping:
CASE: "#/components/schemas/CasePublicRenderModel"
REFERENCE: "#/components/schemas/ReferencePublicRenderModel"
QUESTION: "#/components/schemas/QuestionPublicRenderModel"
PROJECT_DECISION: "#/components/schemas/ProjectDecisionPublicRenderModel"
# ---------------------------------------------------------------- preview
PublicPreview:
type: object
additionalProperties: false
required: [previewId, documentId, previewVersion, validationId, dependencyRevision, createdAt, expiresAt, renderModel]
properties:
previewId: { type: string, format: uuid }
documentId: { type: string, format: uuid }
previewVersion: { type: integer, minimum: 1 }
validationId: { type: string, format: uuid }
dependencyRevision: { $ref: "#/components/schemas/DependencyRevision" }
createdAt: { type: string, format: date-time }
expiresAt: { type: string, format: date-time }
renderModel: { $ref: "#/components/schemas/PublicRenderModel" }
PreviewDetail:
type: object
additionalProperties: false
required: [preview, state, currentDocumentVersion, currentValidationId]
properties:
preview: { $ref: "#/components/schemas/PublicPreview" }
state:
type: string
enum: [CURRENT, STALE, EXPIRED]
description: |
서버가 계산한다.
`STALE`은 working version 또는 dependency revision이 달라진 경우,
`EXPIRED`는 `expiresAt`이 지난 경우다.
currentDocumentVersion: { type: integer, minimum: 1 }
currentValidationId: { type: [string, "null"], format: uuid }
# ------------------------------------------------------------ publication
PublicationAggregate:
type: object
additionalProperties: false
description: 현재 게시 상태다. 게시 이력(`PublicationEvent`)과 구분한다.
required: [publicationId, documentId, status, publishedVersion, publicationRevision, latestEventId, publicPath, updatedAt]
properties:
publicationId: { type: string, format: uuid }
documentId: { type: string, format: uuid }
status: { type: string, enum: [PUBLISHED, UNPUBLISHED] }
publishedVersion: { type: integer, minimum: 1 }
publicationRevision: { type: integer, minimum: 1 }
latestEventId: { type: string, format: uuid }
publicPath: { type: string, minLength: 1, maxLength: 500 }
updatedAt: { type: string, format: date-time }
PublicationEventType: { type: string, enum: [PUBLISHED, REPUBLISHED, UNPUBLISHED] }
PublicationEvent:
type: object
additionalProperties: false
description: 불변 이력이다. 생성 후 수정하지 않는다.
required: [publicationEventId, publicationId, documentId, type, occurredAt, publishedVersion, sourcePublishedEventId, snapshotAvailable]
properties:
publicationEventId: { type: string, format: uuid }
publicationId: { type: string, format: uuid }
documentId: { type: string, format: uuid }
type: { $ref: "#/components/schemas/PublicationEventType" }
occurredAt: { type: string, format: date-time }
publishedVersion: { type: integer, minimum: 1 }
sourcePublishedEventId:
type: [string, "null"]
format: uuid
description: "`UNPUBLISHED` Event가 참조하는 마지막 공개 Snapshot의 Event id다."
snapshotAvailable: { type: boolean }
PublicationSnapshot:
type: object
additionalProperties: false
description: |
`PUBLISHED`/`REPUBLISHED` 시점의 불변 `PublicRenderModel`이다.
현재 source에서 재생성하지 않는다.
required: [event, renderModel, contentFormatVersion, rendererContractVersion]
properties:
event: { $ref: "#/components/schemas/PublicationEvent" }
renderModel: { $ref: "#/components/schemas/PublicRenderModel" }
contentFormatVersion: { type: string, minLength: 1, maxLength: 50 }
rendererContractVersion: { type: string, minLength: 1, maxLength: 50 }
PublishResult:
type: object
additionalProperties: false
required: [publication, event]
properties:
publication: { $ref: "#/components/schemas/PublicationAggregate" }
event: { $ref: "#/components/schemas/PublicationEvent" }
# ---------------------------------------------------------------- listing
DocumentSummary:
type: object
additionalProperties: false
required: [id, title, kind, project, updatedAt, publicationStatus, publishedVersion, hasUnpublishedChanges, nextAction]
properties:
id: { type: string, format: uuid }
title: { type: string, maxLength: 120 }
kind: { $ref: "#/components/schemas/RecordKind" }
project:
oneOf:
- { $ref: "#/components/schemas/DisplayTarget" }
- { type: "null" }
updatedAt: { type: string, format: date-time }
publicationStatus: { $ref: "#/components/schemas/PublicationStatus" }
publishedVersion: { type: [integer, "null"], minimum: 1 }
hasUnpublishedChanges:
type: boolean
description: "`currentWorkingVersion != currentPublication.publishedVersion`. 게시 취소 상태에서도 과거 publishedVersion과 비교한다."
nextAction: { $ref: "#/components/schemas/NextAction" }
PublicationAction: { type: string, enum: [VIEW_SNAPSHOT, VIEW_SOURCE_SNAPSHOT, UNPUBLISH] }
PublicationListItem:
type: object
additionalProperties: false
required: [event, publication, document, availableActions]
properties:
event: { $ref: "#/components/schemas/PublicationEvent" }
publication: { $ref: "#/components/schemas/PublicationAggregate" }
document: { $ref: "#/components/schemas/DocumentSummary" }
availableActions: { type: array, uniqueItems: true, maxItems: 3, items: { $ref: "#/components/schemas/PublicationAction" } }
WorkingCopyDetail:
type: object
additionalProperties: false
required: [document, currentValidation, latestPreview, currentPublication, dependencyRevision, nextAction]
properties:
document: { $ref: "#/components/schemas/WorkingCopy" }
currentValidation:
oneOf:
- { $ref: "#/components/schemas/ValidationReport" }
- { type: "null" }
latestPreview:
oneOf:
- { $ref: "#/components/schemas/PublicPreview" }
- { type: "null" }
currentPublication:
oneOf:
- { $ref: "#/components/schemas/PublicationAggregate" }
- { type: "null" }
dependencyRevision: { $ref: "#/components/schemas/DependencyRevision" }
nextAction: { $ref: "#/components/schemas/NextAction" }
StudioDashboard:
type: object
additionalProperties: false
required: [continueWriting, readyToPublish, recentPublications, totals]
properties:
continueWriting: { type: array, maxItems: 5, items: { $ref: "#/components/schemas/DocumentSummary" } }
readyToPublish: { type: array, maxItems: 5, items: { $ref: "#/components/schemas/DocumentSummary" } }
recentPublications: { type: array, maxItems: 5, items: { $ref: "#/components/schemas/PublicationListItem" } }
totals: { $ref: "#/components/schemas/DashboardTotals" }
DashboardTotals:
type: object
additionalProperties: false
required: [documents, needsValidation, readyToPublish, publications]
properties:
documents: { type: integer, minimum: 0 }
needsValidation: { type: integer, minimum: 0 }
readyToPublish: { type: integer, minimum: 0 }
publications: { type: integer, minimum: 0 }
DocumentPage:
type: object
additionalProperties: false
required: [items, nextCursor]
properties:
items: { type: array, maxItems: 100, items: { $ref: "#/components/schemas/DocumentSummary" } }
nextCursor: { type: [string, "null"], maxLength: 2000 }
PublicationPage:
type: object
additionalProperties: false
required: [items, nextCursor]
properties:
items: { type: array, maxItems: 100, items: { $ref: "#/components/schemas/PublicationListItem" } }
nextCursor: { type: [string, "null"], maxLength: 2000 }
# ---------------------------------------------------------------- catalog
CatalogEntryType: { type: string, enum: [TOPIC, PROJECT, RELATION, EVIDENCE] }
CatalogEntry:
type: object
additionalProperties: false
required: [id, type, label, dependencyRevision]
properties:
id: { type: string, format: uuid }
type: { $ref: "#/components/schemas/CatalogEntryType" }
label: { type: string, minLength: 1, maxLength: 200 }
kind: { type: string, enum: [CASE, REFERENCE, QUESTION, PROJECT, PROJECT_DECISION] }
publicPath: { type: string, minLength: 1, maxLength: 500 }
dependencyRevision: { $ref: "#/components/schemas/DependencyRevision" }
CatalogPage:
type: object
additionalProperties: false
required: [items, nextCursor]
properties:
items: { type: array, maxItems: 100, items: { $ref: "#/components/schemas/CatalogEntry" } }
nextCursor: { type: [string, "null"], maxLength: 2000 }
# ----------------------------------------------------------------- assets
AssetKind: { type: string, enum: [IMAGE, DIAGRAM, ATTACHMENT] }
AssetManagementStatus:
type: string
enum: [READY, ARCHIVED, REJECTED, QUARANTINED]
description: |
`READY`만 Public Preview/Publish에 사용할 수 있다.
`REJECTED`/`QUARANTINED`는 서버 검증 결과이며 클라이언트가 지정할 수 없다.
Asset:
type: object
additionalProperties: false
required: [id, assetKey, kind, mediaType, originalFilename, byteSize, width, height, altText, decorative, managementStatus, publicPath, usageCount, version, createdAt, updatedAt]
properties:
id: { type: string, format: uuid }
assetKey:
type: string
minLength: 1
maxLength: 200
description: |
Public content가 사용하는 안정적인 key다. object storage key나 raw URL이
아니다. immutable이며 공개 이력 이후 재사용을 금지한다.
kind: { $ref: "#/components/schemas/AssetKind" }
mediaType: { type: string, minLength: 1, maxLength: 200 }
originalFilename: { type: string, minLength: 1, maxLength: 500 }
byteSize: { type: integer, minimum: 0 }
width: { type: [integer, "null"], minimum: 1 }
height: { type: [integer, "null"], minimum: 1 }
altText: { type: [string, "null"], maxLength: 1000 }
decorative: { type: boolean }
managementStatus: { $ref: "#/components/schemas/AssetManagementStatus" }
publicPath: { type: [string, "null"], maxLength: 500 }
usageCount: { type: integer, minimum: 0 }
version: { type: integer, minimum: 1 }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
AssetUsage:
type: object
additionalProperties: false
required: [documentId, documentKind, title, published]
properties:
documentId: { type: string, format: uuid }
documentKind: { $ref: "#/components/schemas/RecordKind" }
title: { type: string, minLength: 1, maxLength: 200 }
published: { type: boolean }
AssetDetail:
type: object
additionalProperties: false
required: [asset, usages, hasPublicationHistory]
properties:
asset: { $ref: "#/components/schemas/Asset" }
usages: { type: array, maxItems: 200, items: { $ref: "#/components/schemas/AssetUsage" } }
hasPublicationHistory:
type: boolean
description: true면 hard delete를 금지하고 `ARCHIVED` 전환만 허용한다.
AssetUploadForm:
type: object
additionalProperties: false
required: [file, kind]
properties:
file: { type: string, format: binary }
kind: { $ref: "#/components/schemas/AssetKind" }
altText: { type: string, maxLength: 1000 }
decorative: { type: boolean, default: false }
UpdateAssetCommand:
type: object
additionalProperties: false
required: [expectedVersion]
properties:
expectedVersion: { type: integer, minimum: 1 }
kind: { $ref: "#/components/schemas/AssetKind" }
altText: { type: [string, "null"], maxLength: 1000 }
decorative: { type: boolean }
managementStatus: { type: string, enum: [READY, ARCHIVED] }
AssetPage:
type: object
additionalProperties: false
required: [items, nextCursor]
properties:
items: { type: array, maxItems: 100, items: { $ref: "#/components/schemas/Asset" } }
nextCursor: { type: [string, "null"], maxLength: 2000 }
# ------------------------------------------------------------------ errors
FieldError:
type: object
additionalProperties: false
required: [path, message]
properties:
path:
type: string
pattern: "^(?:/(?:[^~/]|~0|~1)*)*$"
description: JSON Pointer to the invalid field
message: { type: string, minLength: 1, maxLength: 1000 }