Completes Fase 9's "consolidar MCP" half: the daemon now serves its own MCP endpoint at /mcp, exposing every semantic-routes operation as an MCP tool against the daemon's authoritative graph, and the old Electron-local MCP server is retired since it's now redundant. New: src/main/frontend/worker/mcp_server.cljs The CF-Worker's own MCP endpoint (deps/db-sync/worker/entry.mjs) generates its tools from the OpenAPI document via @cloudflare/codemode's openApiMcpServer, a package that only exists inside Cloudflare Workers, plus a pile of ChatGPT-App-specific tooling (asset image resources, upload_asset, Cognito protected-resource metadata) this daemon has no use for -- none of that ports to Node. So this registers one tool per operation by hand instead, using @modelcontextprotocol/sdk directly, the same pattern electron/mcp_server.cljs already used for its own 6 tools (registerTool with an explicit title/description/inputSchema/handler) -- just generated from semantic-routes' operation table and operation-docs (now public) instead of writing 35 one-off registrations by hand, so the MCP tool surface can't drift from the HTTP routes or the OpenAPI document. Each tool's Zod input shape is assembled from path params (auto-derived from the route's :param segments) plus a per-operation-id map of the extra query/body fields; a handful of shared fragments (pagination, time filters, the recursive block-tree shape, property-value/task-status selectors) cover the repetition across list/create/update operations. Each tool's handler makes a loopback HTTP call to this same daemon's own /v1/semantic/* route, forwarding the /mcp request's own Authorization header -- reusing the already-tested HTTP-layer logic (repo-error validation, lock-owner assertion, response shaping) instead of a second request path directly against the thread-api. Session/transport plumbing (handle-post/get/delete-request, the transports-by-session-id map) is adapted from electron/mcp_server.cljs's Fastify-based version: that one gets req/res already unwrapped-to-Node-primitives via `.raw` with the body pre-parsed by Fastify; this one is called straight from the daemon's own raw http.createServer callback, so req/res already are what StreamableHTTPServerTransport wants, and the body needs parsing here instead. No separate auth check either -- the daemon's existing bearer-token check on every route (request-authorized? in db_worker_node.cljs) already gates /mcp before this is reached. Wires POST/GET/DELETE /mcp into db_worker_node.cljs's make-server cond chain, alongside the existing /v1/semantic/* branch. Dependencies: adds @modelcontextprotocol/sdk and zod (zod/v3 -- the SDK doesn't work with zod 4's default export, same constraint electron/mcp_server.cljs already worked around) to the root package.json; previously only resources/package.json (Electron's own bundle) had them. Retired: electron/mcp_server.cljs and its wiring (electron/server.cljs's initialize-mcp-routes, its 3 POST/GET/DELETE /mcp route registrations, the :mcp-enabled? config field; components/server.cljs's "copy MCP URL" popup link; components/settings.cljs's entire AI settings tab, which had nothing in it besides the MCP-server toggle). Electron already talks to the daemon (Fase 3) rather than driving itself locally, so a second, separately-configured MCP surface bolted onto Electron's own local automation API server (which otherwise stays -- /api is a distinct, still-used concern) was redundant with the daemon's new one. Removed the 4 associated i18n keys (server/mcp-url-copied, settings/ai, settings.ai/enable-mcp-server[-desc]) from all ~26 locale dicts. New: src/test/frontend/worker/mcp_server_test.cljs Connects a real @modelcontextprotocol/sdk client over HTTP to a live daemon's /mcp endpoint: lists all registered tools, round-trips createPage/getPage and a recursive captureToToday block tree, and checks a tool call against a nonexistent page surfaces as MCP error content rather than throwing. 4 tests, 11 assertions. Verification: all 5 shadow-cljs targets compile with 0 warnings. Full `bb dev:test` run: the only failures are the same 7 pre-existing, sandboxed-network-related persist_db_test.cljs failures already documented in the Fase 8 and Fase 9 (1/N) commits -- identical test names, identical ECONNREFUSED/Invalid URL errors. Every other shard, including all three daemon-HTTP worker test files sharing one test.js process (db_worker_node_test.cljs, semantic_test.cljs, mcp_server_test.cljs), passes clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
401 lines
28 KiB
Clojure
401 lines
28 KiB
Clojure
(ns frontend.worker.semantic-routes
|
|
"Route table and OpenAPI document for the semantic API served by the
|
|
daemon (Fase 9). Ported from deps/db-sync/.../routes/semantic.cljs: that
|
|
version routes by :graph-id under a Cloudflare Worker Durable Object per
|
|
graph. This daemon is bound to a single repo per process (see
|
|
db_worker_node.cljs's `bound-repo`), so the :graph-id path segment and the
|
|
listGraphs operation are dropped -- every request implicitly targets the
|
|
daemon's own bound repo. OAuth2 is likewise dropped in favor of the
|
|
daemon's actual bearer-token session auth (see db-worker-node-auth)."
|
|
(:require [clojure.string :as string]
|
|
[flatland.ordered.map :refer [ordered-map]]
|
|
[reitit.core :as r]))
|
|
|
|
(def operations
|
|
[{:method "GET" :path "/v1/semantic/pages"
|
|
:handler :semantic/pages-list :operation-id "listPages" :success-status 200}
|
|
{:method "POST" :path "/v1/semantic/pages"
|
|
:handler :semantic/pages-create :operation-id "createPage" :success-status 201}
|
|
{:method "GET" :path "/v1/semantic/pages/:page-id/blocks"
|
|
:handler :semantic/pages-blocks :operation-id "listPageBlocks" :success-status 200}
|
|
{:method "GET" :path "/v1/semantic/pages/:page-id/references"
|
|
:handler :semantic/pages-references :operation-id "listPageReferences" :success-status 200}
|
|
{:method "DELETE" :path "/v1/semantic/pages/:page-id"
|
|
:handler :semantic/pages-delete :operation-id "deletePage" :success-status 204}
|
|
{:method "GET" :path "/v1/semantic/pages/:page-id"
|
|
:handler :semantic/pages-get :operation-id "getPage" :success-status 200}
|
|
{:method "PATCH" :path "/v1/semantic/pages/:page-id"
|
|
:handler :semantic/pages-update :operation-id "updatePage" :success-status 200}
|
|
{:method "GET" :path "/v1/semantic/blocks/:block-id"
|
|
:handler :semantic/blocks-get :operation-id "getBlock" :success-status 200}
|
|
{:method "PATCH" :path "/v1/semantic/blocks/:block-id"
|
|
:handler :semantic/blocks-update :operation-id "updateBlock" :success-status 200}
|
|
{:method "DELETE" :path "/v1/semantic/blocks/:block-id"
|
|
:handler :semantic/blocks-delete :operation-id "deleteBlock" :success-status 204}
|
|
{:method "POST" :path "/v1/semantic/block-moves"
|
|
:handler :semantic/blocks-move :operation-id "moveBlocks" :success-status 200}
|
|
{:method "POST" :path "/v1/semantic/blocks/:block-id/children"
|
|
:handler :semantic/blocks-insert-children :operation-id "insertBlockChildren" :success-status 201}
|
|
{:method "POST" :path "/v1/semantic/block-trees"
|
|
:handler :semantic/blocks-insert-tree :operation-id "insertBlockTree" :success-status 201}
|
|
{:method "PUT" :path "/v1/semantic/blocks/:block-id/properties/:property-id"
|
|
:handler :semantic/blocks-set-property :operation-id "setBlockProperty" :success-status 200}
|
|
{:method "DELETE" :path "/v1/semantic/blocks/:block-id/properties/:property-id"
|
|
:handler :semantic/blocks-delete-property :operation-id "deleteBlockProperty" :success-status 204}
|
|
{:method "POST" :path "/v1/semantic/block-properties/batch-set"
|
|
:handler :semantic/blocks-batch-set-property :operation-id "batchSetBlockProperty" :success-status 200}
|
|
{:method "POST" :path "/v1/semantic/block-properties/batch-delete"
|
|
:handler :semantic/blocks-batch-delete-property :operation-id "batchDeleteBlockProperty" :success-status 200}
|
|
{:method "POST" :path "/v1/semantic/capture"
|
|
:handler :semantic/capture :operation-id "captureToToday" :success-status 201}
|
|
{:method "GET" :path "/v1/semantic/tasks"
|
|
:handler :semantic/tasks-list :operation-id "listTasks" :success-status 200}
|
|
{:method "POST" :path "/v1/semantic/tasks"
|
|
:handler :semantic/tasks-create :operation-id "createTask" :success-status 201}
|
|
{:method "GET" :path "/v1/semantic/tags"
|
|
:handler :semantic/tags-list :operation-id "listTags" :success-status 200}
|
|
{:method "POST" :path "/v1/semantic/tags"
|
|
:handler :semantic/tags-create :operation-id "createTag" :success-status 201}
|
|
{:method "GET" :path "/v1/semantic/tags/:tag-id"
|
|
:handler :semantic/tags-get :operation-id "getTag" :success-status 200}
|
|
{:method "GET" :path "/v1/semantic/tags/:tag-id/objects"
|
|
:handler :semantic/tags-objects :operation-id "listTagObjects" :success-status 200}
|
|
{:method "PATCH" :path "/v1/semantic/tags/:tag-id"
|
|
:handler :semantic/tags-update :operation-id "updateTag" :success-status 200}
|
|
{:method "DELETE" :path "/v1/semantic/tags/:tag-id"
|
|
:handler :semantic/tags-delete :operation-id "deleteTag" :success-status 204}
|
|
{:method "GET" :path "/v1/semantic/properties"
|
|
:handler :semantic/properties-list :operation-id "listProperties" :success-status 200}
|
|
{:method "POST" :path "/v1/semantic/properties"
|
|
:handler :semantic/properties-create :operation-id "createProperty" :success-status 201}
|
|
{:method "GET" :path "/v1/semantic/properties/:property-id"
|
|
:handler :semantic/properties-get :operation-id "getProperty" :success-status 200}
|
|
{:method "PATCH" :path "/v1/semantic/properties/:property-id"
|
|
:handler :semantic/properties-update :operation-id "updateProperty" :success-status 200}
|
|
{:method "DELETE" :path "/v1/semantic/properties/:property-id"
|
|
:handler :semantic/properties-delete :operation-id "deleteProperty" :success-status 204}
|
|
{:method "GET" :path "/v1/semantic/assets"
|
|
:handler :semantic/assets-list :operation-id "listAssets" :success-status 200}
|
|
{:method "POST" :path "/v1/semantic/assets"
|
|
:handler :semantic/assets-create :operation-id "createAsset" :success-status 201}
|
|
{:method "GET" :path "/v1/semantic/assets/:asset-block-id"
|
|
:handler :semantic/assets-get :operation-id "getAsset" :success-status 200}
|
|
{:method "GET" :path "/v1/semantic/search"
|
|
:handler :semantic/search :operation-id "searchGraph" :success-status 200}])
|
|
|
|
(def operation-docs
|
|
"Public so worker.mcp-server can source MCP tool titles/descriptions from
|
|
the same place the OpenAPI document does, instead of a second copy that
|
|
could drift."
|
|
{"listPages" ["List pages" "Returns a cursor-paginated list of page blocks in the graph."]
|
|
"createPage" ["Create a page" "Creates a page block with the supplied title."]
|
|
"listPageBlocks" ["List a page's blocks" "Returns a cursor-paginated list of the page's top-level blocks, including each selected block's descendant tree."]
|
|
"listPageReferences" ["List references to a page" "Returns a cursor-paginated flat list of blocks and pages that reference the addressed page. Results are not recursively expanded."]
|
|
"getPage" ["Get a page" "Returns one page block by UUID without loading its block tree."]
|
|
"updatePage" ["Update a page" "Renames one page identified by its block UUID."]
|
|
"deletePage" ["Delete a page" "Deletes one page through Logseq's page deletion rules, including its block tree."]
|
|
"getBlock" ["Get a block" "Returns one block or page by UUID without loading an unbounded tree."]
|
|
"updateBlock" ["Update a block" "Replaces the title of one block identified by UUID."]
|
|
"deleteBlock" ["Delete a block" "Deletes one block and its descendants. Asset blocks are deleted through this same operation."]
|
|
"moveBlocks" ["Move blocks" "Moves one or more blocks together before, after, into the first-child position, or into the last-child position of a target block. Logseq preserves the blocks' outliner order."]
|
|
"insertBlockChildren" ["Insert child block trees" "Appends or prepends one or more recursive block trees as children of the addressed block."]
|
|
"insertBlockTree" ["Insert block trees" "Inserts one or more recursive block trees relative to an explicit target block."]
|
|
"setBlockProperty" ["Set a block property" "Idempotently adds or replaces one typed property value on one block in a DB graph. The property path value may be its UUID or ident. Never write legacy file-graph key:: value syntax into the block title."]
|
|
"deleteBlockProperty" ["Delete a block property" "Idempotently removes one property from one block. The property definition itself is not deleted."]
|
|
"batchSetBlockProperty" ["Set block properties in a batch" "Atomically adds or replaces multiple typed block-property values in a DB graph after validating every entry. Property IDs are UUIDs or idents; never use legacy file-graph key:: value syntax."]
|
|
"batchDeleteBlockProperty" ["Delete block properties in a batch" "Atomically removes multiple block-property values after validating every entry."]
|
|
"captureToToday" ["Capture to today's journal" "Creates today's journal page when necessary and appends the supplied recursive block trees to its end."]
|
|
"listTasks" ["List DB tasks" "Lists cursor-paginated DB Task objects using the Task class index, with optional status, priority, and title-content filters. Use this operation instead of graph search to retrieve tasks."]
|
|
"createTask" ["Create a DB task" "Creates a DB Task object with the Task class and typed status, priority, scheduled, and deadline properties. Never encode a task as Markdown TODO text."]
|
|
"listTags" ["List tags" "Returns a cursor-paginated list of tag entities in the graph."]
|
|
"createTag" ["Create a tag" "Creates a Logseq tag with the supplied title."]
|
|
"getTag" ["Get a tag" "Returns one tag by UUID."]
|
|
"listTagObjects" ["List objects with a tag" "Returns a cursor-paginated flat list of blocks and pages tagged with the addressed tag. Results are not recursively expanded."]
|
|
"updateTag" ["Update a tag" "Renames one tag identified by UUID."]
|
|
"deleteTag" ["Delete a tag" "Deletes one tag through Logseq's page deletion rules."]
|
|
"listProperties" ["List property definitions" "Returns a cursor-paginated list of property definitions in the graph."]
|
|
"createProperty" ["Create a property definition" "Creates a typed property definition in a DB graph with its name, value type, and cardinality. Use the returned UUID or ident to set values; never use legacy file-graph key:: value syntax."]
|
|
"getProperty" ["Get a property definition" "Returns one property definition by UUID."]
|
|
"updateProperty" ["Update a property definition" "Updates the name, value type, or cardinality of one typed property definition in a DB graph. Never use legacy file-graph key:: value syntax."]
|
|
"deleteProperty" ["Delete a property definition" "Deletes one property definition through Logseq's property deletion rules."]
|
|
"listAssets" ["List assets" "Returns a cursor-paginated list of asset blocks with optional creation and update time filters."]
|
|
"createAsset" ["Upload an asset" "Streams a file of at most 100MB to the daemon's local asset storage and creates its Asset-class block. Requires a client-computed SHA-256 checksum."]
|
|
"getAsset" ["Get an asset" "Returns asset metadata and a server-relative URL for the asset. Resolve the URL against the daemon's own base URL; it requires the same bearer token as every other route (or a ?token= query fallback for direct navigation)."]
|
|
"searchGraph" ["Search graph resources" "Searches blocks, tags, properties, and assets by title and returns cursor-paginated results."]})
|
|
|
|
(defn- operation-routes []
|
|
(->> operations
|
|
(group-by :path)
|
|
(mapv (fn [[path path-operations]]
|
|
[path {:methods (into {} (map (juxt :method identity)) path-operations)}]))))
|
|
|
|
(def ^:private router
|
|
(r/router (operation-routes)))
|
|
|
|
(defn match [method path]
|
|
(when-let [route (r/match-by-path router path)]
|
|
(when-let [operation (get-in route [:data :methods method])]
|
|
(merge operation (select-keys route [:path-params])))))
|
|
|
|
(defn- openapi-path [path]
|
|
(string/replace path #":([^/]+)" "{$1}"))
|
|
|
|
(defn- path-parameters [path]
|
|
(mapv (fn [[_ parameter]]
|
|
(cond-> {:name parameter :in "path" :required true :schema {:type "string"}}
|
|
(= "property-id" parameter)
|
|
(assoc :description "Property UUID, qualified ident, or exact title.")))
|
|
(re-seq #":([^/]+)" path)))
|
|
|
|
(def ^:private pagination-parameters
|
|
[{:name "limit" :in "query" :schema {:type "integer" :minimum 1 :maximum 200 :default 50}}
|
|
{:name "cursor" :in "query" :schema {:type "string"}}])
|
|
|
|
(def ^:private time-filter-parameters
|
|
[{:name "created-after" :in "query"
|
|
:description "Return entities created strictly after this Unix epoch timestamp in milliseconds."
|
|
:schema {:type "integer" :minimum 0}}
|
|
{:name "updated-after" :in "query"
|
|
:description "Return entities updated strictly after this Unix epoch timestamp in milliseconds."
|
|
:schema {:type "integer" :minimum 0}}])
|
|
|
|
(def ^:private asset-upload-parameters
|
|
[{:name "file-name" :in "query" :required true :schema {:type "string"}}
|
|
{:name "size" :in "query" :required true
|
|
:description "Exact decoded file size in bytes. Uploads larger than 100MB are rejected."
|
|
:schema {:type "integer" :minimum 0 :maximum 104857600}}
|
|
{:name "title" :in "query" :schema {:type "string"}}
|
|
{:name "page-id" :in "query"
|
|
:description "Destination page UUID. Omit to append to today's journal."
|
|
:schema {:type "string"}}
|
|
{:name "checksum" :in "query" :required true
|
|
:description "Lowercase or uppercase SHA-256 hex digest of the file."
|
|
:schema {:type "string" :pattern "^[0-9a-fA-F]{64}$"}}
|
|
{:name "encoding" :in "query"
|
|
:description "Use base64 when the caller cannot transfer raw binary. The server decodes it as a stream before writing to disk; size and checksum describe the decoded file."
|
|
:schema {:type "string" :enum ["base64"]}}])
|
|
|
|
(defn- request-schema [operation-id]
|
|
(case operation-id
|
|
"createPage" {:required ["title"] :properties {:title {:type "string"}}}
|
|
"updatePage" {:required ["title"] :properties {:title {:type "string"}}}
|
|
"updateBlock" {:required ["title"] :properties {:title {:type "string"}}}
|
|
"moveBlocks" {:required ["block-ids" "target-id" "position"]
|
|
:properties {:block-ids {:type "array" :minItems 1 :uniqueItems true
|
|
:items {:type "string"}}
|
|
:target-id {:type "string"}
|
|
:position {:enum ["before" "after" "first-child" "last-child"]}}}
|
|
"insertBlockChildren" {:required ["position" "blocks"]
|
|
:properties {:position {:enum ["append" "prepend"]}
|
|
:blocks {:type "array" :items {:$ref "#/components/schemas/BlockTree"}}}}
|
|
"insertBlockTree" {:required ["target-id" "blocks"]
|
|
:properties {:target-id {:type "string"}
|
|
:position {:enum ["append" "prepend"] :default "append"}
|
|
:blocks {:type "array" :items {:$ref "#/components/schemas/BlockTree"}}}}
|
|
"setBlockProperty" {:required ["value"]
|
|
:properties {:value {:$ref "#/components/schemas/PropertyValue"}}}
|
|
"batchSetBlockProperty" {:required ["entries"]
|
|
:properties {:isResetExistingValues
|
|
{:type "boolean" :default false
|
|
:description "For cardinality-many values, replace existing values when true; append when false or omitted."}
|
|
:entries {:type "array"
|
|
:items {:type "object" :required ["block-id" "property-id" "value"]
|
|
:properties {:block-id {:type "string"}
|
|
:property-id {:type "string"}
|
|
:value {:$ref "#/components/schemas/PropertyValue"}}}}}}
|
|
"batchDeleteBlockProperty" {:required ["entries"]
|
|
:properties {:entries {:type "array"
|
|
:items {:type "object" :required ["block-id" "property-id"]
|
|
:properties {:block-id {:type "string"}
|
|
:property-id {:type "string"}}}}}}
|
|
"captureToToday" {:required ["blocks"]
|
|
:properties {:blocks {:type "array" :items {:$ref "#/components/schemas/BlockTree"}}}}
|
|
"createTask" {:required ["title"]
|
|
:properties {:title {:type "string"}
|
|
:page-id {:type "string"
|
|
:description "Destination page UUID. Omit to append to today's journal."}
|
|
:status {:$ref "#/components/schemas/TaskStatusSelector"}
|
|
:priority {:$ref "#/components/schemas/TaskPrioritySelector"}
|
|
:scheduled {:type "integer" :description "Scheduled date as YYYYMMDD."}
|
|
:deadline {:type "integer" :description "Deadline date as YYYYMMDD."}}}
|
|
"createTag" {:required ["title"] :properties {:title {:type "string"}}}
|
|
"updateTag" {:required ["title"] :properties {:title {:type "string"}}}
|
|
"createProperty" {:required ["title"]
|
|
:properties {:title {:type "string"} :type {:$ref "#/components/schemas/PropertyType"}
|
|
:cardinality {:enum ["db.cardinality/one" "db.cardinality/many"]}}}
|
|
"updateProperty" {:properties {:title {:type "string"} :type {:$ref "#/components/schemas/PropertyType"}
|
|
:cardinality {:enum ["db.cardinality/one" "db.cardinality/many"]}}}
|
|
nil))
|
|
|
|
(defn- operation-parameters [{:keys [operation-id path]}]
|
|
(cond-> (path-parameters path)
|
|
(contains? #{"listPages" "listPageBlocks" "listPageReferences" "listTasks" "listTags"
|
|
"listTagObjects" "listProperties" "listAssets" "searchGraph"} operation-id)
|
|
(into pagination-parameters)
|
|
(contains? #{"listPages" "listTasks" "listTags" "listTagObjects" "listProperties" "listAssets" "searchGraph"}
|
|
operation-id)
|
|
(into time-filter-parameters)
|
|
(= "createAsset" operation-id)
|
|
(into asset-upload-parameters)
|
|
(= "searchGraph" operation-id)
|
|
(into [{:name "q" :in "query" :required true :schema {:type "string"}}
|
|
{:name "types" :in "query" :schema {:type "string"}}])
|
|
(= "listTasks" operation-id)
|
|
(into [{:name "status" :in "query" :schema {:$ref "#/components/schemas/TaskStatusSelector"}}
|
|
{:name "priority" :in "query" :schema {:$ref "#/components/schemas/TaskPrioritySelector"}}
|
|
{:name "content" :in "query" :schema {:type "string"}
|
|
:description "Case-insensitive task title filter."}])))
|
|
|
|
(defn- request-body [operation-id]
|
|
(if (= "createAsset" operation-id)
|
|
{:required true
|
|
:content {"application/octet-stream"
|
|
{:schema {:type "string" :format "binary"}}}}
|
|
(when-let [schema (request-schema operation-id)]
|
|
{:required true
|
|
:content {"application/json" {:schema (assoc schema :type "object")}}})))
|
|
|
|
(defn- list-response [response-key schema description]
|
|
{:description description
|
|
:content {"application/json"
|
|
{:schema {:type "object"
|
|
:required [(name response-key)]
|
|
:properties {response-key {:type "array"
|
|
:items {:$ref (str "#/components/schemas/" schema)}}
|
|
:next-cursor {:type "string"}}}}}})
|
|
|
|
(defn- entity-response [schema description]
|
|
{:description description
|
|
:content {"application/json" {:schema {:$ref (str "#/components/schemas/" schema)}}}})
|
|
|
|
(defn- operation-responses [operation-id]
|
|
(merge {"200" (case operation-id
|
|
"listPages" (list-response :pages "PageResponse" "A cursor page of pages")
|
|
"listPageBlocks"
|
|
(list-response :blocks "BlockResponse" "A cursor page of top-level block trees")
|
|
"listPageReferences" (list-response :references "BlockResponse" "A cursor page of page references")
|
|
"listTasks"
|
|
(list-response :tasks "TaskResponse" "A cursor page of DB Task objects")
|
|
"listTags" (list-response :tags "TagResponse" "A cursor page of tags")
|
|
"listTagObjects" (list-response :objects "BlockResponse" "A cursor page of tagged objects")
|
|
"listProperties" (list-response :properties "PropertyResponse" "A cursor page of property definitions")
|
|
"listAssets"
|
|
(list-response :assets "AssetResponse" "A cursor page of asset blocks")
|
|
"searchGraph" (list-response :results "SearchResultResponse" "A cursor page of search results")
|
|
"getPage" (entity-response "PageResponse" "A page")
|
|
"updatePage" (entity-response "PageResponse" "The updated page")
|
|
"getBlock" (entity-response "BlockResponse" "A block")
|
|
"updateBlock" (entity-response "BlockResponse" "The updated block")
|
|
"getTag" (entity-response "TagResponse" "A tag")
|
|
"updateTag" (entity-response "TagResponse" "The updated tag")
|
|
"getProperty"
|
|
(entity-response "PropertyResponse" "A property definition and its user-extensible choices")
|
|
"updateProperty" (entity-response "PropertyResponse" "The updated property definition")
|
|
"getAsset" (entity-response "AssetResponse" "Asset metadata and a server-relative URL")
|
|
{:description "Success"})
|
|
"400" {:description "Invalid request"}
|
|
"401" {:description "Missing or invalid bearer token"}
|
|
"404" {:description "Not found"}}
|
|
(case operation-id
|
|
"createPage" {"201" (entity-response "PageResponse" "The created page")}
|
|
"insertBlockChildren" {"201" {:description "The inserted block trees"}}
|
|
"insertBlockTree" {"201" {:description "The inserted block trees"}}
|
|
"createTag" {"201" (entity-response "TagResponse" "The created tag")}
|
|
"createProperty" {"201" (entity-response "PropertyResponse" "The created property definition")}
|
|
"createTask" {"201" (entity-response "TaskResponse" "The created DB Task object")}
|
|
"createAsset" {"201" (entity-response "AssetResponse" "The created asset block")}
|
|
{})))
|
|
|
|
(defn openapi-document
|
|
[{:keys [repo]}]
|
|
{:openapi "3.1.0"
|
|
:info {:title "Logseq Daemon Semantic API" :version "1.0.0"
|
|
:description (str "Operates on the graph bound to this daemon instance"
|
|
(when repo (str " (" repo ")")) ".")}
|
|
:servers [{:url "/"}]
|
|
:components
|
|
{:schemas (ordered-map
|
|
:BlockTree {:type "object" :required ["title"]
|
|
:properties {:title {:type "string"}
|
|
:children {:type "array" :items {:$ref "#/components/schemas/BlockTree"}}}}
|
|
:BlockResponse {:type "object" :required ["uuid" "kind" "title"]
|
|
:properties {:uuid {:type "string"}
|
|
:kind {:type "string"}
|
|
:title {:type "string"}
|
|
:order {:type "string"}
|
|
:parent-id {:type "string"}
|
|
:page-id {:type "string"}
|
|
:children {:type "array"
|
|
:items {:$ref "#/components/schemas/BlockResponse"}}}}
|
|
:PageResponse {:allOf [{:$ref "#/components/schemas/BlockResponse"}]}
|
|
:TagResponse {:type "object" :required ["uuid" "title"]
|
|
:properties {:uuid {:type "string"}
|
|
:title {:type "string"}
|
|
:ident {:type "string"}}}
|
|
:SearchResultResponse {:type "object" :required ["uuid" "title" "resource"]
|
|
:properties {:uuid {:type "string"}
|
|
:title {:type "string"}
|
|
:resource {:enum ["blocks" "tags" "properties" "assets"]}}}
|
|
:EntitySelector {:type "string"
|
|
:description "An existing entity UUID, qualified ident, or exact title."}
|
|
:TaskStatusSelector
|
|
{:description "A built-in status alias or any user-extensible Status property choice selected by UUID, ident, or exact title."
|
|
:anyOf [{:type "string" :enum ["todo" "doing" "in-review" "done" "canceled" "backlog"]}
|
|
{:$ref "#/components/schemas/EntitySelector"}]}
|
|
:TaskPrioritySelector
|
|
{:description "A built-in priority alias or any user-extensible Priority property choice selected by UUID, ident, or exact title."
|
|
:anyOf [{:type "string" :enum ["low" "medium" "high" "urgent"]}
|
|
{:$ref "#/components/schemas/EntitySelector"}]}
|
|
:PropertyChoice {:type "object" :required ["uuid" "title"]
|
|
:properties {:uuid {:type "string"}
|
|
:ident {:type "string"}
|
|
:title {:type "string"}
|
|
:value {}}}
|
|
:PropertyResponse {:type "object" :required ["uuid" "title" "type" "cardinality"]
|
|
:properties {:uuid {:type "string"}
|
|
:ident {:type "string"}
|
|
:title {:type "string"}
|
|
:type {:type "string"}
|
|
:cardinality {:type "string"}
|
|
:choices {:type "array"
|
|
:description "The current user-extensible choices for this property."
|
|
:items {:$ref "#/components/schemas/PropertyChoice"}}}}
|
|
:PropertyType {:type "string"
|
|
:enum ["default" "number" "date" "datetime" "checkbox" "url" "node" "asset"
|
|
"map" "json" "string"]}
|
|
:AssetResponse {:type "object" :required ["uuid" "title" "type" "size" "checksum"]
|
|
:properties {:uuid {:type "string"}
|
|
:title {:type "string"}
|
|
:type {:type "string"}
|
|
:size {:type "integer" :minimum 0 :maximum 104857600}
|
|
:checksum {:type "string" :pattern "^[0-9a-f]{64}$"}
|
|
:url {:type "string"}}}
|
|
:TaskResponse {:allOf [{:$ref "#/components/schemas/BlockResponse"}
|
|
{:type "object"
|
|
:required ["status"]
|
|
:properties {:status {:$ref "#/components/schemas/PropertyChoice"}
|
|
:priority {:$ref "#/components/schemas/PropertyChoice"}
|
|
:scheduled {:type "integer"}
|
|
:deadline {:type "integer"}}}]}
|
|
:PropertyValue
|
|
{:description "For ref properties, pass an existing entity by UUID, ident, or title. Status and Priority also accept case-insensitive built-in aliases such as TODO, DONE, and HIGH; user-defined choices use UUID, ident, or exact title. Class properties such as Tags accept a class UUID, ident, or title, or an array of those selectors. Scalar properties accept their JSON scalar value."
|
|
:oneOf [{:type "string"} {:type "number"} {:type "boolean"}
|
|
{:type "array" :items {:oneOf [{:type "string"} {:type "number"}]}}]})
|
|
:securitySchemes
|
|
{:bearerAuth {:type "http" :scheme "bearer"
|
|
:description "Session token from POST /v1/auth/login."}}}
|
|
:security [{:bearerAuth []}]
|
|
:paths
|
|
(reduce (fn [paths {:keys [method path operation-id]}]
|
|
(let [[summary description] (get operation-docs operation-id)]
|
|
(assoc-in paths [(openapi-path path) (keyword (.toLowerCase method))]
|
|
(cond-> {:operationId operation-id
|
|
:summary summary
|
|
:description description
|
|
:parameters (operation-parameters {:operation-id operation-id :path path})
|
|
:responses (operation-responses operation-id)}
|
|
(request-body operation-id)
|
|
(assoc :requestBody (request-body operation-id))))))
|
|
(ordered-map)
|
|
operations)})
|