diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ad8bfe..b4a54ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,19 @@ jobs: - name: Build run: CGO_ENABLED=0 go build ./... + spec: + name: OpenAPI is valid + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + + # Structural validity only. That the spec matches the *router* is checked by + # a Go test, which is the half that actually goes stale. + - run: npx --yes @redocly/cli@latest lint docs/openapi.yaml + e2e: name: End-to-end runs-on: ubuntu-latest diff --git a/docs/api.md b/docs/api.md index 421fb98..56076d9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -19,6 +19,16 @@ curl -H "Authorization: Bearer vrd_..." https://todo.example.dk/api/v1/tasks Make one under **Settings → API tokens**. It carries your permissions exactly — there is no separate service identity. +## The machine-readable spec + +[`openapi.yaml`](openapi.yaml) describes the same API as an OpenAPI 3.1 document — +for generating a client, for importing into Bruno or Insomnia, or for reading in a +tool that renders it. + +It is written by hand, and a test walks the router on every build and fails if an +endpoint is routed but not described, or described but no longer routed. So it is +as current as this page is, which is to say: as current as CI is green. + ## Errors Every failure has the same shape: diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 0000000..916db81 --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,2216 @@ +openapi: 3.1.0 + +# The machine-readable half of docs/api.md. +# +# Hand-written, and kept honest by a test: internal/httpapi/openapi_test.go walks +# the router and fails if a route is missing here or described here but not +# routed. Generating it from annotations was the alternative, and it would have +# meant every handler carrying a second copy of itself in a comment. + +info: + title: verdande + version: "0.1.0" + summary: Self-hosted tasks and projects, shared with other people. + description: | + REST under `/api/v1`, JSON in and out. Everything the web interface does goes + through this API, so anything you can do there you can script. + + Authenticate with a session cookie or with a personal API token. Both are + accepted on every endpoint except `/tokens`, which needs a session — a token + that can mint another token is a leak that cannot be revoked. + + Anything you may not see answers `404`, never `403`. A `403` would confirm + the thing exists, which is what somebody trying ids is hoping to learn. + license: + name: MIT + identifier: MIT + +servers: + - url: https://todo.example.dk + description: Your own instance. + +security: + - session: [] + - bearer: [] + +tags: + - name: auth + description: Signing in, the second factor, and the account itself. + - name: tasks + description: Tasks, sub-tasks, comments, attachments and reminders. + - name: projects + description: Projects, sections, members and activity. + - name: views + description: Today, upcoming, search, labels and saved filters. + - name: integrations + description: Calendar feed, mail-to-task, Gmail, AI and Web Push. + - name: data + description: Import, export and project templates. + +paths: + # --- auth ----------------------------------------------------------------- + + /api/v1/auth/setup: + get: + tags: [auth] + summary: Whether this instance still needs its first account. + security: [] + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + needs_setup: { type: boolean } + post: + tags: [auth] + summary: Create the first account, which becomes the administrator. + description: Refused once any account exists. Rate limited. + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email, name, password] + properties: + email: { type: string, format: email } + name: { type: string } + password: { type: string, minLength: 10 } + timezone: { type: string, example: Europe/Copenhagen } + locale: { type: string, enum: [da, en] } + responses: + "201": + description: Created, and signed in. + content: + application/json: + schema: + type: object + properties: + user: { $ref: "#/components/schemas/User" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Validation" } + "429": { $ref: "#/components/responses/RateLimited" } + + /api/v1/auth/login: + post: + tags: [auth] + summary: Sign in. + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email, password] + properties: + email: { type: string, format: email } + password: { type: string } + responses: + "200": + description: | + Signed in, or waiting for a code. When `totp_required` is true the + session exists but reaches nothing until `/auth/login/totp`. + content: + application/json: + schema: + type: object + properties: + user: { $ref: "#/components/schemas/User" } + totp_required: { type: boolean } + "401": { $ref: "#/components/responses/Unauthorized" } + "429": { $ref: "#/components/responses/RateLimited" } + + /api/v1/auth/login/totp: + post: + tags: [auth] + summary: Complete a login with the code from an authenticator. + description: Accepts a recovery code in place of a generated one. + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [code] + properties: + code: { type: string } + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + user: { $ref: "#/components/schemas/User" } + "401": { $ref: "#/components/responses/Unauthorized" } + + /api/v1/auth/signup: + post: + tags: [auth] + summary: Create an account from an invite. + description: | + There is no open registration. The address comes from the invite rather + than from the request, so an invite cannot be redirected to somebody else. + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [token, name, password] + properties: + token: { type: string } + name: { type: string } + password: { type: string, minLength: 10 } + timezone: { type: string } + locale: { type: string, enum: [da, en] } + responses: + "201": + description: Created, and signed in. + content: + application/json: + schema: + type: object + properties: + user: { $ref: "#/components/schemas/User" } + "400": { $ref: "#/components/responses/BadRequest" } + "422": { $ref: "#/components/responses/Validation" } + + /api/v1/auth/password/forgot: + post: + tags: [auth] + summary: Ask for a reset link. + description: | + Always answers 204, whether or not the address has an account. Answering + differently would turn this into a way to test which addresses exist. + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email] + properties: + email: { type: string, format: email } + responses: + "204": { description: Accepted. } + "429": { $ref: "#/components/responses/RateLimited" } + + /api/v1/auth/password/reset: + post: + tags: [auth] + summary: Set a new password with a reset token. + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [token, password] + properties: + token: { type: string } + password: { type: string, minLength: 10 } + responses: + "204": { description: Changed. Every other session is ended. } + "400": { $ref: "#/components/responses/BadRequest" } + "422": { $ref: "#/components/responses/Validation" } + "429": { $ref: "#/components/responses/RateLimited" } + + /api/v1/auth/me: + get: + tags: [auth] + summary: Who the caller is. + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/User" } + "401": { $ref: "#/components/responses/Unauthorized" } + patch: + tags: [auth] + summary: Change your own name, timezone or language. + description: | + The email address is not among them: it identifies the account and is what + invites were sent to. The timezone is not cosmetic — every date is + resolved in it, so "tomorrow at 9" means a different instant afterwards. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: { type: string, maxLength: 80 } + timezone: { type: string, example: Europe/Copenhagen } + locale: { type: string, enum: [da, en] } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/User" } + "422": { $ref: "#/components/responses/Validation" } + + /api/v1/auth/logout: + post: + tags: [auth] + summary: End this session. + responses: + "204": { description: Signed out. } + + /api/v1/auth/password/change: + post: + tags: [auth] + summary: Change your password. + description: Ends every other session, which is most of the reason to do it. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [current_password, new_password] + properties: + current_password: { type: string } + new_password: { type: string, minLength: 10 } + responses: + "204": { description: Changed. } + "422": { $ref: "#/components/responses/Validation" } + + /api/v1/auth/totp/setup: + post: + tags: [auth] + summary: Begin two-factor enrolment. + description: | + Stores the secret but does not switch anything on. `/auth/totp/confirm` + does that, once a code has proved the authenticator really has it. + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + secret: { type: string } + uri: { type: string, description: otpauth:// URI for a QR code. } + "409": { $ref: "#/components/responses/Conflict" } + + /api/v1/auth/totp/confirm: + post: + tags: [auth] + summary: Finish enrolment and switch two-factor on. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [code] + properties: + code: { type: string } + responses: + "200": + description: | + On. The recovery codes are returned once and never again — only their + hashes are kept. + content: + application/json: + schema: { $ref: "#/components/schemas/RecoveryCodes" } + "400": { $ref: "#/components/responses/BadRequest" } + "422": { $ref: "#/components/responses/Validation" } + + /api/v1/auth/totp/disable: + post: + tags: [auth] + summary: Switch two-factor off. + description: | + Needs the password again: switching off a second factor is exactly what + somebody with a borrowed session would do first. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [password] + properties: + password: { type: string } + responses: + "204": { description: Off. } + "422": { $ref: "#/components/responses/Validation" } + + /api/v1/auth/recovery-codes: + get: + tags: [auth] + summary: How many recovery codes are left. + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + remaining: { type: integer } + post: + tags: [auth] + summary: Issue a fresh set and retire the old one. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [password] + properties: + password: { type: string } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/RecoveryCodes" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Validation" } + + # --- tasks ---------------------------------------------------------------- + + /api/v1/tasks: + get: + tags: [tasks] + summary: List tasks. + parameters: + - { name: project_id, in: query, schema: { type: string } } + - { name: section_id, in: query, schema: { type: string } } + - { name: parent_id, in: query, schema: { type: string }, description: Sub-tasks of one task. } + - { name: label_id, in: query, schema: { type: string } } + - { name: priority, in: query, schema: { type: integer, minimum: 1, maximum: 4 } } + - { name: assignee_id, in: query, schema: { type: string }, description: "`me` resolves to the caller." } + - { name: due_before, in: query, schema: { type: string, format: date } } + - { name: due_from, in: query, schema: { type: string, format: date } } + - { name: no_date, in: query, schema: { type: boolean } } + - { name: q, in: query, schema: { type: string }, description: "Full-text search, diacritic-insensitive for Danish." } + - name: completed + in: query + schema: { type: string, enum: [include, only] } + description: Omitted, only open tasks are returned. + - { name: limit, in: query, schema: { type: integer } } + responses: + "200": { $ref: "#/components/responses/TaskList" } + post: + tags: [tasks] + summary: Create a task. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/TaskInput" } + responses: + "201": { $ref: "#/components/responses/Task" } + "404": { $ref: "#/components/responses/NotFound" } + "422": { $ref: "#/components/responses/Validation" } + + /api/v1/tasks/{taskID}: + parameters: + - { name: taskID, in: path, required: true, schema: { type: string } } + get: + tags: [tasks] + summary: One task. + responses: + "200": { $ref: "#/components/responses/Task" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + tags: [tasks] + summary: Change a task. + description: Only the fields present are changed. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/TaskInput" } + responses: + "200": { $ref: "#/components/responses/Task" } + "404": { $ref: "#/components/responses/NotFound" } + "422": { $ref: "#/components/responses/Validation" } + delete: + tags: [tasks] + summary: Delete a task. + description: Soft-deleted, and purged from the trash after 30 days. + responses: + "204": { description: Deleted. } + "404": { $ref: "#/components/responses/NotFound" } + + /api/v1/tasks/{taskID}/complete: + parameters: + - { name: taskID, in: path, required: true, schema: { type: string } } + post: + tags: [tasks] + summary: Tick a task off. + description: | + A repeating task is not closed: it moves forward in the same row, keeping + its id, so sub-tasks and comments stay attached. `recurred` and `next_due` + say when that happened. + responses: + "200": + description: OK + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/Task" + - type: object + properties: + recurred: { type: boolean } + next_due: { type: string, format: date } + "404": { $ref: "#/components/responses/NotFound" } + + /api/v1/tasks/{taskID}/reopen: + parameters: + - { name: taskID, in: path, required: true, schema: { type: string } } + post: + tags: [tasks] + summary: Un-tick a task. + responses: + "200": { $ref: "#/components/responses/Task" } + "404": { $ref: "#/components/responses/NotFound" } + + /api/v1/tasks/{taskID}/move: + parameters: + - { name: taskID, in: path, required: true, schema: { type: string } } + post: + tags: [tasks] + summary: Move a task, and place it between two others. + description: | + `after_id` and `before_id` are the tasks either side of the gap it lands + in. Give both to place between them, one to place at an end, neither to + place first. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + project_id: { type: string, description: "Omitted, the task stays in its project." } + section_id: { type: string, description: Empty string means no section. } + after_id: { type: string } + before_id: { type: string } + responses: + "200": { $ref: "#/components/responses/Task" } + "404": { $ref: "#/components/responses/NotFound" } + + /api/v1/tasks/quick-add: + post: + tags: [tasks] + summary: Create a task from one line of natural language. + description: | + Danish and English. `betal moms i morgen kl 10 p1 #Firma @vigtigt` becomes + a task due tomorrow at 10:00, priority 1, in the project Firma, labelled + vigtigt. An unknown `#project` is not an error — the task goes to the + Inbox rather than a thought being thrown away over a typo. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [text] + properties: + text: { type: string } + project_id: + type: string + description: Where it goes when the text names no project. + responses: + "201": { $ref: "#/components/responses/Task" } + "422": { $ref: "#/components/responses/Validation" } + + /api/v1/tasks/quick-add/preview: + get: + tags: [tasks] + summary: Show what a line would become, without creating anything. + parameters: + - { name: text, in: query, required: true, schema: { type: string } } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/ParsedTask" } + + /api/v1/tasks/{taskID}/comments: + parameters: + - { name: taskID, in: path, required: true, schema: { type: string } } + get: + tags: [tasks] + summary: Comments on a task, and the files attached to the task itself. + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + comments: + type: array + items: { $ref: "#/components/schemas/Comment" } + attachments: + type: array + items: { $ref: "#/components/schemas/Attachment" } + "404": { $ref: "#/components/responses/NotFound" } + post: + tags: [tasks] + summary: Write a comment. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [body] + properties: + body: { type: string, maxLength: 10000 } + responses: + "201": + description: Created + content: + application/json: + schema: { $ref: "#/components/schemas/Comment" } + "404": { $ref: "#/components/responses/NotFound" } + "422": { $ref: "#/components/responses/Validation" } + + /api/v1/comments/{commentID}: + parameters: + - { name: commentID, in: path, required: true, schema: { type: string } } + patch: + tags: [tasks] + summary: Edit a comment. Its author only. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [body] + properties: + body: { type: string, maxLength: 10000 } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/Comment" } + "404": { $ref: "#/components/responses/NotFound" } + delete: + tags: [tasks] + summary: Delete a comment. Its author only. + responses: + "204": { description: Deleted. } + "404": { $ref: "#/components/responses/NotFound" } + + /api/v1/tasks/{taskID}/attachments: + parameters: + - { name: taskID, in: path, required: true, schema: { type: string } } + post: + tags: [tasks] + summary: Attach a file, up to 25 MB. + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: { type: string, format: binary } + comment_id: + type: string + description: Hang it on a comment instead of on the task. + responses: + "201": + description: Created + content: + application/json: + schema: { $ref: "#/components/schemas/Attachment" } + "404": { $ref: "#/components/responses/NotFound" } + "413": { $ref: "#/components/responses/TooLarge" } + "422": { $ref: "#/components/responses/Validation" } + + /api/v1/attachments/{attachmentID}: + parameters: + - { name: attachmentID, in: path, required: true, schema: { type: string } } + get: + tags: [tasks] + summary: Download a file. + description: | + Always `application/octet-stream` and always an attachment, whatever the + file is. An uploaded SVG rendered inline would run its own script on this + origin with the session cookie attached. + responses: + "200": + description: The bytes. + content: + application/octet-stream: + schema: { type: string, format: binary } + "404": { $ref: "#/components/responses/NotFound" } + delete: + tags: [tasks] + summary: Delete a file. + responses: + "204": { description: Deleted. } + "404": { $ref: "#/components/responses/NotFound" } + + /api/v1/tasks/{taskID}/reminders: + parameters: + - { name: taskID, in: path, required: true, schema: { type: string } } + get: + tags: [tasks] + summary: Reminders on a task. + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + reminders: + type: array + items: { $ref: "#/components/schemas/Reminder" } + "404": { $ref: "#/components/responses/NotFound" } + post: + tags: [tasks] + summary: Add a reminder. + description: | + Exactly one of `remind_at` and `offset_min`. The offset is relative to the + task's due time and negative for "before", which is the only kind anybody + wants. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + remind_at: { type: string, format: date-time } + offset_min: { type: integer } + responses: + "201": + description: Created + content: + application/json: + schema: { $ref: "#/components/schemas/Reminder" } + "404": { $ref: "#/components/responses/NotFound" } + "422": { $ref: "#/components/responses/Validation" } + + /api/v1/reminders/{reminderID}: + parameters: + - { name: reminderID, in: path, required: true, schema: { type: string } } + delete: + tags: [tasks] + summary: Delete a reminder. + responses: + "204": { description: Deleted. } + "404": { $ref: "#/components/responses/NotFound" } + + # --- projects ------------------------------------------------------------- + + /api/v1/projects: + get: + tags: [projects] + summary: Projects you can see. + parameters: + - { name: archived, in: query, schema: { type: boolean } } + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + projects: + type: array + items: { $ref: "#/components/schemas/Project" } + post: + tags: [projects] + summary: Create a project. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectInput" } + responses: + "201": { $ref: "#/components/responses/Project" } + "422": { $ref: "#/components/responses/Validation" } + + /api/v1/projects/{projectID}: + parameters: + - { name: projectID, in: path, required: true, schema: { type: string } } + get: + tags: [projects] + summary: One project. + responses: + "200": { $ref: "#/components/responses/Project" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + tags: [projects] + summary: Change a project. Its owner only. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ProjectInput" } + responses: + "200": { $ref: "#/components/responses/Project" } + "404": { $ref: "#/components/responses/NotFound" } + "422": { $ref: "#/components/responses/Validation" } + delete: + tags: [projects] + summary: Delete a project. Its owner only. + description: Soft-deleted, and restorable from the trash for 30 days. + responses: + "204": { description: Deleted. } + "404": { $ref: "#/components/responses/NotFound" } + + /api/v1/trash/projects/{projectID}/restore: + parameters: + - { name: projectID, in: path, required: true, schema: { type: string } } + post: + tags: [projects] + summary: Restore a deleted project. + responses: + "200": { $ref: "#/components/responses/Project" } + "404": { $ref: "#/components/responses/NotFound" } + + /api/v1/projects/{projectID}/sections: + parameters: + - { name: projectID, in: path, required: true, schema: { type: string } } + get: + tags: [projects] + summary: Sections in a project. + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + sections: + type: array + items: { $ref: "#/components/schemas/Section" } + "404": { $ref: "#/components/responses/NotFound" } + post: + tags: [projects] + summary: Add a section. Editors and owners. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: { type: string } + responses: + "201": + description: Created + content: + application/json: + schema: { $ref: "#/components/schemas/Section" } + "404": { $ref: "#/components/responses/NotFound" } + "422": { $ref: "#/components/responses/Validation" } + + /api/v1/sections/{sectionID}: + parameters: + - { name: sectionID, in: path, required: true, schema: { type: string } } + patch: + tags: [projects] + summary: Rename or reorder a section. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: { type: string } + sort_order: { type: number } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/Section" } + "404": { $ref: "#/components/responses/NotFound" } + delete: + tags: [projects] + summary: Delete a section. + description: Its tasks are not deleted; they lose their section. + responses: + "204": { description: Deleted. } + "404": { $ref: "#/components/responses/NotFound" } + + /api/v1/projects/{projectID}/members: + parameters: + - { name: projectID, in: path, required: true, schema: { type: string } } + get: + tags: [projects] + summary: Who the project is shared with. + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + members: + type: array + items: { $ref: "#/components/schemas/Member" } + "404": { $ref: "#/components/responses/NotFound" } + + /api/v1/projects/{projectID}/members/{userID}: + parameters: + - { name: projectID, in: path, required: true, schema: { type: string } } + - { name: userID, in: path, required: true, schema: { type: string } } + delete: + tags: [projects] + summary: Remove somebody from a project. Its owner only. + responses: + "204": { description: Removed. } + "404": { $ref: "#/components/responses/NotFound" } + + /api/v1/projects/{projectID}/invites: + parameters: + - { name: projectID, in: path, required: true, schema: { type: string } } + post: + tags: [projects] + summary: Invite somebody. Its owner only. + description: | + With no mail server configured the response carries the link instead of + emailing it, so the invite is not silently lost. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email, role] + properties: + email: { type: string, format: email } + role: { type: string, enum: [editor, viewer] } + responses: + "201": + description: Created + content: + application/json: + schema: + type: object + properties: + link: { type: string } + emailed: { type: boolean } + "404": { $ref: "#/components/responses/NotFound" } + "422": { $ref: "#/components/responses/Validation" } + + /api/v1/projects/{projectID}/activity: + parameters: + - { name: projectID, in: path, required: true, schema: { type: string } } + get: + tags: [projects] + summary: What has happened in a project. + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + activity: + type: array + items: { $ref: "#/components/schemas/Activity" } + "404": { $ref: "#/components/responses/NotFound" } + + # --- views ---------------------------------------------------------------- + + /api/v1/today: + get: + tags: [views] + summary: What is overdue, and what is due today. + description: Resolved in the caller's timezone, not the server's. + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + overdue: + type: array + items: { $ref: "#/components/schemas/Task" } + today: + type: array + items: { $ref: "#/components/schemas/Task" } + + /api/v1/upcoming: + get: + tags: [views] + summary: The next few days, empty ones included. + parameters: + - { name: days, in: query, schema: { type: integer, default: 7 } } + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + days: + type: array + items: + type: object + properties: + date: { type: string, format: date } + tasks: + type: array + items: { $ref: "#/components/schemas/Task" } + + /api/v1/search: + get: + tags: [views] + summary: Full-text search over tasks. + description: | + Diacritic-folded, so "gron" finds "grøn". Danish treats ø and æ as letters + of their own rather than as accented forms, which is why the index carries + a folded copy of the text beside the original. + parameters: + - { name: q, in: query, required: true, schema: { type: string } } + responses: + "200": { $ref: "#/components/responses/TaskList" } + + /api/v1/labels: + get: + tags: [views] + summary: Your labels, with how many tasks carry each. + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + labels: + type: array + items: { $ref: "#/components/schemas/Label" } + post: + tags: [views] + summary: Create a label. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: { type: string } + responses: + "201": + description: Created + content: + application/json: + schema: { $ref: "#/components/schemas/Label" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Validation" } + + /api/v1/labels/{labelID}: + parameters: + - { name: labelID, in: path, required: true, schema: { type: string } } + patch: + tags: [views] + summary: Rename or recolour a label. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: { type: string } + color: { type: string } + sort_order: { type: number } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/Label" } + "404": { $ref: "#/components/responses/NotFound" } + delete: + tags: [views] + summary: Delete a label. + responses: + "204": { description: Deleted. } + "404": { $ref: "#/components/responses/NotFound" } + + /api/v1/filters: + get: + tags: [views] + summary: Saved filters. + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + filters: + type: array + items: { $ref: "#/components/schemas/Filter" } + post: + tags: [views] + summary: Save a filter. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/FilterInput" } + responses: + "201": + description: Created + content: + application/json: + schema: { $ref: "#/components/schemas/Filter" } + "422": { $ref: "#/components/responses/Validation" } + + /api/v1/filters/{filterID}: + parameters: + - { name: filterID, in: path, required: true, schema: { type: string } } + patch: + tags: [views] + summary: Change a saved filter. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/FilterInput" } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/Filter" } + "404": { $ref: "#/components/responses/NotFound" } + "422": { $ref: "#/components/responses/Validation" } + delete: + tags: [views] + summary: Delete a saved filter. + responses: + "204": { description: Deleted. } + "404": { $ref: "#/components/responses/NotFound" } + + /api/v1/filters/{filterID}/tasks: + parameters: + - { name: filterID, in: path, required: true, schema: { type: string } } + get: + tags: [views] + summary: Run a saved filter. + responses: + "200": { $ref: "#/components/responses/TaskList" } + "404": { $ref: "#/components/responses/NotFound" } + + /api/v1/filters/preview: + get: + tags: [views] + summary: Run a filter expression without saving it. + description: | + The expression is compiled to a bound SQL fragment, never assembled from + the text — see the filter language in the documentation. + parameters: + - { name: query, in: query, required: true, schema: { type: string, example: "i dag & p1 | @arbejde" } } + responses: + "200": { $ref: "#/components/responses/TaskList" } + "422": { $ref: "#/components/responses/Validation" } + + # --- integrations --------------------------------------------------------- + + /api/v1/tokens: + get: + tags: [integrations] + summary: Your API tokens. + description: Needs a session. A bearer token is refused with 403. + security: + - session: [] + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + tokens: + type: array + items: { $ref: "#/components/schemas/ApiToken" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [integrations] + summary: Mint an API token. + description: | + The plaintext comes back here and never again — only its hash is stored. + Needs a session: a token that can mint another token is a leak that + revoking the first one does not close. + security: + - session: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: { type: string, maxLength: 80 } + expires_in_days: + type: integer + minimum: 0 + maximum: 3650 + default: 0 + description: 0 means it never expires. + responses: + "201": + description: Created + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ApiToken" + - type: object + properties: + token: { type: string, example: vrd_… } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/Validation" } + + /api/v1/tokens/{tokenID}: + parameters: + - { name: tokenID, in: path, required: true, schema: { type: string } } + delete: + tags: [integrations] + summary: Revoke an API token, immediately. + security: + - session: [] + responses: + "204": { description: Revoked. } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + + /api/v1/feed: + get: + tags: [integrations] + summary: Your calendar subscription URL. + description: | + Minted on first ask. The token in the URL is the whole credential — a + calendar client cannot log in — so treat the address as a password. + responses: + "200": { $ref: "#/components/responses/FeedURL" } + + /api/v1/feed/rotate: + post: + tags: [integrations] + summary: Issue a new feed URL. + description: | + Breaks every existing subscription at once. That is the point: it is what + you do when the old address has ended up somewhere it should not. + responses: + "200": { $ref: "#/components/responses/FeedURL" } + + /api/v1/mail-address: + get: + tags: [integrations] + summary: Your personal mail-to-task address. + responses: + "200": { $ref: "#/components/responses/MailAddress" } + + /api/v1/mail-address/rotate: + post: + tags: [integrations] + summary: Issue a new mail-to-task address. + responses: + "200": { $ref: "#/components/responses/MailAddress" } + + /api/v1/notifications: + get: + tags: [integrations] + summary: Recent notifications, and how many are unread. + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + notifications: + type: array + items: { $ref: "#/components/schemas/Notification" } + unread: { type: integer } + + /api/v1/notifications/read: + post: + tags: [integrations] + summary: Mark every notification as read. + responses: + "204": { description: Marked. } + + /api/v1/notifications/{notificationID}/read: + parameters: + - { name: notificationID, in: path, required: true, schema: { type: string } } + post: + tags: [integrations] + summary: Mark one notification as read. + responses: + "204": { description: Marked. } + + /api/v1/push/key: + get: + tags: [integrations] + summary: The instance's VAPID public key. + description: What `PushManager.subscribe` needs before it will produce a subscription. + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + public_key: { type: string, description: base64url } + + /api/v1/push/subscribe: + post: + tags: [integrations] + summary: Register a Web Push subscription for this device. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/PushSubscription" } + responses: + "204": { description: Registered. } + "422": { $ref: "#/components/responses/Validation" } + + /api/v1/push/unsubscribe: + post: + tags: [integrations] + summary: Forget a subscription. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/PushSubscription" } + responses: + "204": { description: Forgotten. } + + /api/v1/gmail: + get: + tags: [integrations] + summary: The Gmail connection and what triggers a task. + responses: + "200": { $ref: "#/components/responses/Gmail" } + put: + tags: [integrations] + summary: Change what triggers a task. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + trigger: { type: string, enum: [starred, label, both] } + label: { type: string } + responses: + "204": { description: Saved. } + delete: + tags: [integrations] + summary: Disconnect Gmail and forget its tokens. + responses: + "204": { description: Disconnected. } + + /api/v1/gmail/authorize: + post: + tags: [integrations] + summary: Begin the OAuth flow. + description: | + Returns the Google URL to send the browser to. The PKCE verifier and state + are kept server-side and expire in ten minutes. + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + url: { type: string } + "409": + $ref: "#/components/responses/Conflict" + + /api/v1/gmail/sync: + post: + tags: [integrations] + summary: Poll Gmail now. + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + created: { type: integer } + "502": { $ref: "#/components/responses/Upstream" } + + /api/v1/ai/settings: + get: + tags: [integrations] + summary: The AI provider in use. + description: | + The key itself is never sent back; `has_key` says whether one is stored. + A settings page that repopulates a password field is one that will + eventually leak a key into a screenshot. + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/AISettings" } + put: + tags: [integrations] + summary: Set the AI provider. + description: An empty `api_key` leaves the stored key alone rather than clearing it. + requestBody: + required: true + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/AISettings" + - type: object + properties: + api_key: { type: string } + responses: + "204": { description: Saved. } + "422": { $ref: "#/components/responses/Validation" } + + /api/v1/ai/summary: + post: + tags: [integrations] + summary: A short prioritisation note over what is outstanding. + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + summary: { type: string } + "409": + $ref: "#/components/responses/Conflict" + "502": { $ref: "#/components/responses/Upstream" } + + /api/v1/ai/tasks/{taskID}/split: + parameters: + - { name: taskID, in: path, required: true, schema: { type: string } } + post: + tags: [integrations] + summary: Break a task into sub-tasks and create them. + responses: + "201": + description: Created + content: + application/json: + schema: + type: object + properties: + subtasks: + type: array + items: { $ref: "#/components/schemas/Task" } + "404": { $ref: "#/components/responses/NotFound" } + "409": + $ref: "#/components/responses/Conflict" + "502": { $ref: "#/components/responses/Upstream" } + + /api/v1/mcp: + post: + tags: [integrations] + summary: The Model Context Protocol endpoint. + description: | + JSON-RPC 2.0. A connector authenticating with an API token sees exactly + what its owner sees. A notification — a message with no `id` — is answered + with 202 and no body, as the specification requires. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [jsonrpc, method] + properties: + jsonrpc: { type: string, const: "2.0" } + id: { oneOf: [{ type: string }, { type: integer }] } + method: { type: string } + params: { type: object } + responses: + "200": + description: A JSON-RPC response. + content: + application/json: + schema: + type: object + properties: + jsonrpc: { type: string, const: "2.0" } + id: { oneOf: [{ type: string }, { type: integer }] } + result: { type: object } + error: { type: object } + "202": { description: A notification. No body. } + + /api/v1/version: + get: + tags: [integrations] + summary: The running version, and whether a newer one exists. + description: | + The update notice is only filled in for an administrator — telling an + ordinary member the server is out of date is telling them about somebody + else's job. + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/Version" } + + /api/v1/ws: + get: + tags: [integrations] + summary: Live changes, over a WebSocket. + description: | + One-way by design: nothing a client sends over the socket changes + anything. Writes go through the REST API, where the permission checks are. + Events: `task.created`, `task.updated`, `task.completed`, `task.reopened`, + `task.moved`, `task.deleted`, `comment.created`, `notification`, + `reminder`. + responses: + "101": { description: Switching protocols. } + "401": { $ref: "#/components/responses/Unauthorized" } + + /api/v1/ping: + get: + tags: [integrations] + summary: A liveness check that does not touch the database. + security: [] + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + status: { type: string, const: ok } + + # --- data ----------------------------------------------------------------- + + /api/v1/import/todoist: + post: + tags: [data] + summary: Import a Todoist CSV export. + description: | + Priorities are converted, not copied: Todoist's CSV writes 4 for what its + interface calls P1. What could not be brought across exactly comes back in + `warnings` rather than being dropped silently. + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: { type: string, format: binary } + name: { type: string, description: Defaults to the file's own name. } + responses: + "201": { $ref: "#/components/responses/ImportResult" } + "413": { $ref: "#/components/responses/TooLarge" } + "422": { $ref: "#/components/responses/Validation" } + + /api/v1/import/csv: + post: + tags: [data] + summary: Import mapped rows from any CSV. + description: | + The file is parsed in the browser and the mapped rows arrive as JSON, so + the column-mapping interface does not have to upload the file twice. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [mapping, rows] + properties: + name: { type: string } + mapping: + type: object + additionalProperties: { type: string } + example: { content: Opgave, due: Frist } + rows: + type: array + maxItems: 5000 + items: + type: object + additionalProperties: { type: string } + responses: + "201": { $ref: "#/components/responses/ImportResult" } + "422": { $ref: "#/components/responses/Validation" } + + /api/v1/export/account: + get: + tags: [data] + summary: Everything, as JSON. + description: | + The "you can leave" guarantee, and deliberately the raw shape: an export + that has been tidied is an export that has lost something. + responses: + "200": + description: OK + content: + application/json: + schema: { type: object } + + /api/v1/export/projects/{projectID}.csv: + parameters: + - { name: projectID, in: path, required: true, schema: { type: string } } + get: + tags: [data] + summary: One project as a Todoist-compatible CSV. + responses: + "200": + description: OK + content: + text/csv: + schema: { type: string } + "404": { $ref: "#/components/responses/NotFound" } + + /api/v1/export/projects/{projectID}.ics: + parameters: + - { name: projectID, in: path, required: true, schema: { type: string } } + get: + tags: [data] + summary: One project as a calendar file. + responses: + "200": + description: OK + content: + text/calendar: + schema: { type: string } + "404": { $ref: "#/components/responses/NotFound" } + + /api/v1/templates: + get: + tags: [data] + summary: Your project templates. + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + templates: + type: array + items: { $ref: "#/components/schemas/Template" } + post: + tags: [data] + summary: Save a project as a template. + description: Due dates are stored relative to the project's start. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [project_id] + properties: + project_id: { type: string } + name: { type: string } + description: { type: string } + responses: + "201": + description: Created + content: + application/json: + schema: { $ref: "#/components/schemas/Template" } + "404": { $ref: "#/components/responses/NotFound" } + "422": { $ref: "#/components/responses/Validation" } + + /api/v1/templates/{templateID}: + parameters: + - { name: templateID, in: path, required: true, schema: { type: string } } + delete: + tags: [data] + summary: Delete a template. + responses: + "204": { description: Deleted. } + "404": { $ref: "#/components/responses/NotFound" } + + /api/v1/templates/{templateID}/use: + parameters: + - { name: templateID, in: path, required: true, schema: { type: string } } + post: + tags: [data] + summary: Create a project from a template. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: { type: string } + start_date: + type: string + format: date + description: Day zero for the template's relative dates. Defaults to today. + responses: + "201": { $ref: "#/components/responses/Project" } + "404": { $ref: "#/components/responses/NotFound" } + "422": { $ref: "#/components/responses/Validation" } + + # --- outside /api/v1 ------------------------------------------------------- + + /healthz: + get: + tags: [integrations] + summary: Health check. + description: | + Reads the database rather than merely answering, so a container with a + broken volume reports unhealthy instead of pretending to work. + security: [] + responses: + "200": { description: Healthy. } + "503": { description: Unhealthy. } + + /ics/{token}: + parameters: + - { name: token, in: path, required: true, schema: { type: string } } + get: + tags: [integrations] + summary: The calendar feed itself. + description: | + Unauthenticated by necessity: a calendar client fetches a URL with no + credentials and no way to prompt for any, so the token in the path is the + credential. A missing feed answers 404 rather than 401, because a + calendar client would show a password prompt nobody can answer. + security: [] + responses: + "200": + description: OK + content: + text/calendar: + schema: { type: string } + "404": { description: No such feed. } + + /inbound/mail: + post: + tags: [integrations] + summary: Turn a delivered email into a task. + description: | + Called by the mail server, not by a browser. Authenticated by the token in + the recipient address, which is the only credential inbound mail carries. + The subject line goes through the quick-add parser. + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [to] + properties: + to: { type: string } + from: { type: string } + subject: { type: string } + body: { type: string } + responses: + "201": + description: Created + content: + application/json: + schema: + type: object + properties: + task_id: { type: string } + "404": { description: "No such address. Identical to a malformed one, deliberately." } + + /oauth/gmail/callback: + get: + tags: [integrations] + summary: Where Google sends the browser after consent. + description: | + Answers with a redirect to `/indstillinger`, not with JSON: the person is + looking at a browser tab, not at a fetch response. + parameters: + - { name: code, in: query, schema: { type: string } } + - { name: state, in: query, schema: { type: string } } + - { name: error, in: query, schema: { type: string } } + responses: + "302": { description: "Back into the app, with the outcome as a query parameter." } + + /.well-known/caldav: + get: + tags: [integrations] + summary: CalDAV discovery. + description: | + The rest of the CalDAV surface lives under `/caldav` and speaks WebDAV + verbs — PROPFIND, REPORT — which are outside what OpenAPI describes. See + the CalDAV page in the documentation. + security: [] + responses: + "301": { description: To the CalDAV root. } + +components: + securitySchemes: + session: + type: apiKey + in: cookie + name: __Host-verdande_session + description: | + Set by `/auth/login`. `httpOnly`, `SameSite=Lax`, and prefixed `__Host-` + when served over HTTPS. Over plain HTTP the prefix is invalid and the + cookie is named `verdande_session`. + bearer: + type: http + scheme: bearer + description: | + A personal API token, `vrd_…`. Carries its owner's permissions exactly — + there is no separate service identity — and is refused on `/tokens`. + + responses: + BadRequest: + description: The body could not be read. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + Unauthorized: + description: Not signed in, or the credential is wrong. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + Forbidden: + description: A cross-site request, or a token where a session is required. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + NotFound: + description: Does not exist, or you cannot see it. The two are not distinguished. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + Conflict: + description: Already exists, or the state does not allow it. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + TooLarge: + description: The body or the file is too large. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + Validation: + description: One or more fields are not valid. See `fields`. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + RateLimited: + description: Too many attempts. Carries `Retry-After`. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + Upstream: + description: An external service failed. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + Task: + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/Task" } + TaskList: + description: OK + content: + application/json: + schema: + type: object + properties: + tasks: + type: array + items: { $ref: "#/components/schemas/Task" } + Project: + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/Project" } + FeedURL: + description: OK + content: + application/json: + schema: + type: object + properties: + url: { type: string } + MailAddress: + description: OK + content: + application/json: + schema: + type: object + properties: + address: { type: string, example: todo+abc123@example.dk } + Gmail: + description: OK + content: + application/json: + schema: + type: object + properties: + connected: { type: boolean } + email: { type: string } + trigger: { type: string, enum: ["", starred, label, both] } + label: { type: string } + ImportResult: + description: Created + content: + application/json: + schema: + type: object + properties: + project_id: { type: string } + projects: { type: integer } + tasks: { type: integer } + sections: { type: integer } + comments: { type: integer } + warnings: + type: array + items: { type: string } + + schemas: + Error: + type: object + description: | + The one shape every failure takes. `code` is stable and meant to be + matched on; `error` is English prose for a log, and what a person reads is + decided by the client, which knows what language they are in. + required: [code, error] + properties: + code: + type: string + enum: + - bad_request + - validation_failed + - unauthorized + - totp_required + - forbidden + - not_found + - conflict + - rate_limited + - internal_error + - payload_too_large + error: { type: string } + fields: + type: object + additionalProperties: { type: string } + example: { due_date: "must be a date like 2026-03-15" } + + User: + type: object + description: Never carries the password hash or the TOTP secret. + properties: + id: { type: string } + email: { type: string, format: email } + name: { type: string } + avatar_color: { type: string } + timezone: { type: string } + locale: { type: string, enum: [da, en] } + is_admin: { type: boolean } + totp_enabled: { type: boolean } + created_at: { type: string, format: date-time } + + RecoveryCodes: + type: object + properties: + recovery_codes: + type: array + items: { type: string } + + Task: + type: object + properties: + id: { type: string } + project_id: { type: string } + section_id: { type: string } + parent_id: { type: string } + content: { type: string } + description: { type: string } + priority: { type: integer, minimum: 1, maximum: 4, description: 1 is most urgent. } + due_date: { type: string, format: date } + due_datetime: { type: string, format: date-time } + duration_min: { type: integer } + recurrence_rule: { type: string, description: An RFC 5545 RRULE. } + recurrence_text: { type: string, description: "The rule in words, for display." } + assignee_id: { type: string } + labels: + type: array + items: { type: string } + completed: { type: boolean } + completed_at: { type: string, format: date-time } + created_by: { type: string } + sort_order: { type: number } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + TaskInput: + type: object + properties: + project_id: { type: string, description: "Omitted on create, the task goes to the Inbox." } + section_id: { type: string } + parent_id: { type: string } + content: { type: string } + description: { type: string } + priority: { type: integer, minimum: 1, maximum: 4 } + due_date: { type: string, format: date } + due_time: { type: string, example: "10:00" } + duration_min: { type: integer } + recurrence_rule: { type: string } + assignee_id: { type: string } + labels: + type: array + items: { type: string } + sort_order: { type: number } + + ParsedTask: + type: object + description: What the quick-add parser made of a line. + properties: + content: { type: string } + project: { type: string } + priority: { type: integer } + due_date: { type: string, format: date } + due_datetime: { type: string, format: date-time } + recurrence: { type: string } + labels: + type: array + items: { type: string } + + Project: + type: object + properties: + id: { type: string } + name: { type: string } + color: { type: string } + icon: { type: string } + view_mode: { type: string, enum: [list, board, calendar] } + owner_id: { type: string } + is_inbox: { type: boolean } + archived: { type: boolean } + sort_order: { type: number } + role: { type: string, enum: [owner, editor, viewer] } + member_count: { type: integer } + shared: { type: boolean } + + ProjectInput: + type: object + properties: + name: { type: string } + color: { type: string } + icon: { type: string } + view_mode: { type: string, enum: [list, board, calendar] } + archived: { type: boolean } + sort_order: { type: number } + + Section: + type: object + properties: + id: { type: string } + project_id: { type: string } + name: { type: string } + sort_order: { type: number } + + Member: + type: object + properties: + user_id: { type: string } + email: { type: string, format: email } + name: { type: string } + avatar_color: { type: string } + role: { type: string, enum: [owner, editor, viewer] } + + Activity: + type: object + properties: + id: { type: string } + project_id: { type: string } + task_id: { type: string } + actor_id: { type: string } + actor_name: { type: string } + kind: { type: string, example: task.completed } + data: { type: object } + created_at: { type: string, format: date-time } + + Label: + type: object + properties: + id: { type: string } + name: { type: string } + color: { type: string } + sort_order: { type: number } + task_count: { type: integer } + + Filter: + type: object + properties: + id: { type: string } + name: { type: string } + query: { type: string, example: "i dag & p1" } + color: { type: string } + sort_order: { type: number } + + FilterInput: + type: object + properties: + name: { type: string } + query: { type: string } + color: { type: string } + sort_order: { type: number } + + Comment: + type: object + properties: + id: { type: string } + task_id: { type: string } + user_id: { type: string } + user_name: { type: string } + user_color: { type: string } + body: { type: string } + attachments: + type: array + items: { $ref: "#/components/schemas/Attachment" } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + Attachment: + type: object + properties: + id: { type: string } + filename: { type: string } + mime_type: { type: string } + size: { type: integer } + url: { type: string } + + Reminder: + type: object + properties: + id: { type: string } + task_id: { type: string } + remind_at: { type: string, format: date-time } + offset_min: { type: integer } + sent: { type: boolean } + + Template: + type: object + properties: + id: { type: string } + name: { type: string } + description: { type: string } + color: { type: string } + task_count: { type: integer } + created_at: { type: string, format: date-time } + + ApiToken: + type: object + properties: + id: { type: string } + name: { type: string } + prefix: { type: string, description: "The first characters, which is all that is ever shown again." } + created_at: { type: string, format: date-time } + last_used_at: { type: string, format: date-time } + expires_at: { type: string, format: date-time } + + Notification: + type: object + properties: + id: { type: string } + actor_name: { type: string } + project_id: { type: string } + task_id: { type: string } + kind: { type: string } + title: { type: string } + body: { type: string } + read: { type: boolean } + created_at: { type: string, format: date-time } + + PushSubscription: + type: object + required: [endpoint] + properties: + endpoint: { type: string } + keys: + type: object + properties: + p256dh: { type: string } + auth: { type: string } + + AISettings: + type: object + properties: + provider: { type: string, enum: ["", anthropic, openai, google, compatible] } + base_url: { type: string } + model: { type: string } + has_key: { type: boolean, readOnly: true } + + Version: + type: object + properties: + current: { type: string } + latest: { type: string } + update_available: { type: boolean } + url: { type: string } + notes: { type: string } + checked_at: { type: string, format: date-time } + disabled: + type: boolean + description: | + Distinguishes "no update" from "we never looked", which the interface + has to show differently or it implies a guarantee it cannot make. diff --git a/internal/httpapi/openapi_test.go b/internal/httpapi/openapi_test.go new file mode 100644 index 0000000..a4b03e5 --- /dev/null +++ b/internal/httpapi/openapi_test.go @@ -0,0 +1,158 @@ +package httpapi + +import ( + "bufio" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/kristianwind/verdande/internal/config" + "github.com/kristianwind/verdande/internal/store" +) + +// docs/openapi.yaml is written by hand, which means it can drift from the router +// the moment somebody adds an endpoint. This walks the real router and compares +// the two in both directions: a route with nothing written about it, and a path +// documented that no longer exists. +// +// The spec is read with a small scanner rather than a YAML library. It needs two +// things — which paths are listed and which methods each one has — and both live +// at fixed indentation in a document this file also owns. That is worth more than +// a dependency in a project whose whole shape is "one static binary". + +// specRoutes reads "method path" pairs out of the `paths:` section. +func specRoutes(t *testing.T) map[string]bool { + t.Helper() + + f, err := os.Open(filepath.Join("..", "..", "docs", "openapi.yaml")) + if err != nil { + t.Fatalf("open spec: %v", err) + } + defer f.Close() + + methods := map[string]bool{ + "get": true, "post": true, "put": true, "patch": true, "delete": true, + "head": true, "options": true, "trace": true, + } + + routes := map[string]bool{} + inPaths := false + path := "" + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "#") || strings.TrimSpace(line) == "" { + continue + } + + // A top-level key ends the paths section. + if !strings.HasPrefix(line, " ") { + inPaths = strings.HasPrefix(line, "paths:") + continue + } + if !inPaths { + continue + } + + switch { + // Two spaces: a path. + case strings.HasPrefix(line, " /"): + path = strings.TrimSuffix(strings.TrimSpace(line), ":") + // Four spaces: a key under it, which is a method or `parameters`. + case path != "" && strings.HasPrefix(line, " ") && !strings.HasPrefix(line, " "): + key := strings.TrimSuffix(strings.TrimSpace(line), ":") + if methods[key] { + routes[strings.ToUpper(key)+" "+path] = true + } + } + } + if err := scanner.Err(); err != nil { + t.Fatalf("read spec: %v", err) + } + if len(routes) == 0 { + t.Fatal("no paths were read out of the spec — the scanner and the file disagree") + } + return routes +} + +// undocumented is what the spec is not expected to describe. +func undocumented(method, path string) bool { + switch { + // The SPA fallback and static assets: not an API. + case path == "/*", path == "/": + return true + // CalDAV speaks PROPFIND and REPORT, which OpenAPI has no way to describe. + // The discovery URL is in the spec; the rest is documented in docs/caldav.md. + case strings.HasPrefix(path, "/caldav"): + return true + // chi registers OPTIONS and HEAD of its own accord in places; neither carries + // behaviour worth writing down. + case method == http.MethodOptions || method == http.MethodHead: + return true + } + return false +} + +func TestOpenAPICoversEveryRoute(t *testing.T) { + spec := specRoutes(t) + + // The server itself rather than the test harness's httptest wrapper: this + // needs the router, and nothing here sends a request. + db, err := store.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { db.Close() }) + + srv := New(&config.Config{ + BaseURL: "http://127.0.0.1", DataDir: t.TempDir(), + SessionTTL: time.Hour, InviteTTL: time.Hour, ResetTTL: time.Hour, + }, db, slog.New(slog.NewTextHandler(io.Discard, nil)), nil) + + routed := map[string]bool{} + err = chi.Walk(srv.router, + func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { + // chi writes the index of a sub-router as a trailing slash; the spec + // names it without one. + if route != "/" { + route = strings.TrimSuffix(route, "/") + } + if !undocumented(method, route) { + routed[method+" "+route] = true + } + return nil + }) + if err != nil { + t.Fatalf("walk routes: %v", err) + } + + var missing, stale []string + for route := range routed { + if !spec[route] { + missing = append(missing, route) + } + } + for route := range spec { + if !routed[route] { + stale = append(stale, route) + } + } + sort.Strings(missing) + sort.Strings(stale) + + for _, route := range missing { + t.Errorf("routed but not in docs/openapi.yaml: %s", route) + } + for _, route := range stale { + t.Errorf("in docs/openapi.yaml but not routed: %s", route) + } +} diff --git a/mkdocs.yml b/mkdocs.yml index 5983494..d575c7c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -56,6 +56,8 @@ nav: - Mail to task: mail.md - Claude (MCP): mcp.md - AI features: ai.md + # openapi.yaml is deliberately not in the nav: MkDocs copies it to the site as a + # file, and api.md links to it. A nav entry expects a page to render. - Reference: - API: api.md