diff --git a/README.md b/README.md index 825c17e..8677a76 100644 --- a/README.md +++ b/README.md @@ -12,63 +12,17 @@ curl https://lex.desertthunder.dev/api/resolve/app.bsky.feed.post curl https://lex.desertthunder.dev/api/resolve/com.atproto.repo.getRecord | jq '.lexicon' # Resolve an AT URI or HTTPS getRecord URL: -curl "https://lex.desertthunder.dev/api/uri/at%3A%2F%2Fdid%3Aplc%3Aexample%2Fcom.atproto.lexicon.schema%2Fcom.example.foo" -curl "https://lex.desertthunder.dev/api/uri?uri=https%3A%2F%2Fpds.example.com%2Fxrpc%2Fcom.atproto.repo.getRecord%3Fcollection%3Dcom.atproto.lexicon.schema%26rkey%3Dcom.example.foo" +curl "https://lex.desertthunder.dev/api/uri/at%3A%2F%2Fdid%3Aplc%3Axg2vq45muivyy3xwatcehspu%2Fcom.atproto.lexicon.schema%2Forg.stormlightlabs.malfestio.card" +curl "https://lex.desertthunder.dev/api/uri?uri=https%3A%2F%2Fshaggymane.us-west.host.bsky.network%2Fxrpc%2Fcom.atproto.repo.getRecord%3Frepo%3Ddid%253Aplc%253Axg2vq45muivyy3xwatcehspu%26collection%3Dcom.atproto.lexicon.schema%26rkey%3Dorg.stormlightlabs.malfestio.card" ``` -## API +### Endpoints `GET /api/resolve/{nsid}` resolves a direct Lexicon NSID. `GET /api/uri/{aturi}` resolves an AT URI or HTTPS URL that points at a `com.atproto.lexicon.schema` record. URL-encode inputs that contain `/`, `?`, or `&`. The same input can also be passed as `GET /api/uri?uri=...`. -Successful responses include: - -```json -{ - "version": 1, - "nsid": "app.bsky.feed.post", - "parsed": { - "nsid": "app.bsky.feed.post", - "authority": "app.bsky.feed", - "domain": "feed.bsky.app", - "name": "post", - "dnsName": "_lexicon.feed.bsky.app" - }, - "source": { - "name": "_lexicon.feed.bsky.app", - "url": "at://did:plc:.../com.atproto.lexicon.schema/app.bsky.feed.post" - }, - "trace": { - "dnsName": "_lexicon.feed.bsky.app", - "txtRecords": [], - "selectedDid": "did:plc:...", - "didDocument": { "url": "https://plc.directory/did:plc:...", "status": 200 }, - "pdsEndpoint": "https://pds.example.com/", - "repoGetRecord": { "url": "https://pds.example.com/xrpc/com.atproto.repo.getRecord?...", "status": 200 }, - "final": { "success": true, "message": "Resolved lexicon schema" } - }, - "lexicon": { "lexicon": 1, "id": "app.bsky.feed.post", "defs": {} } -} -``` - -Error responses keep the stable error shape and include the trace collected before failure: - -```json -{ - "version": 1, - "error": { "code": "not_found", "message": "No lexicon DID record found at _lexicon.example.com" }, - "trace": { - "dnsName": "_lexicon.example.com", - "txtRecords": [], - "selectedDid": null, - "didDocument": { "url": null, "status": null }, - "pdsEndpoint": null, - "repoGetRecord": { "url": null, "status": null }, - "final": { "success": false, "code": "not_found", "message": "No lexicon DID record found at _lexicon.example.com" } - } -} -``` +`GET /openapi` returns the generated OpenAPI document. The spec is generated from the Valibot schemas used to document the API routes. ### How it Works diff --git a/package.json b/package.json index 5175d36..644de47 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,11 @@ "release": "changeset publish" }, "dependencies": { - "hono": "^4.10.7" + "@hono/standard-validator": "^0.2.2", + "@valibot/to-json-schema": "^1.7.1", + "hono": "^4.10.7", + "hono-openapi": "^1.3.0", + "valibot": "^1.4.1" }, "devDependencies": { "@changesets/cli": "^2.31.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2b911a..67f17c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,9 +7,21 @@ settings: importers: .: dependencies: + '@hono/standard-validator': + specifier: ^0.2.2 + version: 0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.25) + '@valibot/to-json-schema': + specifier: ^1.7.1 + version: 1.7.1(valibot@1.4.1(typescript@5.9.3)) hono: specifier: ^4.10.7 version: 4.12.25 + hono-openapi: + specifier: ^1.3.0 + version: 1.3.0(@hono/standard-validator@0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.25))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@5.9.3)))(quansync@0.2.11)(valibot@1.4.1(typescript@5.9.3)))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@5.9.3)))(quansync@0.2.11)(valibot@1.4.1(typescript@5.9.3)))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(valibot@1.4.1(typescript@5.9.3)))(@types/json-schema@7.0.15)(hono@4.12.25)(openapi-types@12.1.3) + valibot: + specifier: ^1.4.1 + version: 1.4.1(typescript@5.9.3) devDependencies: '@changesets/cli': specifier: ^2.31.0 @@ -551,6 +563,13 @@ packages: cpu: [x64] os: [win32] + '@hono/standard-validator@0.2.2': + resolution: + { integrity: sha512-mJ7W84Bt/rSvoIl63Ynew+UZOHAzzRAoAXb3JaWuxAkM/Lzg+ZHTCUiz77KOtn2e623WNN8LkD57Dk0szqUrIw== } + peerDependencies: + '@standard-schema/spec': ^1.0.0 + hono: '>=3.9.0' + '@img/colour@1.1.0': resolution: { integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ== } @@ -921,6 +940,69 @@ packages: resolution: { integrity: sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg== } + '@standard-community/standard-json@0.3.5': + resolution: + { integrity: sha512-4+ZPorwDRt47i+O7RjyuaxHRK/37QY/LmgxlGrRrSTLYoFatEOzvqIc85GTlM18SFZ5E91C+v0o/M37wZPpUHA== } + peerDependencies: + '@standard-schema/spec': ^1.0.0 + '@types/json-schema': ^7.0.15 + '@valibot/to-json-schema': ^1.3.0 + arktype: ^2.1.20 + effect: ^3.16.8 + quansync: ^0.2.11 + sury: ^10.0.0 + typebox: ^1.0.17 + valibot: ^1.1.0 + zod: ^3.25.0 || ^4.0.0 + zod-to-json-schema: ^3.24.5 + peerDependenciesMeta: + '@valibot/to-json-schema': + optional: true + arktype: + optional: true + effect: + optional: true + sury: + optional: true + typebox: + optional: true + valibot: + optional: true + zod: + optional: true + zod-to-json-schema: + optional: true + + '@standard-community/standard-openapi@0.2.9': + resolution: + { integrity: sha512-htj+yldvN1XncyZi4rehbf9kLbu8os2Ke/rfqoZHCMHuw34kiF3LP/yQPdA0tQ940y8nDq3Iou8R3wG+AGGyvg== } + peerDependencies: + '@standard-community/standard-json': ^0.3.5 + '@standard-schema/spec': ^1.0.0 + arktype: ^2.1.20 + effect: ^3.17.14 + openapi-types: ^12.1.3 + sury: ^10.0.0 + typebox: ^1.0.0 + valibot: ^1.1.0 + zod: ^3.25.0 || ^4.0.0 + zod-openapi: ^4 + peerDependenciesMeta: + arktype: + optional: true + effect: + optional: true + sury: + optional: true + typebox: + optional: true + valibot: + optional: true + zod: + optional: true + zod-openapi: + optional: true + '@standard-schema/spec@1.1.0': resolution: { integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== } @@ -941,6 +1023,10 @@ packages: resolution: { integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg== } + '@types/json-schema@7.0.15': + resolution: + { integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== } + '@types/node@12.20.55': resolution: { integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ== } @@ -949,6 +1035,12 @@ packages: resolution: { integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA== } + '@valibot/to-json-schema@1.7.1': + resolution: + { integrity: sha512-3qkmU6KXWh8GIThEAW3kuRHPQBMjWkKy+Ppz3WkUucx53DTpOa6siMn4xDGSOhlVyMrDaJTCTMLYPZVAIk1P0A== } + peerDependencies: + valibot: ^1.4.0 + '@vitest/expect@4.1.9': resolution: { integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA== } @@ -1168,6 +1260,22 @@ packages: resolution: { integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== } + hono-openapi@1.3.0: + resolution: + { integrity: sha512-xDvCWpWEIv0weEmnl3EjRQzqbHIO8LnfzMuYOCmbuyE5aes6aXxLg4vM3ybnoZD5TiTUkA6PuRQPJs3R7WRBig== } + peerDependencies: + '@hono/standard-validator': ^0.2.0 + '@standard-community/standard-json': ^0.3.5 + '@standard-community/standard-openapi': ^0.2.9 + '@types/json-schema': ^7.0.15 + hono: ^4.8.3 + openapi-types: ^12.1.3 + peerDependenciesMeta: + '@hono/standard-validator': + optional: true + hono: + optional: true + hono@4.12.25: resolution: { integrity: sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ== } @@ -1367,6 +1475,10 @@ packages: { integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg== } engines: { node: '>=12.20.0' } + openapi-types@12.1.3: + resolution: + { integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw== } + outdent@0.5.0: resolution: { integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q== } @@ -1630,6 +1742,15 @@ packages: { integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== } engines: { node: '>= 4.0.0' } + valibot@1.4.1: + resolution: + { integrity: sha512-klCmFTz2jeDluy9RwX+F884TCiogtdBJ/YaxSx1EOBYXa3NXNWj8kR1jjN8rzluwojJVWWaHJ4r1U5LfICnM3g== } + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + vite@8.0.16: resolution: { integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw== } @@ -2118,6 +2239,11 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@hono/standard-validator@0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.25)': + dependencies: + '@standard-schema/spec': 1.1.0 + hono: 4.12.25 + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.34.5': @@ -2334,6 +2460,23 @@ snapshots: '@speed-highlight/core@1.2.17': {} + '@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@5.9.3)))(quansync@0.2.11)(valibot@1.4.1(typescript@5.9.3))': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/json-schema': 7.0.15 + quansync: 0.2.11 + optionalDependencies: + '@valibot/to-json-schema': 1.7.1(valibot@1.4.1(typescript@5.9.3)) + valibot: 1.4.1(typescript@5.9.3) + + '@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@5.9.3)))(quansync@0.2.11)(valibot@1.4.1(typescript@5.9.3)))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(valibot@1.4.1(typescript@5.9.3))': + dependencies: + '@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@5.9.3)))(quansync@0.2.11)(valibot@1.4.1(typescript@5.9.3)) + '@standard-schema/spec': 1.1.0 + openapi-types: 12.1.3 + optionalDependencies: + valibot: 1.4.1(typescript@5.9.3) + '@standard-schema/spec@1.1.0': {} '@tybys/wasm-util@0.10.2': @@ -2350,12 +2493,18 @@ snapshots: '@types/estree@1.0.9': {} + '@types/json-schema@7.0.15': {} + '@types/node@12.20.55': {} '@types/node@24.13.2': dependencies: undici-types: 7.18.2 + '@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@5.9.3))': + dependencies: + valibot: 1.4.1(typescript@5.9.3) + '@vitest/expect@4.1.9': dependencies: '@standard-schema/spec': 1.1.0 @@ -2576,6 +2725,16 @@ snapshots: graceful-fs@4.2.11: {} + hono-openapi@1.3.0(@hono/standard-validator@0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.25))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@5.9.3)))(quansync@0.2.11)(valibot@1.4.1(typescript@5.9.3)))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@5.9.3)))(quansync@0.2.11)(valibot@1.4.1(typescript@5.9.3)))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(valibot@1.4.1(typescript@5.9.3)))(@types/json-schema@7.0.15)(hono@4.12.25)(openapi-types@12.1.3): + dependencies: + '@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@5.9.3)))(quansync@0.2.11)(valibot@1.4.1(typescript@5.9.3)) + '@standard-community/standard-openapi': 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(@valibot/to-json-schema@1.7.1(valibot@1.4.1(typescript@5.9.3)))(quansync@0.2.11)(valibot@1.4.1(typescript@5.9.3)))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(valibot@1.4.1(typescript@5.9.3)) + '@types/json-schema': 7.0.15 + openapi-types: 12.1.3 + optionalDependencies: + '@hono/standard-validator': 0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.25) + hono: 4.12.25 + hono@4.12.25: {} human-id@4.2.0: {} @@ -2701,6 +2860,8 @@ snapshots: obug@2.1.3: {} + openapi-types@12.1.3: {} + outdent@0.5.0: {} p-filter@2.1.0: @@ -2898,6 +3059,10 @@ snapshots: universalify@0.1.2: {} + valibot@1.4.1(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4): dependencies: lightningcss: 1.32.0 diff --git a/scripts/smoke.dev.hurl b/scripts/smoke.dev.hurl new file mode 100644 index 0000000..d003e74 --- /dev/null +++ b/scripts/smoke.dev.hurl @@ -0,0 +1,90 @@ +# Local dev API smoke test. +# Run with: hurl --test scripts/smoke.dev.hurl + +GET http://localhost:8787/api/resolve/app.bsky.feed.post +HTTP 200 +[Asserts] +header "content-type" contains "application/json" +jsonpath "$.version" == "0.0.0" +jsonpath "$.nsid" == "app.bsky.feed.post" +jsonpath "$.parsed.authority" == "app.bsky.feed" +jsonpath "$.parsed.domain" == "feed.bsky.app" +jsonpath "$.parsed.name" == "post" +jsonpath "$.parsed.dnsName" == "_lexicon.feed.bsky.app" +jsonpath "$.cache" isString +jsonpath "$.trace.dnsName" == "_lexicon.feed.bsky.app" +jsonpath "$.trace.txtRecords" isCollection +jsonpath "$.trace.selectedDid" startsWith "did:" +jsonpath "$.trace.final.success" == true +jsonpath "$.trace.final.message" isString +jsonpath "$.lexicon.lexicon" == 1 +jsonpath "$.lexicon.id" == "app.bsky.feed.post" +jsonpath "$._links.self.href" == "http://localhost:8787/api/resolve/app.bsky.feed.post" +jsonpath "$._links.source.href" startsWith "at://did:" + +GET http://localhost:8787/api/resolve/app.bsky.feed.post +HTTP 200 +[Asserts] +jsonpath "$.version" == "0.0.0" +jsonpath "$.nsid" == "app.bsky.feed.post" +jsonpath "$.cache" == "hit" +jsonpath "$.trace.final.success" == true +jsonpath "$.trace.final.message" == "Resolved lexicon schema from cache" +jsonpath "$.lexicon.id" == "app.bsky.feed.post" + +GET http://localhost:8787/api/uri/at%3A%2F%2Fdid%3Aplc%3Aexample%2Fcom.atproto.lexicon.schema%2Fapp.bsky.feed.post +HTTP 200 +[Asserts] +jsonpath "$.version" == "0.0.0" +jsonpath "$.nsid" == "app.bsky.feed.post" +jsonpath "$.parsed.dnsName" == "_lexicon.feed.bsky.app" +jsonpath "$.trace.final.success" == true +jsonpath "$.lexicon.id" == "app.bsky.feed.post" + +GET http://localhost:8787/api/uri?uri=https%3A%2F%2Fbsky.social%2Fxrpc%2Fcom.atproto.repo.getRecord%3Fcollection%3Dcom.atproto.lexicon.schema%26rkey%3Dapp.bsky.feed.post +HTTP 200 +[Asserts] +jsonpath "$.version" == "0.0.0" +jsonpath "$.nsid" == "app.bsky.feed.post" +jsonpath "$.parsed.dnsName" == "_lexicon.feed.bsky.app" +jsonpath "$.trace.final.success" == true +jsonpath "$.lexicon.id" == "app.bsky.feed.post" + +GET http://localhost:8787/api/uri/https://shaggymane.us-west.host.bsky.network/xrpc/com.atproto.repo.getRecord?repo=did%3Aplc%3Axg2vq45muivyy3xwatcehspu&collection=com.atproto.lexicon.schema&rkey=org.stormlightlabs.malfestio.card +HTTP 200 +[Asserts] +jsonpath "$.version" == "0.0.0" +jsonpath "$.nsid" == "org.stormlightlabs.malfestio.card" +jsonpath "$.trace.selectedDid" == "did:plc:xg2vq45muivyy3xwatcehspu" +jsonpath "$.trace.final.success" == true +jsonpath "$.lexicon.id" == "org.stormlightlabs.malfestio.card" + +GET http://localhost:8787/api/uri?uri=https://shaggymane.us-west.host.bsky.network/xrpc/com.atproto.repo.getRecord?repo=did%3Aplc%3Axg2vq45muivyy3xwatcehspu&collection=com.atproto.lexicon.schema&rkey=org.stormlightlabs.malfestio.card +HTTP 200 +[Asserts] +jsonpath "$.version" == "0.0.0" +jsonpath "$.nsid" == "org.stormlightlabs.malfestio.card" +jsonpath "$.trace.selectedDid" == "did:plc:xg2vq45muivyy3xwatcehspu" +jsonpath "$.trace.final.success" == true +jsonpath "$.lexicon.id" == "org.stormlightlabs.malfestio.card" + +GET http://localhost:8787/api/resolve/not-a-valid-nsid +HTTP 400 +[Asserts] +header "content-type" contains "application/json" +jsonpath "$.version" == "0.0.0" +jsonpath "$.error.code" == "bad_request" +jsonpath "$.error.message" == "Invalid NSID" +jsonpath "$.trace.final.success" == false +jsonpath "$.trace.final.code" == "bad_request" +jsonpath "$.trace.final.message" == "Invalid NSID" + +GET http://localhost:8787/openapi +HTTP 200 +[Asserts] +header "content-type" contains "application/json" +jsonpath "$.openapi" == "3.1.0" +jsonpath "$.info.title" == "lexdns API" +jsonpath "$.info.version" == "0.0.0" +jsonpath "$.paths['/api/resolve/{nsid}'].get.responses['200'].content['application/json'].schema" exists +jsonpath "$.paths['/api/uri/{aturi}'].get.responses['400'].content['application/json'].schema" exists diff --git a/src/lexicon.ts b/src/lexicon.ts index 272e239..9a79581 100644 --- a/src/lexicon.ts +++ b/src/lexicon.ts @@ -9,6 +9,7 @@ import type { ResolutionTrace, ApiErrorCode } from './types'; +import { API_VERSION } from './meta'; const NSID_PATTERN = /^[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(\.[a-zA-Z]([a-zA-Z0-9]{0,62})?)$/; @@ -66,7 +67,7 @@ export async function resolveLexicon(nsid: string, env: RuntimeEnv): Promise(); +type AppContext = Context<{ Bindings: Env }>; const CORS_HEADERS = { 'access-control-allow-origin': '*', @@ -14,6 +18,71 @@ const CORS_HEADERS = { 'access-control-max-age': '86400' }; +export function withLinks(resolved: ResolvedLexicon, origin: string): ResolvedLexiconResponse { + return { + ...resolved, + _links: { + self: { href: `${origin}/api/resolve/${encodeURIComponent(resolved.nsid)}` }, + source: { href: resolved.source.url } + } + }; +} + +function errorBody(error: ReturnType) { + return { version: API_VERSION, error: { code: error.code, message: error.message }, trace: error.trace }; +} + +async function resolveUriResponse(c: AppContext, pathInput = ''): Promise { + try { + const url = new URL(c.req.url); + const input = rawQueryUriInput(url) ?? pathInput; + const parsed = normalizeResolveInput(input); + const resolved = await resolveLexicon(parsed?.nsid ?? '', c.env); + return jsonResponse(withLinks(resolved, url.origin), { headers: CORS_HEADERS }); + } catch (error) { + const mapped = mapResolveError(error); + return jsonResponse(errorBody(mapped), { status: mapped.status, headers: CORS_HEADERS }); + } +} + +function rawQueryUriInput(url: URL): string | null { + const rawQuery = url.search.slice(1); + if (!rawQuery.startsWith('uri=')) { + return null; + } + + return decodeUriComponentSafe(rawQuery.slice('uri='.length)); +} + +function rawPathUriInput(url: URL): string { + const input = resolvePathUri(url.pathname); + if (url.search.length === 0 || !/^https?:\/\//.test(input)) { + return input; + } + + return `${input}${url.search}`; +} + +function decodeUriComponentSafe(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function apiResponses() { + return { + 200: { description: 'Resolved Lexicon schema', content: JSON_CONTENT }, + 400: { description: 'Bad request', content: ERROR_JSON_CONTENT }, + 404: { description: 'Lexicon publisher or route not found', content: ERROR_JSON_CONTENT }, + 502: { description: 'Upstream DNS, DID, or PDS failure', content: ERROR_JSON_CONTENT }, + 500: { description: 'Internal error', content: ERROR_JSON_CONTENT } + }; +} + +const app = new Hono<{ Bindings: Env }>(); + app.get('/', (c) => { return c.html(Index({ baseUrl: new URL(c.req.url).origin })); }); @@ -37,48 +106,89 @@ app.options('/api/uri/*', () => { return new Response(null, { status: 204, headers: CORS_HEADERS }); }); -app.get('/api/resolve/*', async (c) => { - try { - const url = new URL(c.req.url); - const nsid = resolvePathNsid(url.pathname); - const resolved = await resolveLexicon(nsid, c.env); - return jsonResponse(withLinks(resolved, url.origin), { headers: CORS_HEADERS }); - } catch (error) { - const mapped = mapResolveError(error); - return jsonResponse(errorBody(mapped), { status: mapped.status, headers: CORS_HEADERS }); - } +app.options('/api/uri', () => { + return new Response(null, { status: 204, headers: CORS_HEADERS }); }); -app.get('/api/uri/*', async (c) => { - try { - const url = new URL(c.req.url); - const input = c.req.query('uri') ?? resolvePathUri(url.pathname); - const parsed = normalizeResolveInput(input); - const resolved = await resolveLexicon(parsed?.nsid ?? '', c.env); - return jsonResponse(withLinks(resolved, url.origin), { headers: CORS_HEADERS }); - } catch (error) { - const mapped = mapResolveError(error); - return jsonResponse(errorBody(mapped), { status: mapped.status, headers: CORS_HEADERS }); +app.get( + '/api/resolve/:nsid', + describeRoute({ + operationId: 'resolveLexiconByNsid', + summary: 'Resolve a Lexicon schema by NSID', + description: 'Resolves an ATProto Lexicon schema through DNS TXT discovery, DID resolution, and repo.getRecord.', + tags: ['Lexicons'], + responses: apiResponses() + }), + validator('param', NsidParamSchema), + async (c) => { + try { + const url = new URL(c.req.url); + const nsid = c.req.valid('param').nsid; + const resolved = await resolveLexicon(nsid, c.env); + return jsonResponse(withLinks(resolved, url.origin), { headers: CORS_HEADERS }); + } catch (error) { + const mapped = mapResolveError(error); + return jsonResponse(errorBody(mapped), { status: mapped.status, headers: CORS_HEADERS }); + } } -}); +); -app.notFound(() => { - return apiError(404, 'not_found', 'Not found'); -}); +app.get( + '/api/uri/:aturi', + describeRoute({ + operationId: 'resolveLexiconByUri', + summary: 'Resolve a Lexicon schema by URI', + description: + 'Resolves an AT URI or HTTPS URL that points at a com.atproto.lexicon.schema record. Use the uri query parameter for URLs with their own query string.', + tags: ['Lexicons'], + parameters: [ + { + name: 'aturi', + in: 'path', + required: true, + description: 'URL-encoded AT URI or HTTPS URL.', + schema: { type: 'string' } + } + ], + responses: apiResponses() + }), + validator('query', UriQuerySchema), + async (c) => resolveUriResponse(c, c.req.param('aturi')) +); -export function withLinks(resolved: ResolvedLexicon, origin: string): ResolvedLexiconResponse { - return { - ...resolved, - _links: { - self: { href: `${origin}/api/resolve/${encodeURIComponent(resolved.nsid)}` }, - source: { href: resolved.source.url } +app.get( + '/api/uri', + describeRoute({ + operationId: 'resolveLexiconByUriQuery', + summary: 'Resolve a Lexicon schema by URI query parameter', + description: 'Resolves an AT URI or HTTPS URL passed as the uri query parameter.', + tags: ['Lexicons'], + responses: apiResponses() + }), + validator('query', UriQuerySchema), + async (c) => resolveUriResponse(c) +); + +app.get('/api/uri/*', describeRoute({ hide: true }), validator('query', UriQuerySchema), async (c) => + resolveUriResponse(c, rawPathUriInput(new URL(c.req.url))) +); + +app.get( + '/openapi', + openAPIRouteHandler(app, { + documentation: { + info: { + title: 'lexdns API', + version: API_VERSION, + description: 'Resolve ATProto Lexicon schemas from DNS-discovered publishers.' + } } - }; -} + }) +); -function errorBody(error: ReturnType) { - return { version: 1, error: { code: error.code, message: error.message }, trace: error.trace }; -} +app.notFound(() => { + return apiError(404, 'not_found', 'Not found'); +}); export { app }; export default app; diff --git a/src/meta.ts b/src/meta.ts new file mode 100644 index 0000000..16f6079 --- /dev/null +++ b/src/meta.ts @@ -0,0 +1,4 @@ +import pkg from '../package.json'; + +/** Application version exported from package metadata for API responses and OpenAPI info. */ +export const API_VERSION = pkg.version; diff --git a/src/schema.ts b/src/schema.ts new file mode 100644 index 0000000..fc3fa33 --- /dev/null +++ b/src/schema.ts @@ -0,0 +1,68 @@ +import { resolver } from 'hono-openapi'; +import * as v from 'valibot'; + +export const NsidParamSchema = v.object({ + nsid: v.pipe(v.string(), v.description('Lexicon schema NSID, such as app.bsky.feed.post')) +}); + +export const UriQuerySchema = v.object({ + uri: v.optional(v.pipe(v.string(), v.description('AT URI or HTTPS URL for a com.atproto.lexicon.schema record'))) +}); + +const VersionSchema = v.pipe(v.string(), v.description('lexdns package version')); + +const ParsedNsidSchema = v.object({ + nsid: v.string(), + authority: v.string(), + domain: v.string(), + name: v.string(), + dnsName: v.string() +}); + +const TxtRecordSchema = v.object({ name: v.string(), data: v.string(), ttl: v.optional(v.number()) }); + +const TraceFetchSchema = v.object({ url: v.nullable(v.string()), status: v.nullable(v.number()) }); + +const ApiErrorCodeSchema = v.picklist(['bad_request', 'not_found', 'upstream_error', 'internal_error']); + +const TraceFinalSchema = v.object({ success: v.boolean(), code: v.optional(ApiErrorCodeSchema), message: v.string() }); + +export const ResolutionTraceSchema = v.object({ + dnsName: v.nullable(v.string()), + txtRecords: v.array(TxtRecordSchema), + selectedDid: v.nullable(v.string()), + didDocument: TraceFetchSchema, + pdsEndpoint: v.nullable(v.string()), + repoGetRecord: TraceFetchSchema, + final: TraceFinalSchema +}); + +const LexiconDocumentSchema = v.objectWithRest( + { lexicon: v.literal(1), id: v.string(), defs: v.record(v.string(), v.unknown()) }, + v.unknown() +); + +const LinksSchema = v.object({ self: v.object({ href: v.string() }), source: v.object({ href: v.string() }) }); + +export const ResolvedLexiconResponseSchema = v.object({ + version: VersionSchema, + nsid: v.string(), + parsed: ParsedNsidSchema, + hash: v.string(), + fetchedAt: v.string(), + source: v.object({ name: v.string(), url: v.string() }), + lexicon: LexiconDocumentSchema, + cache: v.picklist(['hit', 'miss']), + trace: ResolutionTraceSchema, + _links: LinksSchema +}); + +export const ErrorResponseSchema = v.object({ + version: VersionSchema, + error: v.object({ code: ApiErrorCodeSchema, message: v.string() }), + trace: v.optional(ResolutionTraceSchema) +}); + +export const JSON_CONTENT = { 'application/json': { schema: resolver(ResolvedLexiconResponseSchema) } }; + +export const ERROR_JSON_CONTENT = { 'application/json': { schema: resolver(ErrorResponseSchema) } }; diff --git a/src/types.ts b/src/types.ts index f41582a..f1dd46e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -34,7 +34,7 @@ export type ResolutionTrace = { /** Versioned successful API payload for a resolved Lexicon schema. */ export type ResolvedLexicon = { - version: 1; + version: string; nsid: string; parsed: ParsedNsid; hash: string; diff --git a/test/lexicon.test.ts b/test/lexicon.test.ts index 369a4cb..c918820 100644 --- a/test/lexicon.test.ts +++ b/test/lexicon.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { isLexiconDocument, normalizeResolveInput, parseNsid, resolveLexicon } from '../src/lexicon'; +import { API_VERSION } from '../src/meta'; import type { RuntimeEnv, ResolvedLexicon } from '../src/types'; const originalFetch = globalThis.fetch; @@ -11,7 +12,7 @@ function resolvedLexicon(nsid = 'com.example.foo'): ResolvedLexicon { } return { - version: 1, + version: API_VERSION, nsid, parsed, hash: 'sha256:test', @@ -117,7 +118,7 @@ describe('Lexicon resolver', () => { await expect(resolveLexicon('com.example.foo', env)).resolves.toMatchObject({ nsid: 'com.example.foo', - version: 1, + version: API_VERSION, cache: 'hit', source: { name: '_lexicon.example.com', url: 'at://did:plc:example/com.atproto.lexicon.schema/com.example.foo' }, parsed: { dnsName: '_lexicon.example.com' }, @@ -164,7 +165,7 @@ describe('Lexicon resolver', () => { await expect(resolveLexicon('app.bsky.feed.post', env)).resolves.toMatchObject({ nsid: 'app.bsky.feed.post', - version: 1, + version: API_VERSION, cache: 'miss', parsed: { authority: 'app.bsky.feed', domain: 'feed.bsky.app', name: 'post', dnsName: '_lexicon.feed.bsky.app' }, source: { diff --git a/test/main.test.ts b/test/main.test.ts index 0ca97a5..89ad451 100644 --- a/test/main.test.ts +++ b/test/main.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { app, withLinks } from '../src/main'; import { parseNsid } from '../src/lexicon'; +import { API_VERSION } from '../src/meta'; import type { ResolvedLexicon } from '../src/types'; const originalFetch = globalThis.fetch; @@ -12,7 +13,7 @@ function resolvedLexicon(nsid = 'com.example.foo'): ResolvedLexicon { } return { - version: 1, + version: API_VERSION, nsid, parsed, hash: 'sha256:test', @@ -101,22 +102,49 @@ describe('API response presenter', () => { expect(response.status).toBe(200); expect(body).toMatchObject({ - version: 1, + version: API_VERSION, nsid: 'com.example.foo', parsed: { dnsName: '_lexicon.example.com' }, trace: { selectedDid: 'did:plc:example', final: { success: true } } }); }); + it('reconstructs raw HTTPS URL path and query inputs', async () => { + const cached = resolvedLexicon('com.example.foo'); + const env = testEnv(new Map([['lexicon:com.example.foo', JSON.stringify(cached)]])); + const rawUrl = + 'https://pds.example.com/xrpc/com.atproto.repo.getRecord?repo=did%3Aplc%3Aexample&collection=com.atproto.lexicon.schema&rkey=com.example.foo'; + + const pathResponse = await app.fetch(new Request(`https://lexdns.example/api/uri/${rawUrl}`), env); + const queryResponse = await app.fetch(new Request(`https://lexdns.example/api/uri?uri=${rawUrl}`), env); + const pathBody = (await pathResponse.json()) as Record; + const queryBody = (await queryResponse.json()) as Record; + + expect(pathResponse.status).toBe(200); + expect(queryResponse.status).toBe(200); + expect(pathBody).toMatchObject({ nsid: 'com.example.foo', cache: 'hit' }); + expect(queryBody).toMatchObject({ nsid: 'com.example.foo', cache: 'hit' }); + }); + it('returns stable API errors with traces', async () => { const response = await app.fetch(new Request('https://lexdns.example/api/uri/not-a-uri'), testEnv()); const body = await response.json(); expect(response.status).toBe(400); expect(body).toMatchObject({ - version: 1, + version: API_VERSION, error: { code: 'bad_request', message: 'Invalid NSID' }, trace: { final: { success: false, code: 'bad_request', message: 'Invalid NSID' } } }); }); + + it('serves an OpenAPI document with package version and schemas', async () => { + const response = await app.fetch(new Request('https://lexdns.example/openapi'), testEnv()); + const body = (await response.json()) as Record; + + expect(response.status).toBe(200); + expect(body.info).toMatchObject({ title: 'lexdns API', version: API_VERSION }); + expect(body.paths['/api/resolve/{nsid}'].get.responses['200'].content['application/json'].schema).toBeTruthy(); + expect(body.paths['/api/uri/{aturi}'].get.responses['400'].content['application/json'].schema).toBeTruthy(); + }); }); diff --git a/tsconfig.json b/tsconfig.json index b2972f5..9ac835b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "moduleResolution": "Bundler", "lib": ["ES2022"], "strict": true, + "resolveJsonModule": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "skipLibCheck": true,