Files
DongHyeonkaandClaude Opus 5 862e502af3 docs(skill): GET 을 그대로 되보내면 400 이라는 것을 PUT 설명 옆에 적는다
`references/studio-api.md` 는 「서버가 주지만 보내지 않는 것: id · version ·
updatedAt」을 이미 적고 있었는데, 그 문장이 PUT 요청 모양을 설명하는 절이
아니라 한 절 아래 「종류마다의 칸」에 있었다. 2026-09-17 에 서로 다른 두
작업이 같은 자리에서 400 VALIDATION_FAILED / UnrecognizedPropertyException
을 맞았다 — 둘 다 GET 으로 받은 document 를 그대로 되보냈다.

내용을 더한 것이 아니라 PUT 을 설명하는 자리에서 그 표를 가리키게 했다.
서버 상태는 안 바뀌므로 셋을 빼고 다시 보내면 된다는 것도 적었다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 20:34:56 +09:00

181 lines
8.4 KiB
Markdown

# Studio 가 실제로 주고받는 것
여기 적은 것은 **2026-09-17 에 로그인한 브라우저에서 직접 불러 확인한 것**이다. 문서를 보고
적은 것이 아니라 요청을 보내고 돌아온 것을 옮겼다. 확인한 판이 바뀌면 이 파일도 낡는다.
## 왜 이 파일이 필요해졌나
**편집 화면의 본문이 블록 편집기로 바뀌었다.** 전에는 본문이 `본문 Markdown` 이라는 textarea
하나였고 거기에 통째로 붙여넣으면 됐다. 지금은 블록 하나가 `<textarea>` 하나다 — 기록 한 편이
블록 171개인 것을 실제로 봤다. 화면으로 그것을 채우는 것은 사람이 한 편을 쓸 때의 방식이지
쓰인 기록을 옮기는 방식이 아니다.
그런데 **저장이 서버로 보내는 것은 블록이 아니라 마크다운 문자열 하나**다. 화면은 그 문자열
위의 편집기이고, 저장 요청은 `bodyMarkdown` 한 칸을 보낸다. 그래서 옮기는 일은 화면이 아니라
이 API 로 한다.
## 인증
**자격증명을 이 저장소에 두지 않는다.** 이미 로그인된 브라우저 세션을 쓰고, 요청은 그 페이지
안에서(`fetch`) 보낸다. 쿠키가 그대로 실린다.
CSRF 토큰은 `XSRF-TOKEN` 쿠키에 있고 `x-csrf-token` 헤더로 되돌려 준다.
```js
document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/)?.[1]
```
로그인이 안 돼 있으면 `/studio` 가 「기록을 쓰려면 로그인이 필요합니다」를 낸다.
**로그인 화면을 자동으로 통과하려 들지 않는다** — 사람에게 로그인해 달라고 말하고 멈춘다.
## 응답 껍데기
성공이든 실패든 같은 모양이다.
```json
{ "success": true, "data": { }, "error": null,
"meta": { "requestId": "…", "traceId": "…", "correlationId": "…", "page": null } }
```
실패하면 `data``null` 이고 `error``{code, category, message, retryable, details}` 가 온다.
`details.supportedMethods` 처럼 고칠 방법을 담아 주는 경우가 있다.
## 경로
| 무엇 | 요청 |
|---|---|
| 목록 | `GET /api/v1/studio/documents?limit=100` |
| 한 편 | `GET /api/v1/studio/documents/{id}` |
| 새로 만들기 | `POST /api/v1/studio/documents` → 201 |
| **저장** | `PUT /api/v1/studio/documents/{id}` → 200 |
| 지우기 | `DELETE /api/v1/studio/{종류}/{id}` → 204 |
**`limit` 은 100 이 상한이다.** 200 을 주면 422 `REQUEST_VALIDATION_FAILED` 다. 더 받으려면
`data.nextCursor``cursor` 로 넘긴다.
**`/documents/{id}``PUT``GET` 만 받는다.** 거기에 `DELETE` 를 보내면 405 이고
`details.supportedMethods``["PUT","GET"]` 을 준다. 삭제는 종류별 경로다 —
`cases` · `references` · `concepts` · `setups` · `questions`, 그리고 Decision 은
`projects/{projectId}/decisions/{decisionId}`.
## 한 편 읽기
```
GET /api/v1/studio/documents/{id}
→ data: { document, currentValidation, latestPreview, currentPublication,
dependencyRevision, nextAction }
```
`nextAction` 이 다음에 할 일을 말한다 — 저장만 된 기록은 `VALIDATE` 다. 검증 화면은
`/studio/documents/{id}/validation` 이고 편집 화면의 `aside` 에는 **`저장``게시` 둘뿐**이라
검증은 그 화면에서 따로 한다.
## 저장
```
PUT /api/v1/studio/documents/{id}
headers: content-type: application/json
accept: application/json
x-csrf-token: <XSRF-TOKEN 쿠키>
idempotency-key: <요청마다 새로>
body: { "expectedVersion": <읽어 온 document.version>, "document": { … } }
```
**`expectedVersion` 은 방금 읽은 `document.version` 이다.** 저장이 끝나면 1 올라간다. 지어내지
말고 저장 직전에 `GET` 으로 읽는다.
`document`**그 종류의 칸을 전부** 담는다. 빠뜨린 칸은 비는 것으로 저장된다 — 부분 갱신이
아니다.
**다만 `GET` 으로 받은 것을 그대로 되보내면 400 이다.** `id` · `version` · `updatedAt` 은 서버가
주기만 하고 받지는 않아서, 그 셋이 들어 있으면 `VALIDATION_FAILED`
`UnrecognizedPropertyException` 으로 거절된다(2026-09-17 에 두 번 겪었다). 서버 상태는 안
바뀌니 셋을 빼고 다시 보내면 된다. 어느 칸을 보내고 어느 칸을 빼는지는 바로 아래 표에 있다.
## 종류마다의 칸
여섯이 함께 갖는 것: `kind` · `title` · `slug` · `summary` · `relations` · `projectId` ·
`topicId` · `variantIds`. 서버가 주지만 보내지 않는 것: `id` · `version` · `updatedAt`.
| kind | 그 종류만의 칸 |
|---|---|
| `CASE` | `problem` · `conclusion` · `environment` · `reproduction` · `bodyMarkdown` · `lastVerifiedOn` |
| `CONCEPT` | `bodyMarkdown` · `basisVersion` |
| `SETUP` | `bodyMarkdown` · `pinnedVersions` |
| `REFERENCE` | `purpose` · `rules` · `applyWhen` · `exceptions` · `examples` · `verifiedOn` |
| `QUESTION` | `facts` · `assumptions` · `unknowns` · `constraints` · `options` · `nextValidation` · `resolution` · `questionStatus` |
되풀이되는 칸의 모양이다. `id` 는 서버가 붙이므로 새로 넣을 때는 빼고 `order` 만 0 부터 센다.
| 칸 | 항목 |
|---|---|
| `relations` | `{ targetId, reason, order }` |
| `facts` · `assumptions` · `unknowns` · `constraints` · `applyWhen` · `exceptions` · `examples` | `{ text, order }` |
| `options` | `{ title, description, order }` |
| `rules` | `{ title, body, order }`**제목과 본문이 따로다** |
| `pinnedVersions` | `{ name, version }``order` 가 없다 |
## `bodyMarkdown` 은 바이트 그대로 돌아온다
넣은 것과 읽어 온 것이 같은지 확인했다. **코드 울타리의 정보 문자열이 그대로 살아남는다.**
```
```bash label="[host] ① 친다"
```
넣고 다시 읽었을 때 `label="…"` 까지 한 글자도 안 바뀌었다. 그래서 `studio-body.py` 가 만든
본문을 그대로 실어 보내면 된다.
## 화면에서 블록이 어떻게 생겼나
옮기는 일은 API 로 하지만, 사람이 화면을 읽을 때와 무엇이 어긋났는지 볼 때 필요하다.
```
.studio-block-editor
├ textarea.studio-markdown-source 본문 전체의 거울. display:none · aria-hidden · readonly
├ .studio-block-editor__blocks
│ └ .studio-authoring-block[data-kind] 블록 하나
└ button + 블록 추가
```
`data-kind``heading` · `paragraph` · `code` · `bullet` · `raw` 다. `raw`(화면 이름
**고급 블록**)가 표처럼 블록으로 안 갈리는 것을 그대로 담는다.
**코드 블록의 `input.studio-code-language` 는 언어 이름만 담지 않는다.** 울타리 뒤의 정보
문자열 **전체**가 거기 들어간다 — `bash label="[kc-lab-1] ① 기동 로그에서 쿠키 설정을 찾는다"`
가 그 입력의 값이었다. 라벨을 넣을 별도 칸은 없다.
**`.studio-markdown-source` 는 읽는 데만 쓴다.** `readonly` 이고 `display:none` 이라 입력
자리가 아니다. 다만 화면이 지금 무엇을 직렬화할지를 그대로 보여 주므로, 저장 전에 눈으로
대조하기에 좋다.
합성한 `ClipboardEvent` 로 붙여넣는 것은 **안 먹는다.** 화면으로 본문을 채우려면 블록을 하나씩
만들어야 한다.
## 화면의 칸 셀렉터
칸은 이제 `aria-label` 로 찾는다. `label.studio-field` 는 제목과 요약 둘만 감싼다.
```js
document.querySelector('input[aria-label="제목"]')
document.querySelector('textarea[aria-label="요약"]')
document.querySelector('input[aria-label="slug"]')
document.querySelector('input[aria-label="판단 기준 1 제목"]')
document.querySelector('textarea[aria-label="적용할 때 3"]')
```
되풀이 칸을 늘리는 버튼은 `+ 판단 기준` · `+ 적용할 때` · `+ 예외와 주의` · `+ 예시` ·
`+ 버전 추가` 다.
상태 레일은 그대로다 — `aside[class*="studio-document-status"]`(실제 class 는
`studio-document-status-bar`). 그 안에 **`저장``게시` 둘뿐**이고, 저장된 상태에서는
`저장``disabled` 이며 글자가 `저장됨` 이다.
## 하지 않는 것
- **게시하지 않는다.** 이 스킬은 저장까지다. 한 번이라도 게시한 문서는 게시를 취소해도 삭제가
409 로 거절된다
- **`expectedVersion` 을 지어내지 않는다.** 저장 직전에 읽는다
- **부분 갱신이라고 생각하지 않는다.** 보내지 않은 칸은 비워진다
- **로그인 화면을 자동으로 넘기지 않는다**