Something went wrong. Try again.
A decentralized music tracking and discovery platform built on AT Protocol 🎵 rocksky.app
spotify atproto lastfm musicbrainz scrobbling listenbrainz
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385// Generate documentation only. Never executes API handlers or modifies lexicons.const fs = require("node:fs");const path = require("node:path");const ts = require("typescript");const root = path.resolve(__dirname, "../../..");const api = path.join(root, "apps/api");const target = path.join(root, "docs/api-reference/openapi.json");const read = (p) => fs.readFileSync(p, "utf8");function files(dir) { return fs .readdirSync(dir) .sort() .flatMap((name) => { const p = path.join(dir, name); return fs.statSync(p).isDirectory() ? files(p) : [p]; });}const docs = Object.fromEntries( files(path.join(api, "lexicons")) .filter((p) => p.endsWith(".json")) .map((p) => { const doc = JSON.parse(read(p)); return [doc.id, doc]; }),);// Follow the actual registration tree, excluding unregistered lexicon methods.const methods = new Map();const visited = new Set();function registrations(file) { if (visited.has(file)) return; visited.add(file); const source = read(file); const ast = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true); const imports = new Map(); for (const node of ast.statements) { if ( ts.isImportDeclaration(node) && node.importClause?.name && node.moduleSpecifier.text.startsWith(".") ) { const base = path.resolve(path.dirname(file), node.moduleSpecifier.text); imports.set( node.importClause.name.text, fs.existsSync(base + ".ts") ? base + ".ts" : path.join(base, "index.ts"), ); } } function visit(node) { if (ts.isCallExpression(node)) { const name = node.expression.getText(ast); if (imports.has(name) && node.arguments[0]?.getText(ast) === "server") registrations(imports.get(name)); if (name.startsWith("server.app.rocksky.")) methods.set(name.slice(7), { source, file }); } ts.forEachChild(node, visit); } visit(ast);}registrations(path.join(api, "src/xrpc/index.ts"));const key = (id, name = "main") => `${id}__${name}`;const ref = (value, id) => { const [base, name = "main"] = value.split("#"); if (!docs[base || id]?.defs[name]) throw new Error(`Unresolved lexicon ref: ${value} from ${id}`); return { $ref: `#/components/schemas/${key(base || id, name)}` };};function schema(s, id) { let out; switch (s.type) { case "ref": out = ref(s.ref, id); break; case "union": out = { anyOf: s.refs.map((r) => ref(r, id)) }; if (!s.closed) out.anyOf.push({ type: "object", required: ["$type"], properties: { $type: { type: "string" } }, }); break; case "record": return schema(s.record, id); case "object": case "params": out = { type: "object", properties: {} }; for (const [name, value] of Object.entries(s.properties || {})) { const prop = schema(value, id); out.properties[name] = s.nullable?.includes(name) && value.type !== "unknown" ? { anyOf: [prop, { type: "null" }] } : prop; } if (s.required?.length) out.required = s.required; break; case "array": out = { type: "array", items: schema(s.items, id) }; break; case "string": case "integer": case "boolean": out = { type: s.type }; break; case "unknown": out = {}; break; // A token is a symbolic value: a string that is the token's own NSID // fragment (e.g. `community.lexicon.calendar.rsvp#going`). case "token": out = { type: "string" }; break; case "blob": out = { type: "object", description: "AT Protocol blob reference.", properties: { $type: { const: "blob", type: "string" }, ref: { type: "object", properties: { $link: { type: "string" } } }, mimeType: { type: "string" }, size: { type: "integer", minimum: 0 }, }, }; break; default: throw new Error(`Unsupported lexicon schema type: ${s.type}`); } for (const attr of [ "description", "default", "enum", "const", "minimum", "maximum", ]) if (s[attr] !== undefined) out[attr] = s[attr]; for (const attr of ["minLength", "maxLength"]) if (s[attr] !== undefined) out[s.type === "array" ? attr.replace("Length", "Items") : attr] = s[attr]; for (const attr of [ "knownValues", "maxGraphemes", "minGraphemes", "accept", "maxSize", ]) if (s[attr] !== undefined) out[`x-lexicon-${attr}`] = s[attr]; if (s.format) { if (["datetime", "uri"].includes(s.format)) out.format = s.format === "datetime" ? "date-time" : "uri"; else out["x-lexicon-format"] = s.format; } return out;}const optionalAuth = new Set([ "notification.listNotifications", "notification.getUnreadCount", "notification.updateSeen", "actor.getActorAlbums", "actor.getActorCompatibility", "actor.getActorScrobbles", "actor.getProfile", "album.getAlbum", "song.getSong", "scrobble.getScrobble", "playlist.getPlaylist", "feed.getFeed", "feed.getStories", "graph.getKnownFollowers", "mirror.getMirrorSources", "equalizer.listPresets", "rockbox.getAudioSettings", "shout.getAlbumShouts", "shout.getArtistShouts", "shout.getProfileShouts", "shout.getTrackShouts", "shout.getShoutReplies",]);const notes = { "notification.listNotifications": "Authenticate to retrieve your notifications. Without a token, returns an empty list and zero unread count.", "notification.getUnreadCount": "Authenticate to retrieve your unread count. Without a token, returns zero.", "notification.updateSeen": "Authentication is needed to change notifications. Without a token, makes no changes and returns zero unread count.", "feed.getFeed": "Authentication is required when filtering by following.", "feed.getStories": "Authentication is required when filtering by following.", "equalizer.listPresets": "Supply did for public lookup, or authenticate to use your own DID.", "rockbox.getAudioSettings": "Supply did for public lookup, or authenticate to use your own DID.", "graph.getKnownFollowers": "Without authentication, returns an empty result.", "mirror.getMirrorSources": "Without authentication, returns the default disabled provider settings.",};const spec = { openapi: "3.1.0", info: { title: "Rocksky XRPC API", version: "1.0.0", description: "Generated from the registered Rocksky XRPC handlers and current lexicons. Provider-defined payloads remain open schemas. Some handlers return empty fallback objects on service failures. This reference describes the repository implementation; production may run an earlier revision.", license: { name: "MIT License", identifier: "MIT" }, }, servers: [ { url: "https://api.rocksky.app/xrpc", description: "Rocksky API" }, ], paths: {}, components: { securitySchemes: { Bearer: { type: "http", scheme: "bearer", description: "Rocksky bearer token.", }, }, schemas: { XrpcError: { type: "object", required: ["error"], properties: { error: { type: "string" }, message: { type: "string" } }, }, }, },};for (const [id, doc] of Object.entries(docs).sort()) { if (id.startsWith("app.bsky.")) continue; for (const [name, def] of Object.entries(doc.defs)) { if (!["query", "procedure", "subscription"].includes(def.type)) spec.components.schemas[key(id, name)] = schema(def, id); }}// Wire values that Lexicon cannot express (floats and untagged unions).for (const exception of JSON.parse( read(path.join(api, "tests/xrpc-response-exceptions.json")),)) { const parent = spec.components.schemas[key(exception.lexicon, exception.definition)]; if (!parent?.properties) throw new Error(`Unknown exception definition: ${exception.lexicon}`); parent.properties[exception.field] = exception.field === "updatedAt" ? { anyOf: [{ type: "string" }, { type: "object" }], description: exception.reason, } : { type: exception.field === "bpm" ? ["number", "null"] : "number", description: exception.reason, };}for (const [id, { source }] of [...methods].sort()) { const def = docs[id]?.defs.main; if (!def || !["query", "procedure"].includes(def.type)) throw new Error(`Missing HTTP lexicon for ${id}`); const short = id.replace("app.rocksky.", ""); const hasAuth = /auth:\s*ctx.authVerifier|proxyMethod\(ctx/.test(source); const security = hasAuth ? optionalAuth.has(short) ? [{}, { Bearer: [] }] : [{ Bearer: [] }] : []; const operation = { tags: [id.split(".").slice(0, -1).join(".")], summary: def.description || short, operationId: id, security, parameters: [], responses: {}, }; if (notes[short]) operation.description = notes[short]; for (const [name, param] of Object.entries( def.parameters?.properties || {}, )) { operation.parameters.push({ name, in: "query", required: def.parameters.required?.includes(name) || false, ...(param.description ? { description: param.description } : {}), schema: schema(param, id), ...(param.type === "array" ? { style: "form", explode: true } : {}), }); } if (def.input) operation.requestBody = { required: true, content: { [def.input.encoding]: { schema: def.input.schema ? schema(def.input.schema, id) : { type: "string", format: "binary" }, }, }, }; const stub = /Not implemented yet/.test(source); if (stub) { operation.description = "This route is registered but currently throws a not-implemented error. It does not return the download described by its lexicon."; operation["x-implementation-status"] = "not-implemented"; } else { operation.responses["200"] = { description: "Success", ...(def.output ? { content: { [def.output.encoding]: { schema: def.output.schema ? schema(def.output.schema, id) : { type: "string", format: "binary" }, }, }, } : {}), }; if ( short.startsWith("player.") || (/\/\/ Logic to /.test(source) && !short.startsWith("spotify.")) ) { operation.description = [ operation.description, "The current handler contains placeholder logic; a successful response does not guarantee the requested operation was performed.", ] .filter(Boolean) .join("\n\n"); operation["x-implementation-status"] = "placeholder"; } } operation.responses.default = { description: "XRPC error response.", content: { "application/json": { schema: { $ref: "#/components/schemas/XrpcError" }, }, }, }; operation.responses["400"] = { description: "Invalid XRPC request or parameters.", content: { "application/json": { schema: { $ref: "#/components/schemas/XrpcError" }, }, }, }; if (def.errors?.length) operation["x-xrpc-errors"] = def.errors; spec.paths["/" + id] = { [def.type === "query" ? "get" : "post"]: operation };}// Include only schemas reachable from registered endpoints.const reachable = new Set();function collectRefs(value) { if (!value || typeof value !== "object") return; if (value.$ref) { const name = value.$ref.split("/").at(-1); if (!reachable.has(name)) { reachable.add(name); collectRefs(spec.components.schemas[name]); } } Object.values(value).forEach(collectRefs);}collectRefs(spec.paths);for (const name of Object.keys(spec.components.schemas)) { if (!reachable.has(name)) delete spec.components.schemas[name];}// Fail instead of shipping dangling refs.function checkRefs(value) { if (!value || typeof value !== "object") return; if (value.$ref && !spec.components.schemas[value.$ref.split("/").at(-1)]) throw new Error(`Dangling ref: ${value.$ref}`); Object.values(value).forEach(checkRefs);}checkRefs(spec);const output = JSON.stringify(spec, null, 2) + "\n";if (process.argv.includes("--check")) { if (read(target) !== output) throw new Error( "OpenAPI is stale. Run node apps/api/scripts/generate-openapi.cjs", );} else fs.writeFileSync(target, output);console.log( `${process.argv.includes("--check") ? "Checked" : "Generated"} ${methods.size} registered XRPC endpoints.`,);