diff --git a/README.md b/README.md index 5a3ae58..8c2c611 100644 --- a/README.md +++ b/README.md @@ -5,23 +5,18 @@ A bridge between ATProto (server-side) and GraphQL (client-side) for developing **Installation:** ``` -git submodule update --init --recursive npm install ``` ## Schema Generation -This folder contains scripts to generate a GraphQL schema from Lexicon. Standard ATProto definitions are initialized in the `deps/atproto` folder. +This project uses [@atproto/lex](https://www.npmjs.com/package/@atproto/lex-cli) to download Lexicons and generate xRPC calls that are used in the backend. Example usage: ``` -ts-lex install app.bsky.actor.getProfile \ - com.atproto.server.getSession -npx tsx src/bin/main.ts \ - app.bsky.actor.getProfile \ - com.atproto.server.getSession \ - -o schema-generated.graphql +ts-lex install app.bsky.actor.getProfile com.atproto.server.getSession +npm run generate ``` This will generate schema definitions for the `getProfile` and `getSession` procedures and recursively import all their referenced types, e.g. @@ -64,7 +59,7 @@ type Lexicon_app_bsky_actor_defs_profileViewDetailed { This repo also contains a Rust GraphQL server that can bridge an exported schema to the AT protocol. You can use this as a reference or even expand it to include non-AT GraphQL types. - GraphQL API endpoint at `/graphql` -- Interactive GraphiQL interface for development +- Interactive GraphiQL interface for development at `/graphiql` ### Building @@ -72,7 +67,52 @@ This repo also contains a Rust GraphQL server that can bridge an exported schema npm run server ``` -The server will start on `http://localhost:8000`. +The server will start on `http://localhost:8000`. An example query and variables: + +``` +# Query +query ($actor: ID!) { + lexicon { + app { + bsky { + actor { + getProfile(actor: $actor) { + avatar + postsCount + description + displayName + } + } + } + } + } +} + +# Variables +{ + "actor": "did:plc:olka44iewlycp4vxa6srsabp" +} + +# Response +{ + "data": { + "lexicon": { + "app": { + "bsky": { + "actor": { + "getProfile": { + "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:olka44iewlycp4vxa6srsabp/bafkreicozgr2pxfq5cdnfxlhrc6si3sunraekbp2pccqukvh6dapr4en5i", + "postsCount": 374, + "description": "browser tab collector\nnon-recurring engineer\nsomerville, ma", + "displayName": "tim ryan" + } + } + } + } + } + } +} +``` ## License diff --git a/package-lock.json b/package-lock.json index 97305bf..41d0eb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ }, "devDependencies": { "@atproto/lex": "^0.0.23", + "@atproto/lex-cli": "^0.9.9", "@types/node": "^25.5.0", "tsx": "^4.21.0", "typescript": "^6.0.2", @@ -174,6 +175,85 @@ "tslib": "^2.8.1" } }, + "node_modules/@atproto/lex-cli": { + "version": "0.9.9", + "resolved": "https://registry.npmjs.org/@atproto/lex-cli/-/lex-cli-0.9.9.tgz", + "integrity": "sha512-n/ClCBNnrjbqflLFt+j8Vx8q+lQd6OPQiDToi80akUoW2wNyPmsWtK5tKje0HS+7aHZD3gO3yX4B/x6ZqqrEeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@atproto/lexicon": "^0.6.2", + "@atproto/syntax": "^0.5.0", + "chalk": "^4.1.2", + "commander": "^9.4.0", + "prettier": "^3.2.5", + "ts-morph": "^24.0.0", + "yesno": "^0.4.0", + "zod": "^3.23.8" + }, + "bin": { + "lex": "dist/index.js" + }, + "engines": { + "node": ">=18.7.0" + } + }, + "node_modules/@atproto/lex-cli/node_modules/@ts-morph/common": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.25.0.tgz", + "integrity": "sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^9.0.4", + "path-browserify": "^1.0.1", + "tinyglobby": "^0.2.9" + } + }, + "node_modules/@atproto/lex-cli/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@atproto/lex-cli/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@atproto/lex-cli/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@atproto/lex-cli/node_modules/ts-morph": { + "version": "24.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-24.0.0.tgz", + "integrity": "sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.25.0", + "code-block-writer": "^13.0.3" + } + }, "node_modules/@atproto/lex-client": { "version": "0.0.18", "resolved": "https://registry.npmjs.org/@atproto/lex-client/-/lex-client-0.0.18.tgz", @@ -272,6 +352,20 @@ "tslib": "^2.8.1" } }, + "node_modules/@atproto/lexicon": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.6.2.tgz", + "integrity": "sha512-p3Ly6hinVZW0ETuAXZMeUGwuMm3g8HvQMQ41yyEE6AL0hAkfeKFaZKos6BdBrr6CjkpbrDZqE8M+5+QOceysMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@atproto/common-web": "^0.4.18", + "@atproto/syntax": "^0.5.0", + "iso-datestring-validator": "^2.2.2", + "multiformats": "^9.9.0", + "zod": "^3.23.8" + } + }, "node_modules/@atproto/repo": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@atproto/repo/-/repo-0.9.0.tgz", @@ -2647,6 +2741,16 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -5426,6 +5530,13 @@ "node": ">=12" } }, + "node_modules/yesno": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/yesno/-/yesno-0.4.0.tgz", + "integrity": "sha512-tdBxmHvbXPBKYIg81bMCB7bVeDmHkRzk5rVJyYYXurwKkHq/MCd8rz4HSJUP7hW0H2NlXiq8IFiWvYKEHhlotA==", + "dev": true, + "license": "BSD" + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index cb1f76f..b31f613 100644 --- a/package.json +++ b/package.json @@ -5,13 +5,16 @@ "type": "module", "main": "src/bin/main.ts", "scripts": { - "test": "vitest run" + "test": "vitest run", + "generate": "tsx src/bin/generate", + "server": "tsx src/bin/server" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "@atproto/lex": "^0.0.23", + "@atproto/lex-cli": "^0.9.9", "@types/node": "^25.5.0", "tsx": "^4.21.0", "typescript": "^6.0.2", diff --git a/src/__tests__/schema-generated.expected.graphql b/src/__tests__/schema-generated.expected.graphql index 2aa3f60..9cf8f4b 100644 --- a/src/__tests__/schema-generated.expected.graphql +++ b/src/__tests__/schema-generated.expected.graphql @@ -56,7 +56,6 @@ type Lexicon_com_atproto_label_defs_label { type Lexicon_app_bsky_actor_defs_statusView { cid: String uri: String - embed: Unknown status: String! isActive: Boolean expiresAt: String diff --git a/src/__tests__/schema-generation.test.ts b/src/__tests__/schema-generation.test.ts index 72e5512..53410a9 100644 --- a/src/__tests__/schema-generation.test.ts +++ b/src/__tests__/schema-generation.test.ts @@ -26,7 +26,7 @@ describe("schema generation output stability", () => { const generatedDefinitions = generateDefinitions(lexiconIds); // Join with double newlines like main.ts does - const outputContent = generatedDefinitions.join("\n\n") + "\n"; + const outputContent = generatedDefinitions.chunks.join("\n\n") + "\n"; // The output should match the expected schema expect(outputContent).toBe(expectedOutput); diff --git a/src/bin/main.ts b/src/bin/generate.ts similarity index 84% rename from src/bin/main.ts rename to src/bin/generate.ts index 471f15d..ff7fdf9 100644 --- a/src/bin/main.ts +++ b/src/bin/generate.ts @@ -2,8 +2,9 @@ import { readSchemaFiles } from "../generateLexiconSchema"; import { generateDefinitions } from "../generateLexiconSchema"; import fs from "fs"; +const lexiconIds = (await import("../../lexicons.json")).lexicons; + const args = process.argv.slice(2); -let lexiconIds: string[] = []; let output: string | null = null; let appendSchema: string[] | null = null; @@ -32,11 +33,6 @@ while (i < args.length) { } } -if (lexiconIds.length === 0) { - console.error("Error: No lexicon identifiers provided"); - process.exit(1); -} - let outputContent = ""; // Append additional schemas if provided via --append-schema @@ -46,7 +42,7 @@ if (appendSchema) { } // Append the new schema. -outputContent += generateDefinitions(lexiconIds).join("\n\n"); +outputContent += generateDefinitions(lexiconIds).chunks.join("\n\n"); // Write to output file if (output) { diff --git a/src/server/server.js b/src/bin/server.js similarity index 53% rename from src/server/server.js rename to src/bin/server.js index ee4b866..55eb78f 100644 --- a/src/server/server.js +++ b/src/bin/server.js @@ -1,49 +1,52 @@ import express from "express"; -import { createHandler } from 'graphql-http/lib/use/http'; +import { createHandler } from "graphql-http/lib/use/http"; import { buildSchema } from "graphql"; import { ruruHTML } from "ruru/server"; import { serveStatic } from "ruru/static"; +import { generateDefinitions } from "../generateLexiconSchema"; // Initialize Express app const app = express(); +const lexiconIds = (await import("../../lexicons.json")).lexicons; + +// Generate definitions (this is what main.ts does when no --output flag is provided) +const { chunks: generatedDefinitions, lexiconCalls } = + generateDefinitions(lexiconIds); + // Define GraphQL schema using GraphQL-JS -const schema = buildSchema(` - type Query { - hello: String - user(id: ID!): User - } +const schema = buildSchema( + generatedDefinitions.join("\n\n") + + ` +type Query { + lexicon: Lexicon +}`, +); - type User { - id: ID! - name: String! - email: String! - } -`); - -// Define GraphQL resolvers -const root = { - hello: () => "Hello, World!", - user: ({ id }) => { - // Mock data - const users = { - 1: { id: "1", name: "Alice", email: "alice@example.com" }, - 2: { id: "2", name: "Bob", email: "bob@example.com" }, - }; - return users[id]; - }, -}; +const ty = (schema.getQueryType().getFields()["lexicon"].resolve = () => ({})); // Serve static files +for (const key of Object.keys(lexiconCalls)) { + const segments = key.split("."); + const ty = schema.getType( + ["Lexicon"].concat(segments.slice(0, -1)).join("_"), + ); + ty.getFields()[segments.slice(-1)[0]].resolve = lexiconCalls[key]; + + for (let i = 1; i < segments.length; i++) { + const ty = schema.getType( + ["Lexicon"].concat(segments.slice(0, i - 1)).join("_"), + ); + ty.getFields()[segments.slice(0, i).pop()].resolve = () => ({}); + } +} + app.use(express.static("public")); const handler = createHandler({ schema }); // GraphQL endpoint -app.all( - "/graphql", - createHandler({ schema }) -); +app.all("/graphql", handler); /** * Setup GraphiQL diff --git a/src/generateLexiconSchema.ts b/src/generateLexiconSchema.ts index 402abf5..bb9c373 100644 --- a/src/generateLexiconSchema.ts +++ b/src/generateLexiconSchema.ts @@ -1,6 +1,6 @@ #!/usr/bin/env ts-node -import { jsonToLex } from "@atproto/lex"; +import { jsonToLex, LexValue, xrpc } from "@atproto/lex"; import fs from "fs"; import path from "path"; import { glob } from "glob"; @@ -50,7 +50,7 @@ const FORMAT_MAPPING: Record = { "at-identifier": "ID", }; -const SKIP_FIELDS = new Set(["debug"]); // Fields to skip in GraphQL output +const SKIP_FIELDS = new Set(["debug", "embed"]); // Fields to skip in GraphQL output function resolveLexiconPath(lexiconPath: LexiconPath): string { const dirParts = lexiconPath.segments; @@ -74,7 +74,7 @@ function normalizeReference(base: string, ref: string): string { } } -function resolveReference(ref: string): any | null { +function resolveReference(ref: string): LexValue | null { try { if (ref.includes("#")) { const [lexiconId, typeName] = ref.split("#", 2); @@ -88,7 +88,10 @@ function resolveReference(ref: string): any | null { } } -function resolveReferenceType(lexiconId: string, typeName: string): any | null { +function resolveReferenceType( + lexiconId: string, + typeName: string, +): LexValue | null { try { const lexiconPath = resolveLexiconPath( LexiconPathImpl.fromString(lexiconId), @@ -240,7 +243,8 @@ export function outputLexiconNamespaces( export function generateLexiconStructure(lexiconPath: LexiconPath): { methodDef: string; objectDefinitions: Record; -} { + methodCall: Function; +} | null { const methodName = lexiconPath.segments[lexiconPath.segments.length - 1]; try { @@ -249,14 +253,14 @@ export function generateLexiconStructure(lexiconPath: LexiconPath): { const parsed = jsonToLex(lexiconData); if (!parsed || typeof parsed !== "object" || !("defs" in parsed)) { - return { methodDef: "", objectDefinitions: {} }; + return null; } const defs = parsed.defs; const mainDef = defs.main; if (!mainDef || mainDef.type !== "query") { - return { methodDef: "", objectDefinitions: {} }; + return null; } const referencedTypes = new Set(); @@ -321,33 +325,61 @@ export function generateLexiconStructure(lexiconPath: LexiconPath): { ? `${methodName}${methodParams}: ${returnType}` : `${methodName}${methodParams}`; - return { methodDef, objectDefinitions }; + const methodCall = async function (_, params: any) { + const result = await xrpc( + "https://api.bsky.app", + await import("./lexicons/" + lexiconPath.segments.join("/")), + { + params, + }, + ); + if (!result.success) { + throw new Error("Error in response: " + JSON.stringify(result.payload)); + } + return result.payload.body; + }; + + return { methodDef, objectDefinitions, methodCall }; } catch (e) { console.error( `Error processing lexicon ${lexiconPath.segments.join(".")}: ${e}`, ); - return { methodDef: "", objectDefinitions: {} }; + return null; } } -export function generateDefinitions(lexiconIds: string[]): string[] { +export function generateDefinitions(lexiconIds: string[]): { + chunks: string[]; + lexiconCalls: Record; +} { const chunks: string[] = []; const lexiconFields: Record = {}; - let objectDefinitions: Record = {}; + let lexiconCalls: Record = {}; + let objectDefinitions: Record = {}; for (const lexiconId of lexiconIds) { const lexiconPath = LexiconPathImpl.fromString(lexiconId); // Generate main lexicon structure - const { methodDef, objectDefinitions: collectedObjectDefinitions } = - generateLexiconStructure(lexiconPath); - lexiconFields[lexiconPath.dotPath()] = methodDef; - objectDefinitions = { ...objectDefinitions, ...collectedObjectDefinitions }; + const result = generateLexiconStructure(lexiconPath); + if (result) { + const { + methodDef, + objectDefinitions: collectedObjectDefinitions, + methodCall, + } = result; + lexiconFields[lexiconPath.dotPath()] = methodDef; + lexiconCalls[lexiconPath.dotPath()] = methodCall; + objectDefinitions = { + ...objectDefinitions, + ...collectedObjectDefinitions, + }; + } } // Walk output types for more refs - const finalDefinitions: Record = {}; - let newDefinitions: Record = {}; + const finalDefinitions: Record = {}; + let newDefinitions: Record = {}; while (Object.keys(objectDefinitions).length > 0) { for (const [objName, objDef] of Object.entries(objectDefinitions)) { @@ -404,7 +436,7 @@ export function generateDefinitions(lexiconIds: string[]): string[] { ...outputLexiconNamespaces(lexiconFields, new LexiconPathImpl([])), ); - return chunks; + return { chunks, lexiconCalls }; } export async function readSchemaFiles(