summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Cargo.toml1
-rw-r--r--docs/openapi.yaml1697
-rw-r--r--src/api/mod.rs59
-rw-r--r--src/api/peers.rs40
-rw-r--r--src/api/store.rs74
-rw-r--r--src/state/peer.rs2
-rw-r--r--src/transport/firewall.rs3
-rw-r--r--src/transport/traffic.rs7
8 files changed, 1729 insertions, 154 deletions
diff --git a/Cargo.toml b/Cargo.toml
index 14cc7f5..0bde0c4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -34,6 +34,5 @@ tokio = { version = "1.53", features = ["full"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
url = "2.5"
-utoipa = { version = "6", features = ["axum_extras"] }
utoipa-swagger-ui = { version = "10", features = ["axum"] }
uuid = { version = "1", features = ["v4"] }
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
new file mode 100644
index 0000000..f81ed4c
--- /dev/null
+++ b/docs/openapi.yaml
@@ -0,0 +1,1697 @@
+openapi: 3.1.0
+info:
+ title: continuum
+ version: 0.1.0
+ description: |
+ HTTP API of a continuum node: a replicated Automerge key-value store that syncs
+ over Mycelium.
+
+ Every endpoint requires HTTP basic authentication (users from `--htpasswd`,
+ Argon2 hashes), except `/health`, `/ready`, the Swagger UI (`/swagger-ui`) and
+ this description (`/openapi.yaml`).
+ `/debug/*` exists only with `--debug-api`.
+
+ Written by hand, and served by every node as it is (`/openapi.yaml`).
+
+ Conventions:
+ * Store paths are slash-separated keys; empty segments are invalid. Keys
+ containing `/` can't be addressed through the path.
+ * Timestamps in remake and quarantine records are Unix milliseconds.
+ * Change hashes and epoch IDs are lowercase hex (64 characters).
+ * Request bodies are limited to 2 MiB, except `POST /admin/remakes` (64 MiB).
+
+servers:
+ - url: http://127.0.0.1:3000
+
+security:
+ - basic: []
+
+tags:
+ - name: Store
+ description: Reading and writing the replicated document.
+ - name: Peers
+ description: Other nodes this node knows.
+ - name: Admin
+ description: Node operation (log level, statistics, document export).
+ - name: Remakes
+ description: |
+ Replacing the swarm's document with a new epoch (compaction, replacement,
+ import), and what happened to local writes that a remake couldn't place.
+ - name: Debug
+ description: Test hooks, only with `--debug-api`.
+
+paths:
+ /store:
+ get:
+ tags: [Store]
+ summary: Read the whole store
+ description: |
+ The merged view of the document as JSON (an empty object if empty), or with
+ `Accept: text/plain` as text. Includes protocol data under `_continuum`.
+
+ While the node rejoins a newer epoch, reads are served from the previous
+ document, marked with `x-continuum-stale`.
+ parameters:
+ - $ref: '#/components/parameters/conflicts'
+ - $ref: '#/components/parameters/heads'
+ responses:
+ '200':
+ $ref: '#/components/responses/Value'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '500':
+ description: Reading or serializing failed.
+ '503':
+ description: Node is not ready (and has no stale view to serve).
+
+ /store/{key}:
+ parameters:
+ - $ref: '#/components/parameters/key'
+ get:
+ tags: [Store]
+ summary: Read a value
+ description: |
+ The value at the path, as JSON, or with `Accept: text/plain` as text (strings
+ unquoted, anything else as JSON). Concurrent writes resolve as follows:
+ concurrent maps merge, anything else keeps the newest write.
+ parameters:
+ - $ref: '#/components/parameters/conflicts'
+ - $ref: '#/components/parameters/heads'
+ responses:
+ '200':
+ $ref: '#/components/responses/Value'
+ '400':
+ description: Invalid path.
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '404':
+ description: No value at this path.
+ '500':
+ description: Reading or serializing failed.
+ '503':
+ description: Node is not ready (and has no stale view to serve).
+ put:
+ tags: [Store]
+ summary: Write a value
+ description: |
+ Writes the value, creating intermediate maps. One request is one Automerge
+ change. Answers with the value written (JSON, or text with
+ `Accept: text/plain`).
+
+ JSON maps and arrays become Automerge maps and lists; numbers become integers
+ when they fit, floats otherwise; strings are plain strings (not text objects).
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema: {}
+ example: { "theme": "dark", "fontSize": 14 }
+ text/plain:
+ schema:
+ type: string
+ example: dark
+ responses:
+ '200':
+ $ref: '#/components/responses/Value'
+ '400':
+ description: Invalid path, malformed JSON or invalid UTF-8.
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ description: Protocol data (`_continuum`) and the user isn't in `protocol-writers`, or epoch metadata or the swarm ID (`_continuum/epochs`, `_continuum/swarm`, never writable).
+ '409':
+ description: The path crosses an existing non-map value.
+ '415':
+ description: Content-Type is neither `application/json` nor `text/plain` (absent means JSON).
+ '500':
+ description: Writing failed.
+ '503':
+ description: Node is not ready.
+ delete:
+ tags: [Store]
+ summary: Delete a value
+ parameters:
+ - name: prune
+ in: query
+ description: Also remove ancestor maps left empty.
+ schema:
+ type: boolean
+ default: false
+ responses:
+ '204':
+ description: Deleted.
+ '400':
+ description: Invalid path.
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '403':
+ description: Protocol data (`_continuum`) and the user isn't in `protocol-writers`, or epoch metadata or the swarm ID (`_continuum/epochs`, `_continuum/swarm`, never writable).
+ '404':
+ description: No value at this path.
+ '500':
+ description: Deleting failed.
+ '503':
+ description: Node is not ready.
+
+ /stream:
+ get:
+ tags: [Store]
+ summary: Follow the whole store
+ description: |
+ As `/stream/{key}`, for the store root (as `GET /store` reads it): one event
+ with the whole store, then one whenever anything in it changes. Events have an
+ empty `key`.
+ responses:
+ '200':
+ description: Event stream.
+ content:
+ application/x-ndjson:
+ schema:
+ $ref: '#/components/schemas/StreamEvent'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '500':
+ $ref: '#/components/responses/Error'
+ '503':
+ $ref: '#/components/responses/Error'
+
+ /stream/{key}:
+ parameters:
+ - $ref: '#/components/parameters/key'
+ get:
+ tags: [Store]
+ summary: Follow a value
+ description: |
+ Newline-delimited JSON: one event with the current value, then one whenever
+ the resolved value changes (by a local write or by sync). The connection stays
+ open.
+ responses:
+ '200':
+ description: Event stream.
+ content:
+ application/x-ndjson:
+ schema:
+ $ref: '#/components/schemas/StreamEvent'
+ example: |
+ {"key":"settings/theme","value":"dark"}
+ {"key":"settings/theme","deleted":true}
+ '400':
+ description: Invalid path.
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '500':
+ description: Reading the initial value failed.
+ '503':
+ description: Node is not ready.
+
+ /peers:
+ get:
+ tags: [Peers]
+ summary: List known peers
+ responses:
+ '200':
+ description: Peers, sorted by public key.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PeerList'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+
+ /peers/{peer}:
+ delete:
+ tags: [Peers]
+ summary: Forget a peer swarm-wide
+ description: |
+ Writes a forget record into the document and removes the peer from the member
+ list, so every node drops it. A forgotten node that is still alive registers
+ again on its next message.
+ parameters:
+ - name: peer
+ in: path
+ required: true
+ description: The peer's public key.
+ schema:
+ type: string
+ - name: force
+ in: query
+ description: '`true` or `alive`: also forget a peer that is currently alive.'
+ schema:
+ type: string
+ responses:
+ '204':
+ description: Forget recorded.
+ '400':
+ description: Empty key, this node's own key, or an unknown `force`.
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '409':
+ description: The peer is alive; pass `force=alive`.
+ '500':
+ description: Writing the record failed.
+ '503':
+ description: Node is not ready.
+
+ /admin/log:
+ get:
+ tags: [Admin]
+ summary: Current log filter
+ responses:
+ '200':
+ description: The filter in `RUST_LOG` syntax.
+ content:
+ text/plain:
+ schema:
+ type: string
+ example: info,continuum::swarm::sync=debug
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '500':
+ description: The filter can't be read.
+ put:
+ tags: [Admin]
+ summary: Replace the log filter
+ description: Takes effect immediately; not kept across restarts.
+ requestBody:
+ required: true
+ content:
+ text/plain:
+ schema:
+ type: string
+ example: info,continuum::swarm::sync=trace
+ responses:
+ '200':
+ description: The filter now in effect.
+ content:
+ text/plain:
+ schema:
+ type: string
+ '400':
+ description: Not UTF-8, or not a valid filter (the reason is in `error`); the old filter stays.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+
+ /health:
+ get:
+ tags: [Admin]
+ summary: Liveness
+ description: The HTTP API is up. Unauthenticated.
+ security: []
+ responses:
+ '200':
+ description: Up.
+ content:
+ text/plain:
+ schema:
+ type: string
+ example: "ok\n"
+
+ /ready:
+ get:
+ tags: [Admin]
+ summary: Readiness
+ description: |
+ The node serves reads and writes: `200` when ready, `503` while joining or
+ rejoining. Unauthenticated.
+ security: []
+ responses:
+ '200':
+ description: Ready.
+ content:
+ text/plain:
+ schema:
+ type: string
+ example: "ready\n"
+ '503':
+ description: Not ready (joining or rejoining).
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+
+ /admin/stats:
+ get:
+ tags: [Admin]
+ summary: Node statistics
+ description: |
+ JSON by default. When `Accept` names `application/openmetrics-text` or
+ `text/plain` (as Prometheus does), the Prometheus text format (0.0.4) instead,
+ with per-peer series labelled `peer` (the public key). Aliases are only in
+ `continuum_info` and `continuum_peer_info`, for joining.
+ responses:
+ '200':
+ description: Statistics.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Stats'
+ text/plain; version=0.0.4:
+ schema:
+ type: string
+ example: |
+ # HELP continuum_uptime_seconds Seconds since this node started.
+ # TYPE continuum_uptime_seconds gauge
+ continuum_uptime_seconds 3600
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+
+ /admin/document:
+ get:
+ tags: [Admin]
+ summary: Export the document
+ description: |
+ The whole local document as saved by Automerge (history and `_continuum`
+ included): a backup, and input to an `import` remake.
+ responses:
+ '200':
+ description: The saved document.
+ headers:
+ Content-Disposition:
+ schema:
+ type: string
+ example: attachment; filename="continuum-<swarm>-3.automerge"
+ X-Continuum-Swarm:
+ description: The swarm ID.
+ schema:
+ type: string
+ X-Continuum-Epoch:
+ description: '`<generation>:<epoch id hex>`.'
+ schema:
+ type: string
+ X-Continuum-Heads:
+ description: The document's heads (hex, comma-separated).
+ schema:
+ type: string
+ content:
+ application/octet-stream:
+ schema:
+ type: string
+ format: binary
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '429':
+ description: Another export is in progress (one at a time; each holds copies of the whole document).
+ '500':
+ description: Saving failed.
+ '503':
+ description: Node is not ready.
+
+ /admin/config:
+ get:
+ tags: [Admin]
+ summary: Effective configuration
+ description: |
+ Every option with its value and where it came from (`default`, `file` with the
+ file, or `argument`), as `--print-config` prints it. Imported values (`env:`,
+ `file:`, `cmd:`) are left out; `imported` lists their importers.
+ responses:
+ '200':
+ description: The configuration.
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [files, options]
+ properties:
+ files:
+ type: array
+ items:
+ type: string
+ options:
+ type: object
+ additionalProperties:
+ type: object
+ required: [source]
+ properties:
+ source:
+ type: string
+ enum: [default, file, argument]
+ file:
+ type: string
+ imported:
+ type: array
+ items:
+ type: string
+ value: {}
+ example:
+ files: [/etc/continuum.toml]
+ options:
+ neighbours: { source: default, value: 12 }
+ alias: { source: argument, imported: ['env:HOSTNAME'] }
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+
+ /admin/remakes:
+ get:
+ tags: [Remakes]
+ summary: List remakes
+ description: All remake records in the local document, oldest proposal first.
+ responses:
+ '200':
+ description: Remakes.
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/RemakeReport'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ post:
+ tags: [Remakes]
+ summary: Propose a remake
+ description: |
+ Proposes a remake of the current epoch; this node coordinates it and votes for
+ it. The electorate is fixed now: the members that renewed their alive stamp
+ within this node's `--electorate-window`. Each member votes for at most
+ one remake of its epoch (again only once that one is aborted). The coordinator commits as soon as a majority of
+ the electorate voted for it, and aborts once that is out of reach or
+ `settle` passed without it. Every node switches to the new epoch after the
+ switch delay. Writes made after the base are carried over (translated by the
+ kind's rules; what no rule covers is quarantined on the node that made it).
+
+ An `import` can also be uploaded as the raw document with
+ `Content-Type: application/octet-stream`; its options then go in the query
+ (`rules`, `baseHeads`, `settle`, `switchDelay`). Those query parameters
+ are refused with a JSON body.
+ parameters:
+ - $ref: '#/components/parameters/wait'
+ - $ref: '#/components/parameters/remakeForce'
+ - name: rules
+ in: query
+ description: 'Octet-stream import only: the rules as JSON, e.g. `[{"from":"","to":""}]`.'
+ schema:
+ type: string
+ - name: baseHeads
+ in: query
+ description: Octet-stream import only, comma-separated hex.
+ schema:
+ type: string
+ - name: settle
+ in: query
+ description: Octet-stream import only; a duration (seconds, or e.g. `500ms`, `8m`).
+ schema:
+ type: string
+ - name: switchDelay
+ in: query
+ description: Octet-stream import only; a duration.
+ schema:
+ type: string
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RemakeRequest'
+ examples:
+ compact:
+ value: {}
+ replace:
+ value:
+ kind: replace
+ spec:
+ document: { "accounts": {} }
+ rules:
+ - { "from": "users", "to": "accounts" }
+ - { "from": "tmp", "drop": true }
+ restore:
+ summary: Import a backup, keeping later writes at their paths
+ value:
+ kind: import
+ spec:
+ document: <base64>
+ rules:
+ - { "from": "", "to": "" }
+ application/octet-stream:
+ schema:
+ type: string
+ format: binary
+ responses:
+ '200':
+ description: With `wait=true`, the final report.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RemakeReport'
+ '202':
+ description: Proposed.
+ headers:
+ Location:
+ schema:
+ type: string
+ example: /admin/remakes/0f8e…
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RemakeReport'
+ '400':
+ $ref: '#/components/responses/Error'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '404':
+ description: With `wait=true`, the record vanished.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ '409':
+ description: |
+ Another remake of this epoch is still open (`remake` holds its ID); an
+ expired one still holds votes or can't be superseded yet (`expired` lists
+ each with `id`, `coordinator`, `coordinatorActive`, `expiredAt`,
+ `abortableAt` and `tally`; `force` names the override, if any); or this
+ node can't vote for a new one: it voted for another remake of this epoch,
+ or may have before it restarted.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ '413':
+ description: Request larger than 64 MiB.
+ '500':
+ $ref: '#/components/responses/Error'
+ '503':
+ $ref: '#/components/responses/Error'
+ delete:
+ tags: [Remakes]
+ summary: Abort the remake in progress
+ description: |
+ Aborts whichever remake of the local epoch is still undecided (the oldest if
+ several), as `DELETE /admin/remakes/{id}` does.
+ parameters:
+ - $ref: '#/components/parameters/wait'
+ - name: force
+ in: query
+ description: 'As for `DELETE /admin/remakes/{id}`: `true`, or a comma-separated list of `coordinatorActive` and `coordinatorInactive`.'
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Aborted (or decided otherwise meanwhile).
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RemakeReport'
+ '202':
+ description: Abort requested; the coordinator applies it within about a second.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RemakeReport'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '404':
+ $ref: '#/components/responses/Error'
+ '409':
+ $ref: '#/components/responses/AlreadyDecided'
+ '500':
+ $ref: '#/components/responses/Error'
+
+ /admin/remakes/{id}:
+ parameters:
+ - $ref: '#/components/parameters/remakeId'
+ get:
+ tags: [Remakes]
+ summary: Report one remake
+ parameters:
+ - name: wait
+ in: query
+ description: Long-poll until decided (bounded by the proposal's expiry, unless adopted).
+ schema:
+ type: boolean
+ default: false
+ responses:
+ '200':
+ description: The report.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RemakeReport'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '404':
+ $ref: '#/components/responses/Error'
+ delete:
+ tags: [Remakes]
+ summary: Abort a remake
+ description: |
+ Only while undecided; a committed remake can't be undone. An undecided one
+ whose coordinator this node doesn't reach answers `409` (the coordinator
+ could commit first), unless `force=coordinatorInactive`. An expired remake
+ is aborted by this node (`200`), as its coordinator is gone, once a grace
+ period has passed since it expired (`409` with `abortableAt` before), and,
+ if this node sees its coordinator as an active peer, only with
+ `force=coordinatorActive`.
+ parameters:
+ - $ref: '#/components/parameters/wait'
+ - name: force
+ in: query
+ description: '`true`, or a comma-separated list of `coordinatorActive` (abort an expired remake whose coordinator this node sees as an active peer) and `coordinatorInactive` (request the abort of an undecided remake whose coordinator this node doesn''t reach).'
+ schema:
+ type: string
+ responses:
+ '200':
+ description: Aborted (or decided otherwise meanwhile).
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RemakeReport'
+ '202':
+ description: Abort requested.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RemakeReport'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '404':
+ $ref: '#/components/responses/Error'
+ '409':
+ $ref: '#/components/responses/AlreadyDecided'
+ '500':
+ $ref: '#/components/responses/Error'
+
+ /admin/remakes/{id}/adopt:
+ parameters:
+ - $ref: '#/components/parameters/remakeId'
+ post:
+ tags: [Remakes]
+ summary: Adopt a remake
+ description: |
+ This node becomes the coordinator of an expired remake (its coordinator is
+ gone), or of one adopted before. An adopted remake has no voting period: it
+ commits once a majority of its electorate voted for it, and aborts once that
+ is out of reach or on request. Adopt on one node only.
+ parameters:
+ - $ref: '#/components/parameters/wait'
+ responses:
+ '200':
+ description: With `wait=true`, the final report.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RemakeReport'
+ '202':
+ description: Adopted.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RemakeReport'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '404':
+ $ref: '#/components/responses/Error'
+ '409':
+ description: |
+ Still in its voting period, already decided, or not a remake of this
+ node's epoch.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ '500':
+ $ref: '#/components/responses/Error'
+ '503':
+ $ref: '#/components/responses/Error'
+
+ /admin/epochs:
+ get:
+ tags: [Remakes]
+ summary: Epochs on this node
+ responses:
+ '200':
+ description: The local epoch, archived ones, and a catch-up with a newer epoch in progress.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Epochs'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+
+ /admin/quarantine:
+ get:
+ tags: [Remakes]
+ summary: List quarantined writes
+ description: |
+ This node's own writes that a remake had no place for, kept in memory on this
+ node only.
+ responses:
+ '200':
+ description: Entries.
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/QuarantineSummary'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+
+ /admin/quarantine/{id}:
+ parameters:
+ - $ref: '#/components/parameters/quarantineId'
+ get:
+ tags: [Remakes]
+ summary: Show quarantined writes
+ responses:
+ '200':
+ description: The entry and every path it holds, with the value the old document has there.
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [entry, writes]
+ properties:
+ entry:
+ $ref: '#/components/schemas/QuarantineSummary'
+ writes:
+ type: array
+ items:
+ $ref: '#/components/schemas/QuarantinedWrite'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '404':
+ $ref: '#/components/responses/Error'
+ '500':
+ $ref: '#/components/responses/Error'
+ delete:
+ tags: [Remakes]
+ summary: Discard quarantined writes
+ responses:
+ '204':
+ description: Discarded.
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '404':
+ $ref: '#/components/responses/Error'
+
+ /admin/quarantine/{id}/document:
+ parameters:
+ - $ref: '#/components/parameters/quarantineId'
+ get:
+ tags: [Remakes]
+ summary: Download the old document
+ responses:
+ '200':
+ description: The old epoch's document, as saved by Automerge.
+ headers:
+ Content-Disposition:
+ schema:
+ type: string
+ example: attachment; filename="quarantine-<id>.automerge"
+ content:
+ application/octet-stream:
+ schema:
+ type: string
+ format: binary
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '404':
+ $ref: '#/components/responses/Error'
+ '500':
+ $ref: '#/components/responses/Error'
+
+ /admin/quarantine/{id}/replay:
+ parameters:
+ - $ref: '#/components/parameters/quarantineId'
+ post:
+ tags: [Remakes]
+ summary: Replay quarantined writes
+ description: |
+ Writes them into the current epoch at their old paths, as fresh writes (they
+ win over what is there), and removes the entry. If none can be written, the
+ entry stays.
+ responses:
+ '200':
+ description: Replayed.
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [replayed, failed]
+ properties:
+ replayed:
+ type: integer
+ failed:
+ type: integer
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '404':
+ $ref: '#/components/responses/Error'
+ '409':
+ description: None of the writes could be replayed (`failed` holds the count).
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ '503':
+ $ref: '#/components/responses/Error'
+
+ /debug/firewall:
+ get:
+ tags: [Debug]
+ summary: Current firewall rules
+ responses:
+ '200':
+ description: Rules.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/FirewallRules'
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ put:
+ tags: [Debug]
+ summary: Replace the firewall rules
+ description: Drops messages from (`inbound`) or to (`outbound`) these peers, like a lossy network.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/FirewallRules'
+ responses:
+ '204':
+ description: Replaced.
+ '401':
+ $ref: '#/components/responses/Unauthorized'
+ '422':
+ description: Invalid body.
+
+components:
+ securitySchemes:
+ basic:
+ type: http
+ scheme: basic
+
+ parameters:
+ key:
+ name: key
+ in: path
+ required: true
+ description: Slash-separated store path, e.g. `settings/theme`.
+ schema:
+ type: string
+ conflicts:
+ name: conflicts
+ in: query
+ description: |
+ Answer `{"value": …, "conflicts": [...]}` (always JSON), listing every
+ conflict resolved on the way.
+ schema:
+ type: boolean
+ default: false
+ heads:
+ name: heads
+ in: query
+ description: Return the document's heads at the time of the read in `x-continuum-heads` (e.g. as the base of a remake).
+ schema:
+ type: boolean
+ default: false
+ wait:
+ name: wait
+ in: query
+ description: Wait until the remake is decided and answer with the final report.
+ schema:
+ type: boolean
+ default: false
+ remakeForce:
+ name: force
+ in: query
+ description: |
+ Checks to override: `true` for all, or a comma-separated list of
+ `switchDelay` (accept one below a keepalive interval; tests only) and
+ `supersede` (abort expired remakes of this epoch that still hold votes, and
+ propose in their place) and `coordinatorActive` (supersede even though this
+ node sees their coordinator as an active peer).
+ schema:
+ type: string
+ examples:
+ all:
+ value: 'true'
+ supersede:
+ value: supersede
+ remakeId:
+ name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ format: uuid
+ quarantineId:
+ name: id
+ in: path
+ required: true
+ schema:
+ type: string
+ format: uuid
+
+ responses:
+ Unauthorized:
+ description: Missing or wrong credentials.
+ headers:
+ WWW-Authenticate:
+ schema:
+ type: string
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ Value:
+ description: The value.
+ headers:
+ x-continuum-heads:
+ description: With `heads=true`, the heads at the time of the read (sorted hex, comma-separated).
+ schema:
+ type: string
+ x-continuum-stale:
+ description: Served from the previous epoch's document while rejoining; the value is that epoch (`<generation>:<id prefix>`).
+ schema:
+ type: string
+ ETag:
+ description: SHA-256 of the body.
+ schema:
+ type: string
+ content:
+ application/json:
+ schema:
+ oneOf:
+ - description: The value.
+ - $ref: '#/components/schemas/ValueWithConflicts'
+ text/plain:
+ schema:
+ type: string
+ Error:
+ description: Error.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ AlreadyDecided:
+ description: Already committed (can't be undone) or already over; `remake` holds the report.
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+
+ schemas:
+ Error:
+ type: object
+ required: [error]
+ properties:
+ error:
+ type: string
+ additionalProperties: true
+ description: >-
+ The body of every error answer (4xx and 5xx), on every endpoint. `error` is a
+ human-readable message; some errors add fields (`remake`, `failed`, `force`).
+
+ StreamEvent:
+ type: object
+ required: [key]
+ properties:
+ key:
+ type: string
+ value:
+ description: The current value; absent when deleted.
+ deleted:
+ type: boolean
+ const: true
+
+ ValueWithConflicts:
+ type: object
+ required: [value, conflicts]
+ properties:
+ value: {}
+ conflicts:
+ type: array
+ items:
+ $ref: '#/components/schemas/Conflict'
+
+ Conflict:
+ type: object
+ required: [path, resolution, candidates]
+ properties:
+ path:
+ type: array
+ items:
+ type: string
+ ancestor:
+ type: boolean
+ description: On the way to the requested path (candidates without values).
+ resolution:
+ type: string
+ enum: [mergedMaps, newestWins]
+ candidates:
+ type: array
+ items:
+ type: object
+ required: [opId, selected]
+ properties:
+ opId:
+ type: string
+ selected:
+ type: boolean
+ value: {}
+
+ Epoch:
+ type: object
+ required: [generation, id]
+ properties:
+ generation:
+ type: integer
+ minimum: 0
+ id:
+ type: string
+ description: Hex snapshot hash; all zeros for the genesis epoch.
+
+ PeerList:
+ type: object
+ required: [localEpoch, localHeads, peers]
+ properties:
+ localAlias:
+ type: [string, 'null']
+ localEpoch:
+ $ref: '#/components/schemas/Epoch'
+ localHeads:
+ type: integer
+ description: 'Heads of the local document (more than one: concurrent changes not yet merged).'
+ peers:
+ type: array
+ items:
+ $ref: '#/components/schemas/Peer'
+
+ Peer:
+ type: object
+ required: [publicKey, status, handshakeComplete, hasPendingDelivery, neighbour, traffic, incidents]
+ properties:
+ publicKey:
+ type: string
+ alias:
+ type: [string, 'null']
+ description: The name the peer gave itself (`--alias`); not unique.
+ validatedSwarm:
+ type: [string, 'null']
+ status:
+ type: string
+ enum: [active, dormant]
+ handshakeComplete:
+ type: boolean
+ lastSeenSecsAgo:
+ type: [integer, 'null']
+ dormantForSecs:
+ type: [integer, 'null']
+ hasPendingDelivery:
+ type: boolean
+ epoch:
+ oneOf:
+ - $ref: '#/components/schemas/Epoch'
+ - type: 'null'
+ sameEpoch:
+ type: [boolean, 'null']
+ heads:
+ type: [integer, 'null']
+ description: Heads in its last keepalive; null before one or when it sent a digest (more than 32).
+ neighbour:
+ type: boolean
+ description: We keep it alive and sync with it on our own; other peers we only answer.
+ rttMs:
+ type: [number, 'null']
+ description: Effective round-trip time (smoothed, raised by unanswered requests); null without a recent sample.
+ traffic:
+ $ref: '#/components/schemas/Traffic'
+ incidents:
+ $ref: '#/components/schemas/Incidents'
+ syncThroughput:
+ oneOf:
+ - $ref: '#/components/schemas/Throughput'
+ - type: 'null'
+
+ Counter:
+ type: object
+ required: [messages, bytes]
+ properties:
+ messages:
+ type: integer
+ bytes:
+ type: integer
+ description: Payload bytes (without Mycelium framing).
+
+ ByKind:
+ type: object
+ required: [hello, helloReply, sync, ping, pong, blob]
+ properties:
+ hello:
+ $ref: '#/components/schemas/Counter'
+ helloReply:
+ $ref: '#/components/schemas/Counter'
+ sync:
+ $ref: '#/components/schemas/Counter'
+ ping:
+ $ref: '#/components/schemas/Counter'
+ pong:
+ $ref: '#/components/schemas/Counter'
+ blob:
+ $ref: '#/components/schemas/Counter'
+ description: Blob requests and answers (chunked downloads, e.g. of an epoch's snapshot).
+
+ Traffic:
+ type: object
+ description: Since start. Every PONG answers a PING, so received pongs over sent pings is the keepalive answer ratio.
+ required: [sent, received, refused]
+ properties:
+ sent:
+ $ref: '#/components/schemas/ByKind'
+ received:
+ $ref: '#/components/schemas/ByKind'
+ refused:
+ $ref: '#/components/schemas/ByKind'
+ description: Sends Mycelium refused (not counted as sent).
+
+ Incidents:
+ type: object
+ required: [replyTimeouts, heals, dormancies, dormantMs]
+ properties:
+ replyTimeouts:
+ type: integer
+ description: Our sync messages unanswered until the reply timeout (then resent).
+ heals:
+ type: integer
+ description: Re-handshakes because documents differed while sync was idle.
+ dormancies:
+ type: integer
+ dormantMs:
+ type: integer
+ description: Time spent dormant up to the last return (the current dormancy isn't included).
+
+ Throughput:
+ type: object
+ required: [bytesPerSec, samples, secsAgo]
+ properties:
+ bytesPerSec:
+ type: number
+ description: Smoothed, from sync exchanges of at least 32 KiB.
+ samples:
+ type: integer
+ secsAgo:
+ type: integer
+ description: Age of the last sample.
+
+ Stats:
+ type: object
+ required: [publicKey, uptimeSecs, ready, epoch, document, catchUp, remakes, sync, relay, topology, stability, staleHelloReplies, traffic, incidents, propagation, disk]
+ properties:
+ publicKey:
+ type: string
+ alias:
+ type: [string, 'null']
+ uptimeSecs:
+ type: integer
+ ready:
+ type: boolean
+ epoch:
+ $ref: '#/components/schemas/Epoch'
+ document:
+ type: object
+ required: [heads, maxHeads]
+ properties:
+ heads:
+ type: integer
+ maxHeads:
+ type: integer
+ description: Most heads after applying a sync message, since start.
+ savedBytes:
+ type: [integer, 'null']
+ description: Size as saved (compressed), measured in the background; null until first measured.
+ savedBytesSecsAgo:
+ type: [integer, 'null']
+ savedBytesTookMs:
+ type: [integer, 'null']
+ catchUp:
+ type: object
+ required: [sources]
+ properties:
+ sources:
+ type: integer
+ description: Catch-up sources chosen (more than one means it switched).
+ doneAfterSecs:
+ type: [number, 'null']
+ description: From start until the initial sync was done; null until then and on the genesis node.
+ syncBytes:
+ type: [integer, 'null']
+ remakes:
+ type: object
+ description: Remakes and their aftermath here (`docs/measurements.md`, "Remakes").
+ required: [undecided, committed, rejoining, catchUpAttempts, quarantined, archivedEpochs]
+ properties:
+ undecided:
+ type: integer
+ committed:
+ type: integer
+ description: More than one is a double commit.
+ rejoining:
+ type: boolean
+ catchUpAttempts:
+ type: integer
+ quarantined:
+ type: integer
+ oldestQuarantineSecs:
+ type: [integer, 'null']
+ archivedEpochs:
+ type: integer
+ sync:
+ type: object
+ required: [divergedPeers]
+ properties:
+ lastFromNeighbourSecs:
+ type: [integer, 'null']
+ divergedPeers:
+ type: integer
+ relay:
+ type: object
+ required: [announceRounds, pulls, pullsQueued, blobRequestsDropped]
+ properties:
+ announceRounds:
+ type: integer
+ pulls:
+ type: integer
+ pullsQueued:
+ type: integer
+ blobRequestsDropped:
+ type: integer
+ topology:
+ type: object
+ required: [k]
+ properties:
+ k:
+ type: integer
+ view:
+ oneOf:
+ - type: 'null'
+ - type: object
+ required: [members, reachable, stable, neighbours, stableNeighbours, fullMesh]
+ properties:
+ members:
+ type: integer
+ description: Registered members, this node included.
+ reachable:
+ type: integer
+ description: Members other than this node not judged unreachable.
+ stable:
+ type: integer
+ neighbours:
+ type: integer
+ stableNeighbours:
+ type: integer
+ fullMesh:
+ type: boolean
+ stability:
+ type: object
+ required: [tier, connectedFraction, observedSecs]
+ properties:
+ tier:
+ type: string
+ enum: [stable, transient]
+ connectedFraction:
+ type: number
+ description: Fraction of the observed time (up to a day) with a live peer.
+ observedSecs:
+ type: integer
+ staleHelloReplies:
+ type: integer
+ disk:
+ type: object
+ description: |
+ This node's own files, by kind (`state`: the state file, `document`: the
+ document file, `backup`: a backup recovered from, `remake`: the input of a
+ genesis remake), since it started.
+ additionalProperties:
+ type: object
+ required: [reads, readBytes, writes, writtenBytes, appends, appendedBytes, errors]
+ properties:
+ reads:
+ type: integer
+ readBytes:
+ type: integer
+ writes:
+ type: integer
+ writtenBytes:
+ type: integer
+ description: Whole files written.
+ appends:
+ type: integer
+ appendedBytes:
+ type: integer
+ errors:
+ type: integer
+ description: Failed reads and writes (a missing file is no failure).
+ traffic:
+ $ref: '#/components/schemas/Traffic'
+ description: All peers, including ones no longer listed.
+ incidents:
+ $ref: '#/components/schemas/Incidents'
+ description: The peers currently listed.
+ propagation:
+ type: object
+ description: Time from a change's commit elsewhere until applied here, once caught up (includes clock skew).
+ required: [count, sumMs, ahead, buckets]
+ properties:
+ count:
+ type: integer
+ sumMs:
+ type: integer
+ ahead:
+ type: integer
+ description: Changes stamped later than our clock on arrival.
+ buckets:
+ type: array
+ description: Not cumulative; the last bucket has no bound.
+ items:
+ type: object
+ required: [count]
+ properties:
+ leMs:
+ type: [integer, 'null']
+ count:
+ type: integer
+
+ Retention:
+ type: object
+ description: Kept until every given limit has passed (as reports show it).
+ properties:
+ epochs:
+ type: integer
+ ageMs:
+ type: integer
+
+ RetentionRequest:
+ type: object
+ description: Kept until every given limit has passed (as a request gives it).
+ properties:
+ epochs:
+ type: integer
+ age:
+ oneOf: [{ type: string }, { type: number }]
+ description: A duration (seconds, or e.g. `7d`, `12h`).
+
+ Rule:
+ type: object
+ description: |
+ Where writes made after the base go. Paths are slash-separated strings (`""`
+ is the root and matches everything) or arrays of segments. The longest
+ matching `from` wins; the rest of the path is appended to `to`. Exactly one of
+ `to` and `drop`. Paths not covered are quarantined.
+ required: [from]
+ properties:
+ from:
+ $ref: '#/components/schemas/RulePath'
+ to:
+ $ref: '#/components/schemas/RulePath'
+ drop:
+ type: boolean
+
+ RulePath:
+ oneOf:
+ - type: string
+ - type: array
+ items:
+ type: string
+
+ RemakeRequest:
+ type: object
+ additionalProperties: false
+ properties:
+ kind:
+ type: string
+ enum: [compact, replace, import]
+ default: compact
+ spec:
+ description: |
+ `compact`: none. `replace`: `{document: <store root object>, rules}`.
+ `import`: `{document: <base64 of an Automerge document>, rules}`; its
+ `_continuum` and history are left out, Automerge types are kept.
+ oneOf:
+ - type: 'null'
+ - type: object
+ required: [document]
+ additionalProperties: false
+ properties:
+ document:
+ oneOf:
+ - type: object
+ - type: string
+ contentEncoding: base64
+ rules:
+ type: array
+ items:
+ $ref: '#/components/schemas/Rule'
+ baseHeads:
+ type: array
+ items:
+ type: string
+ description: |
+ Heads (hex) whose content the new document accounts for; the current heads
+ if absent. Not for `compact`, whose base is the coordinator's heads when it
+ commits (`400`).
+ replayWindow:
+ $ref: '#/components/schemas/RetentionRequest'
+ description: 'How stale a returning node''s writes may be and still be replayed. Default: 1 epoch, 7 days.'
+ archiveRetention:
+ $ref: '#/components/schemas/RetentionRequest'
+ description: 'How long nodes keep the old document. Default: 2 epochs, 30 days.'
+ settle:
+ oneOf: [{ type: string }, { type: number }]
+ description: How long votes are collected, a duration (seconds, or e.g. `500ms`, `8m`); default twice the dormancy threshold.
+ switchDelay:
+ oneOf: [{ type: string }, { type: number }]
+ description: A duration; default one keepalive interval, at least that without `force=switchDelay`.
+
+ RemakeReport:
+ type: object
+ required: [id, phase, proposal]
+ properties:
+ id:
+ type: string
+ format: uuid
+ phase:
+ type: string
+ enum: [proposed, expired, committed, aborted]
+ proposal:
+ type: object
+ required: [coordinator, fromEpoch, policy, settleMs, proposedAt, settleUntil, electorate, quorum, kind]
+ properties:
+ coordinator:
+ type: string
+ fromEpoch:
+ $ref: '#/components/schemas/Epoch'
+ baseHeads:
+ type: array
+ description: Absent for `compact`; its base is in the decision.
+ items:
+ type: string
+ policy:
+ type: object
+ properties:
+ replayWindow:
+ $ref: '#/components/schemas/Retention'
+ archiveRetention:
+ $ref: '#/components/schemas/Retention'
+ settleMs:
+ type: integer
+ proposedAt:
+ type: integer
+ settleUntil:
+ type: integer
+ switchDelayMs:
+ type: integer
+ electorate:
+ type: array
+ description: Members whose votes count, fixed at proposal time.
+ items:
+ type: string
+ quorum:
+ type: integer
+ description: Votes needed to commit (a majority of the electorate).
+ kind:
+ type: string
+ content:
+ type: object
+ description: The new document of a replace or import remake, by reference; it stays on the coordinator and every voter downloads it.
+ required: [blake3, size]
+ properties:
+ blake3:
+ type: string
+ description: BLAKE3 hash of the content, hex.
+ size:
+ type: integer
+ translation:
+ description: The kind's rules, as stored.
+ decision:
+ oneOf:
+ - type: object
+ required: [outcome, toEpoch, snapshotBytes, decidedAt]
+ properties:
+ outcome:
+ const: committed
+ toEpoch:
+ $ref: '#/components/schemas/Epoch'
+ baseHeads:
+ type: array
+ description: The base fixed at the commit (`compact`).
+ items:
+ type: string
+ snapshotBytes:
+ type: integer
+ decidedAt:
+ type: integer
+ - type: object
+ required: [outcome, reason, decidedAt]
+ properties:
+ outcome:
+ const: aborted
+ reason:
+ type: string
+ decidedAt:
+ type: integer
+ abortRequested:
+ type: object
+ properties:
+ by:
+ type: string
+ at:
+ type: integer
+ adoptedBy:
+ type: object
+ description: The node that took over coordinating, if any.
+ properties:
+ by:
+ type: string
+ at:
+ type: integer
+ contested:
+ type: boolean
+ description: |
+ Present (true) when decisions written concurrently disagree; `decision` is
+ then the commit, and nodes don't switch to it on their own.
+ votes:
+ type: object
+ description: |
+ Votes for this remake by member public key, from the votes register (only
+ electorate members count; one that also voted for another remake counts
+ for neither).
+ additionalProperties:
+ type: object
+ required: [remake, at]
+ properties:
+ remake:
+ type: string
+ at:
+ type: integer
+ local:
+ type: object
+ description: What happened to this remake on the answering node.
+ properties:
+ switchedAt:
+ type: integer
+ replayedPaths:
+ type: integer
+ failedPaths:
+ type: integer
+ quarantine:
+ type: string
+ description: ID of the quarantine entry with this node's writes that weren't replayed.
+ error:
+ type: string
+ description: Why the switch didn't happen (yet).
+
+ Epochs:
+ type: object
+ required: [current, archived, quarantine]
+ properties:
+ current:
+ $ref: '#/components/schemas/Epoch'
+ archived:
+ type: array
+ items:
+ type: object
+ required: [generation, id, archivedAt]
+ properties:
+ generation:
+ type: integer
+ id:
+ type: string
+ archivedAt:
+ type: integer
+ catchUp:
+ oneOf:
+ - type: 'null'
+ - type: object
+ required: [target, attempts, rejoining]
+ properties:
+ target:
+ type: string
+ description: '`<generation>:<id prefix>`.'
+ attempts:
+ type: integer
+ rejoining:
+ type: boolean
+ description: Syncing the target epoch (reads are served stale meanwhile).
+ error:
+ type: string
+ quarantine:
+ type: array
+ description: IDs of quarantine entries.
+ items:
+ type: string
+
+ QuarantineSummary:
+ type: object
+ required: [id, epoch, target, createdAt, reason, paths]
+ properties:
+ id:
+ type: string
+ epoch:
+ type: string
+ description: The epoch the writes were made in (`<generation>:<id prefix>`).
+ target:
+ type: string
+ description: The epoch the node rejoined.
+ createdAt:
+ type: integer
+ reason:
+ type: string
+ paths:
+ type: integer
+
+ QuarantinedWrite:
+ type: object
+ required: [path]
+ properties:
+ path:
+ type: array
+ items:
+ type: string
+ value:
+ description: The old document's value there.
+ deleted:
+ type: boolean
+ const: true
+ error:
+ type: string
+
+ FirewallRules:
+ type: object
+ properties:
+ inbound:
+ type: array
+ items:
+ type: string
+ uniqueItems: true
+ outbound:
+ type: array
+ items:
+ type: string
+ uniqueItems: true
diff --git a/src/api/mod.rs b/src/api/mod.rs
index bf7b06b..20237e1 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -1,4 +1,4 @@
-//! HTTP API: router, OpenAPI document and shared response helpers.
+//! HTTP API: router, the API description and shared response helpers.
pub(crate) mod auth;
mod debug;
@@ -24,7 +24,6 @@ use axum::{
use serde_json::{Value as JsonValue, json};
use sha2::{Digest, Sha256};
use tracing::{error, warn};
-use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
use crate::api::auth::{AuthState, AuthenticatedUser, basic_auth};
@@ -32,8 +31,8 @@ use crate::document::meta;
use crate::document::store::StoreError;
use crate::state::AppState;
-/// Builds the complete HTTP router. Everything except Swagger, `/health` and `/ready` is
-/// authenticated.
+/// Builds the complete HTTP router. Everything except the API description (`/swagger-ui`,
+/// `/openapi.yaml`), `/health` and `/ready` is authenticated.
pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) -> Router {
let mut api: Router<AppState> = Router::new()
.route("/store", get(store::get_store_root))
@@ -65,7 +64,11 @@ pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) ->
}
api.layer(middleware::from_fn_with_state(auth_state, basic_auth))
- .merge(SwaggerUi::new("/swagger-ui").url("/openapi.json", ApiDoc::openapi()))
+ // The hand-written spec (`docs/openapi.yaml`), and the Swagger UI showing it.
+ .merge(
+ SwaggerUi::new("/swagger-ui").config(utoipa_swagger_ui::Config::from("/openapi.yaml")),
+ )
+ .route("/openapi.yaml", get(openapi_spec))
.route("/health", get(health::health))
.route("/ready", get(health::ready))
// Outermost, so authentication failures get the error body too.
@@ -125,6 +128,14 @@ async fn json_errors(request: Request, next: Next) -> Response {
Response::from_parts(parts, Body::from(body))
}
+/// `GET /openapi.yaml`: the API description (`docs/openapi.yaml`, built in).
+async fn openapi_spec() -> impl IntoResponse {
+ (
+ [(CONTENT_TYPE, HeaderValue::from_static("application/yaml"))],
+ include_str!("../../docs/openapi.yaml"),
+ )
+}
+
/// `GET /admin/config`: the effective configuration with each value's source;
/// imported values are left out.
async fn get_config(State(state): State<AppState>) -> Json<JsonValue> {
@@ -231,41 +242,3 @@ fn response_for_value(
Ok(response)
}
-
-// --- openapi spec ------------------------------------------------------------
-// TODO spec is mostly out of sync
-
-#[derive(OpenApi)]
-#[openapi(
- paths(
- store::get_store_root,
- store::get_store_entry,
- store::put_store_entry,
- store::delete_store_entry,
- peers::list_peers,
- peers::delete_peer,
- ),
- components(
- schemas(
- peers::PeerStatus,
- peers::PeerListResponse,
- peers::PeerLivenessStatus,
- peers::EpochInfo,
- crate::transport::traffic::PeerTraffic,
- crate::transport::traffic::ByKind,
- crate::transport::traffic::Counter,
- crate::state::Incidents,
- peers::ThroughputInfo,
- )
- ),
- tags(
- (name = "Store", description = "Key-value store operations"),
- (name = "Peers", description = "Peer discovery and retirement")
- ),
- info(
- title = "Automerge Replication API",
- version = "1.0.0",
- description = "HTTP API for a replicated Automerge key-value store"
- )
-)]
-struct ApiDoc;
diff --git a/src/api/peers.rs b/src/api/peers.rs
index eb522b0..9af24c0 100644
--- a/src/api/peers.rs
+++ b/src/api/peers.rs
@@ -8,7 +8,6 @@ use axum::{
use serde::{Deserialize, Serialize};
use tokio::time::Instant;
use tracing::warn;
-use utoipa::ToSchema;
use super::store_status;
use crate::document::replica::Epoch;
@@ -18,14 +17,14 @@ use crate::state::{AppState, HelloState, Incidents, Liveness};
use crate::swarm::peer::forgotten_path;
use crate::transport::traffic::PeerTraffic;
-#[derive(Debug, Serialize, ToSchema)]
+#[derive(Debug, Serialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum PeerLivenessStatus {
Active,
Dormant,
}
-#[derive(Debug, Serialize, ToSchema)]
+#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct EpochInfo {
generation: u64,
@@ -42,7 +41,7 @@ impl From<Epoch> for EpochInfo {
}
}
-#[derive(Debug, Serialize, ToSchema)]
+#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PeerStatus {
public_key: String,
@@ -75,7 +74,7 @@ pub(crate) struct PeerStatus {
sync_throughput: Option<ThroughputInfo>,
}
-#[derive(Debug, Serialize, ToSchema)]
+#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ThroughputInfo {
bytes_per_sec: f64,
@@ -84,7 +83,7 @@ pub(crate) struct ThroughputInfo {
secs_ago: u64,
}
-#[derive(Debug, Serialize, ToSchema)]
+#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PeerListResponse {
/// Our own alias (`--alias`).
@@ -102,18 +101,7 @@ pub(crate) struct DeletePeerQuery {
force: Option<String>,
}
-#[utoipa::path(
- get,
- path = "/peers",
- tag = "Peers",
- responses(
- (
- status = 200,
- description = "Lists known peers and their local replication status",
- body = PeerListResponse
- )
- )
-)]
+/// `GET /peers` (`docs/openapi.yaml`).
pub(crate) async fn list_peers(
State(state): State<AppState>,
) -> Result<Json<PeerListResponse>, StatusCode> {
@@ -182,21 +170,7 @@ pub(crate) async fn list_peers(
}))
}
-#[utoipa::path(
- delete,
- path = "/peers/{peer}",
- tag = "Peers",
- params(
- ("peer" = String, Path, description = "Peer public key"),
- ("force" = Option<String>, Query, description = "`true` or `alive`: also forget a peer that is currently alive; it returns on its next message")
- ),
- responses(
- (status = 204, description = "Swarm-wide forget recorded"),
- (status = 400, description = "Not a public key, or the local node's own key"),
- (status = 409, description = "Peer is alive; pass force=alive"),
- (status = 503, description = "Node is not ready")
- )
-)]
+/// `DELETE /peers/{pk}`: forgets a peer swarm-wide.
pub(crate) async fn delete_peer(
State(state): State<AppState>,
Path(peer): Path<String>,
diff --git a/src/api/store.rs b/src/api/store.rs
index c8a072f..a05bc23 100644
--- a/src/api/store.rs
+++ b/src/api/store.rs
@@ -48,20 +48,7 @@ fn conflicts_response(
response_for_value(&body, None) // always JSON
}
-#[utoipa::path(
- get,
- path = "/store",
- tag = "Store",
- params(
- ("conflicts" = Option<bool>, Query, description = "Wrap the value and list every conflict resolved on the way"),
- ("heads" = Option<bool>, Query, description = "Return the document heads at the time of the read in x-continuum-heads"),
- ),
- responses(
- (status = 200, description = "Returns the complete store as JSON or text, depending on Accept"),
- (status = 503, description = "Node is not ready"),
- (status = 500, description = "Failed to read or serialize the document")
- )
-)]
+/// `GET /store`: the whole store (`docs/openapi.yaml`).
pub(crate) async fn get_store_root(
State(state): State<AppState>,
headers: HeaderMap,
@@ -96,23 +83,7 @@ pub(crate) async fn get_store_root(
mark_source(response, source, heads)
}
-#[utoipa::path(
- get,
- path = "/store/{key}",
- tag = "Store",
- params(
- ("key" = String, Path, description = "Slash-separated store path, for example settings/theme"),
- ("conflicts" = Option<bool>, Query, description = "Wrap the value and list every conflict resolved on the way"),
- ("heads" = Option<bool>, Query, description = "Return the document heads at the time of the read in x-continuum-heads"),
- ),
- responses(
- (status = 200, description = "Returns the value as JSON or text, depending on Accept"),
- (status = 400, description = "Invalid store path"),
- (status = 404, description = "Value does not exist"),
- (status = 500, description = "Failed to read or serialize the value"),
- (status = 503, description = "Node is not ready (and not rejoining with a stale view)")
- )
-)]
+/// `GET /store/{key}`.
pub(crate) async fn get_store_entry(
State(state): State<AppState>,
Path(key): Path<String>,
@@ -181,28 +152,7 @@ fn mark_source(
Ok(response)
}
-#[utoipa::path(
- put,
- path = "/store/{key}",
- tag = "Store",
- params(
- ("key" = String, Path, description = "Slash-separated store path")
- ),
- request_body(
- content = String,
- description = "Value to write. Use application/json for JSON values or text/plain for strings.",
- content_type = "application/json"
- ),
- responses(
- (status = 200, description = "Value written successfully"),
- (status = 400, description = "Invalid path, malformed JSON, invalid UTF-8, or unsupported JSON value"),
- (status = 403, description = "Protocol data (_continuum) and the user isn't a protocol writer, or epoch metadata"),
- (status = 409, description = "Path conflicts with an existing non-map value"),
- (status = 415, description = "Unsupported Content-Type"),
- (status = 500, description = "Internal error or synchronization notification failure"),
- (status = 503, description = "Node is not ready")
- )
-)]
+/// `PUT /store/{key}`.
pub(crate) async fn put_store_entry(
State(state): State<AppState>,
Extension(user): Extension<AuthenticatedUser>,
@@ -244,23 +194,7 @@ pub(crate) async fn put_store_entry(
response_for_value(&value, headers.get(ACCEPT))
}
-#[utoipa::path(
- delete,
- path = "/store/{key}",
- tag = "Store",
- params(
- ("key" = String, Path, description = "Slash-separated store path"),
- ("prune" = Option<bool>, Query, description = "Remove empty ancestor maps after deletion")
- ),
- responses(
- (status = 204, description = "Value deleted successfully"),
- (status = 400, description = "Invalid store path"),
- (status = 403, description = "Protocol data (_continuum) and the user isn't a protocol writer, or epoch metadata"),
- (status = 404, description = "Value does not exist"),
- (status = 500, description = "Deletion or synchronization notification failed"),
- (status = 503, description = "Node is not ready")
- )
-)]
+/// `DELETE /store/{key}`.
#[instrument(skip(state), fields(path, prune = query.prune))]
pub(crate) async fn delete_store_entry(
State(state): State<AppState>,
diff --git a/src/state/peer.rs b/src/state/peer.rs
index 944b886..344bcdb 100644
--- a/src/state/peer.rs
+++ b/src/state/peer.rs
@@ -134,7 +134,7 @@ pub(crate) struct PeerState {
/// Trouble with a peer, counted since it was first listed. Lost keepalives and refused
/// sends are in the traffic counters (`transport::traffic`): every PONG answers a PING.
-#[derive(Debug, Default, Clone, Copy, serde::Serialize, utoipa::ToSchema)]
+#[derive(Debug, Default, Clone, Copy, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Incidents {
/// Our sync messages left unanswered until the reply timeout (then sent again).
diff --git a/src/transport/firewall.rs b/src/transport/firewall.rs
index 23d91fe..037e739 100644
--- a/src/transport/firewall.rs
+++ b/src/transport/firewall.rs
@@ -4,13 +4,12 @@ use std::sync::{Arc, RwLock, RwLockReadGuard};
use anyhow::Result;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
-use utoipa::ToSchema;
use crate::locks::RwLockExt;
use crate::transport::EncodedMessage;
use crate::transport::{MessageHandler, ParsedMessage, Transport};
-#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FirewallRules {
/// Messages from these peers are dropped before the application sees them.
#[serde(default)]
diff --git a/src/transport/traffic.rs b/src/transport/traffic.rs
index 78c5fee..7d245ac 100644
--- a/src/transport/traffic.rs
+++ b/src/transport/traffic.rs
@@ -14,12 +14,11 @@ use std::sync::{Arc, Mutex, MutexGuard};
use anyhow::Result;
use async_trait::async_trait;
use serde::Serialize;
-use utoipa::ToSchema;
use crate::locks::MutexExt;
use crate::transport::{EncodedMessage, MessageHandler, MessageKind, ParsedMessage, Transport};
-#[derive(Debug, Default, Clone, Copy, Serialize, ToSchema)]
+#[derive(Debug, Default, Clone, Copy, Serialize)]
pub struct Counter {
pub messages: u64,
pub bytes: u64,
@@ -32,7 +31,7 @@ impl Counter {
}
}
-#[derive(Debug, Default, Clone, Copy, Serialize, ToSchema)]
+#[derive(Debug, Default, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ByKind {
pub hello: Counter,
@@ -56,7 +55,7 @@ impl ByKind {
}
}
-#[derive(Debug, Default, Clone, Copy, Serialize, ToSchema)]
+#[derive(Debug, Default, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PeerTraffic {
/// Handed to Mycelium (it accepted them).