From 5b3699cd3bcff65fa9c31bc7362f18f7a0ed8546 Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Thu, 11 Sep 2025 22:30:46 -0700 Subject: [PATCH] social.slices -> network.slices --- api/.spectral.yaml | 4 +- ...a6c126bf23dea5a8f3fe9f7e9952807a478f.json} | 4 +- ...52ca09f805ecdee4b28a4891bc527ab0d915a.json | 34 + ...c8a77e42c5013e3deb5ce32afdcba0d150ab.json} | 4 +- ...81f4502e4af3a4704a84a615a5044f48f169.json} | 4 +- ...37414cf6ebedc5e021f67d684806bcb6b1146.json | 22 - ...7b9f0e31ecd04a40e033aee2be995b3c54b28.json | 34 - ...e83f0e299c7def29dd1452706563ee58f9e3f.json | 22 + api/scripts/generate_typescript.ts | 95 +- api/scripts/prod_sync.sh | 10 +- api/scripts/test_sync.sh | 10 +- api/src/database.rs | 333 ++++--- api/src/handler_sync_user_collections.rs | 71 +- api/src/handler_xrpc_dynamic.rs | 597 +++++++----- api/src/lexicon/test.rs | 858 ++++++++++++------ api/src/lexicon/validator.rs | 727 +++++++++------ api/src/main.rs | 55 +- docs/api-reference.md | 81 +- docs/concepts.md | 14 +- docs/getting-started.md | 31 +- docs/sdk-usage.md | 83 +- frontend/.env.example | 2 +- frontend/fly.toml | 2 +- frontend/src/client.test.ts | 228 ++--- frontend/src/client.ts | 218 ++--- frontend/src/features/auth/handlers.tsx | 2 +- frontend/src/features/dashboard/handlers.tsx | 28 +- frontend/src/features/settings/handlers.tsx | 18 +- .../src/features/slices/api-docs/handlers.tsx | 6 +- .../api-docs/templates/SliceApiDocsPage.tsx | 96 +- .../src/features/slices/codegen/handlers.tsx | 4 +- .../features/slices/jetstream/handlers.tsx | 8 +- .../src/features/slices/lexicon/handlers.tsx | 18 +- .../lexicon/templates/SliceLexiconPage.tsx | 2 +- .../src/features/slices/oauth/handlers.tsx | 18 +- .../src/features/slices/overview/handlers.tsx | 12 +- .../src/features/slices/records/handlers.tsx | 15 +- .../src/features/slices/settings/handlers.tsx | 8 +- .../features/slices/sync-logs/handlers.tsx | 8 +- .../src/features/slices/sync/handlers.tsx | 16 +- frontend/src/utils/at-uri.ts | 20 +- frontend/src/utils/client.ts | 13 +- .../{social => network}/slices/lexicon.json | 2 +- .../{social => network}/slices/profile.json | 2 +- .../{social => network}/slices/slice.json | 2 +- 45 files changed, 2317 insertions(+), 1524 deletions(-) rename api/.sqlx/{query-e583ef70eb5262bb062a4e1bab103e99fd2757e4086aef695098861151a9fb75.json => query-112e2cbb7ee10d0ec1261e8beebda6c126bf23dea5a8f3fe9f7e9952807a478f.json} (55%) create mode 100644 api/.sqlx/query-5fb1c2c7b29bfd9614e24fd391e52ca09f805ecdee4b28a4891bc527ab0d915a.json rename api/.sqlx/{query-4895a6217c253c74fae4332b31dabe04d0840ee1966d6fdb4c3dee6d2c87ad1b.json => query-6fa4f18b67c4dc5b175f353c6c03c8a77e42c5013e3deb5ce32afdcba0d150ab.json} (54%) rename api/.sqlx/{query-fd7e15432f1c5e2b04ab38b6828bc3862e6e2b4e6d3b45a3e5aef024e815c358.json => query-732081a59997cde317e535cf85e281f4502e4af3a4704a84a615a5044f48f169.json} (63%) delete mode 100644 api/.sqlx/query-88080d24df604cfdcd827c8201a37414cf6ebedc5e021f67d684806bcb6b1146.json delete mode 100644 api/.sqlx/query-940380fe9b34d64b3d700032ab87b9f0e31ecd04a40e033aee2be995b3c54b28.json create mode 100644 api/.sqlx/query-e62f397eb38f35e17988429c36de83f0e299c7def29dd1452706563ee58f9e3f.json rename lexicons/{social => network}/slices/lexicon.json (97%) rename lexicons/{social => network}/slices/profile.json (95%) rename lexicons/{social => network}/slices/slice.json (96%) diff --git a/api/.spectral.yaml b/api/.spectral.yaml index 527f7b8..2140e98 100644 --- a/api/.spectral.yaml +++ b/api/.spectral.yaml @@ -1,10 +1,10 @@ # Spectral linting for OpenAPI specs -# +# # Usage: # spectral lint # spectral lint openapi.json --ruleset .spectral.yaml # # To lint the generated OpenAPI spec from the API: -# curl "http://localhost:3000/xrpc/social.slices.slice.openapi?slice=at://did:plc:example/social.slices.slice/example" | spectral lint - +# curl "http://localhost:3000/xrpc/network.slices.openapi?slice=at://did:plc:example/network.slices.slice/example" | spectral lint - # extends: ["spectral:oas"] diff --git a/api/.sqlx/query-e583ef70eb5262bb062a4e1bab103e99fd2757e4086aef695098861151a9fb75.json b/api/.sqlx/query-112e2cbb7ee10d0ec1261e8beebda6c126bf23dea5a8f3fe9f7e9952807a478f.json similarity index 55% rename from api/.sqlx/query-e583ef70eb5262bb062a4e1bab103e99fd2757e4086aef695098861151a9fb75.json rename to api/.sqlx/query-112e2cbb7ee10d0ec1261e8beebda6c126bf23dea5a8f3fe9f7e9952807a478f.json index c1f28ed..0e52fae 100644 --- a/api/.sqlx/query-e583ef70eb5262bb062a4e1bab103e99fd2757e4086aef695098861151a9fb75.json +++ b/api/.sqlx/query-112e2cbb7ee10d0ec1261e8beebda6c126bf23dea5a8f3fe9f7e9952807a478f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT DISTINCT json->>'nsid' as collection_nsid\n FROM record\n WHERE collection = 'social.slices.lexicon'\n AND json->>'slice' = $1\n AND json->>'nsid' IS NOT NULL\n AND (json->>'definitions')::jsonb->'main'->>'type' = 'record'\n ORDER BY json->>'nsid'\n ", + "query": "\n SELECT DISTINCT json->>'nsid' as collection_nsid\n FROM record\n WHERE collection = 'network.slices.lexicon'\n AND json->>'slice' = $1\n AND json->>'nsid' IS NOT NULL\n AND (json->>'definitions')::jsonb->'main'->>'type' = 'record'\n ORDER BY json->>'nsid'\n ", "describe": { "columns": [ { @@ -18,5 +18,5 @@ null ] }, - "hash": "e583ef70eb5262bb062a4e1bab103e99fd2757e4086aef695098861151a9fb75" + "hash": "112e2cbb7ee10d0ec1261e8beebda6c126bf23dea5a8f3fe9f7e9952807a478f" } diff --git a/api/.sqlx/query-5fb1c2c7b29bfd9614e24fd391e52ca09f805ecdee4b28a4891bc527ab0d915a.json b/api/.sqlx/query-5fb1c2c7b29bfd9614e24fd391e52ca09f805ecdee4b28a4891bc527ab0d915a.json new file mode 100644 index 0000000..38b1bd3 --- /dev/null +++ b/api/.sqlx/query-5fb1c2c7b29bfd9614e24fd391e52ca09f805ecdee4b28a4891bc527ab0d915a.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH slice_collections AS (\n SELECT DISTINCT\n json->>'nsid' as collection_nsid\n FROM record\n WHERE collection = 'network.slices.lexicon'\n AND json->>'slice' = $1\n AND json->>'nsid' IS NOT NULL\n AND (json->>'definitions')::jsonb->'main'->>'type' = 'record'\n )\n SELECT\n r.collection,\n COUNT(*) as record_count,\n COUNT(DISTINCT r.did) as unique_actors\n FROM record r\n INNER JOIN slice_collections sc ON r.collection = sc.collection_nsid\n WHERE r.slice_uri = $1\n GROUP BY r.collection\n ORDER BY r.collection\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "collection", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "record_count", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "unique_actors", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + null, + null + ] + }, + "hash": "5fb1c2c7b29bfd9614e24fd391e52ca09f805ecdee4b28a4891bc527ab0d915a" +} diff --git a/api/.sqlx/query-4895a6217c253c74fae4332b31dabe04d0840ee1966d6fdb4c3dee6d2c87ad1b.json b/api/.sqlx/query-6fa4f18b67c4dc5b175f353c6c03c8a77e42c5013e3deb5ce32afdcba0d150ab.json similarity index 54% rename from api/.sqlx/query-4895a6217c253c74fae4332b31dabe04d0840ee1966d6fdb4c3dee6d2c87ad1b.json rename to api/.sqlx/query-6fa4f18b67c4dc5b175f353c6c03c8a77e42c5013e3deb5ce32afdcba0d150ab.json index 0efa549..5f6b67a 100644 --- a/api/.sqlx/query-4895a6217c253c74fae4332b31dabe04d0840ee1966d6fdb4c3dee6d2c87ad1b.json +++ b/api/.sqlx/query-6fa4f18b67c4dc5b175f353c6c03c8a77e42c5013e3deb5ce32afdcba0d150ab.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT COUNT(*) as count\n FROM record\n WHERE collection = 'social.slices.lexicon'\n AND json->>'slice' = $1\n AND (json->>'definitions')::jsonb->'main'->>'type' = 'record'\n ", + "query": "\n SELECT COUNT(*) as count\n FROM record\n WHERE collection = 'network.slices.lexicon'\n AND json->>'slice' = $1\n AND (json->>'definitions')::jsonb->'main'->>'type' = 'record'\n ", "describe": { "columns": [ { @@ -18,5 +18,5 @@ null ] }, - "hash": "4895a6217c253c74fae4332b31dabe04d0840ee1966d6fdb4c3dee6d2c87ad1b" + "hash": "6fa4f18b67c4dc5b175f353c6c03c8a77e42c5013e3deb5ce32afdcba0d150ab" } diff --git a/api/.sqlx/query-fd7e15432f1c5e2b04ab38b6828bc3862e6e2b4e6d3b45a3e5aef024e815c358.json b/api/.sqlx/query-732081a59997cde317e535cf85e281f4502e4af3a4704a84a615a5044f48f169.json similarity index 63% rename from api/.sqlx/query-fd7e15432f1c5e2b04ab38b6828bc3862e6e2b4e6d3b45a3e5aef024e815c358.json rename to api/.sqlx/query-732081a59997cde317e535cf85e281f4502e4af3a4704a84a615a5044f48f169.json index 0f85fed..b1028f1 100644 --- a/api/.sqlx/query-fd7e15432f1c5e2b04ab38b6828bc3862e6e2b4e6d3b45a3e5aef024e815c358.json +++ b/api/.sqlx/query-732081a59997cde317e535cf85e281f4502e4af3a4704a84a615a5044f48f169.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT json->>'domain' as domain\n FROM record\n WHERE collection = 'social.slices.slice'\n AND uri = $1\n ", + "query": "\n SELECT json->>'domain' as domain\n FROM record\n WHERE collection = 'network.slices.slice'\n AND uri = $1\n ", "describe": { "columns": [ { @@ -18,5 +18,5 @@ null ] }, - "hash": "fd7e15432f1c5e2b04ab38b6828bc3862e6e2b4e6d3b45a3e5aef024e815c358" + "hash": "732081a59997cde317e535cf85e281f4502e4af3a4704a84a615a5044f48f169" } diff --git a/api/.sqlx/query-88080d24df604cfdcd827c8201a37414cf6ebedc5e021f67d684806bcb6b1146.json b/api/.sqlx/query-88080d24df604cfdcd827c8201a37414cf6ebedc5e021f67d684806bcb6b1146.json deleted file mode 100644 index 9910e40..0000000 --- a/api/.sqlx/query-88080d24df604cfdcd827c8201a37414cf6ebedc5e021f67d684806bcb6b1146.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n WITH slice_collections AS (\n SELECT DISTINCT\n json->>'nsid' as collection_nsid\n FROM record\n WHERE collection = 'social.slices.lexicon'\n AND json->>'slice' = $1\n AND json->>'nsid' IS NOT NULL\n AND (json->>'definitions')::jsonb->'main'->>'type' = 'record'\n )\n SELECT COUNT(*) as count\n FROM record r\n INNER JOIN slice_collections sc ON r.collection = sc.collection_nsid\n WHERE r.slice_uri = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "count", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "88080d24df604cfdcd827c8201a37414cf6ebedc5e021f67d684806bcb6b1146" -} diff --git a/api/.sqlx/query-940380fe9b34d64b3d700032ab87b9f0e31ecd04a40e033aee2be995b3c54b28.json b/api/.sqlx/query-940380fe9b34d64b3d700032ab87b9f0e31ecd04a40e033aee2be995b3c54b28.json deleted file mode 100644 index 9ac13e6..0000000 --- a/api/.sqlx/query-940380fe9b34d64b3d700032ab87b9f0e31ecd04a40e033aee2be995b3c54b28.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n WITH slice_collections AS (\n SELECT DISTINCT\n json->>'nsid' as collection_nsid\n FROM record\n WHERE collection = 'social.slices.lexicon'\n AND json->>'slice' = $1\n AND json->>'nsid' IS NOT NULL\n AND (json->>'definitions')::jsonb->'main'->>'type' = 'record'\n )\n SELECT\n r.collection,\n COUNT(*) as record_count,\n COUNT(DISTINCT r.did) as unique_actors\n FROM record r\n INNER JOIN slice_collections sc ON r.collection = sc.collection_nsid\n WHERE r.slice_uri = $1\n GROUP BY r.collection\n ORDER BY r.collection\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "collection", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "record_count", - "type_info": "Int8" - }, - { - "ordinal": 2, - "name": "unique_actors", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - null, - null - ] - }, - "hash": "940380fe9b34d64b3d700032ab87b9f0e31ecd04a40e033aee2be995b3c54b28" -} diff --git a/api/.sqlx/query-e62f397eb38f35e17988429c36de83f0e299c7def29dd1452706563ee58f9e3f.json b/api/.sqlx/query-e62f397eb38f35e17988429c36de83f0e299c7def29dd1452706563ee58f9e3f.json new file mode 100644 index 0000000..e9b7091 --- /dev/null +++ b/api/.sqlx/query-e62f397eb38f35e17988429c36de83f0e299c7def29dd1452706563ee58f9e3f.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH slice_collections AS (\n SELECT DISTINCT\n json->>'nsid' as collection_nsid\n FROM record\n WHERE collection = 'network.slices.lexicon'\n AND json->>'slice' = $1\n AND json->>'nsid' IS NOT NULL\n AND (json->>'definitions')::jsonb->'main'->>'type' = 'record'\n )\n SELECT COUNT(*) as count\n FROM record r\n INNER JOIN slice_collections sc ON r.collection = sc.collection_nsid\n WHERE r.slice_uri = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "e62f397eb38f35e17988429c36de83f0e299c7def29dd1452706563ee58f9e3f" +} diff --git a/api/scripts/generate_typescript.ts b/api/scripts/generate_typescript.ts index 40b1278..39fc2d9 100644 --- a/api/scripts/generate_typescript.ts +++ b/api/scripts/generate_typescript.ts @@ -30,7 +30,7 @@ interface Lexicon { const lexiconsInput = Deno.args[0] || ""; const sliceUri = Deno.args[1] || - "at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/3lx5zq4t56s2q"; + "at://did:plc:bcgltzqazw5tb6k2g3ttenbj/network.slices.slice/3lymhd4jhrd2z"; if (!lexiconsInput) { console.error("No lexicon data provided"); @@ -46,11 +46,11 @@ const lexicons: Lexicon[] = JSON.parse(lexiconsInput).map((lex: any) => ({ // Generate usage example based on available lexicons function generateUsageExample(): string { - // Find the first non-social.slices lexicon that has a record type + // Find the first non-network.slices lexicon that has a record type const nonSlicesLexicon = lexicons.find( (lex) => lex.id && - !lex.id.startsWith("social.slices.") && + !lex.id.startsWith("network.slices.") && lex.definitions && Object.values(lex.definitions).some((def) => def.type === "record") ); @@ -85,14 +85,18 @@ function generateUsageExample(): string { * }); * * // Use slice-level methods for cross-collection queries with type safety - * const sliceRecords = await client.social.slices.slice.getSliceRecords<${nsidToPascalCase(nonSlicesLexicon.id)}>({ + * const sliceRecords = await client.network.slices.slice.getSliceRecords<${nsidToPascalCase( + nonSlicesLexicon.id + )}>({ * where: { * collection: { eq: '${nonSlicesLexicon.id}' } * } * }); * * // Search across multiple collections using union types - * const multiCollectionRecords = await client.social.slices.slice.getSliceRecords<${nsidToPascalCase(nonSlicesLexicon.id)} | AppBskyActorProfile>({ + * const multiCollectionRecords = await client.network.slices.slice.getSliceRecords<${nsidToPascalCase( + nonSlicesLexicon.id + )} | AppBskyActorProfile>({ * where: { * collection: { in: ['${nonSlicesLexicon.id}', 'app.bsky.actor.profile'] }, * text: { contains: 'example search term' }, @@ -106,7 +110,7 @@ function generateUsageExample(): string { * \`\`\` */`; } else { - // Fallback: find any lexicon with a record type (including social.slices) + // Fallback: find any lexicon with a record type (including network.slices) const anyRecordLexicon = lexicons.find( (lex) => lex.definitions && @@ -141,7 +145,9 @@ function generateUsageExample(): string { * }); * * // Cross-collection operations using base client with type safety - * const multiCollectionResults = await client.getSliceRecords<${nsidToPascalCase(anyRecordLexicon.id)}>({ + * const multiCollectionResults = await client.getSliceRecords<${nsidToPascalCase( + anyRecordLexicon.id + )}>({ * where: { * collection: { eq: '${anyRecordLexicon.id}' } * }, @@ -405,25 +411,19 @@ function addBaseInterfaces(): void { sourceFile.addInterface({ name: "GetJobLogsResponse", isExported: true, - properties: [ - { name: "logs", type: "LogEntry[]" }, - ], + properties: [{ name: "logs", type: "LogEntry[]" }], }); sourceFile.addInterface({ name: "GetJetstreamLogsParams", isExported: true, - properties: [ - { name: "limit", type: "number", hasQuestionToken: true }, - ], + properties: [{ name: "limit", type: "number", hasQuestionToken: true }], }); sourceFile.addInterface({ name: "GetJetstreamLogsResponse", isExported: true, - properties: [ - { name: "logs", type: "LogEntry[]" }, - ], + properties: [{ name: "logs", type: "LogEntry[]" }], }); sourceFile.addInterface({ @@ -438,7 +438,11 @@ function addBaseInterfaces(): void { { name: "sliceUri", type: "string", hasQuestionToken: true }, { name: "level", type: "string" }, { name: "message", type: "string" }, - { name: "metadata", type: "Record", hasQuestionToken: true }, + { + name: "metadata", + type: "Record", + hasQuestionToken: true, + }, ], }); @@ -523,7 +527,6 @@ function addBaseInterfaces(): void { type: "{ [K in T]?: WhereCondition }", }); - // IndexedRecord fields that are always available for filtering sourceFile.addTypeAlias({ name: "IndexedRecordFields", @@ -707,9 +710,7 @@ function addBaseInterfaces(): void { sourceFile.addInterface({ name: "ListOAuthClientsResponse", isExported: true, - properties: [ - { name: "clients", type: "OAuthClientDetails[]" }, - ], + properties: [{ name: "clients", type: "OAuthClientDetails[]" }], }); sourceFile.addInterface({ @@ -1625,10 +1626,10 @@ function addClientClass(): void { } } - // Add codegen method to the social.slices.slice class + // Add codegen method to the network.slices.slice class if ( currentPath.length === 3 && - currentPath[0] === "social" && + currentPath[0] === "network" && currentPath[1] === "slices" && currentPath[2] === "slice" ) { @@ -1638,7 +1639,7 @@ function addClientClass(): void { returnType: "Promise", isAsync: true, statements: [ - `return await this.makeRequest('social.slices.slice.codegen', 'POST', request);`, + `return await this.makeRequest('network.slices.slice.codegen', 'POST', request);`, ], }); @@ -1648,7 +1649,7 @@ function addClientClass(): void { returnType: "Promise", isAsync: true, statements: [ - `return await this.makeRequest('social.slices.slice.stats', 'POST', params);`, + `return await this.makeRequest('network.slices.slice.stats', 'POST', params);`, ], }); @@ -1656,7 +1657,10 @@ function addClientClass(): void { name: "getSliceRecords", typeParameters: [{ name: "T", default: "Record" }], parameters: [ - { name: "params", type: "Omit, 'slice'>" }, + { + name: "params", + type: "Omit, 'slice'>", + }, ], returnType: "Promise>", isAsync: true, @@ -1672,7 +1676,7 @@ function addClientClass(): void { ` orWhere: undefined, // Remove orWhere as it's now in where.$or`, ` slice: this.sliceUri`, `};`, - `return await this.makeRequest>('social.slices.slice.getSliceRecords', 'POST', requestParams);`, + `return await this.makeRequest>('network.slices.slice.getSliceRecords', 'POST', requestParams);`, ], }); @@ -1685,15 +1689,15 @@ function addClientClass(): void { isAsync: true, statements: [ `const requestParams = { ...params, slice: this.sliceUri };`, - `return await this.makeRequest('social.slices.slice.getActors', 'POST', requestParams);`, + `return await this.makeRequest('network.slices.slice.getActors', 'POST', requestParams);`, ], }); } - // Add sync methods to the social.slices.slice class + // Add sync methods to the network.slices.slice class if ( currentPath.length === 3 && - currentPath[0] === "social" && + currentPath[0] === "network" && currentPath[1] === "slices" && currentPath[2] === "slice" ) { @@ -1704,7 +1708,7 @@ function addClientClass(): void { isAsync: true, statements: [ `const requestParams = { ...params, slice: this.sliceUri };`, - `return await this.makeRequest('social.slices.slice.startSync', 'POST', requestParams);`, + `return await this.makeRequest('network.slices.slice.startSync', 'POST', requestParams);`, ], }); @@ -1714,7 +1718,7 @@ function addClientClass(): void { returnType: "Promise", isAsync: true, statements: [ - `return await this.makeRequest('social.slices.slice.getJobStatus', 'GET', params);`, + `return await this.makeRequest('network.slices.slice.getJobStatus', 'GET', params);`, ], }); @@ -1724,7 +1728,7 @@ function addClientClass(): void { returnType: "Promise", isAsync: true, statements: [ - `return await this.makeRequest('social.slices.slice.getJobHistory', 'GET', params);`, + `return await this.makeRequest('network.slices.slice.getJobHistory', 'GET', params);`, ], }); @@ -1734,7 +1738,7 @@ function addClientClass(): void { returnType: "Promise", isAsync: true, statements: [ - `return await this.makeRequest('social.slices.slice.getJobLogs', 'GET', params);`, + `return await this.makeRequest('network.slices.slice.getJobLogs', 'GET', params);`, ], }); @@ -1743,7 +1747,7 @@ function addClientClass(): void { returnType: "Promise", isAsync: true, statements: [ - `return await this.makeRequest('social.slices.slice.getJetstreamStatus', 'GET');`, + `return await this.makeRequest('network.slices.slice.getJetstreamStatus', 'GET');`, ], }); @@ -1753,7 +1757,7 @@ function addClientClass(): void { returnType: "Promise", isAsync: true, statements: [ - `return await this.makeRequest('social.slices.slice.getJetstreamLogs', 'GET', params);`, + `return await this.makeRequest('network.slices.slice.getJetstreamLogs', 'GET', params);`, ], }); @@ -1770,7 +1774,7 @@ function addClientClass(): void { isAsync: true, statements: [ `const requestParams = { slice: this.sliceUri, ...params };`, - `return await this.makeRequest('social.slices.slice.syncUserCollections', 'POST', requestParams);`, + `return await this.makeRequest('network.slices.slice.syncUserCollections', 'POST', requestParams);`, ], }); @@ -1782,7 +1786,7 @@ function addClientClass(): void { isAsync: true, statements: [ `const requestParams = { ...params, sliceUri: this.sliceUri };`, - `return await this.makeRequest('social.slices.slice.createOAuthClient', 'POST', requestParams);`, + `return await this.makeRequest('network.slices.slice.createOAuthClient', 'POST', requestParams);`, ], }); @@ -1792,7 +1796,7 @@ function addClientClass(): void { isAsync: true, statements: [ `const requestParams = { slice: this.sliceUri };`, - `return await this.makeRequest('social.slices.slice.getOAuthClients', 'GET', requestParams);`, + `return await this.makeRequest('network.slices.slice.getOAuthClients', 'GET', requestParams);`, ], }); @@ -1803,7 +1807,7 @@ function addClientClass(): void { isAsync: true, statements: [ `const requestParams = { ...params, sliceUri: this.sliceUri };`, - `return await this.makeRequest('social.slices.slice.updateOAuthClient', 'POST', requestParams);`, + `return await this.makeRequest('network.slices.slice.updateOAuthClient', 'POST', requestParams);`, ], }); @@ -1813,7 +1817,7 @@ function addClientClass(): void { returnType: "Promise", isAsync: true, statements: [ - `return await this.makeRequest('social.slices.slice.deleteOAuthClient', 'POST', { clientId });`, + `return await this.makeRequest('network.slices.slice.deleteOAuthClient', 'POST', { clientId });`, ], }); } @@ -1827,7 +1831,7 @@ function addClientClass(): void { isAsync: true, statements: [ `const requestParams = { ...params, slice: this.sliceUri };`, - `return await this.makeRequest('social.slices.slice.getActors', 'POST', requestParams);`, + `return await this.makeRequest('network.slices.slice.getActors', 'POST', requestParams);`, ], }); @@ -1835,7 +1839,10 @@ function addClientClass(): void { name: "getSliceRecords", typeParameters: [{ name: "T", default: "Record" }], parameters: [ - { name: "params", type: "Omit, 'slice'>" }, + { + name: "params", + type: "Omit, 'slice'>", + }, ], returnType: "Promise>", isAsync: true, @@ -1851,7 +1858,7 @@ function addClientClass(): void { ` orWhere: undefined, // Remove orWhere as it's now in where.$or`, ` slice: this.sliceUri`, `};`, - `return await this.makeRequest>('social.slices.slice.getSliceRecords', 'POST', requestParams);`, + `return await this.makeRequest>('network.slices.slice.getSliceRecords', 'POST', requestParams);`, ], }); diff --git a/api/scripts/prod_sync.sh b/api/scripts/prod_sync.sh index da537d5..b0bb962 100755 --- a/api/scripts/prod_sync.sh +++ b/api/scripts/prod_sync.sh @@ -10,15 +10,15 @@ echo "🔄 Testing Production Sync Endpoint..." echo "🎯 Syncing slice collections with specific repos" -curl -s -X POST https://slices-api.fly.dev/xrpc/social.slices.slice.startSync \ +curl -s -X POST https://slices-api.fly.dev/xrpc/network.slices.slice.startSync \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -d '{ - "slice": "at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/3lwzmbjpqxk2q", + "slice": "at://did:plc:bcgltzqazw5tb6k2g3ttenbj/network.slices.slice/3lymhd4jhrd2z", "collections": [ - "social.slices.slice", - "social.slices.lexicon", - "social.slices.actor.profile" + "network.slices.slice", + "network.slices.lexicon", + "network.slices.actor.profile" ], "externalCollections": [ "app.bsky.actor.profile" diff --git a/api/scripts/test_sync.sh b/api/scripts/test_sync.sh index dc28265..e7e924b 100755 --- a/api/scripts/test_sync.sh +++ b/api/scripts/test_sync.sh @@ -10,15 +10,15 @@ TOKEN="$1" echo "🔄 Testing Sync Endpoint..." echo "🎯 Syncing specific collections with specific repos" -curl -s -X POST http://localhost:3000/xrpc/social.slices.slice.startSync \ +curl -s -X POST http://localhost:3000/xrpc/network.slices.slice.startSync \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TOKEN" \ -d '{ - "slice": "at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/3lwzmbjpqxk2q", + "slice": "at://did:plc:bcgltzqazw5tb6k2g3ttenbj/network.slices.slice/3lymhd4jhrd2z", "collections": [ - "social.slices.actor.profile", - "social.slices.slice", - "social.slices.lexicon" + "network.slices.actor.profile", + "network.slices.slice", + "network.slices.lexicon" ], "externalCollections": [ "app.bsky.actor.profile" diff --git a/api/src/database.rs b/api/src/database.rs index a48a2e1..3580dbc 100644 --- a/api/src/database.rs +++ b/api/src/database.rs @@ -1,11 +1,13 @@ -use sqlx::PgPool; use base64::{Engine as _, engine::general_purpose}; +use sqlx::PgPool; use crate::errors::DatabaseError; -use crate::models::{Actor, CollectionStats, IndexedRecord, Record, WhereCondition, WhereClause, SortField, OAuthClient}; +use crate::models::{ + Actor, CollectionStats, IndexedRecord, OAuthClient, Record, SortField, WhereClause, + WhereCondition, +}; use std::collections::HashMap; - // Helper function to build ORDER BY clause from sortBy array fn build_order_by_clause(sort_by: Option<&Vec>) -> String { match sort_by { @@ -19,8 +21,16 @@ fn build_order_by_clause(sort_by: Option<&Vec>) -> String { }; // Validate field name to prevent SQL injection - if field.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.') { - if field == "indexed_at" || field == "uri" || field == "cid" || field == "did" || field == "collection" { + if field + .chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == '.') + { + if field == "indexed_at" + || field == "uri" + || field == "cid" + || field == "did" + || field == "collection" + { order_clauses.push(format!("{field} {direction}")); } else { // For JSON fields, handle nested paths and NULLs properly @@ -43,7 +53,9 @@ fn build_order_by_clause(sort_by: Option<&Vec>) -> String { } if !order_clauses.is_empty() { // Always add indexed_at as tie-breaker if not already included - let has_indexed_at = order_clauses.iter().any(|clause| clause.contains("indexed_at")); + let has_indexed_at = order_clauses + .iter() + .any(|clause| clause.contains("indexed_at")); if !has_indexed_at { order_clauses.push("indexed_at DESC".to_string()); } @@ -56,8 +68,11 @@ fn build_order_by_clause(sort_by: Option<&Vec>) -> String { } } - -fn generate_cursor(sort_value: &str, indexed_at: chrono::DateTime, cid: &str) -> String { +fn generate_cursor( + sort_value: &str, + indexed_at: chrono::DateTime, + cid: &str, +) -> String { let cursor_content = format!("{}::{}::{}", sort_value, indexed_at.to_rfc3339(), cid); general_purpose::URL_SAFE_NO_PAD.encode(cursor_content) } @@ -65,9 +80,7 @@ fn generate_cursor(sort_value: &str, indexed_at: chrono::DateTime, // Extract the primary sort field from sortBy array for cursor generation fn get_primary_sort_field(sort_by: Option<&Vec>) -> String { match sort_by { - Some(sort_fields) if !sort_fields.is_empty() => { - sort_fields[0].field.clone() - } + Some(sort_fields) if !sort_fields.is_empty() => sort_fields[0].field.clone(), _ => "indexed_at".to_string(), } } @@ -81,7 +94,9 @@ fn generate_cursor_from_record(record: &Record, sort_by: Option<&Vec> "indexed_at" => record.indexed_at.to_rfc3339(), field => { // Extract field value from JSON - record.json.get(field) + record + .json + .get(field) .and_then(|v| match v { serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()), serde_json::Value::Number(n) => Some(n.to_string()), @@ -103,14 +118,14 @@ fn build_where_conditions( ) -> (Vec, Vec) { let mut where_clauses = Vec::new(); let mut or_clauses = Vec::new(); - + if let Some(clause) = where_clause { // Process regular AND conditions for (field, condition) in &clause.conditions { let field_clause = build_single_condition(field, condition, param_count); where_clauses.push(field_clause); } - + // Process OR conditions if let Some(or_conditions) = &clause.or_conditions { for (field, condition) in or_conditions { @@ -119,22 +134,27 @@ fn build_where_conditions( } } } - + (where_clauses, or_clauses) } // Helper function to bind parameters from WhereClause fn bind_where_parameters<'q>( - mut query_builder: sqlx::query::QueryAs<'q, sqlx::Postgres, Record, sqlx::postgres::PgArguments>, - where_clause: Option<&'q WhereClause> + mut query_builder: sqlx::query::QueryAs< + 'q, + sqlx::Postgres, + Record, + sqlx::postgres::PgArguments, + >, + where_clause: Option<&'q WhereClause>, ) -> sqlx::query::QueryAs<'q, sqlx::Postgres, Record, sqlx::postgres::PgArguments> { if let Some(clause) = where_clause { // Bind AND condition parameters for (_, condition) in &clause.conditions { query_builder = bind_single_condition(query_builder, condition); } - - // Bind OR condition parameters + + // Bind OR condition parameters if let Some(or_conditions) = &clause.or_conditions { for (_, condition) in or_conditions { query_builder = bind_single_condition(query_builder, condition); @@ -146,8 +166,13 @@ fn bind_where_parameters<'q>( // Helper function to bind parameters for a single condition fn bind_single_condition<'q>( - mut query_builder: sqlx::query::QueryAs<'q, sqlx::Postgres, Record, sqlx::postgres::PgArguments>, - condition: &'q WhereCondition + mut query_builder: sqlx::query::QueryAs< + 'q, + sqlx::Postgres, + Record, + sqlx::postgres::PgArguments, + >, + condition: &'q WhereCondition, ) -> sqlx::query::QueryAs<'q, sqlx::Postgres, Record, sqlx::postgres::PgArguments> { if let Some(eq_value) = &condition.eq { if let Some(str_val) = eq_value.as_str() { @@ -156,23 +181,28 @@ fn bind_single_condition<'q>( query_builder = query_builder.bind(eq_value); } } - + if let Some(in_values) = &condition.in_values { - let str_values: Vec = in_values.iter() + let str_values: Vec = in_values + .iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) .collect(); query_builder = query_builder.bind(str_values); } - + if let Some(contains_value) = &condition.contains { query_builder = query_builder.bind(contains_value); } - + query_builder } // Helper function to build a single condition clause -fn build_single_condition(field: &str, condition: &WhereCondition, param_count: &mut usize) -> String { +fn build_single_condition( + field: &str, + condition: &WhereCondition, + param_count: &mut usize, +) -> String { if let Some(_eq_value) = &condition.eq { let clause = match field { "did" | "collection" | "uri" | "cid" => { @@ -309,7 +339,7 @@ impl Database { // Build bulk INSERT with multiple VALUES let mut query = String::from( - r#"INSERT INTO "record" ("uri", "cid", "did", "collection", "json", "indexed_at", "slice_uri") VALUES "# + r#"INSERT INTO "record" ("uri", "cid", "did", "collection", "json", "indexed_at", "slice_uri") VALUES "#, ); // Add placeholders for each record @@ -320,17 +350,25 @@ impl Database { let base = i * 7 + 1; // 7 fields per record query.push_str(&format!( "(${}, ${}, ${}, ${}, ${}, ${}, ${})", - base, base + 1, base + 2, base + 3, base + 4, base + 5, base + 6 + base, + base + 1, + base + 2, + base + 3, + base + 4, + base + 5, + base + 6 )); } - query.push_str(r#" + query.push_str( + r#" ON CONFLICT ON CONSTRAINT record_pkey DO UPDATE SET "cid" = EXCLUDED."cid", "json" = EXCLUDED."json", "indexed_at" = EXCLUDED."indexed_at" - "#); + "#, + ); // Bind all parameters let mut sqlx_query = sqlx::query(&query); @@ -351,7 +389,12 @@ impl Database { Ok(()) } - pub async fn get_existing_record_cids_for_slice(&self, did: &str, collection: &str, slice_uri: &str) -> Result, DatabaseError> { + pub async fn get_existing_record_cids_for_slice( + &self, + did: &str, + collection: &str, + slice_uri: &str, + ) -> Result, DatabaseError> { let records = sqlx::query!( r#"SELECT "uri", "cid" FROM "record" @@ -392,12 +435,14 @@ impl Database { Ok(indexed_record) } - - pub async fn get_lexicons_by_slice(&self, slice_uri: &str) -> Result, DatabaseError> { + pub async fn get_lexicons_by_slice( + &self, + slice_uri: &str, + ) -> Result, DatabaseError> { let records = sqlx::query_as::<_, Record>( r#"SELECT "uri", "cid", "did", "collection", "json", "indexed_at", "slice_uri" FROM "record" - WHERE "collection" = 'social.slices.lexicon' + WHERE "collection" = 'network.slices.lexicon' AND "json"->>'slice' = $1 ORDER BY "indexed_at" DESC"#, ) @@ -422,9 +467,6 @@ impl Database { Ok(lexicon_definitions) } - - - pub async fn update_record(&self, record: &Record) -> Result<(), DatabaseError> { let result = sqlx::query!( r#"UPDATE "record" @@ -440,13 +482,14 @@ impl Database { .await?; if result.rows_affected() == 0 { - return Err(DatabaseError::RecordNotFound { uri: record.uri.clone() }); + return Err(DatabaseError::RecordNotFound { + uri: record.uri.clone(), + }); } Ok(()) } - pub async fn batch_insert_actors(&self, actors: &[Actor]) -> Result<(), DatabaseError> { if actors.is_empty() { return Ok(()); @@ -456,7 +499,7 @@ impl Database { // Process actors in chunks to avoid hitting parameter limits const CHUNK_SIZE: usize = 1000; - + for chunk in actors.chunks(CHUNK_SIZE) { for actor in chunk { sqlx::query!( @@ -480,14 +523,17 @@ impl Database { Ok(()) } - pub async fn get_slice_collection_stats(&self, slice_uri: &str) -> Result, DatabaseError> { + pub async fn get_slice_collection_stats( + &self, + slice_uri: &str, + ) -> Result, DatabaseError> { let stats = sqlx::query!( r#" WITH slice_collections AS ( SELECT DISTINCT json->>'nsid' as collection_nsid FROM record - WHERE collection = 'social.slices.lexicon' + WHERE collection = 'network.slices.lexicon' AND json->>'slice' = $1 AND json->>'nsid' IS NOT NULL AND (json->>'definitions')::jsonb->'main'->>'type' = 'record' @@ -507,19 +553,25 @@ impl Database { .fetch_all(&self.pool) .await?; - Ok(stats.into_iter().map(|row| CollectionStats { - collection: row.collection, - record_count: row.record_count.unwrap_or(0), - unique_actors: row.unique_actors.unwrap_or(0), - }).collect()) + Ok(stats + .into_iter() + .map(|row| CollectionStats { + collection: row.collection, + record_count: row.record_count.unwrap_or(0), + unique_actors: row.unique_actors.unwrap_or(0), + }) + .collect()) } - pub async fn get_slice_collections_list(&self, slice_uri: &str) -> Result, DatabaseError> { + pub async fn get_slice_collections_list( + &self, + slice_uri: &str, + ) -> Result, DatabaseError> { let rows = sqlx::query!( r#" SELECT DISTINCT json->>'nsid' as collection_nsid FROM record - WHERE collection = 'social.slices.lexicon' + WHERE collection = 'network.slices.lexicon' AND json->>'slice' = $1 AND json->>'nsid' IS NOT NULL AND (json->>'definitions')::jsonb->'main'->>'type' = 'record' @@ -530,7 +582,8 @@ impl Database { .fetch_all(&self.pool) .await?; - Ok(rows.into_iter() + Ok(rows + .into_iter() .filter_map(|row| row.collection_nsid) .collect()) } @@ -542,7 +595,7 @@ impl Database { SELECT DISTINCT json->>'nsid' as collection_nsid FROM record - WHERE collection = 'social.slices.lexicon' + WHERE collection = 'network.slices.lexicon' AND json->>'slice' = $1 AND json->>'nsid' IS NOT NULL AND (json->>'definitions')::jsonb->'main'->>'type' = 'record' @@ -580,10 +633,10 @@ impl Database { slice_uri: &str, limit: Option, cursor: Option<&str>, - where_conditions: Option<&HashMap> + where_conditions: Option<&HashMap>, ) -> Result<(Vec, Option), DatabaseError> { let limit = limit.unwrap_or(50).min(100); // Cap at 100 - + // Handle where conditions with specific cases let records = if let Some(conditions) = where_conditions { // Check for handle contains filter @@ -662,15 +715,17 @@ impl Database { } } else { // Default case with basic filtering - self.query_actors_with_cursor(slice_uri, cursor, limit).await? + self.query_actors_with_cursor(slice_uri, cursor, limit) + .await? } } else if let Some(did_condition) = conditions.get("did") { if let Some(in_values) = &did_condition.in_values { - let string_values: Vec = in_values.iter() + let string_values: Vec = in_values + .iter() .filter_map(|v| v.as_str()) .map(|s| s.to_string()) .collect(); - + sqlx::query_as!( Actor, r#" @@ -724,17 +779,20 @@ impl Database { } } else { // Default case with basic filtering - self.query_actors_with_cursor(slice_uri, cursor, limit).await? + self.query_actors_with_cursor(slice_uri, cursor, limit) + .await? } } else { // Default case with basic filtering - self.query_actors_with_cursor(slice_uri, cursor, limit).await? + self.query_actors_with_cursor(slice_uri, cursor, limit) + .await? } } else { // No where conditions, just basic slice + cursor filtering - self.query_actors_with_cursor(slice_uri, cursor, limit).await? + self.query_actors_with_cursor(slice_uri, cursor, limit) + .await? }; - + // Generate cursor from the last record if there are any records let cursor = if records.is_empty() { None @@ -744,44 +802,45 @@ impl Database { Ok((records, cursor)) } - - async fn query_actors_with_cursor(&self, slice_uri: &str, cursor: Option<&str>, limit: i32) -> Result, DatabaseError> { + + async fn query_actors_with_cursor( + &self, + slice_uri: &str, + cursor: Option<&str>, + limit: i32, + ) -> Result, DatabaseError> { match cursor { - Some(cursor_did) => { - sqlx::query_as!( - Actor, - r#" + Some(cursor_did) => sqlx::query_as!( + Actor, + r#" SELECT did, handle, slice_uri, indexed_at FROM actor WHERE slice_uri = $1 AND did > $2 ORDER BY did ASC LIMIT $3 "#, - slice_uri, - cursor_did, - limit as i64 - ) - .fetch_all(&self.pool) - .await - .map_err(DatabaseError::from) - }, - None => { - sqlx::query_as!( - Actor, - r#" + slice_uri, + cursor_did, + limit as i64 + ) + .fetch_all(&self.pool) + .await + .map_err(DatabaseError::from), + None => sqlx::query_as!( + Actor, + r#" SELECT did, handle, slice_uri, indexed_at FROM actor WHERE slice_uri = $1 ORDER BY did ASC LIMIT $2 "#, - slice_uri, - limit as i64 - ) - .fetch_all(&self.pool) - .await - .map_err(DatabaseError::from) - } + slice_uri, + limit as i64 + ) + .fetch_all(&self.pool) + .await + .map_err(DatabaseError::from), } } @@ -790,7 +849,7 @@ impl Database { r#" SELECT COUNT(*) as count FROM record - WHERE collection = 'social.slices.lexicon' + WHERE collection = 'network.slices.lexicon' AND json->>'slice' = $1 AND (json->>'definitions')::jsonb->'main'->>'type' = 'record' "#, @@ -802,14 +861,13 @@ impl Database { Ok(count.count.unwrap_or(0)) } - pub async fn get_slice_collections_records( &self, slice_uri: &str, limit: Option, cursor: Option<&str>, sort_by: Option<&Vec>, - where_clause: Option<&WhereClause> + where_clause: Option<&WhereClause>, ) -> Result<(Vec, Option), DatabaseError> { let limit = limit.unwrap_or(50).min(100); // Cap at 100 let order_by = build_order_by_clause(sort_by); @@ -818,15 +876,15 @@ impl Database { let mut where_clauses = Vec::new(); let mut param_count = 1; - // Always filter by slice_uri, except for social.slices.lexicon which uses json->>'slice' - let is_lexicon = where_clause.as_ref() + // Always filter by slice_uri, except for network.slices.lexicon which uses json->>'slice' + let is_lexicon = where_clause + .as_ref() .and_then(|wc| wc.conditions.get("collection")) .and_then(|c| c.eq.as_ref()) .and_then(|v| v.as_str()) - .map_or(false, |s| s == "social.slices.lexicon"); - - if is_lexicon - { + .map_or(false, |s| s == "network.slices.lexicon"); + + if is_lexicon { where_clauses.push(format!("json->>'slice' = ${}", param_count)); } else { where_clauses.push(format!("slice_uri = ${}", param_count)); @@ -840,16 +898,16 @@ impl Database { } // Use helper function to build where conditions - let (and_conditions, or_conditions) = build_where_conditions(where_clause, &mut param_count); + let (and_conditions, or_conditions) = + build_where_conditions(where_clause, &mut param_count); where_clauses.extend(and_conditions); - + // Add OR conditions with proper parentheses if present if !or_conditions.is_empty() { let or_clause = format!("({})", or_conditions.join(" OR ")); where_clauses.push(or_clause); } - // Build the final query let where_sql = where_clauses.join(" AND "); let query = format!( @@ -869,7 +927,8 @@ impl Database { // Bind cursor if present if let Some(cursor_time) = cursor { - let cursor_dt = cursor_time.parse::>() + let cursor_dt = cursor_time + .parse::>() .unwrap_or_else(|_| chrono::Utc::now()); query_builder = query_builder.bind(cursor_dt); } @@ -887,7 +946,9 @@ impl Database { let cursor = if records.is_empty() { None } else { - records.last().map(|record| generate_cursor_from_record(record, sort_by)) + records + .last() + .map(|record| generate_cursor_from_record(record, sort_by)) }; Ok((records, cursor)) @@ -896,21 +957,21 @@ impl Database { pub async fn count_slice_collections_records( &self, slice_uri: &str, - where_clause: Option<&WhereClause> + where_clause: Option<&WhereClause>, ) -> Result { // Build WHERE clause dynamically let mut where_clauses = Vec::new(); let mut param_count = 1; - // Always filter by slice_uri, except for social.slices.lexicon which uses json->>'slice' - let is_lexicon = where_clause.as_ref() + // Always filter by slice_uri, except for network.slices.lexicon which uses json->>'slice' + let is_lexicon = where_clause + .as_ref() .and_then(|wc| wc.conditions.get("collection")) .and_then(|c| c.eq.as_ref()) .and_then(|v| v.as_str()) - .map_or(false, |s| s == "social.slices.lexicon"); - - if is_lexicon - { + .map_or(false, |s| s == "network.slices.lexicon"); + + if is_lexicon { where_clauses.push(format!("json->>'slice' = ${}", param_count)); } else { where_clauses.push(format!("slice_uri = ${}", param_count)); @@ -918,16 +979,16 @@ impl Database { param_count += 1; // Use helper function to build where conditions - let (and_conditions, or_conditions) = build_where_conditions(where_clause, &mut param_count); + let (and_conditions, or_conditions) = + build_where_conditions(where_clause, &mut param_count); where_clauses.extend(and_conditions); - + // Add OR conditions with proper parentheses if present if !or_conditions.is_empty() { let or_clause = format!("({})", or_conditions.join(" OR ")); where_clauses.push(or_clause); } - // Build the final query let where_sql = if where_clauses.is_empty() { String::new() @@ -937,7 +998,7 @@ impl Database { let query = format!("SELECT COUNT(*) as count FROM record{}", where_sql); - // Execute query with parameters + // Execute query with parameters let mut query_builder = sqlx::query_scalar::<_, i64>(&query); query_builder = query_builder.bind(slice_uri); @@ -953,7 +1014,8 @@ impl Database { } } if let Some(in_values) = &condition.in_values { - let str_values: Vec = in_values.iter() + let str_values: Vec = in_values + .iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) .collect(); query_builder = query_builder.bind(str_values); @@ -962,7 +1024,7 @@ impl Database { query_builder = query_builder.bind(contains_value); } } - + // Bind OR condition parameters if let Some(or_conditions) = &clause.or_conditions { for (_, condition) in or_conditions { @@ -974,7 +1036,8 @@ impl Database { } } if let Some(in_values) = &condition.in_values { - let str_values: Vec = in_values.iter() + let str_values: Vec = in_values + .iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) .collect(); query_builder = query_builder.bind(str_values); @@ -990,7 +1053,11 @@ impl Database { Ok(count) } - pub async fn delete_record_by_uri(&self, uri: &str, slice_uri: Option<&str>) -> Result { + pub async fn delete_record_by_uri( + &self, + uri: &str, + slice_uri: Option<&str>, + ) -> Result { let result = if let Some(slice_uri) = slice_uri { sqlx::query("DELETE FROM record WHERE uri = $1 AND slice_uri = $2") .bind(uri) @@ -1007,10 +1074,10 @@ impl Database { Ok(result.rows_affected()) } - pub async fn upsert_record(&self, record: &Record) -> Result { // Returns true if inserted, false if updated - let result = sqlx::query_scalar::<_, bool>(r#" + let result = sqlx::query_scalar::<_, bool>( + r#" INSERT INTO record (uri, cid, did, collection, json, indexed_at, slice_uri) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT ON CONSTRAINT record_pkey DO UPDATE @@ -1018,7 +1085,8 @@ impl Database { json = EXCLUDED.json, indexed_at = EXCLUDED.indexed_at RETURNING (xmax = 0) - "#) + "#, + ) .bind(&record.uri) .bind(&record.cid) .bind(&record.did) @@ -1032,19 +1100,20 @@ impl Database { } pub async fn get_all_slices(&self) -> Result, DatabaseError> { - let rows: Vec<(String,)> = sqlx::query_as(r#" + let rows: Vec<(String,)> = sqlx::query_as( + r#" SELECT DISTINCT json->>'slice' as slice_uri FROM record - WHERE collection = 'social.slices.lexicon' + WHERE collection = 'network.slices.lexicon' AND json->>'slice' IS NOT NULL - "#) + "#, + ) .fetch_all(&self.pool) .await?; Ok(rows.into_iter().map(|(uri,)| uri).collect()) } - pub async fn get_all_actors(&self) -> Result, DatabaseError> { let rows = sqlx::query!( r#" @@ -1055,7 +1124,10 @@ impl Database { .fetch_all(&self.pool) .await?; - Ok(rows.into_iter().map(|row| (row.did, row.slice_uri)).collect()) + Ok(rows + .into_iter() + .map(|row| (row.did, row.slice_uri)) + .collect()) } pub async fn get_slice_domain(&self, slice_uri: &str) -> Result, DatabaseError> { @@ -1063,7 +1135,7 @@ impl Database { r#" SELECT json->>'domain' as domain FROM record - WHERE collection = 'social.slices.slice' + WHERE collection = 'network.slices.slice' AND uri = $1 "#, slice_uri @@ -1099,7 +1171,10 @@ impl Database { Ok(client) } - pub async fn get_oauth_clients_for_slice(&self, slice_uri: &str) -> Result, DatabaseError> { + pub async fn get_oauth_clients_for_slice( + &self, + slice_uri: &str, + ) -> Result, DatabaseError> { let clients = sqlx::query_as!( OAuthClient, r#" @@ -1116,7 +1191,10 @@ impl Database { Ok(clients) } - pub async fn get_oauth_client_by_id(&self, client_id: &str) -> Result, DatabaseError> { + pub async fn get_oauth_client_by_id( + &self, + client_id: &str, + ) -> Result, DatabaseError> { let client = sqlx::query_as!( OAuthClient, r#" @@ -1144,10 +1222,11 @@ impl Database { .await?; if result.rows_affected() == 0 { - return Err(DatabaseError::RecordNotFound { uri: client_id.to_string() }); + return Err(DatabaseError::RecordNotFound { + uri: client_id.to_string(), + }); } Ok(()) } - } diff --git a/api/src/handler_sync_user_collections.rs b/api/src/handler_sync_user_collections.rs index 2775ad4..4be2776 100644 --- a/api/src/handler_sync_user_collections.rs +++ b/api/src/handler_sync_user_collections.rs @@ -6,9 +6,9 @@ use axum::{ use serde::Deserialize; use tracing::{info, warn}; +use crate::AppState; use crate::auth::{extract_bearer_token, verify_oauth_token}; use crate::sync::{SyncService, SyncUserCollectionsResult}; -use crate::AppState; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -22,7 +22,7 @@ fn default_timeout() -> u64 { 30 // 30 second default timeout for login scenarios } -/// Handler for social.slices.slice.syncUserCollections +/// Handler for network.slices.slice.syncUserCollections /// Synchronously syncs external collections for the authenticated user with timeout protection /// Automatically discovers external collections based on the slice's domain configuration pub async fn sync_user_collections( @@ -32,22 +32,29 @@ pub async fn sync_user_collections( ) -> Result, (StatusCode, Json)> { // Extract and verify OAuth token let token = extract_bearer_token(&headers).map_err(|e| { - (StatusCode::UNAUTHORIZED, Json(serde_json::json!({ - "error": "AuthenticationRequired", - "message": format!("Bearer token required: {}", e) - }))) + ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ + "error": "AuthenticationRequired", + "message": format!("Bearer token required: {}", e) + })), + ) })?; - let user_info = verify_oauth_token(&token, &state.config.auth_base_url).await + let user_info = verify_oauth_token(&token, &state.config.auth_base_url) + .await .map_err(|e| { - (StatusCode::UNAUTHORIZED, Json(serde_json::json!({ - "error": "InvalidToken", - "message": format!("Token verification failed: {}", e) - }))) + ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ + "error": "InvalidToken", + "message": format!("Token verification failed: {}", e) + })), + ) })?; let user_did = user_info.did.unwrap_or(user_info.sub); - + info!( "🔄 Starting user collections sync for {} on slice {} (timeout: {}s)", user_did, request.slice, request.timeout_seconds @@ -55,21 +62,24 @@ pub async fn sync_user_collections( // Validate timeout (max 5 minutes for sync operations) if request.timeout_seconds > 300 { - return Err((StatusCode::BAD_REQUEST, Json(serde_json::json!({ - "error": "InvalidTimeout", - "message": "Maximum timeout is 300 seconds (5 minutes)" - })))); + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "InvalidTimeout", + "message": "Maximum timeout is 300 seconds (5 minutes)" + })), + )); } // Create sync service - let sync_service = SyncService::new(state.database.clone(), state.config.relay_endpoint.clone()); - + let sync_service = + SyncService::new(state.database.clone(), state.config.relay_endpoint.clone()); + // Perform timeout-protected sync with auto-discovered external collections - match sync_service.sync_user_collections( - &user_did, - &request.slice, - request.timeout_seconds, - ).await { + match sync_service + .sync_user_collections(&user_did, &request.slice, request.timeout_seconds) + .await + { Ok(result) => { if result.timed_out { info!( @@ -83,13 +93,16 @@ pub async fn sync_user_collections( ); } Ok(Json(result)) - }, + } Err(e) => { warn!("❌ Sync failed for user {}: {}", user_did, e); - Err((StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": "SyncFailed", - "message": format!("Sync operation failed: {}", e) - })))) + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": "SyncFailed", + "message": format!("Sync operation failed: {}", e) + })), + )) } } -} \ No newline at end of file +} diff --git a/api/src/handler_xrpc_dynamic.rs b/api/src/handler_xrpc_dynamic.rs index ec45cda..1afb219 100644 --- a/api/src/handler_xrpc_dynamic.rs +++ b/api/src/handler_xrpc_dynamic.rs @@ -1,17 +1,22 @@ +use atproto_client::com::atproto::repo::{ + CreateRecordRequest, CreateRecordResponse, DeleteRecordRequest, PutRecordRequest, + PutRecordResponse, create_record, delete_record, put_record, +}; use axum::{ extract::{Path, Query, State}, http::{HeaderMap, StatusCode}, response::Json, }; -use serde::Deserialize; use chrono::Utc; -use atproto_client::com::atproto::repo::{CreateRecordRequest, PutRecordRequest, DeleteRecordRequest, create_record, put_record, delete_record, CreateRecordResponse, PutRecordResponse}; +use serde::Deserialize; -use crate::auth::{extract_bearer_token, verify_oauth_token, get_atproto_auth_for_user}; +use crate::AppState; +use crate::auth::{extract_bearer_token, get_atproto_auth_for_user, verify_oauth_token}; use crate::lexicon::LexiconValidator; -use crate::models::{Record, SliceRecordsParams, SliceRecordsOutput, IndexedRecord, WhereCondition, SortField}; +use crate::models::{ + IndexedRecord, Record, SliceRecordsOutput, SliceRecordsParams, SortField, WhereCondition, +}; use std::collections::HashMap; -use crate::AppState; // Helper function to convert StatusCode errors to JSON error responses fn status_to_error_response(status: StatusCode) -> (StatusCode, Json) { @@ -24,14 +29,15 @@ fn status_to_error_response(status: StatusCode) -> (StatusCode, Json "Request failed", }; - (status, Json(serde_json::json!({ - "error": status.as_str(), - "message": message - }))) + ( + status, + Json(serde_json::json!({ + "error": status.as_str(), + "message": message + })), + ) } - - #[derive(Deserialize)] pub struct GetRecordParams { pub uri: String, @@ -66,7 +72,6 @@ pub async fn dynamic_xrpc_post_handler( headers: HeaderMap, Json(body): Json, ) -> Result, (StatusCode, Json)> { - // Handle dynamic collection methods (e.g., social.grain.gallery.createRecord) if method.ends_with(".getRecords") { let collection = method.trim_end_matches(".getRecords").to_string(); @@ -95,7 +100,8 @@ async fn dynamic_get_records_handler( params: serde_json::Value, ) -> Result, StatusCode> { // Parse parameters into SliceRecordsParams format - let slice = params.get("slice") + let slice = params + .get("slice") .and_then(|v| v.as_str()) .ok_or(StatusCode::BAD_REQUEST)? .to_string(); @@ -108,66 +114,75 @@ async fn dynamic_get_records_handler( } }); - let cursor = params.get("cursor") + let cursor = params + .get("cursor") .and_then(|v| v.as_str()) .map(|s| s.to_string()); // Parse sortBy from params - convert legacy sort string to new array format if present - let sort_by = params.get("sort") - .and_then(|v| v.as_str()) - .map(|sort_str| { - // Convert legacy "field:direction" format to new array format - let mut sort_fields = Vec::new(); - for sort_item in sort_str.split(',') { - let parts: Vec<&str> = sort_item.trim().split(':').collect(); - if parts.len() == 2 { - sort_fields.push(SortField { - field: parts[0].trim().to_string(), - direction: parts[1].trim().to_string(), - }); - } else if parts.len() == 1 && !parts[0].is_empty() { - // Default to ascending if no direction specified - sort_fields.push(SortField { - field: parts[0].trim().to_string(), - direction: "asc".to_string(), - }); - } + let sort_by = params.get("sort").and_then(|v| v.as_str()).map(|sort_str| { + // Convert legacy "field:direction" format to new array format + let mut sort_fields = Vec::new(); + for sort_item in sort_str.split(',') { + let parts: Vec<&str> = sort_item.trim().split(':').collect(); + if parts.len() == 2 { + sort_fields.push(SortField { + field: parts[0].trim().to_string(), + direction: parts[1].trim().to_string(), + }); + } else if parts.len() == 1 && !parts[0].is_empty() { + // Default to ascending if no direction specified + sort_fields.push(SortField { + field: parts[0].trim().to_string(), + direction: "asc".to_string(), + }); } - sort_fields - }); + } + sort_fields + }); // Parse where conditions from query params if present let mut where_conditions = HashMap::new(); // Handle legacy author/authors params by converting to where clause if let Some(author_str) = params.get("author").and_then(|v| v.as_str()) { - where_conditions.insert("did".to_string(), WhereCondition { - eq: Some(serde_json::Value::String(author_str.to_string())), - in_values: None, - contains: None, - }); + where_conditions.insert( + "did".to_string(), + WhereCondition { + eq: Some(serde_json::Value::String(author_str.to_string())), + in_values: None, + contains: None, + }, + ); } else if let Some(authors_str) = params.get("authors").and_then(|v| v.as_str()) { let authors: Vec = authors_str .split(',') .map(|s| serde_json::Value::String(s.trim().to_string())) .collect(); - where_conditions.insert("did".to_string(), WhereCondition { - eq: None, - in_values: Some(authors), - contains: None, - }); + where_conditions.insert( + "did".to_string(), + WhereCondition { + eq: None, + in_values: Some(authors), + contains: None, + }, + ); } // Handle legacy query param by converting to where clause with contains if let Some(query_str) = params.get("query").and_then(|v| v.as_str()) { - let field = params.get("field") + let field = params + .get("field") .and_then(|v| v.as_str()) .unwrap_or("text"); // Default to text field for search - where_conditions.insert(field.to_string(), WhereCondition { - eq: None, - in_values: None, - contains: Some(query_str.to_string()), - }); + where_conditions.insert( + field.to_string(), + WhereCondition { + eq: None, + in_values: None, + contains: Some(query_str.to_string()), + }, + ); } let where_clause = if where_conditions.is_empty() { @@ -188,34 +203,44 @@ async fn dynamic_get_records_handler( }; // First verify the collection belongs to this slice - let slice_collections = state.database.get_slice_collections_list(&records_params.slice).await + let slice_collections = state + .database + .get_slice_collections_list(&records_params.slice) + .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - // Special handling: social.slices.lexicon is always allowed as it defines the schema - if collection != "social.slices.lexicon" && !slice_collections.contains(&collection) { + // Special handling: network.slices.lexicon is always allowed as it defines the schema + if collection != "network.slices.lexicon" && !slice_collections.contains(&collection) { return Err(StatusCode::NOT_FOUND); } // Use the unified database method - match state.database.get_slice_collections_records( - &records_params.slice, - records_params.limit, - records_params.cursor.as_deref(), - sort_by.as_ref(), - records_params.where_clause.as_ref(), - ).await { + match state + .database + .get_slice_collections_records( + &records_params.slice, + records_params.limit, + records_params.cursor.as_deref(), + sort_by.as_ref(), + records_params.where_clause.as_ref(), + ) + .await + { Ok((mut records, cursor)) => { // Filter records to only include the specific collection records.retain(|record| record.collection == collection); - let indexed_records: Vec = records.into_iter().map(|record| IndexedRecord { - uri: record.uri, - cid: record.cid, - did: record.did, - collection: record.collection, - value: record.json, - indexed_at: record.indexed_at.to_rfc3339(), - }).collect(); + let indexed_records: Vec = records + .into_iter() + .map(|record| IndexedRecord { + uri: record.uri, + cid: record.cid, + did: record.did, + collection: record.collection, + value: record.json, + indexed_at: record.indexed_at.to_rfc3339(), + }) + .collect(); let output = SliceRecordsOutput { success: true, @@ -224,46 +249,44 @@ async fn dynamic_get_records_handler( message: None, }; - Ok(Json(serde_json::to_value(output) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?)) - }, + Ok(Json( + serde_json::to_value(output).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?, + )) + } Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), } } - - // Implementation for get record async fn dynamic_get_record_impl( collection: String, state: AppState, params: serde_json::Value, ) -> Result, StatusCode> { - let get_params: GetRecordParams = serde_json::from_value(params) - .map_err(|_| StatusCode::BAD_REQUEST)?; + let get_params: GetRecordParams = + serde_json::from_value(params).map_err(|_| StatusCode::BAD_REQUEST)?; // First verify the collection belongs to this slice - let slice_collections = state.database.get_slice_collections_list(&get_params.slice).await + let slice_collections = state + .database + .get_slice_collections_list(&get_params.slice) + .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - // Special handling: social.slices.lexicon is always allowed as it defines the schema - if collection != "social.slices.lexicon" && !slice_collections.contains(&collection) { + // Special handling: network.slices.lexicon is always allowed as it defines the schema + if collection != "network.slices.lexicon" && !slice_collections.contains(&collection) { return Err(StatusCode::NOT_FOUND); } // Use direct database query by URI for efficiency match state.database.get_record(&get_params.uri).await { Ok(Some(record)) => { - let json_value = serde_json::to_value(record) - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let json_value = + serde_json::to_value(record).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(json_value)) - }, - Ok(None) => { - Err(StatusCode::NOT_FOUND) - }, - Err(_e) => { - Err(StatusCode::INTERNAL_SERVER_ERROR) - }, + } + Ok(None) => Err(StatusCode::NOT_FOUND), + Err(_e) => Err(StatusCode::INTERNAL_SERVER_ERROR), } } @@ -274,50 +297,77 @@ async fn dynamic_get_records_post_handler( body: serde_json::Value, ) -> Result, (StatusCode, Json)> { // Parse the JSON body into SliceRecordsParams - let mut records_params: SliceRecordsParams = serde_json::from_value(body) - .map_err(|_| (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "Invalid request body"}))))?; + let mut records_params: SliceRecordsParams = serde_json::from_value(body).map_err(|_| { + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid request body"})), + ) + })?; // First verify the collection belongs to this slice - let slice_collections = state.database.get_slice_collections_list(&records_params.slice).await - .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Database error"}))))?; + let slice_collections = state + .database + .get_slice_collections_list(&records_params.slice) + .await + .map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "Database error"})), + ) + })?; - // Special handling: social.slices.lexicon is always allowed as it defines the schema - if collection != "social.slices.lexicon" && !slice_collections.contains(&collection) { - return Err((StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Collection not found"})))); + // Special handling: network.slices.lexicon is always allowed as it defines the schema + if collection != "network.slices.lexicon" && !slice_collections.contains(&collection) { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Collection not found"})), + )); } // Add collection filter to where conditions - let mut where_clause = records_params.where_clause.unwrap_or(crate::models::WhereClause { - conditions: HashMap::new(), - or_conditions: None, - }); - where_clause.conditions.insert("collection".to_string(), WhereCondition { - eq: Some(serde_json::Value::String(collection.clone())), - in_values: None, - contains: None, - }); + let mut where_clause = records_params + .where_clause + .unwrap_or(crate::models::WhereClause { + conditions: HashMap::new(), + or_conditions: None, + }); + where_clause.conditions.insert( + "collection".to_string(), + WhereCondition { + eq: Some(serde_json::Value::String(collection.clone())), + in_values: None, + contains: None, + }, + ); records_params.where_clause = Some(where_clause); // Use the unified database method - match state.database.get_slice_collections_records( - &records_params.slice, - records_params.limit, - records_params.cursor.as_deref(), - records_params.sort_by.as_ref(), - records_params.where_clause.as_ref(), - ).await { + match state + .database + .get_slice_collections_records( + &records_params.slice, + records_params.limit, + records_params.cursor.as_deref(), + records_params.sort_by.as_ref(), + records_params.where_clause.as_ref(), + ) + .await + { Ok((records, cursor)) => { // No need to filter - collection filter is in the SQL query now // Transform Record to IndexedRecord for the response - let indexed_records: Vec = records.into_iter().map(|record| IndexedRecord { - uri: record.uri, - cid: record.cid, - did: record.did, - collection: record.collection, - value: record.json, - indexed_at: record.indexed_at.to_rfc3339(), - }).collect(); + let indexed_records: Vec = records + .into_iter() + .map(|record| IndexedRecord { + uri: record.uri, + cid: record.cid, + did: record.did, + collection: record.collection, + value: record.json, + indexed_at: record.indexed_at.to_rfc3339(), + }) + .collect(); let output = SliceRecordsOutput { success: true, @@ -326,10 +376,17 @@ async fn dynamic_get_records_post_handler( message: None, }; - Ok(Json(serde_json::to_value(output) - .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Serialization error"}))))?)) - }, - Err(_) => Err((StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Database error"})))), + Ok(Json(serde_json::to_value(output).map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "Serialization error"})), + ) + })?)) + } + Err(_) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "Database error"})), + )), } } @@ -340,31 +397,46 @@ async fn dynamic_count_records_handler( params: serde_json::Value, ) -> Result, StatusCode> { // Convert query parameters to SliceRecordsParams - let mut records_params: SliceRecordsParams = serde_json::from_value(params) - .map_err(|_| StatusCode::BAD_REQUEST)?; + let mut records_params: SliceRecordsParams = + serde_json::from_value(params).map_err(|_| StatusCode::BAD_REQUEST)?; // First verify the collection belongs to this slice - let slice_collections = state.database.get_slice_collections_list(&records_params.slice).await + let slice_collections = state + .database + .get_slice_collections_list(&records_params.slice) + .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - // Special handling: social.slices.lexicon is always allowed as it defines the schema - if collection != "social.slices.lexicon" && !slice_collections.contains(&collection) { + // Special handling: network.slices.lexicon is always allowed as it defines the schema + if collection != "network.slices.lexicon" && !slice_collections.contains(&collection) { return Err(StatusCode::NOT_FOUND); } // Add collection filter to where conditions - let mut where_clause = records_params.where_clause.unwrap_or(crate::models::WhereClause { - conditions: HashMap::new(), - or_conditions: None, - }); - where_clause.conditions.insert("collection".to_string(), WhereCondition { - eq: Some(collection.clone().into()), - contains: None, - in_values: None, - }); + let mut where_clause = records_params + .where_clause + .unwrap_or(crate::models::WhereClause { + conditions: HashMap::new(), + or_conditions: None, + }); + where_clause.conditions.insert( + "collection".to_string(), + WhereCondition { + eq: Some(collection.clone().into()), + contains: None, + in_values: None, + }, + ); records_params.where_clause = Some(where_clause); - match state.database.count_slice_collections_records(&records_params.slice, records_params.where_clause.as_ref()).await { + match state + .database + .count_slice_collections_records( + &records_params.slice, + records_params.where_clause.as_ref(), + ) + .await + { Ok(count) => Ok(Json(serde_json::json!({ "success": true, "count": count, @@ -385,31 +457,58 @@ async fn dynamic_count_records_post_handler( body: serde_json::Value, ) -> Result, (StatusCode, Json)> { // Parse the JSON body into SliceRecordsParams - let mut records_params: SliceRecordsParams = serde_json::from_value(body) - .map_err(|_| (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "Invalid request body"}))))?; + let mut records_params: SliceRecordsParams = serde_json::from_value(body).map_err(|_| { + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Invalid request body"})), + ) + })?; // First verify the collection belongs to this slice - let slice_collections = state.database.get_slice_collections_list(&records_params.slice).await - .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"error": "Database error"}))))?; + let slice_collections = state + .database + .get_slice_collections_list(&records_params.slice) + .await + .map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "Database error"})), + ) + })?; - // Special handling: social.slices.lexicon is always allowed as it defines the schema - if collection != "social.slices.lexicon" && !slice_collections.contains(&collection) { - return Err((StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "Collection not found"})))); + // Special handling: network.slices.lexicon is always allowed as it defines the schema + if collection != "network.slices.lexicon" && !slice_collections.contains(&collection) { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": "Collection not found"})), + )); } // Add collection filter to where conditions - let mut where_clause = records_params.where_clause.unwrap_or(crate::models::WhereClause { - conditions: HashMap::new(), - or_conditions: None, - }); - where_clause.conditions.insert("collection".to_string(), WhereCondition { - eq: Some(collection.clone().into()), - in_values: None, - contains: None, - }); + let mut where_clause = records_params + .where_clause + .unwrap_or(crate::models::WhereClause { + conditions: HashMap::new(), + or_conditions: None, + }); + where_clause.conditions.insert( + "collection".to_string(), + WhereCondition { + eq: Some(collection.clone().into()), + in_values: None, + contains: None, + }, + ); records_params.where_clause = Some(where_clause); - match state.database.count_slice_collections_records(&records_params.slice, records_params.where_clause.as_ref()).await { + match state + .database + .count_slice_collections_records( + &records_params.slice, + records_params.where_clause.as_ref(), + ) + .await + { Ok(count) => Ok(Json(serde_json::json!({ "success": true, "count": count, @@ -432,11 +531,13 @@ async fn dynamic_collection_create_impl( ) -> Result, (StatusCode, Json)> { // Extract and verify OAuth token let token = extract_bearer_token(&headers).map_err(status_to_error_response)?; - let user_info = verify_oauth_token(&token, &state.config.auth_base_url).await + let user_info = verify_oauth_token(&token, &state.config.auth_base_url) + .await .map_err(status_to_error_response)?; // Get AT Protocol DPoP auth and PDS URL - let (dpop_auth, pds_url) = get_atproto_auth_for_user(&token, &state.config.auth_base_url).await + let (dpop_auth, pds_url) = get_atproto_auth_for_user(&token, &state.config.auth_base_url) + .await .map_err(status_to_error_response)?; // Extract the repo DID from user info @@ -446,45 +547,47 @@ async fn dynamic_collection_create_impl( let http_client = reqwest::Client::new(); // Extract slice URI, rkey, and record value from structured body - let slice_uri = body.get("slice") + let slice_uri = body + .get("slice") .and_then(|v| v.as_str()) .ok_or_else(|| status_to_error_response(StatusCode::BAD_REQUEST))? .to_string(); - let record_key = body.get("rkey") + let record_key = body + .get("rkey") .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) // Filter out empty strings .map(|s| s.to_string()); - let record_data = body.get("record") + let record_data = body + .get("record") .ok_or_else(|| status_to_error_response(StatusCode::BAD_REQUEST))? .clone(); - // Validate the record against its lexicon - // For social.slices.lexicon collection, validate against the system slice - let validation_slice_uri = if collection == "social.slices.lexicon" { - "at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/3lwzmbjpqxk2q" + // For network.slices.lexicon collection, validate against the system slice + let validation_slice_uri = if collection == "network.slices.lexicon" { + "at://did:plc:bcgltzqazw5tb6k2g3ttenbj/network.slices.slice/3lymhd4jhrd2z" } else { &slice_uri }; - match LexiconValidator::for_slice(&state.database, validation_slice_uri).await { Ok(validator) => { - // Debug: Get lexicons from the system slice to see what's there - if collection == "social.slices.lexicon" { - } + if collection == "network.slices.lexicon" {} if let Err(e) = validator.validate_record(&collection, &record_data) { - return Err((StatusCode::BAD_REQUEST, Json(serde_json::json!({ - "error": "ValidationError", - "message": format!("Lexicon validation failed: {}", e) - })))); + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "ValidationError", + "message": format!("Lexicon validation failed: {}", e) + })), + )); } - }, + } Err(e) => { // If no lexicon found, continue without validation (backwards compatibility) eprintln!("Could not load lexicon validator: {:?}", e); @@ -502,21 +605,16 @@ async fn dynamic_collection_create_impl( validate: false, }; - let result = create_record(&http_client, &dpop_auth, &pds_url, create_request) .await - .map_err(|_e| { - status_to_error_response(StatusCode::INTERNAL_SERVER_ERROR) - })?; + .map_err(|_e| status_to_error_response(StatusCode::INTERNAL_SERVER_ERROR))?; // Extract URI and CID from the response enum let (uri, cid) = match result { - CreateRecordResponse::StrongRef { uri, cid, .. } => { - (uri, cid) - }, + CreateRecordResponse::StrongRef { uri, cid, .. } => (uri, cid), CreateRecordResponse::Error(_e) => { return Err(status_to_error_response(StatusCode::INTERNAL_SERVER_ERROR)); - }, + } }; // Also store in local database for indexing @@ -550,25 +648,30 @@ async fn dynamic_collection_update_impl( ) -> Result, (StatusCode, Json)> { // Extract and verify OAuth token let token = extract_bearer_token(&headers).map_err(status_to_error_response)?; - let user_info = verify_oauth_token(&token, &state.config.auth_base_url).await + let user_info = verify_oauth_token(&token, &state.config.auth_base_url) + .await .map_err(status_to_error_response)?; // Get AT Protocol DPoP auth and PDS URL - let (dpop_auth, pds_url) = get_atproto_auth_for_user(&token, &state.config.auth_base_url).await + let (dpop_auth, pds_url) = get_atproto_auth_for_user(&token, &state.config.auth_base_url) + .await .map_err(status_to_error_response)?; // Extract slice URI, rkey, and record value from structured body - let slice_uri = body.get("slice") + let slice_uri = body + .get("slice") .and_then(|v| v.as_str()) .ok_or_else(|| status_to_error_response(StatusCode::BAD_REQUEST))? .to_string(); - let rkey = body.get("rkey") + let rkey = body + .get("rkey") .and_then(|v| v.as_str()) .ok_or_else(|| status_to_error_response(StatusCode::BAD_REQUEST))? .to_string(); - let record_data = body.get("record") + let record_data = body + .get("record") .ok_or_else(|| status_to_error_response(StatusCode::BAD_REQUEST))? .clone(); @@ -579,12 +682,15 @@ async fn dynamic_collection_update_impl( match LexiconValidator::for_slice(&state.database, &slice_uri).await { Ok(validator) => { if let Err(e) = validator.validate_record(&collection, &record_data) { - return Err((StatusCode::BAD_REQUEST, Json(serde_json::json!({ - "error": "ValidationError", - "message": format!("Lexicon validation failed: {}", e) - })))); + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "ValidationError", + "message": format!("Lexicon validation failed: {}", e) + })), + )); } - }, + } Err(e) => { // If no lexicon found, continue without validation (backwards compatibility) eprintln!("Could not load lexicon validator: {:?}", e); @@ -612,7 +718,9 @@ async fn dynamic_collection_update_impl( // Extract URI and CID from the response enum let (uri, cid) = match result { PutRecordResponse::StrongRef { uri, cid, .. } => (uri, cid), - PutRecordResponse::Error(_) => return Err(status_to_error_response(StatusCode::INTERNAL_SERVER_ERROR)), + PutRecordResponse::Error(_) => { + return Err(status_to_error_response(StatusCode::INTERNAL_SERVER_ERROR)); + } }; // Also update in local database for indexing @@ -644,16 +752,19 @@ async fn dynamic_collection_delete_impl( ) -> Result, (StatusCode, Json)> { // Extract and verify OAuth token let token = extract_bearer_token(&headers).map_err(status_to_error_response)?; - let user_info = verify_oauth_token(&token, &state.config.auth_base_url).await + let user_info = verify_oauth_token(&token, &state.config.auth_base_url) + .await .map_err(status_to_error_response)?; // Get AT Protocol DPoP auth and PDS URL - let (dpop_auth, pds_url) = get_atproto_auth_for_user(&token, &state.config.auth_base_url).await + let (dpop_auth, pds_url) = get_atproto_auth_for_user(&token, &state.config.auth_base_url) + .await .map_err(status_to_error_response)?; // Extract repo and rkey from body let repo = user_info.did.unwrap_or(user_info.sub); - let rkey = body["rkey"].as_str() + let rkey = body["rkey"] + .as_str() .ok_or_else(|| status_to_error_response(StatusCode::BAD_REQUEST))? .to_string(); @@ -680,40 +791,37 @@ async fn dynamic_collection_delete_impl( Ok(Json(serde_json::json!({}))) } - #[cfg(test)] mod dynamic_validation_tests { use crate::lexicon::LexiconValidator; use serde_json::json; fn create_test_lexicons() -> Vec { - vec![ - json!({ - "id": "social.slices.testRecord", - "lexicon": 1, - "defs": { - "main": { - "type": "record", - "record": { - "type": "object", - "required": ["title", "aspectRatio"], - "properties": { - "title": { "type": "string", "maxLength": 100 }, - "aspectRatio": { "type": "ref", "ref": "#aspectRatio" } - } - } - }, - "aspectRatio": { + vec![json!({ + "id": "network.slices.testRecord", + "lexicon": 1, + "defs": { + "main": { + "type": "record", + "record": { "type": "object", - "required": ["width", "height"], + "required": ["title", "aspectRatio"], "properties": { - "width": { "type": "integer", "minimum": 1 }, - "height": { "type": "integer", "minimum": 1 } + "title": { "type": "string", "maxLength": 100 }, + "aspectRatio": { "type": "ref", "ref": "#aspectRatio" } } } + }, + "aspectRatio": { + "type": "object", + "required": ["width", "height"], + "properties": { + "width": { "type": "integer", "minimum": 1 }, + "height": { "type": "integer", "minimum": 1 } + } } - }) - ] + } + })] } #[test] @@ -729,8 +837,12 @@ mod dynamic_validation_tests { } }); - let result = validator.validate_record("social.slices.testRecord", &valid_record); - assert!(result.is_ok(), "Valid record should pass validation: {:?}", result); + let result = validator.validate_record("network.slices.testRecord", &valid_record); + assert!( + result.is_ok(), + "Valid record should pass validation: {:?}", + result + ); } #[test] @@ -743,8 +855,11 @@ mod dynamic_validation_tests { // Missing required aspectRatio field }); - let result = validator.validate_record("social.slices.testRecord", &invalid_record); - assert!(result.is_err(), "Record missing required field should fail validation"); + let result = validator.validate_record("network.slices.testRecord", &invalid_record); + assert!( + result.is_err(), + "Record missing required field should fail validation" + ); } #[test] @@ -761,8 +876,12 @@ mod dynamic_validation_tests { } }); - let result = validator.validate_record("social.slices.testRecord", &valid_record); - assert!(result.is_ok(), "Cross-lexicon reference should work: {:?}", result); + let result = validator.validate_record("network.slices.testRecord", &valid_record); + assert!( + result.is_ok(), + "Cross-lexicon reference should work: {:?}", + result + ); } #[test] @@ -776,15 +895,21 @@ mod dynamic_validation_tests { // Missing required aspectRatio field }); - let result = validator.validate_record("social.slices.testRecord", &invalid_record); + let result = validator.validate_record("network.slices.testRecord", &invalid_record); assert!(result.is_err(), "Invalid record should fail validation"); if let Err(e) = result { let error_message = format!("{}", e); - assert!(!error_message.is_empty(), "Error message should not be empty"); + assert!( + !error_message.is_empty(), + "Error message should not be empty" + ); // Error message should be user-friendly and descriptive - assert!(error_message.contains("aspectRatio") || error_message.contains("required"), - "Error message should indicate what's wrong: {}", error_message); + assert!( + error_message.contains("aspectRatio") || error_message.contains("required"), + "Error message should indicate what's wrong: {}", + error_message + ); } } @@ -802,14 +927,22 @@ mod dynamic_validation_tests { } }); - let result = validator.validate_record("social.slices.testRecord", &invalid_record); - assert!(result.is_err(), "Constraint violation should fail validation"); + let result = validator.validate_record("network.slices.testRecord", &invalid_record); + assert!( + result.is_err(), + "Constraint violation should fail validation" + ); if let Err(e) = result { let error_message = format!("{}", e); // Should indicate the specific constraint that was violated - assert!(error_message.contains("length") || error_message.contains("maximum") || error_message.contains("100"), - "Error message should indicate length constraint: {}", error_message); + assert!( + error_message.contains("length") + || error_message.contains("maximum") + || error_message.contains("100"), + "Error message should indicate length constraint: {}", + error_message + ); } } } diff --git a/api/src/lexicon/test.rs b/api/src/lexicon/test.rs index 213e092..7754433 100644 --- a/api/src/lexicon/test.rs +++ b/api/src/lexicon/test.rs @@ -19,7 +19,7 @@ mod tests { "required": [ "object", "array", - "boolean", + "boolean", "integer", "string", "bytes", @@ -408,14 +408,14 @@ mod tests { } } } - }) + }), ] } #[test] fn test_kitchen_sink_validation() { let validator = LexiconValidator::new(get_test_lexicons()).unwrap(); - + // Valid kitchen sink record (mirroring TypeScript test) let valid_record = json!({ "object": { @@ -434,9 +434,13 @@ mod tests { "$link": "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a" } }); - - assert!(validator.validate_record("com.example.kitchenSink", &valid_record).is_ok()); - + + assert!( + validator + .validate_record("com.example.kitchenSink", &valid_record) + .is_ok() + ); + // Missing required field (object) let invalid_record = json!({ "array": ["one", "two"], @@ -448,14 +452,18 @@ mod tests { "$link": "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a" } }); - - assert!(validator.validate_record("com.example.kitchenSink", &invalid_record).is_err()); + + assert!( + validator + .validate_record("com.example.kitchenSink", &invalid_record) + .is_err() + ); } - + #[test] fn test_local_refs() { let validator = LexiconValidator::new(get_test_lexicons()).unwrap(); - + // Valid with local ref (#object -> #subobject) let valid_record = json!({ "object": { "boolean": true }, // subobject ref @@ -464,132 +472,224 @@ mod tests { "integer": 123, "string": "string" }); - - assert!(validator.validate_record("com.example.kitchenSink#object", &valid_record).is_ok()); - + + assert!( + validator + .validate_record("com.example.kitchenSink#object", &valid_record) + .is_ok() + ); + // Invalid - missing boolean in subobject let invalid_record = json!({ "object": {}, // missing required boolean - "array": ["one", "two"], + "array": ["one", "two"], "boolean": true, "integer": 123, "string": "string" }); - - assert!(validator.validate_record("com.example.kitchenSink#object", &invalid_record).is_err()); + + assert!( + validator + .validate_record("com.example.kitchenSink#object", &invalid_record) + .is_err() + ); } - + #[test] fn test_array_length_constraints() { let validator = LexiconValidator::new(get_test_lexicons()).unwrap(); - + // Valid array length let valid = json!({ "array": [1, 2, 3] }); - assert!(validator.validate_record("com.example.arrayLength", &valid).is_ok()); - + assert!( + validator + .validate_record("com.example.arrayLength", &valid) + .is_ok() + ); + // Too short let too_short = json!({ "array": [1] }); - assert!(validator.validate_record("com.example.arrayLength", &too_short).is_err()); - + assert!( + validator + .validate_record("com.example.arrayLength", &too_short) + .is_err() + ); + // Too long let too_long = json!({ "array": [1, 2, 3, 4, 5] }); - assert!(validator.validate_record("com.example.arrayLength", &too_long).is_err()); - + assert!( + validator + .validate_record("com.example.arrayLength", &too_long) + .is_err() + ); + // Wrong item type let wrong_type = json!({ "array": [1, "2", 3] }); - assert!(validator.validate_record("com.example.arrayLength", &wrong_type).is_err()); + assert!( + validator + .validate_record("com.example.arrayLength", &wrong_type) + .is_err() + ); } - + #[test] fn test_boolean_const_constraint() { let validator = LexiconValidator::new(get_test_lexicons()).unwrap(); - + // Valid const let valid = json!({ "boolean": false }); - assert!(validator.validate_record("com.example.boolConst", &valid).is_ok()); - + assert!( + validator + .validate_record("com.example.boolConst", &valid) + .is_ok() + ); + // Invalid const let invalid = json!({ "boolean": true }); - assert!(validator.validate_record("com.example.boolConst", &invalid).is_err()); + assert!( + validator + .validate_record("com.example.boolConst", &invalid) + .is_err() + ); } - + #[test] fn test_integer_constraints() { let validator = LexiconValidator::new(get_test_lexicons()).unwrap(); - + // Range test - valid let valid_range = json!({ "integer": 2 }); - assert!(validator.validate_record("com.example.integerRange", &valid_range).is_ok()); - + assert!( + validator + .validate_record("com.example.integerRange", &valid_range) + .is_ok() + ); + // Range test - too low let too_low = json!({ "integer": 1 }); - assert!(validator.validate_record("com.example.integerRange", &too_low).is_err()); - - // Range test - too high + assert!( + validator + .validate_record("com.example.integerRange", &too_low) + .is_err() + ); + + // Range test - too high let too_high = json!({ "integer": 5 }); - assert!(validator.validate_record("com.example.integerRange", &too_high).is_err()); - + assert!( + validator + .validate_record("com.example.integerRange", &too_high) + .is_err() + ); + // Enum test - valid let valid_enum = json!({ "integer": 2 }); - assert!(validator.validate_record("com.example.integerEnum", &valid_enum).is_ok()); - + assert!( + validator + .validate_record("com.example.integerEnum", &valid_enum) + .is_ok() + ); + // Enum test - invalid let invalid_enum = json!({ "integer": 0 }); - assert!(validator.validate_record("com.example.integerEnum", &invalid_enum).is_err()); - + assert!( + validator + .validate_record("com.example.integerEnum", &invalid_enum) + .is_err() + ); + // Const test - valid let valid_const = json!({ "integer": 0 }); - assert!(validator.validate_record("com.example.integerConst", &valid_const).is_ok()); - + assert!( + validator + .validate_record("com.example.integerConst", &valid_const) + .is_ok() + ); + // Const test - invalid let invalid_const = json!({ "integer": 1 }); - assert!(validator.validate_record("com.example.integerConst", &invalid_const).is_err()); + assert!( + validator + .validate_record("com.example.integerConst", &invalid_const) + .is_err() + ); } - + #[test] fn test_string_constraints() { let validator = LexiconValidator::new(get_test_lexicons()).unwrap(); - + // Length test - valid let valid_length = json!({ "string": "ab" }); - assert!(validator.validate_record("com.example.stringLength", &valid_length).is_ok()); - + assert!( + validator + .validate_record("com.example.stringLength", &valid_length) + .is_ok() + ); + let valid_length_max = json!({ "string": "abcd" }); - assert!(validator.validate_record("com.example.stringLength", &valid_length_max).is_ok()); - + assert!( + validator + .validate_record("com.example.stringLength", &valid_length_max) + .is_ok() + ); + // Length test - too short let too_short = json!({ "string": "a" }); - assert!(validator.validate_record("com.example.stringLength", &too_short).is_err()); - + assert!( + validator + .validate_record("com.example.stringLength", &too_short) + .is_err() + ); + // Length test - too long let too_long = json!({ "string": "abcde" }); - assert!(validator.validate_record("com.example.stringLength", &too_long).is_err()); - + assert!( + validator + .validate_record("com.example.stringLength", &too_long) + .is_err() + ); + // Enum test - valid let valid_enum = json!({ "string": "a" }); - assert!(validator.validate_record("com.example.stringEnum", &valid_enum).is_ok()); - + assert!( + validator + .validate_record("com.example.stringEnum", &valid_enum) + .is_ok() + ); + // Enum test - invalid let invalid_enum = json!({ "string": "c" }); - assert!(validator.validate_record("com.example.stringEnum", &invalid_enum).is_err()); - + assert!( + validator + .validate_record("com.example.stringEnum", &invalid_enum) + .is_err() + ); + // Const test - valid let valid_const = json!({ "string": "a" }); - assert!(validator.validate_record("com.example.stringConst", &valid_const).is_ok()); - + assert!( + validator + .validate_record("com.example.stringConst", &valid_const) + .is_ok() + ); + // Const test - invalid let invalid_const = json!({ "string": "b" }); - assert!(validator.validate_record("com.example.stringConst", &invalid_const).is_err()); + assert!( + validator + .validate_record("com.example.stringConst", &invalid_const) + .is_err() + ); } - + #[test] fn test_string_formats() { let validator = LexiconValidator::new(get_test_lexicons()).unwrap(); - + // DateTime format tests (matching TypeScript test cases) let valid_datetimes = vec![ "2022-12-12T00:50:36.809Z", - "2022-12-12T00:50:36Z", + "2022-12-12T00:50:36Z", "2022-12-12T00:50:36.8Z", "2022-12-12T00:50:36.80Z", "2022-12-12T00:50:36+00:00", @@ -597,159 +697,242 @@ mod tests { "2022-12-11T19:50:36-05:00", "2022-12-11T19:50:36.8-05:00", "2022-12-11T19:50:36.80-05:00", - "2022-12-11T19:50:36.809-05:00" + "2022-12-11T19:50:36.809-05:00", ]; - + for datetime in valid_datetimes { let record = json!({ "datetime": datetime }); - assert!(validator.validate_record("com.example.datetime", &record).is_ok(), - "Should accept datetime: {}", datetime); + assert!( + validator + .validate_record("com.example.datetime", &record) + .is_ok(), + "Should accept datetime: {}", + datetime + ); } - + let invalid_datetime = json!({ "datetime": "bad date" }); - assert!(validator.validate_record("com.example.datetime", &invalid_datetime).is_err()); - + assert!( + validator + .validate_record("com.example.datetime", &invalid_datetime) + .is_err() + ); + // URI format tests let valid_uris = vec![ "https://example.com", "https://example.com/with/path", "https://example.com/with/path?and=query", - "at://bsky.social", - "did:example:test" + "at://bsky.social", + "did:example:test", ]; - + for uri in valid_uris { let record = json!({ "uri": uri }); - assert!(validator.validate_record("com.example.uri", &record).is_ok(), - "Should accept URI: {}", uri); + assert!( + validator + .validate_record("com.example.uri", &record) + .is_ok(), + "Should accept URI: {}", + uri + ); } - + let invalid_uri = json!({ "uri": "not a uri" }); - assert!(validator.validate_record("com.example.uri", &invalid_uri).is_err()); - + assert!( + validator + .validate_record("com.example.uri", &invalid_uri) + .is_err() + ); + // AT-URI format test let valid_at_uri = json!({ "atUri": "at://did:web:example.com/com.example.test/self" }); - assert!(validator.validate_record("com.example.atUri", &valid_at_uri).is_ok()); - + assert!( + validator + .validate_record("com.example.atUri", &valid_at_uri) + .is_ok() + ); + let invalid_at_uri = json!({ "atUri": "http://not-atproto.com" }); - assert!(validator.validate_record("com.example.atUri", &invalid_at_uri).is_err()); - - // DID format tests - let valid_dids = vec![ - "did:web:example.com", - "did:plc:12345678abcdefghijklmnop" - ]; - + assert!( + validator + .validate_record("com.example.atUri", &invalid_at_uri) + .is_err() + ); + + // DID format tests + let valid_dids = vec!["did:web:example.com", "did:plc:12345678abcdefghijklmnop"]; + for did in valid_dids { let record = json!({ "did": did }); - assert!(validator.validate_record("com.example.did", &record).is_ok(), - "Should accept DID: {}", did); + assert!( + validator + .validate_record("com.example.did", &record) + .is_ok(), + "Should accept DID: {}", + did + ); } - + let invalid_dids = vec!["bad did", "did:short"]; - + for did in invalid_dids { let record = json!({ "did": did }); - assert!(validator.validate_record("com.example.did", &record).is_err(), - "Should reject DID: {}", did); + assert!( + validator + .validate_record("com.example.did", &record) + .is_err(), + "Should reject DID: {}", + did + ); } - + // Handle format tests - let valid_handles = vec![ - "test.bsky.social", - "bsky.test" - ]; - + let valid_handles = vec!["test.bsky.social", "bsky.test"]; + for handle in valid_handles { let record = json!({ "handle": handle }); - assert!(validator.validate_record("com.example.handle", &record).is_ok(), - "Should accept handle: {}", handle); + assert!( + validator + .validate_record("com.example.handle", &record) + .is_ok(), + "Should accept handle: {}", + handle + ); } - + let invalid_handles = vec!["bad handle", "-bad-.test"]; - + for handle in invalid_handles { let record = json!({ "handle": handle }); - assert!(validator.validate_record("com.example.handle", &record).is_err(), - "Should reject handle: {}", handle); + assert!( + validator + .validate_record("com.example.handle", &record) + .is_err(), + "Should reject handle: {}", + handle + ); } - + // AT-identifier format tests - let valid_at_identifiers = vec![ - "bsky.test", - "did:plc:12345678abcdefghijklmnop" - ]; - + let valid_at_identifiers = vec!["bsky.test", "did:plc:12345678abcdefghijklmnop"]; + for at_id in valid_at_identifiers { let record = json!({ "atIdentifier": at_id }); - assert!(validator.validate_record("com.example.atIdentifier", &record).is_ok(), - "Should accept at-identifier: {}", at_id); + assert!( + validator + .validate_record("com.example.atIdentifier", &record) + .is_ok(), + "Should accept at-identifier: {}", + at_id + ); } - + let invalid_at_identifiers = vec!["bad id", "-bad-.test"]; - + for at_id in invalid_at_identifiers { let record = json!({ "atIdentifier": at_id }); - assert!(validator.validate_record("com.example.atIdentifier", &record).is_err(), - "Should reject at-identifier: {}", at_id); + assert!( + validator + .validate_record("com.example.atIdentifier", &record) + .is_err(), + "Should reject at-identifier: {}", + at_id + ); } - + // NSID format tests - let valid_nsids = vec![ - "com.atproto.test", - "app.bsky.nested.test" - ]; - + let valid_nsids = vec!["com.atproto.test", "app.bsky.nested.test"]; + for nsid in valid_nsids { let record = json!({ "nsid": nsid }); - assert!(validator.validate_record("com.example.nsid", &record).is_ok(), - "Should accept NSID: {}", nsid); + assert!( + validator + .validate_record("com.example.nsid", &record) + .is_ok(), + "Should accept NSID: {}", + nsid + ); } - + let invalid_nsids = vec!["bad nsid", "com.bad-.foo"]; - + for nsid in invalid_nsids { let record = json!({ "nsid": nsid }); - assert!(validator.validate_record("com.example.nsid", &record).is_err(), - "Should reject NSID: {}", nsid); + assert!( + validator + .validate_record("com.example.nsid", &record) + .is_err(), + "Should reject NSID: {}", + nsid + ); } - + // CID format test - let valid_cid = json!({ "cid": "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a" }); - assert!(validator.validate_record("com.example.cid", &valid_cid).is_ok()); - + let valid_cid = + json!({ "cid": "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a" }); + assert!( + validator + .validate_record("com.example.cid", &valid_cid) + .is_ok() + ); + let invalid_cid = json!({ "cid": "abapsdofiuwrpoiasdfuaspdfoiu" }); - assert!(validator.validate_record("com.example.cid", &invalid_cid).is_err()); - + assert!( + validator + .validate_record("com.example.cid", &invalid_cid) + .is_err() + ); + // Language format test let valid_language = json!({ "language": "en-US-boont" }); - assert!(validator.validate_record("com.example.language", &valid_language).is_ok()); - + assert!( + validator + .validate_record("com.example.language", &valid_language) + .is_ok() + ); + let invalid_language = json!({ "language": "not-a-language-" }); - assert!(validator.validate_record("com.example.language", &invalid_language).is_err()); + assert!( + validator + .validate_record("com.example.language", &invalid_language) + .is_err() + ); } - + #[test] fn test_bytes_validation() { let validator = LexiconValidator::new(get_test_lexicons()).unwrap(); - + // Valid bytes (base64, 3 bytes when decoded) let valid_bytes = json!({ "bytes": "SGVs" }); // 3 bytes when decoded - assert!(validator.validate_record("com.example.byteLength", &valid_bytes).is_ok()); - + assert!( + validator + .validate_record("com.example.byteLength", &valid_bytes) + .is_ok() + ); + // Too short (1 byte when decoded) let too_short = json!({ "bytes": "SA==" }); - assert!(validator.validate_record("com.example.byteLength", &too_short).is_err()); - - // Too long (5+ bytes when decoded) + assert!( + validator + .validate_record("com.example.byteLength", &too_short) + .is_err() + ); + + // Too long (5+ bytes when decoded) let too_long = json!({ "bytes": "SGVsbG9Xb3JsZA==" }); // "HelloWorld" is 10 bytes - assert!(validator.validate_record("com.example.byteLength", &too_long).is_err()); + assert!( + validator + .validate_record("com.example.byteLength", &too_long) + .is_err() + ); } - + #[test] fn test_union_validation() { let validator = LexiconValidator::new(get_test_lexicons()).unwrap(); - + // Valid union - open union with object variant let valid_union = json!({ "unionOpen": { @@ -761,13 +944,17 @@ mod tests { "string": "string" }, "unionClosed": { - "$type": "com.example.kitchenSink#subobject", + "$type": "com.example.kitchenSink#subobject", "boolean": true } }); - - assert!(validator.validate_record("com.example.union", &valid_union).is_ok()); - + + assert!( + validator + .validate_record("com.example.union", &valid_union) + .is_ok() + ); + // Valid union - open union with other type let valid_open = json!({ "unionOpen": { @@ -775,39 +962,55 @@ mod tests { }, "unionClosed": { "$type": "com.example.kitchenSink#subobject", - "boolean": true + "boolean": true } }); - + // This should work for open unions (they allow unknown types) - assert!(validator.validate_record("com.example.union", &valid_open).is_ok()); - + assert!( + validator + .validate_record("com.example.union", &valid_open) + .is_ok() + ); + // Missing $type in union let missing_type = json!({ "unionOpen": {}, "unionClosed": {} }); - - assert!(validator.validate_record("com.example.union", &missing_type).is_err()); + + assert!( + validator + .validate_record("com.example.union", &missing_type) + .is_err() + ); } - + #[test] fn test_unknown_type() { let validator = LexiconValidator::new(get_test_lexicons()).unwrap(); - + // Valid unknown field let valid_unknown = json!({ "unknown": { "foo": "bar" } }); - assert!(validator.validate_record("com.example.unknown", &valid_unknown).is_ok()); - + assert!( + validator + .validate_record("com.example.unknown", &valid_unknown) + .is_ok() + ); + // Missing required unknown field let missing_unknown = json!({}); - assert!(validator.validate_record("com.example.unknown", &missing_unknown).is_err()); + assert!( + validator + .validate_record("com.example.unknown", &missing_unknown) + .is_err() + ); } - - #[test] + + #[test] fn test_type_validation_errors() { let validator = LexiconValidator::new(get_test_lexicons()).unwrap(); - + let base_record = json!({ "object": { "object": { "boolean": true }, @@ -825,47 +1028,76 @@ mod tests { "$link": "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a" } }); - + // Wrong boolean type let mut bad_boolean = base_record.clone(); bad_boolean["object"]["object"]["boolean"] = json!("not boolean"); - assert!(validator.validate_record("com.example.kitchenSink", &bad_boolean).is_err()); - - // Wrong object type + assert!( + validator + .validate_record("com.example.kitchenSink", &bad_boolean) + .is_err() + ); + + // Wrong object type let mut bad_object = base_record.clone(); bad_object["object"] = json!(true); - assert!(validator.validate_record("com.example.kitchenSink", &bad_object).is_err()); - + assert!( + validator + .validate_record("com.example.kitchenSink", &bad_object) + .is_err() + ); + // Wrong array type let mut bad_array = base_record.clone(); bad_array["array"] = json!(1234); - assert!(validator.validate_record("com.example.kitchenSink", &bad_array).is_err()); - + assert!( + validator + .validate_record("com.example.kitchenSink", &bad_array) + .is_err() + ); + // Wrong integer type let mut bad_integer = base_record.clone(); bad_integer["integer"] = json!(true); - assert!(validator.validate_record("com.example.kitchenSink", &bad_integer).is_err()); - + assert!( + validator + .validate_record("com.example.kitchenSink", &bad_integer) + .is_err() + ); + // Wrong string type let mut bad_string = base_record.clone(); bad_string["string"] = json!({}); - assert!(validator.validate_record("com.example.kitchenSink", &bad_string).is_err()); - + assert!( + validator + .validate_record("com.example.kitchenSink", &bad_string) + .is_err() + ); + // Wrong bytes type let mut bad_bytes = base_record.clone(); bad_bytes["bytes"] = json!(1234); - assert!(validator.validate_record("com.example.kitchenSink", &bad_bytes).is_err()); - + assert!( + validator + .validate_record("com.example.kitchenSink", &bad_bytes) + .is_err() + ); + // Wrong CID link type let mut bad_cid_link = base_record.clone(); - bad_cid_link["cidLink"] = json!("bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a"); - assert!(validator.validate_record("com.example.kitchenSink", &bad_cid_link).is_err()); + bad_cid_link["cidLink"] = + json!("bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a"); + assert!( + validator + .validate_record("com.example.kitchenSink", &bad_cid_link) + .is_err() + ); } - + #[test] fn test_lexicon_definitions_validation() { let lexicons = vec![serde_json::json!({ - "id": "social.slices.lexicon", + "id": "network.slices.lexicon", "defs": { "main": { "type": "record", @@ -880,58 +1112,81 @@ mod tests { } } })]; - + let validator = LexiconValidator::new(lexicons).unwrap(); - + // Valid lexicon record with valid definitions JSON let valid_record = serde_json::json!({ "nsid": "com.example.test", "definitions": r#"{"main": {"type": "record", "record": {"type": "object", "properties": {"title": {"type": "string"}}}}}"# }); - - let result = validator.validate_record("social.slices.lexicon", &valid_record); - assert!(result.is_ok(), "Valid lexicon definitions should pass: {:?}", result); - + + let result = validator.validate_record("network.slices.lexicon", &valid_record); + assert!( + result.is_ok(), + "Valid lexicon definitions should pass: {:?}", + result + ); + // Invalid JSON in definitions field let invalid_json_record = serde_json::json!({ - "nsid": "com.example.test", + "nsid": "com.example.test", "definitions": r#"{"main": invalid json}"# }); - - let result = validator.validate_record("social.slices.lexicon", &invalid_json_record); + + let result = validator.validate_record("network.slices.lexicon", &invalid_json_record); assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("Invalid JSON in definitions field")); - + assert!( + result + .unwrap_err() + .to_string() + .contains("Invalid JSON in definitions field") + ); + // Invalid lexicon structure (missing type) let invalid_structure_record = serde_json::json!({ "nsid": "com.example.test", "definitions": r#"{"main": {"record": {"type": "object"}}}"# }); - - let result = validator.validate_record("social.slices.lexicon", &invalid_structure_record); + + let result = validator.validate_record("network.slices.lexicon", &invalid_structure_record); assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("missing required 'type' field")); - + assert!( + result + .unwrap_err() + .to_string() + .contains("missing required 'type' field") + ); + // Invalid definition name (with hyphen - not camelCase) let invalid_name_record = serde_json::json!({ "nsid": "com.example.test", "definitions": r#"{"my-invalid-name": {"type": "object"}}"# }); - - let result = validator.validate_record("social.slices.lexicon", &invalid_name_record); + + let result = validator.validate_record("network.slices.lexicon", &invalid_name_record); assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("must be camelCase")); - + assert!( + result + .unwrap_err() + .to_string() + .contains("must be camelCase") + ); + // Valid camelCase definition names should work let valid_camel_record = serde_json::json!({ "nsid": "com.example.test", "definitions": r#"{"listViewBasic": {"type": "object"}, "starterPackView": {"type": "object"}}"# }); - - let result = validator.validate_record("social.slices.lexicon", &valid_camel_record); - assert!(result.is_ok(), "CamelCase definition names should be valid: {:?}", result); + + let result = validator.validate_record("network.slices.lexicon", &valid_camel_record); + assert!( + result.is_ok(), + "CamelCase definition names should be valid: {:?}", + result + ); } - + #[test] fn test_lexicon_set_completeness_validation() { // Test with complete lexicon set (should pass) @@ -961,125 +1216,131 @@ mod tests { } } } - }) + }), ]; - + let validator = LexiconValidator::new(complete_lexicons).unwrap(); let result = validator.validate_lexicon_set_completeness(); - assert!(result.is_ok(), "Complete lexicon set should pass validation"); - - // Test with missing lexicon reference (should fail) - let incomplete_lexicons = vec![ - serde_json::json!({ - "id": "com.example.posts", - "defs": { - "main": { - "type": "record", - "record": { - "type": "object", - "properties": { - "author": { "type": "ref", "ref": "com.example.missing#user" }, - "content": { "type": "string" } - } + assert!( + result.is_ok(), + "Complete lexicon set should pass validation" + ); + + // Test with missing lexicon reference (should fail) + let incomplete_lexicons = vec![serde_json::json!({ + "id": "com.example.posts", + "defs": { + "main": { + "type": "record", + "record": { + "type": "object", + "properties": { + "author": { "type": "ref", "ref": "com.example.missing#user" }, + "content": { "type": "string" } } } } - }) - ]; - + } + })]; + let validator = LexiconValidator::new(incomplete_lexicons).unwrap(); let result = validator.validate_lexicon_set_completeness(); assert!(result.is_err()); - assert!(result.unwrap_err().contains("Missing lexicon 'com.example.missing'")); - + assert!( + result + .unwrap_err() + .contains("Missing lexicon 'com.example.missing'") + ); + // Test with local references (should pass - local refs are valid) - let local_ref_lexicons = vec![ - serde_json::json!({ - "id": "com.example.media", - "defs": { - "main": { - "type": "record", - "record": { - "type": "object", - "properties": { - "image": { "type": "ref", "ref": "#imageObject" } - } - } - }, - "imageObject": { + let local_ref_lexicons = vec![serde_json::json!({ + "id": "com.example.media", + "defs": { + "main": { + "type": "record", + "record": { "type": "object", "properties": { - "url": { "type": "string" } + "image": { "type": "ref", "ref": "#imageObject" } } } + }, + "imageObject": { + "type": "object", + "properties": { + "url": { "type": "string" } + } } - }) - ]; - + } + })]; + let validator = LexiconValidator::new(local_ref_lexicons).unwrap(); let result = validator.validate_lexicon_set_completeness(); - assert!(result.is_ok(), "Local references should not cause validation errors"); - + assert!( + result.is_ok(), + "Local references should not cause validation errors" + ); + // Test with direct lexicon reference (no # fragment) - let direct_ref_lexicons = vec![ - serde_json::json!({ - "id": "com.example.gallery", - "defs": { - "main": { - "type": "record", - "record": { - "type": "object", - "properties": { - "facets": { - "type": "array", - "items": { - "type": "ref", - "ref": "app.bsky.richtext.facet" - } + let direct_ref_lexicons = vec![serde_json::json!({ + "id": "com.example.gallery", + "defs": { + "main": { + "type": "record", + "record": { + "type": "object", + "properties": { + "facets": { + "type": "array", + "items": { + "type": "ref", + "ref": "app.bsky.richtext.facet" } } } } } - }) - ]; - + } + })]; + let validator = LexiconValidator::new(direct_ref_lexicons).unwrap(); let result = validator.validate_lexicon_set_completeness(); assert!(result.is_err()); - assert!(result.unwrap_err().contains("Missing lexicon 'app.bsky.richtext.facet'")); - + assert!( + result + .unwrap_err() + .contains("Missing lexicon 'app.bsky.richtext.facet'") + ); + // Test with union refs array - let union_refs_lexicons = vec![ - serde_json::json!({ - "id": "com.example.content", - "defs": { - "main": { - "type": "record", - "record": { - "type": "object", - "properties": { - "subject": { - "type": "union", - "refs": [ - "com.example.missing#post", - "app.bsky.feed.post" - ] - } + let union_refs_lexicons = vec![serde_json::json!({ + "id": "com.example.content", + "defs": { + "main": { + "type": "record", + "record": { + "type": "object", + "properties": { + "subject": { + "type": "union", + "refs": [ + "com.example.missing#post", + "app.bsky.feed.post" + ] } } } } - }) - ]; - + } + })]; + let validator = LexiconValidator::new(union_refs_lexicons).unwrap(); let result = validator.validate_lexicon_set_completeness(); assert!(result.is_err()); let error_msg = result.unwrap_err(); assert!(error_msg.contains("Missing lexicon 'com.example.missing'")); assert!(error_msg.contains("Missing lexicon 'app.bsky.feed.post'")); - + // Test that validation passes when all references are present let complete_with_direct_refs = vec![ serde_json::json!({ @@ -1112,11 +1373,14 @@ mod tests { } } } - }) + }), ]; - + let validator = LexiconValidator::new(complete_with_direct_refs).unwrap(); let result = validator.validate_lexicon_set_completeness(); - assert!(result.is_ok(), "Should pass when all direct references are present"); + assert!( + result.is_ok(), + "Should pass when all direct references are present" + ); } -} \ No newline at end of file +} diff --git a/api/src/lexicon/validator.rs b/api/src/lexicon/validator.rs index cc6036c..9630858 100644 --- a/api/src/lexicon/validator.rs +++ b/api/src/lexicon/validator.rs @@ -1,11 +1,11 @@ -use std::collections::HashMap; -use serde_json::Value; use chrono::DateTime; use regex::Regex; +use serde_json::Value; +use std::collections::HashMap; -use crate::database::Database; use super::errors::ValidationError; use super::types::{LexiconDoc, StringFormat, ValidationContext}; +use crate::database::Database; #[derive(Clone)] pub struct LexiconValidator { @@ -16,33 +16,39 @@ impl LexiconValidator { /// Create a new validator with the given lexicon documents pub fn new(lexicons: Vec) -> Result { let mut lexicon_map = HashMap::new(); - + for lexicon_value in lexicons { let id = lexicon_value["id"] .as_str() .ok_or_else(|| ValidationError::InvalidSchema("Missing lexicon id".to_string()))? .to_string(); - + let defs = lexicon_value["defs"].clone(); if defs.is_null() { - return Err(ValidationError::InvalidSchema(format!("Missing defs in lexicon {}", id))); + return Err(ValidationError::InvalidSchema(format!( + "Missing defs in lexicon {}", + id + ))); } - + lexicon_map.insert(id.clone(), LexiconDoc { id, defs }); } - - Ok(Self { lexicons: lexicon_map }) + + Ok(Self { + lexicons: lexicon_map, + }) } - + /// Load lexicons for a specific slice from the database pub async fn for_slice(db: &Database, slice_uri: &str) -> Result { - let lexicon_records = db.get_lexicons_by_slice(slice_uri) - .await - .map_err(|e| ValidationError::Unknown { - path: "database".to_string(), - message: e.to_string(), - })?; - + let lexicon_records = + db.get_lexicons_by_slice(slice_uri) + .await + .map_err(|e| ValidationError::Unknown { + path: "database".to_string(), + message: e.to_string(), + })?; + // lexicon_records already has the correct format from get_lexicons_by_slice let lexicons: Vec = lexicon_records .into_iter() @@ -55,28 +61,31 @@ impl LexiconValidator { lexicon }) .collect(); - + Self::new(lexicons) } - + /// Validate a record against its collection's lexicon pub fn validate_record(&self, collection: &str, record: &Value) -> Result<(), ValidationError> { // Parse collection string which might have fragment (#object, #main, etc) let parts: Vec<&str> = collection.split('#').collect(); let nsid = parts[0]; let fragment = parts.get(1).map(|s| *s).unwrap_or("main"); - - let lexicon = self.lexicons.get(nsid) + + let lexicon = self + .lexicons + .get(nsid) .ok_or_else(|| ValidationError::LexiconNotFound(nsid.to_string()))?; - + // Get the definition schema let def_schema = &lexicon.defs[fragment]; if def_schema.is_null() { - return Err(ValidationError::InvalidSchema( - format!("No {} definition in lexicon {}", fragment, nsid) - )); + return Err(ValidationError::InvalidSchema(format!( + "No {} definition in lexicon {}", + fragment, nsid + ))); } - + // For record types, validate against the record schema let record_schema = if def_schema["type"] == "record" { &def_schema["record"] @@ -84,146 +93,177 @@ impl LexiconValidator { // For other types like object, validate directly def_schema }; - + let ctx = ValidationContext::new(); - - // Special validation for social.slices.lexicon records - if nsid == "social.slices.lexicon" { + + // Special validation for network.slices.lexicon records + if nsid == "network.slices.lexicon" { self.validate_lexicon_record(record, record_schema, &ctx)?; } else { self.validate_value(record, record_schema, &ctx)?; } - + Ok(()) } - - /// Special validation for social.slices.lexicon records - fn validate_lexicon_record(&self, record: &Value, schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> { + + /// Special validation for network.slices.lexicon records + fn validate_lexicon_record( + &self, + record: &Value, + schema: &Value, + ctx: &ValidationContext, + ) -> Result<(), ValidationError> { // First, validate against the normal schema self.validate_value(record, schema, ctx)?; - + // Then, validate that the "definitions" field contains valid lexicon JSON if let Some(definitions_str) = record.get("definitions").and_then(|v| v.as_str()) { // Parse the definitions JSON - let definitions_json: Value = serde_json::from_str(definitions_str) - .map_err(|e| ValidationError::InvalidSchema( - format!("Invalid JSON in definitions field: {}", e) - ))?; - + let definitions_json: Value = serde_json::from_str(definitions_str).map_err(|e| { + ValidationError::InvalidSchema(format!("Invalid JSON in definitions field: {}", e)) + })?; + // Validate that it looks like a proper lexicon definition self.validate_lexicon_definitions(&definitions_json)?; } - + Ok(()) } - + /// Validate that a JSON value contains valid lexicon definitions fn validate_lexicon_definitions(&self, definitions: &Value) -> Result<(), ValidationError> { - let definitions_obj = definitions.as_object() - .ok_or_else(|| ValidationError::InvalidSchema( - "Lexicon definitions must be a JSON object".to_string() - ))?; - + let definitions_obj = definitions.as_object().ok_or_else(|| { + ValidationError::InvalidSchema("Lexicon definitions must be a JSON object".to_string()) + })?; + // Each key should be a valid definition name, each value should be a valid definition for (def_name, def_value) in definitions_obj { // Validate definition name (should be camelCase identifier - letters and numbers only) if def_name.is_empty() || !def_name.chars().all(|c| c.is_ascii_alphanumeric()) { - return Err(ValidationError::InvalidSchema( - format!("Invalid definition name '{}': must be camelCase (letters and numbers only)", def_name) - )); + return Err(ValidationError::InvalidSchema(format!( + "Invalid definition name '{}': must be camelCase (letters and numbers only)", + def_name + ))); } - + // Validate definition structure - let def_type = def_value.get("type") + let def_type = def_value + .get("type") .and_then(|v| v.as_str()) - .ok_or_else(|| ValidationError::InvalidSchema( - format!("Definition '{}' missing required 'type' field", def_name) - ))?; - + .ok_or_else(|| { + ValidationError::InvalidSchema(format!( + "Definition '{}' missing required 'type' field", + def_name + )) + })?; + // Validate based on definition type match def_type { "record" => self.validate_record_definition(def_name, def_value)?, "object" => self.validate_object_definition(def_name, def_value)?, - "string" | "integer" | "boolean" | "array" | "union" | "ref" | "blob" | "bytes" | "cid-link" | "unknown" => { + "string" | "integer" | "boolean" | "array" | "union" | "ref" | "blob" | "bytes" + | "cid-link" | "unknown" => { // Basic types are valid, could add more specific validation here - }, - _ => return Err(ValidationError::InvalidSchema( - format!("Definition '{}' has unknown type '{}'", def_name, def_type) - )) + } + _ => { + return Err(ValidationError::InvalidSchema(format!( + "Definition '{}' has unknown type '{}'", + def_name, def_type + ))); + } } } - + Ok(()) } - + /// Validate a record definition structure - fn validate_record_definition(&self, def_name: &str, def_value: &Value) -> Result<(), ValidationError> { + fn validate_record_definition( + &self, + def_name: &str, + def_value: &Value, + ) -> Result<(), ValidationError> { // Record definitions should have a "record" field - let record_def = def_value.get("record") - .ok_or_else(|| ValidationError::InvalidSchema( - format!("Record definition '{}' missing 'record' field", def_name) - ))?; - + let record_def = def_value.get("record").ok_or_else(|| { + ValidationError::InvalidSchema(format!( + "Record definition '{}' missing 'record' field", + def_name + )) + })?; + // The record field should be an object type if record_def.get("type").and_then(|v| v.as_str()) != Some("object") { - return Err(ValidationError::InvalidSchema( - format!("Record definition '{}' record field must be type 'object'", def_name) - )); + return Err(ValidationError::InvalidSchema(format!( + "Record definition '{}' record field must be type 'object'", + def_name + ))); } - + // Validate properties if they exist if let Some(properties) = record_def.get("properties") { if !properties.is_object() { - return Err(ValidationError::InvalidSchema( - format!("Record definition '{}' properties must be an object", def_name) - )); + return Err(ValidationError::InvalidSchema(format!( + "Record definition '{}' properties must be an object", + def_name + ))); } } - + Ok(()) } - - /// Validate an object definition structure - fn validate_object_definition(&self, def_name: &str, def_value: &Value) -> Result<(), ValidationError> { + + /// Validate an object definition structure + fn validate_object_definition( + &self, + def_name: &str, + def_value: &Value, + ) -> Result<(), ValidationError> { // Object definitions should have properties if let Some(properties) = def_value.get("properties") { if !properties.is_object() { - return Err(ValidationError::InvalidSchema( - format!("Object definition '{}' properties must be an object", def_name) - )); + return Err(ValidationError::InvalidSchema(format!( + "Object definition '{}' properties must be an object", + def_name + ))); } } - + // Validate required field if it exists if let Some(required) = def_value.get("required") { if !required.is_array() { - return Err(ValidationError::InvalidSchema( - format!("Object definition '{}' required field must be an array", def_name) - )); + return Err(ValidationError::InvalidSchema(format!( + "Object definition '{}' required field must be an array", + def_name + ))); } } - + Ok(()) } - + /// Validate that all cross-lexicon references can be resolved within the current lexicon set /// This is used before code generation to ensure no missing references pub fn validate_lexicon_set_completeness(&self) -> Result<(), String> { let mut missing_refs = Vec::new(); - + for (lexicon_id, lexicon) in &self.lexicons { self.collect_missing_references(lexicon_id, &lexicon.defs, &mut missing_refs)?; } - + if missing_refs.is_empty() { Ok(()) } else { Err(missing_refs.join(", ")) } } - + /// Recursively collect missing cross-lexicon references - fn collect_missing_references(&self, current_lexicon: &str, value: &Value, missing_refs: &mut Vec) -> Result<(), String> { + fn collect_missing_references( + &self, + current_lexicon: &str, + value: &Value, + missing_refs: &mut Vec, + ) -> Result<(), String> { match value { Value::Object(obj) => { // Check for ref field @@ -236,7 +276,10 @@ impl LexiconValidator { if parts.len() == 2 { let target_lexicon = parts[0]; if !self.lexicons.contains_key(target_lexicon) { - let missing_ref = format!("Missing lexicon '{}' (referenced as '{}')", target_lexicon, ref_str); + let missing_ref = format!( + "Missing lexicon '{}' (referenced as '{}')", + target_lexicon, ref_str + ); if !missing_refs.contains(&missing_ref) { missing_refs.push(missing_ref); } @@ -246,7 +289,10 @@ impl LexiconValidator { // Direct lexicon reference without fragment (e.g., app.bsky.richtext.facet) // This references the main definition of another lexicon if !self.lexicons.contains_key(ref_str) { - let missing_ref = format!("Missing lexicon '{}' (referenced as '{}')", ref_str, ref_str); + let missing_ref = format!( + "Missing lexicon '{}' (referenced as '{}')", + ref_str, ref_str + ); if !missing_refs.contains(&missing_ref) { missing_refs.push(missing_ref); } @@ -254,7 +300,7 @@ impl LexiconValidator { } } } - + // Check for refs array (used in union types) if let Some(refs_array) = obj.get("refs").and_then(|v| v.as_array()) { for ref_value in refs_array { @@ -266,7 +312,10 @@ impl LexiconValidator { if parts.len() == 2 { let target_lexicon = parts[0]; if !self.lexicons.contains_key(target_lexicon) { - let missing_ref = format!("Missing lexicon '{}' (referenced as '{}')", target_lexicon, ref_str); + let missing_ref = format!( + "Missing lexicon '{}' (referenced as '{}')", + target_lexicon, ref_str + ); if !missing_refs.contains(&missing_ref) { missing_refs.push(missing_ref); } @@ -275,7 +324,10 @@ impl LexiconValidator { } else if ref_str.contains('.') { // Direct lexicon reference if !self.lexicons.contains_key(ref_str) { - let missing_ref = format!("Missing lexicon '{}' (referenced as '{}')", ref_str, ref_str); + let missing_ref = format!( + "Missing lexicon '{}' (referenced as '{}')", + ref_str, ref_str + ); if !missing_refs.contains(&missing_ref) { missing_refs.push(missing_ref); } @@ -285,7 +337,7 @@ impl LexiconValidator { } } } - + // Recursively check nested objects for (_, nested_value) in obj { self.collect_missing_references(current_lexicon, nested_value, missing_refs)?; @@ -301,14 +353,19 @@ impl LexiconValidator { // Primitive values, nothing to check } } - + Ok(()) } - + /// Validate a value against a schema definition - fn validate_value(&self, value: &Value, schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> { + fn validate_value( + &self, + value: &Value, + schema: &Value, + ctx: &ValidationContext, + ) -> Result<(), ValidationError> { let schema_type = schema["type"].as_str().unwrap_or(""); - + match schema_type { "object" => self.validate_object(value, schema, ctx), "string" => self.validate_string(value, schema, ctx), @@ -322,19 +379,33 @@ impl LexiconValidator { "cid-link" => self.validate_cid_link(value, schema, ctx), "unknown" => Ok(()), // Unknown type accepts anything "null" => self.validate_null(value, schema, ctx), - "" => Err(ValidationError::InvalidSchema(format!("Missing type in schema at {}", ctx.path_string()))), - _ => Err(ValidationError::InvalidSchema(format!("Unknown schema type: {} at {}", schema_type, ctx.path_string()))), + "" => Err(ValidationError::InvalidSchema(format!( + "Missing type in schema at {}", + ctx.path_string() + ))), + _ => Err(ValidationError::InvalidSchema(format!( + "Unknown schema type: {} at {}", + schema_type, + ctx.path_string() + ))), } } - + /// Validate an object against an object schema - fn validate_object(&self, value: &Value, schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> { - let obj = value.as_object().ok_or_else(|| ValidationError::TypeMismatch { - path: ctx.path_string(), - expected: "object".to_string(), - actual: format!("{:?}", value), - })?; - + fn validate_object( + &self, + value: &Value, + schema: &Value, + ctx: &ValidationContext, + ) -> Result<(), ValidationError> { + let obj = value + .as_object() + .ok_or_else(|| ValidationError::TypeMismatch { + path: ctx.path_string(), + expected: "object".to_string(), + actual: format!("{:?}", value), + })?; + // Check required fields if let Some(required) = schema["required"].as_array() { for req_field in required { @@ -347,18 +418,20 @@ impl LexiconValidator { } } } - + // Validate properties if let Some(properties) = schema["properties"].as_object() { for (prop_name, prop_schema) in properties { if let Some(prop_value) = obj.get(prop_name) { // Check if field can be null let nullable = if let Some(nullable_fields) = schema["nullable"].as_array() { - nullable_fields.iter().any(|f| f.as_str() == Some(prop_name)) + nullable_fields + .iter() + .any(|f| f.as_str() == Some(prop_name)) } else { false }; - + if prop_value.is_null() && !nullable { return Err(ValidationError::TypeMismatch { path: ctx.with_field(prop_name).path_string(), @@ -366,65 +439,86 @@ impl LexiconValidator { actual: "null".to_string(), }); } - + if !prop_value.is_null() { self.validate_value(prop_value, prop_schema, &ctx.with_field(prop_name))?; } } } } - + Ok(()) } - + /// Validate a string value - fn validate_string(&self, value: &Value, schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> { - let string_val = value.as_str().ok_or_else(|| ValidationError::TypeMismatch { - path: ctx.path_string(), - expected: "string".to_string(), - actual: format!("{:?}", value), - })?; - + fn validate_string( + &self, + value: &Value, + schema: &Value, + ctx: &ValidationContext, + ) -> Result<(), ValidationError> { + let string_val = value + .as_str() + .ok_or_else(|| ValidationError::TypeMismatch { + path: ctx.path_string(), + expected: "string".to_string(), + actual: format!("{:?}", value), + })?; + // Check min/max length (in UTF-8 bytes) if let Some(min_length) = schema["minLength"].as_u64() { if (string_val.len() as u64) < min_length { return Err(ValidationError::StringValidationFailed { path: ctx.path_string(), - message: format!("String length {} is less than minimum {}", string_val.len(), min_length), + message: format!( + "String length {} is less than minimum {}", + string_val.len(), + min_length + ), }); } } - + if let Some(max_length) = schema["maxLength"].as_u64() { if (string_val.len() as u64) > max_length { return Err(ValidationError::StringValidationFailed { path: ctx.path_string(), - message: format!("String length {} exceeds maximum {}", string_val.len(), max_length), + message: format!( + "String length {} exceeds maximum {}", + string_val.len(), + max_length + ), }); } } - + // Check min/max graphemes (simplified grapheme counting) if let Some(min_graphemes) = schema["minGraphemes"].as_u64() { let grapheme_count = self.count_graphemes(string_val); if (grapheme_count as u64) < min_graphemes { return Err(ValidationError::StringValidationFailed { path: ctx.path_string(), - message: format!("String has {} graphemes, less than minimum {}", grapheme_count, min_graphemes), + message: format!( + "String has {} graphemes, less than minimum {}", + grapheme_count, min_graphemes + ), }); } } - + if let Some(max_graphemes) = schema["maxGraphemes"].as_u64() { let grapheme_count = self.count_graphemes(string_val); if (grapheme_count as u64) > max_graphemes { return Err(ValidationError::StringValidationFailed { path: ctx.path_string(), - message: format!("String has {} graphemes, exceeds maximum {}", grapheme_count, max_graphemes), + message: format!( + "String has {} graphemes, exceeds maximum {}", + grapheme_count, max_graphemes + ), }); } } - + // Check const value if let Some(const_val) = schema["const"].as_str() { if string_val != const_val { @@ -435,7 +529,7 @@ impl LexiconValidator { }); } } - + // Check enum values if let Some(enum_values) = schema["enum"].as_array() { let valid = enum_values.iter().any(|v| v.as_str() == Some(string_val)); @@ -445,27 +539,34 @@ impl LexiconValidator { }); } } - + // Check format if let Some(format_str) = schema["format"].as_str() { if let Some(format) = StringFormat::from_str(format_str) { self.validate_string_format(string_val, format, ctx)?; } } - + Ok(()) } - + /// Validate string formats - fn validate_string_format(&self, value: &str, format: StringFormat, ctx: &ValidationContext) -> Result<(), ValidationError> { + fn validate_string_format( + &self, + value: &str, + format: StringFormat, + ctx: &ValidationContext, + ) -> Result<(), ValidationError> { match format { StringFormat::DateTime => { // Validate RFC3339/ISO8601 datetime - DateTime::parse_from_rfc3339(value).map_err(|_| ValidationError::FormatValidationFailed { - path: ctx.path_string(), - format: "datetime".to_string(), + DateTime::parse_from_rfc3339(value).map_err(|_| { + ValidationError::FormatValidationFailed { + path: ctx.path_string(), + format: "datetime".to_string(), + } })?; - }, + } StringFormat::Uri => { // Basic URI validation - must have scheme followed by colon // Valid schemes include http://, https://, urn:, did:, etc. @@ -475,17 +576,21 @@ impl LexiconValidator { format: "uri".to_string(), }); } - + // Check that scheme contains only valid characters let colon_pos = value.find(':').unwrap(); let scheme = &value[..colon_pos]; - if scheme.is_empty() || !scheme.chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.') { + if scheme.is_empty() + || !scheme + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.') + { return Err(ValidationError::FormatValidationFailed { path: ctx.path_string(), format: "uri".to_string(), }); } - }, + } StringFormat::AtUri => { // AT-URI format: at://[authority]/[collection]/[rkey] if !value.starts_with("at://") { @@ -494,7 +599,7 @@ impl LexiconValidator { format: "at-uri".to_string(), }); } - }, + } StringFormat::Did => { // DID format: did:method:identifier if !value.starts_with("did:") { @@ -503,16 +608,20 @@ impl LexiconValidator { format: "did".to_string(), }); } - + // Must have at least 3 parts: did:method:identifier let parts: Vec<&str> = value.split(':').collect(); - if parts.len() < 3 || parts[0] != "did" || parts[1].is_empty() || parts[2].is_empty() { + if parts.len() < 3 + || parts[0] != "did" + || parts[1].is_empty() + || parts[2].is_empty() + { return Err(ValidationError::FormatValidationFailed { path: ctx.path_string(), format: "did".to_string(), }); } - }, + } StringFormat::Handle => { // Handle format: domain-like (e.g., user.bsky.social) let handle_regex = Regex::new(r"^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$").unwrap(); @@ -522,22 +631,22 @@ impl LexiconValidator { format: "handle".to_string(), }); } - }, + } StringFormat::AtIdentifier => { // Either a DID or a handle (per spec: at-identifier can be either) let is_did = value.starts_with("did:"); - - // Handle format: domain-like (e.g., user.bsky.social) + + // Handle format: domain-like (e.g., user.bsky.social) let handle_regex = Regex::new(r"^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$").unwrap(); let is_handle = handle_regex.is_match(value); - + if !is_did && !is_handle { return Err(ValidationError::FormatValidationFailed { path: ctx.path_string(), format: "at-identifier".to_string(), }); } - }, + } StringFormat::Nsid => { // NSID format: reversed domain with name (e.g., com.example.foo) let nsid_regex = Regex::new(r"^[a-zA-Z]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$").unwrap(); @@ -547,16 +656,19 @@ impl LexiconValidator { format: "nsid".to_string(), }); } - }, + } StringFormat::Cid => { // Basic CID validation (starts with correct multibase prefix) - if !value.starts_with("bafy") && !value.starts_with("bafk") && !value.starts_with("b") { + if !value.starts_with("bafy") + && !value.starts_with("bafk") + && !value.starts_with("b") + { return Err(ValidationError::FormatValidationFailed { path: ctx.path_string(), format: "cid".to_string(), }); } - }, + } StringFormat::Tid => { // TID format: timestamp-based identifier (13 chars, base32) let tid_regex = Regex::new(r"^[234567abcdefghijklmnopqrstuvwxyz]{13}$").unwrap(); @@ -566,7 +678,7 @@ impl LexiconValidator { format: "tid".to_string(), }); } - }, + } StringFormat::RecordKey => { // Record key: alphanumeric, dash, underscore, colon, tilde, or TID let rkey_regex = Regex::new(r"^[a-zA-Z0-9._:~-]+$").unwrap(); @@ -576,7 +688,7 @@ impl LexiconValidator { format: "record-key".to_string(), }); } - }, + } StringFormat::Language => { // BCP47 language tag (simplified validation) // Allows for language-region-extension pattern like "en-US-boont" @@ -587,20 +699,27 @@ impl LexiconValidator { format: "language".to_string(), }); } - }, + } } - + Ok(()) } - + /// Validate an integer value - fn validate_integer(&self, value: &Value, schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> { - let int_val = value.as_i64().ok_or_else(|| ValidationError::TypeMismatch { - path: ctx.path_string(), - expected: "integer".to_string(), - actual: format!("{:?}", value), - })?; - + fn validate_integer( + &self, + value: &Value, + schema: &Value, + ctx: &ValidationContext, + ) -> Result<(), ValidationError> { + let int_val = value + .as_i64() + .ok_or_else(|| ValidationError::TypeMismatch { + path: ctx.path_string(), + expected: "integer".to_string(), + actual: format!("{:?}", value), + })?; + if let Some(minimum) = schema["minimum"].as_i64() { if int_val < minimum { return Err(ValidationError::IntegerValidationFailed { @@ -609,7 +728,7 @@ impl LexiconValidator { }); } } - + if let Some(maximum) = schema["maximum"].as_i64() { if int_val > maximum { return Err(ValidationError::IntegerValidationFailed { @@ -618,7 +737,7 @@ impl LexiconValidator { }); } } - + // Check const value if let Some(const_val) = schema["const"].as_i64() { if int_val != const_val { @@ -629,7 +748,7 @@ impl LexiconValidator { }); } } - + if let Some(enum_values) = schema["enum"].as_array() { let valid = enum_values.iter().any(|v| v.as_i64() == Some(int_val)); if !valid { @@ -638,18 +757,25 @@ impl LexiconValidator { }); } } - + Ok(()) } - + /// Validate a boolean value - fn validate_boolean(&self, value: &Value, schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> { - value.as_bool().ok_or_else(|| ValidationError::TypeMismatch { - path: ctx.path_string(), - expected: "boolean".to_string(), - actual: format!("{:?}", value), - })?; - + fn validate_boolean( + &self, + value: &Value, + schema: &Value, + ctx: &ValidationContext, + ) -> Result<(), ValidationError> { + value + .as_bool() + .ok_or_else(|| ValidationError::TypeMismatch { + path: ctx.path_string(), + expected: "boolean".to_string(), + actual: format!("{:?}", value), + })?; + // Check const value if specified if let Some(const_val) = schema["const"].as_bool() { if value.as_bool() != Some(const_val) { @@ -660,60 +786,80 @@ impl LexiconValidator { }); } } - + Ok(()) } - + /// Validate an array - fn validate_array(&self, value: &Value, schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> { - let array = value.as_array().ok_or_else(|| ValidationError::TypeMismatch { - path: ctx.path_string(), - expected: "array".to_string(), - actual: format!("{:?}", value), - })?; - + fn validate_array( + &self, + value: &Value, + schema: &Value, + ctx: &ValidationContext, + ) -> Result<(), ValidationError> { + let array = value + .as_array() + .ok_or_else(|| ValidationError::TypeMismatch { + path: ctx.path_string(), + expected: "array".to_string(), + actual: format!("{:?}", value), + })?; + // Check min/max length if let Some(min_length) = schema["minLength"].as_u64() { if (array.len() as u64) < min_length { return Err(ValidationError::ArrayValidationFailed { path: ctx.path_string(), - message: format!("Array length {} is less than minimum {}", array.len(), min_length), + message: format!( + "Array length {} is less than minimum {}", + array.len(), + min_length + ), }); } } - + if let Some(max_length) = schema["maxLength"].as_u64() { if (array.len() as u64) > max_length { return Err(ValidationError::ArrayValidationFailed { path: ctx.path_string(), - message: format!("Array length {} exceeds maximum {}", array.len(), max_length), + message: format!( + "Array length {} exceeds maximum {}", + array.len(), + max_length + ), }); } } - + // Validate items if let Some(items_schema) = schema.get("items") { for (i, item) in array.iter().enumerate() { self.validate_value(item, items_schema, &ctx.with_index(i))?; } } - + Ok(()) } - + /// Validate a reference - fn validate_ref(&self, value: &Value, schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> { + fn validate_ref( + &self, + value: &Value, + schema: &Value, + ctx: &ValidationContext, + ) -> Result<(), ValidationError> { let ref_path = schema["ref"].as_str().ok_or_else(|| { ValidationError::InvalidSchema(format!("Missing ref path at {}", ctx.path_string())) })?; - + // Resolve the reference let resolved_schema = self.resolve_ref(ref_path)?; - + // Validate against the resolved schema self.validate_value(value, &resolved_schema, ctx) } - + /// Resolve a reference to its schema fn resolve_ref(&self, ref_path: &str) -> Result { // Note: Local references need context of which lexicon we're in @@ -723,7 +869,7 @@ impl LexiconValidator { // Local reference - would need current lexicon context to resolve // For comprehensive testing, we'll implement a basic version let fragment = &ref_path[1..]; // Remove the # prefix - + // Try to find this fragment in any loaded lexicon (simplified approach) for lexicon in self.lexicons.values() { let def = &lexicon.defs[fragment]; @@ -731,46 +877,55 @@ impl LexiconValidator { return Ok(def.clone()); } } - + return Err(ValidationError::ReferenceNotFound(ref_path.to_string())); } - + // Parse NSID#fragment format let parts: Vec<&str> = ref_path.split('#').collect(); let nsid = parts[0]; let fragment = parts.get(1).map(|s| *s).unwrap_or("main"); - - let lexicon = self.lexicons.get(nsid) + + let lexicon = self + .lexicons + .get(nsid) .ok_or_else(|| ValidationError::ReferenceNotFound(nsid.to_string()))?; - + let def = &lexicon.defs[fragment]; if def.is_null() { return Err(ValidationError::ReferenceNotFound(ref_path.to_string())); } - + Ok(def.clone()) } - + /// Validate a union type - fn validate_union(&self, value: &Value, schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> { + fn validate_union( + &self, + value: &Value, + schema: &Value, + ctx: &ValidationContext, + ) -> Result<(), ValidationError> { let refs = schema["refs"].as_array().ok_or_else(|| { ValidationError::InvalidSchema(format!("Union missing refs at {}", ctx.path_string())) })?; - + let is_closed = schema["closed"].as_bool().unwrap_or(false); - + // Check if value has $type field if let Some(type_field) = value.get("$type").and_then(|v| v.as_str()) { // Try to match against the specific type for ref_value in refs { if let Some(ref_str) = ref_value.as_str() { // For exact type match, validate against the reference - if ref_str == type_field || ref_str.split('#').next().unwrap_or(ref_str) == type_field { + if ref_str == type_field + || ref_str.split('#').next().unwrap_or(ref_str) == type_field + { return self.validate_ref(value, &serde_json::json!({"ref": ref_str}), ctx); } } } - + // If this is an open union and we have a $type field, allow unknown types if !is_closed { // For open unions, any object with a $type is valid as long as it's structured properly @@ -779,7 +934,7 @@ impl LexiconValidator { } } } - + // Try each variant (fallback for objects without $type or when $type doesn't match) for ref_value in refs { if let Some(ref_str) = ref_value.as_str() { @@ -789,20 +944,27 @@ impl LexiconValidator { } } } - + Err(ValidationError::UnionValidationFailed { path: ctx.path_string(), }) } - + /// Validate a blob reference - fn validate_blob(&self, value: &Value, _schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> { - let obj = value.as_object().ok_or_else(|| ValidationError::TypeMismatch { - path: ctx.path_string(), - expected: "blob object".to_string(), - actual: format!("{:?}", value), - })?; - + fn validate_blob( + &self, + value: &Value, + _schema: &Value, + ctx: &ValidationContext, + ) -> Result<(), ValidationError> { + let obj = value + .as_object() + .ok_or_else(|| ValidationError::TypeMismatch { + path: ctx.path_string(), + expected: "blob object".to_string(), + actual: format!("{:?}", value), + })?; + // Check required blob fields if !obj.contains_key("$type") || obj["$type"] != "blob" { return Err(ValidationError::TypeMismatch { @@ -811,7 +973,7 @@ impl LexiconValidator { actual: format!("{:?}", value), }); } - + // Check for ref object with $link let ref_obj = obj.get("ref").and_then(|v| v.as_object()).ok_or_else(|| { ValidationError::TypeMismatch { @@ -820,7 +982,7 @@ impl LexiconValidator { actual: "missing or invalid".to_string(), } })?; - + if !ref_obj.contains_key("$link") { return Err(ValidationError::TypeMismatch { path: ctx.with_field("ref.$link").path_string(), @@ -828,93 +990,126 @@ impl LexiconValidator { actual: "missing".to_string(), }); } - + // Check other required fields if !obj.contains_key("mimeType") { return Err(ValidationError::RequiredFieldMissing { path: ctx.with_field("mimeType").path_string(), }); } - + if !obj.contains_key("size") { return Err(ValidationError::RequiredFieldMissing { path: ctx.with_field("size").path_string(), }); } - + Ok(()) } - + /// Validate bytes type - fn validate_bytes(&self, value: &Value, schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> { + fn validate_bytes( + &self, + value: &Value, + schema: &Value, + ctx: &ValidationContext, + ) -> Result<(), ValidationError> { // Bytes in JSON are typically base64 encoded strings - let string_val = value.as_str().ok_or_else(|| ValidationError::TypeMismatch { - path: ctx.path_string(), - expected: "bytes (base64 string)".to_string(), - actual: format!("{:?}", value), - })?; - + let string_val = value + .as_str() + .ok_or_else(|| ValidationError::TypeMismatch { + path: ctx.path_string(), + expected: "bytes (base64 string)".to_string(), + actual: format!("{:?}", value), + })?; + // Basic base64 validation (simplified) - if !string_val.chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=') { + if !string_val + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=') + { return Err(ValidationError::TypeMismatch { path: ctx.path_string(), expected: "valid base64 string".to_string(), actual: "invalid base64".to_string(), }); } - + // Check length constraints (bytes length, not string length) - let decoded_len = (string_val.len() * 3 / 4) - string_val.chars().filter(|&c| c == '=').count(); - + let decoded_len = + (string_val.len() * 3 / 4) - string_val.chars().filter(|&c| c == '=').count(); + if let Some(min_length) = schema["minLength"].as_u64() { if (decoded_len as u64) < min_length { return Err(ValidationError::StringValidationFailed { path: ctx.path_string(), - message: format!("Bytes length {} is less than minimum {}", decoded_len, min_length), + message: format!( + "Bytes length {} is less than minimum {}", + decoded_len, min_length + ), }); } } - + if let Some(max_length) = schema["maxLength"].as_u64() { if (decoded_len as u64) > max_length { return Err(ValidationError::StringValidationFailed { path: ctx.path_string(), - message: format!("Bytes length {} exceeds maximum {}", decoded_len, max_length), + message: format!( + "Bytes length {} exceeds maximum {}", + decoded_len, max_length + ), }); } } - + Ok(()) } - + /// Validate cid-link type - fn validate_cid_link(&self, value: &Value, _schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> { - let obj = value.as_object().ok_or_else(|| ValidationError::TypeMismatch { - path: ctx.path_string(), - expected: "cid-link object".to_string(), - actual: format!("{:?}", value), - })?; - + fn validate_cid_link( + &self, + value: &Value, + _schema: &Value, + ctx: &ValidationContext, + ) -> Result<(), ValidationError> { + let obj = value + .as_object() + .ok_or_else(|| ValidationError::TypeMismatch { + path: ctx.path_string(), + expected: "cid-link object".to_string(), + actual: format!("{:?}", value), + })?; + // CID-link must have $link field with a valid CID let link = obj.get("$link").and_then(|v| v.as_str()).ok_or_else(|| { ValidationError::RequiredFieldMissing { path: ctx.with_field("$link").path_string(), } })?; - + // Basic CID validation (simplified - should start with appropriate multibase prefix) - if !link.starts_with("bafy") && !link.starts_with("bafk") && !link.starts_with("b") && link.len() < 20 { + if !link.starts_with("bafy") + && !link.starts_with("bafk") + && !link.starts_with("b") + && link.len() < 20 + { return Err(ValidationError::FormatValidationFailed { path: ctx.with_field("$link").path_string(), format: "cid".to_string(), }); } - + Ok(()) } - + /// Validate null type - fn validate_null(&self, value: &Value, _schema: &Value, ctx: &ValidationContext) -> Result<(), ValidationError> { + fn validate_null( + &self, + value: &Value, + _schema: &Value, + ctx: &ValidationContext, + ) -> Result<(), ValidationError> { if !value.is_null() { return Err(ValidationError::TypeMismatch { path: ctx.path_string(), @@ -924,18 +1119,18 @@ impl LexiconValidator { } Ok(()) } - + /// Simplified grapheme counting (approximation) - /// This is a basic implementation - for full compliance with TS version, + /// This is a basic implementation - for full compliance with TS version, /// would need proper Unicode grapheme cluster segmentation fn count_graphemes(&self, s: &str) -> usize { // Very simplified approach: count Unicode scalar values, with basic combining character handling let mut count = 0; let mut chars = s.chars().peekable(); - + while let Some(_ch) = chars.next() { count += 1; - + // Skip combining characters that follow this base character while let Some(&next_ch) = chars.peek() { if self.is_combining_character(next_ch) { @@ -945,15 +1140,15 @@ impl LexiconValidator { } } } - + count } - + /// Check if a character is a combining character (very simplified) fn is_combining_character(&self, ch: char) -> bool { // This is a simplified check for common combining marks // In a full implementation, would need proper Unicode category checking - matches!(ch as u32, + matches!(ch as u32, 0x0300..=0x036F | // Combining Diacritical Marks 0x1AB0..=0x1AFF | // Combining Diacritical Marks Extended 0x1DC0..=0x1DFF | // Combining Diacritical Marks Supplement @@ -961,4 +1156,4 @@ impl LexiconValidator { 0xFE20..=0xFE2F // Combining Half Marks ) } -} \ No newline at end of file +} diff --git a/api/src/main.rs b/api/src/main.rs index c66d6c0..9d06326 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -38,7 +38,7 @@ use tracing_subscriber; use crate::database::Database; use crate::errors::AppError; use crate::jetstream::JetstreamConsumer; -use crate::logging::{Logger, LogLevel}; +use crate::logging::{LogLevel, Logger}; #[derive(Clone)] pub struct Config { @@ -158,7 +158,7 @@ async fn main() -> Result<(), AppError> { Logger::global().log_jetstream( LogLevel::Info, "Starting Jetstream consumer", - Some(serde_json::json!({"action": "starting_consumer"})) + Some(serde_json::json!({"action": "starting_consumer"})), ); // Use existing consumer or create new one @@ -173,7 +173,10 @@ async fn main() -> Result<(), AppError> { { Ok(consumer) => std::sync::Arc::new(consumer), Err(e) => { - let message = format!("Failed to create Jetstream consumer: {} - will retry in {:?}", e, retry_delay); + let message = format!( + "Failed to create Jetstream consumer: {} - will retry in {:?}", + e, retry_delay + ); tracing::error!("{}", message); Logger::global().log_jetstream( LogLevel::Error, @@ -182,7 +185,7 @@ async fn main() -> Result<(), AppError> { "error": e.to_string(), "retry_delay_secs": retry_delay.as_secs(), "action": "consumer_creation_failed" - })) + })), ); jetstream_connected_clone .store(false, std::sync::atomic::Ordering::Relaxed); @@ -221,7 +224,10 @@ async fn main() -> Result<(), AppError> { // No events for 60+ seconds - mark as disconnected health_check_connected .store(false, std::sync::atomic::Ordering::Relaxed); - let message = format!("Jetstream marked as disconnected: no events processed in {} seconds", no_events_duration); + let message = format!( + "Jetstream marked as disconnected: no events processed in {} seconds", + no_events_duration + ); tracing::warn!("{}", message); Logger::global().log_jetstream( LogLevel::Warn, @@ -229,7 +235,7 @@ async fn main() -> Result<(), AppError> { Some(serde_json::json!({ "no_events_duration_secs": no_events_duration, "action": "health_check_disconnected" - })) + })), ); } } else { @@ -238,7 +244,8 @@ async fn main() -> Result<(), AppError> { health_check_connected .store(true, std::sync::atomic::Ordering::Relaxed); if last_count == 0 && current_count > 0 { - let message = "Jetstream health check: events flowing, marked as connected"; + let message = + "Jetstream health check: events flowing, marked as connected"; tracing::info!("{}", message); Logger::global().log_jetstream( LogLevel::Info, @@ -246,7 +253,7 @@ async fn main() -> Result<(), AppError> { Some(serde_json::json!({ "event_count": current_count, "action": "health_check_connected" - })) + })), ); } } @@ -313,68 +320,68 @@ async fn main() -> Result<(), AppError> { ) // XRPC endpoints .route( - "/xrpc/social.slices.slice.startSync", + "/xrpc/network.slices.slice.startSync", post(handler_sync::sync), ) .route( - "/xrpc/social.slices.slice.syncUserCollections", + "/xrpc/network.slices.slice.syncUserCollections", post(handler_sync_user_collections::sync_user_collections), ) .route( - "/xrpc/social.slices.slice.getJobStatus", + "/xrpc/network.slices.slice.getJobStatus", get(handler_jobs::get_job_status), ) .route( - "/xrpc/social.slices.slice.getJobHistory", + "/xrpc/network.slices.slice.getJobHistory", get(handler_jobs::get_slice_job_history), ) .route( - "/xrpc/social.slices.slice.getJobLogs", + "/xrpc/network.slices.slice.getJobLogs", get(handler_logs::get_sync_job_logs_handler), ) .route( - "/xrpc/social.slices.slice.getJetstreamLogs", + "/xrpc/network.slices.slice.getJetstreamLogs", get(handler_logs::get_jetstream_logs_handler), ) .route( - "/xrpc/social.slices.slice.stats", + "/xrpc/network.slices.slice.stats", post(handler_stats::stats), ) .route( - "/xrpc/social.slices.slice.getSliceRecords", + "/xrpc/network.slices.slice.getSliceRecords", post(handler_get_records::get_records), ) .route( - "/xrpc/social.slices.slice.codegen", + "/xrpc/network.slices.slice.codegen", post(handler_xrpc_codegen::generate_client_xrpc), ) .route( - "/xrpc/social.slices.slice.openapi", + "/xrpc/network.slices.slice.openapi", get(handler_openapi_spec::get_openapi_spec), ) .route( - "/xrpc/social.slices.slice.getJetstreamStatus", + "/xrpc/network.slices.slice.getJetstreamStatus", get(handler_jetstream_status::get_jetstream_status), ) .route( - "/xrpc/social.slices.slice.getActors", + "/xrpc/network.slices.slice.getActors", post(handler_get_actors::get_actors), ) // OAuth client management endpoints .route( - "/xrpc/social.slices.slice.createOAuthClient", + "/xrpc/network.slices.slice.createOAuthClient", post(handler_oauth_clients::create_oauth_client), ) .route( - "/xrpc/social.slices.slice.getOAuthClients", + "/xrpc/network.slices.slice.getOAuthClients", get(handler_oauth_clients::get_oauth_clients), ) .route( - "/xrpc/social.slices.slice.updateOAuthClient", + "/xrpc/network.slices.slice.updateOAuthClient", post(handler_oauth_clients::update_oauth_client), ) .route( - "/xrpc/social.slices.slice.deleteOAuthClient", + "/xrpc/network.slices.slice.deleteOAuthClient", post(handler_oauth_clients::delete_oauth_client), ) // Dynamic collection-specific XRPC endpoints (wildcard routes must come last) diff --git a/docs/api-reference.md b/docs/api-reference.md index 8d1fe90..8caaf87 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -10,7 +10,8 @@ https://your-api-domain.com/xrpc/ ## Authentication -Most write operations require OAuth 2.0 authentication. Include the access token in the Authorization header: +Most write operations require OAuth 2.0 authentication. Include the access token +in the Authorization header: ``` Authorization: Bearer YOUR_ACCESS_TOKEN @@ -22,13 +23,14 @@ Read operations typically work without authentication. ### Slice Management -#### `social.slices.slice.listRecords` +#### `network.slices.slice.listRecords` List all slices. **Method**: GET **Parameters**: + - `limit` (number, optional): Maximum records to return (default: 50) - `cursor` (string, optional): Pagination cursor - `sort` (string, optional): Sort field and order (e.g., `createdAt:desc`) @@ -36,14 +38,15 @@ List all slices. - `authors` (string[], optional): Filter by multiple author DIDs **Response**: + ```json { "records": [ { - "uri": "at://did:plc:abc/social.slices.slice/xyz", + "uri": "at://did:plc:abc/network.slices.slice/xyz", "cid": "bafyrei...", "did": "did:plc:abc", - "collection": "social.slices.slice", + "collection": "network.slices.slice", "value": { "name": "My Slice", "domain": "com.example", @@ -56,18 +59,19 @@ List all slices. } ``` -#### `social.slices.slice.getRecord` +#### `network.slices.slice.getRecord` Get a specific slice by URI. **Method**: GET **Parameters**: + - `uri` (string, required): AT Protocol URI of the slice **Response**: Single record object (same structure as listRecords item) -#### `social.slices.slice.createRecord` +#### `network.slices.slice.createRecord` Create a new slice. @@ -76,11 +80,12 @@ Create a new slice. **Authentication**: Required **Body**: + ```json { "slice": "at://your-slice-uri", "record": { - "$type": "social.slices.slice", + "$type": "network.slices.slice", "name": "My New Slice", "domain": "com.example", "createdAt": "2024-01-01T00:00:00Z" @@ -90,22 +95,24 @@ Create a new slice. ``` **Response**: + ```json { - "uri": "at://did:plc:abc/social.slices.slice/xyz", + "uri": "at://did:plc:abc/network.slices.slice/xyz", "cid": "bafyrei..." } ``` ### Slice Operations -#### `social.slices.slice.stats` +#### `network.slices.slice.stats` Get statistics for a slice. **Method**: POST **Body**: + ```json { "slice": "at://your-slice-uri" @@ -113,6 +120,7 @@ Get statistics for a slice. ``` **Response**: + ```json { "success": true, @@ -131,13 +139,14 @@ Get statistics for a slice. } ``` -#### `social.slices.slice.listSliceRecords` +#### `network.slices.slice.listSliceRecords` List records across multiple collections in a slice. **Method**: POST **Body**: + ```json { "slice": "at://your-slice-uri", @@ -149,6 +158,7 @@ List records across multiple collections in a slice. ``` **Response**: + ```json { "success": true, @@ -158,7 +168,7 @@ List records across multiple collections in a slice. "cid": "bafyrei...", "did": "did:plc:abc", "collection": "com.example.post", - "value": { /* record data */ }, + "value": {/* record data */}, "indexedAt": "2024-01-01T00:00:00Z" } ], @@ -166,13 +176,14 @@ List records across multiple collections in a slice. } ``` -#### `social.slices.slice.searchSliceRecords` +#### `network.slices.slice.searchSliceRecords` Search records across multiple collections in a slice by content. **Method**: POST **Body**: + ```json { "slice": "at://your-slice-uri", @@ -185,6 +196,7 @@ Search records across multiple collections in a slice by content. ``` **Response**: + ```json { "success": true, @@ -194,7 +206,7 @@ Search records across multiple collections in a slice by content. "cid": "bafyrei...", "did": "did:plc:abc", "collection": "com.example.post", - "value": { /* record data */ }, + "value": {/* record data */}, "indexedAt": "2024-01-01T00:00:00Z" } ], @@ -202,7 +214,7 @@ Search records across multiple collections in a slice by content. } ``` -#### `social.slices.slice.syncUserCollections` +#### `network.slices.slice.syncUserCollections` Synchronously sync collections for the authenticated user. @@ -211,6 +223,7 @@ Synchronously sync collections for the authenticated user. **Authentication**: Required **Body**: + ```json { "slice": "at://your-slice-uri", @@ -219,6 +232,7 @@ Synchronously sync collections for the authenticated user. ``` **Response**: + ```json { "success": true, @@ -229,7 +243,7 @@ Synchronously sync collections for the authenticated user. } ``` -#### `social.slices.slice.startSync` +#### `network.slices.slice.startSync` Start an asynchronous bulk sync job. @@ -238,6 +252,7 @@ Start an asynchronous bulk sync job. **Authentication**: Required **Body**: + ```json { "slice": "at://your-slice-uri", @@ -249,6 +264,7 @@ Start an asynchronous bulk sync job. ``` **Response**: + ```json { "success": true, @@ -257,13 +273,14 @@ Start an asynchronous bulk sync job. } ``` -#### `social.slices.slice.codegen` +#### `network.slices.slice.codegen` Generate TypeScript client code. **Method**: POST **Body**: + ```json { "target": "typescript", @@ -272,6 +289,7 @@ Generate TypeScript client code. ``` **Response**: + ```json { "success": true, @@ -281,7 +299,8 @@ Generate TypeScript client code. ## Dynamic Collection Endpoints -For each collection in your slice, the following endpoints are automatically generated: +For each collection in your slice, the following endpoints are automatically +generated: ### `[collection].listRecords` @@ -290,6 +309,7 @@ List records in a collection. **Method**: GET **Parameters**: + - `slice` (string, required): Slice URI - `limit` (number, optional): Maximum records (default: 50) - `cursor` (string, optional): Pagination cursor @@ -304,6 +324,7 @@ Get a single record. **Method**: GET **Parameters**: + - `slice` (string, required): Slice URI - `uri` (string, required): Record URI @@ -314,6 +335,7 @@ Search within a collection. **Method**: GET **Parameters**: + - `slice` (string, required): Slice URI - `query` (string, required): Search query - `field` (string, optional): Specific field to search @@ -330,11 +352,12 @@ Create a new record. **Authentication**: Required **Body**: + ```json { "slice": "at://your-slice-uri", "record": { - "$type": "collection.name", + "$type": "collection.name" /* record fields */ }, "rkey": "optional-key" @@ -350,12 +373,13 @@ Update an existing record. **Authentication**: Required **Body**: + ```json { "slice": "at://your-slice-uri", "rkey": "record-key", "record": { - "$type": "collection.name", + "$type": "collection.name" /* updated fields */ } } @@ -370,6 +394,7 @@ Delete a record. **Authentication**: Required **Body**: + ```json { "rkey": "record-key" @@ -378,7 +403,7 @@ Delete a record. ## Lexicon Management -### `social.slices.lexicon.listRecords` +### `network.slices.lexicon.listRecords` List lexicons in a slice. @@ -386,7 +411,7 @@ List lexicons in a slice. **Parameters**: Same as collection.listRecords -### `social.slices.lexicon.createRecord` +### `network.slices.lexicon.createRecord` Add a lexicon to a slice. @@ -395,11 +420,12 @@ Add a lexicon to a slice. **Authentication**: Required **Body**: + ```json { "slice": "at://your-slice-uri", "record": { - "$type": "social.slices.lexicon", + "$type": "network.slices.lexicon", "nsid": "com.example.post", "definitions": "{\"lexicon\": 1, ...}", "createdAt": "2024-01-01T00:00:00Z", @@ -410,13 +436,14 @@ Add a lexicon to a slice. ## Actor Management -### `social.slices.slice.getActors` +### `network.slices.slice.getActors` Get actors (users) in a slice. **Method**: GET **Parameters**: + - `slice` (string, required): Slice URI - `search` (string, optional): Search query - `dids` (string[], optional): Filter by DIDs @@ -424,6 +451,7 @@ Get actors (users) in a slice. - `cursor` (string, optional): Pagination cursor **Response**: + ```json { "actors": [ @@ -449,11 +477,13 @@ Upload a blob (image, file). **Authentication**: Required **Headers**: + - `Content-Type`: MIME type of the blob **Body**: Raw binary data **Response**: + ```json { "blob": { @@ -477,6 +507,7 @@ All endpoints may return error responses: ``` Common HTTP status codes: + - `200`: Success - `400`: Bad request - `401`: Authentication required @@ -493,6 +524,7 @@ List endpoints support cursor-based pagination: 3. Continue until no cursor returned Example: + ```javascript let cursor = undefined; do { @@ -508,6 +540,7 @@ do { Sort parameter format: `field:order` or `field1:order1,field2:order2` Examples: + - `createdAt:desc` - Newest first - `name:asc` - Alphabetical - `createdAt:desc,name:asc` - Newest first, then alphabetical @@ -516,4 +549,4 @@ Examples: - [SDK Usage](./sdk-usage.md) - Using generated TypeScript clients - [Getting Started](./getting-started.md) - Build your first application -- [Concepts](./concepts.md) - Understand the architecture \ No newline at end of file +- [Concepts](./concepts.md) - Understand the architecture diff --git a/docs/concepts.md b/docs/concepts.md index 130afe3..c2cc8d4 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -10,7 +10,7 @@ as your own data universe with custom schemas and records. ### Key Properties - **URI**: Unique AT Protocol URI (e.g., - `at://did:plc:abc123/social.slices.slice/3xyz`) + `at://did:plc:abc123/network.slices.slice/3xyz`) - **Name**: Human-readable identifier - **Domain**: Namespace for lexicons (e.g., `com.example`, `social.grain`) - **Creation Date**: When the slice was created @@ -65,7 +65,7 @@ the structure, validation rules, and metadata for records. Lexicons follow reverse domain naming: - `com.example.post` - A post in the example.com namespace -- `social.slices.slice` - Core slice record type +- `network.slices.slice` - Core slice record type - `app.bsky.actor.profile` - Bluesky profile (external) ## Collections @@ -217,10 +217,10 @@ Automatically generated from lexicons: Built-in endpoints for slice management: -- `social.slices.slice.stats` - Slice statistics -- `social.slices.slice.records` - Browse records -- `social.slices.slice.codegen` - Generate SDKs -- `social.slices.slice.sync` - Trigger sync +- `network.slices.slice.stats` - Slice statistics +- `network.slices.slice.records` - Browse records +- `network.slices.slice.codegen` - Generate SDKs +- `network.slices.slice.sync` - Trigger sync ### Handler Authentication @@ -255,7 +255,7 @@ const client = new AtProtoClient(apiUrl, sliceUri, oauthClient); // Use nested structure matching lexicons await client.com.example.post.listRecords(); -await client.social.slices.slice.stats(); +await client.network.slices.slice.stats(); await client.app.bsky.actor.profile.getRecord({ uri }); ``` diff --git a/docs/getting-started.md b/docs/getting-started.md index 2ae82f0..6262102 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -38,6 +38,7 @@ CREATE DATABASE slices; Create `.env` files for both API and frontend: **API (`/api/.env`)**: + ```bash DATABASE_URL=postgres://user:password@localhost:5432/slices AUTH_BASE_URL=https://aip.your-domain.com @@ -45,6 +46,7 @@ PORT=3000 ``` **Frontend (`/frontend/.env`)**: + ```bash OAUTH_CLIENT_ID=your-client-id OAUTH_CLIENT_SECRET=your-client-secret @@ -52,7 +54,7 @@ OAUTH_REDIRECT_URI=http://localhost:8000/oauth/callback OAUTH_AIP_BASE_URL=https://aip.your-domain.com SESSION_ENCRYPTION_KEY=your-32-char-key API_URL=http://localhost:3000 -SLICE_URI=at://did:plc:your-did/social.slices.slice/your-slice-id +SLICE_URI=at://did:plc:your-did/network.slices.slice/your-slice-id DATABASE_URL=slices.db ``` @@ -70,12 +72,14 @@ Save the client ID and secret to your `.env` file. ### 5. Start the Services Start the API server: + ```bash cd api cargo run ``` Start the frontend: + ```bash cd frontend deno task dev @@ -92,12 +96,14 @@ Click "Login" and authenticate with your AT Protocol account. ### 2. Create a Slice Click "Create Slice" and provide: + - **Name**: A friendly name for your slice - **Domain**: Your namespace (e.g., `com.example`) ### 3. Define a Lexicon -Navigate to your slice and go to the Lexicon tab. Create a lexicon for your first record type: +Navigate to your slice and go to the Lexicon tab. Create a lexicon for your +first record type: ```json { @@ -140,7 +146,8 @@ Navigate to your slice and go to the Lexicon tab. Create a lexicon for your firs ### 4. Generate TypeScript Client -Navigate to the Code Generation tab and click "Generate TypeScript Client". This creates a type-safe client library for your slice. +Navigate to the Code Generation tab and click "Generate TypeScript Client". This +creates a type-safe client library for your slice. ### 5. Use the Generated Client @@ -150,8 +157,8 @@ In your application: import { AtProtoClient } from "./generated-client.ts"; const client = new AtProtoClient( - 'http://localhost:3000', - 'at://did:plc:your-did/social.slices.slice/your-slice-id' + "http://localhost:3000", + "at://did:plc:your-did/network.slices.slice/your-slice-id", ); // List posts @@ -162,12 +169,12 @@ const newPost = await client.com.example.post.createRecord({ title: "My First Post", content: "Hello from Slices!", createdAt: new Date().toISOString(), - tags: ["introduction", "slices"] + tags: ["introduction", "slices"], }); // Get a specific post const post = await client.com.example.post.getRecord({ - uri: newPost.uri + uri: newPost.uri, }); ``` @@ -182,12 +189,14 @@ Go to your slice and click the Sync tab. ### 2. Configure Sync Choose collections to sync: + - **Primary Collections**: Your slice's lexicons - **External Collections**: Bluesky or other AT Protocol collections ### 3. Start Sync -Specify repositories (DIDs) to sync from, or leave empty to sync all available data. +Specify repositories (DIDs) to sync from, or leave empty to sync all available +data. ### 4. Monitor Progress @@ -203,21 +212,25 @@ The sync will run in the background. Check the status in the UI or via API. ## Troubleshooting ### Database Connection Issues + - Verify PostgreSQL is running: `docker ps` - Check DATABASE_URL format - Ensure database exists ### OAuth Errors + - Verify client ID and secret - Check redirect URI matches configuration - Ensure AIP server is accessible ### Sync Not Working + - Check user has necessary permissions - Verify lexicons are valid - Check API server logs for errors ### Generated Client Issues + - Regenerate client after lexicon changes - Ensure API server is running -- Check for TypeScript compilation errors \ No newline at end of file +- Check for TypeScript compilation errors diff --git a/docs/sdk-usage.md b/docs/sdk-usage.md index cbda32d..58c38bc 100644 --- a/docs/sdk-usage.md +++ b/docs/sdk-usage.md @@ -19,7 +19,7 @@ import { OAuthClient } from "@slices/oauth"; ```typescript const client = new AtProtoClient( "https://api.your-domain.com", - "at://did:plc:abc/social.slices.slice/your-slice-id", + "at://did:plc:abc/network.slices.slice/your-slice-id", ); // Read operations work without auth @@ -43,7 +43,7 @@ const oauthClient = new OAuthClient({ // Initialize API client with OAuth const client = new AtProtoClient( "https://api.your-domain.com", - "at://did:plc:abc/social.slices.slice/your-slice-id", + "at://did:plc:abc/network.slices.slice/your-slice-id", oauthClient, ); ``` @@ -292,7 +292,7 @@ if (profile.value.avatar) { ### Get Slice Statistics ```typescript -const stats = await client.social.slices.slice.stats({ +const stats = await client.network.slices.slice.stats({ slice: "at://your-slice-uri", }); @@ -311,33 +311,33 @@ filtering and sorting capabilities: ```typescript // Get all actors in the slice -const actors = await client.social.slices.slice.getActors(); +const actors = await client.network.slices.slice.getActors(); // With pagination -const page1 = await client.social.slices.slice.getActors({ +const page1 = await client.network.slices.slice.getActors({ limit: 20, }); -const page2 = await client.social.slices.slice.getActors({ +const page2 = await client.network.slices.slice.getActors({ limit: 20, cursor: page1.cursor, }); // Filter by specific DIDs -const specificActors = await client.social.slices.slice.getActors({ +const specificActors = await client.network.slices.slice.getActors({ where: { did: { in: ["did:plc:user1", "did:plc:user2"] }, }, }); // Search by handle -const searchByHandle = await client.social.slices.slice.getActors({ +const searchByHandle = await client.network.slices.slice.getActors({ where: { handle: { contains: "alice" }, }, }); // Filter by exact handle -const exactHandle = await client.social.slices.slice.getActors({ +const exactHandle = await client.network.slices.slice.getActors({ where: { handle: { eq: "alice.bsky.social" }, }, @@ -357,7 +357,7 @@ The `getSliceRecords` method uses the same unified `where` clause approach: ```typescript // Get records from specific collections -const records = await client.social.slices.slice.getSliceRecords({ +const records = await client.network.slices.slice.getSliceRecords({ where: { collection: { eq: "com.example.post" }, did: { eq: "did:plc:specific-author" }, // optional @@ -370,7 +370,7 @@ records.records.forEach((record) => { }); // Search across collections using specific fields -const searchResults = await client.social.slices.slice.getSliceRecords({ +const searchResults = await client.network.slices.slice.getSliceRecords({ where: { collection: { eq: "com.example.post" }, title: { contains: "hello world" }, @@ -380,7 +380,7 @@ const searchResults = await client.social.slices.slice.getSliceRecords({ }); // Global search across ALL fields in records -const globalSearchResults = await client.social.slices.slice.getSliceRecords({ +const globalSearchResults = await client.network.slices.slice.getSliceRecords({ where: { collection: { eq: "com.example.post" }, json: { contains: "hello world" }, // Searches entire record content @@ -394,7 +394,7 @@ searchResults.records.forEach((record) => { }); // Get records from any collection with global text search -const allCollectionSearch = await client.social.slices.slice.getSliceRecords({ +const allCollectionSearch = await client.network.slices.slice.getSliceRecords({ where: { json: { contains: "important content" }, // Searches ALL fields in ALL collections }, @@ -451,14 +451,16 @@ When using `getSliceRecords`, you can search across multiple collections: ```typescript // Search for "tutorial" across all collections -const crossCollectionSearch = await client.social.slices.slice.getSliceRecords({ - where: { - json: { contains: "tutorial" }, +const crossCollectionSearch = await client.network.slices.slice.getSliceRecords( + { + where: { + json: { contains: "tutorial" }, + }, }, -}); +); // Limit to specific collections -const specificSearch = await client.social.slices.slice.getSliceRecords({ +const specificSearch = await client.network.slices.slice.getSliceRecords({ where: { collection: { in: ["com.example.post", "com.example.article"] }, json: { contains: "guide" }, @@ -468,46 +470,49 @@ const specificSearch = await client.social.slices.slice.getSliceRecords({ ### OR Query Support -You can use OR queries to find records that match any of multiple conditions using the separate `orWhere` parameter. This provides clean type safety and autocomplete for field names: +You can use OR queries to find records that match any of multiple conditions +using the separate `orWhere` parameter. This provides clean type safety and +autocomplete for field names: ```typescript // Find posts by either user1 OR user2 const posts = await client.com.example.post.getRecords({ orWhere: { - did: { in: ["did:plc:user1", "did:plc:user2"] } - } + did: { in: ["did:plc:user1", "did:plc:user2"] }, + }, }); // Find posts that either have "typescript" in title OR are by a specific user const posts = await client.com.example.post.getRecords({ orWhere: { title: { contains: "typescript" }, - did: { eq: "did:plc:alice" } - } + did: { eq: "did:plc:alice" }, + }, }); // Combining OR with regular AND conditions const posts = await client.com.example.post.getRecords({ where: { - createdAt: { eq: "2025-09-03" }, // AND conditions + createdAt: { eq: "2025-09-03" }, // AND conditions }, - orWhere: { // OR conditions + orWhere: { // OR conditions title: { contains: "guide" }, - did: { eq: "did:plc:user1" } - } + did: { eq: "did:plc:user1" }, + }, }); // SQL: WHERE created_at = '2025-09-03' AND (title LIKE '%guide%' OR did = 'did:plc:user1') // OR queries work with cross-collection searches too -const crossCollectionOrSearch = await client.social.slices.slice.getSliceRecords({ - where: { - collection: { eq: "com.example.post" }, - }, - orWhere: { - title: { contains: "javascript" }, - tags: { contains: "tutorial" } - } -}); +const crossCollectionOrSearch = await client.network.slices.slice + .getSliceRecords({ + where: { + collection: { eq: "com.example.post" }, + }, + orWhere: { + title: { contains: "javascript" }, + tags: { contains: "tutorial" }, + }, + }); // You get full autocomplete and type safety for field names in both where and orWhere const typedSearch = await client.com.example.post.getRecords({ @@ -519,7 +524,7 @@ const typedSearch = await client.com.example.post.getRecords({ // And also provides autocomplete here description: { contains: "tutorial" }, tags: { contains: "guide" }, - } + }, }); ``` @@ -527,7 +532,7 @@ const typedSearch = await client.com.example.post.getRecords({ ```typescript // Sync current user's data (requires auth) -const syncResult = await client.social.slices.slice.syncUserCollections({ +const syncResult = await client.network.slices.slice.syncUserCollections({ timeoutSeconds: 30, }); @@ -593,7 +598,7 @@ await oauthClient.handleCallback({ code, state }); const client = new AtProtoClient(apiUrl, sliceUri, oauthClient); // OAuth tokens are automatically managed -const profile = await client.social.slices.actor.profile.createRecord({ +const profile = await client.network.slices.actor.profile.createRecord({ displayName: "New User", description: "My profile", }, true); // useSelfRkey for profile diff --git a/frontend/.env.example b/frontend/.env.example index affeedd..61515f6 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -3,4 +3,4 @@ OAUTH_CLIENT_SECRET="your-oauth-client-secret" OAUTH_REDIRECT_URI="http://localhost:8080/oauth/callback" OAUTH_AIP_BASE_URL="https://your-domain.com" API_URL="http://localhost:3000" -SLICE_URI="at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/3lwzmbjpqxk2q" +SLICE_URI="at://did:plc:bcgltzqazw5tb6k2g3ttenbj/network.slices.slice/3lymhd4jhrd2z" diff --git a/frontend/fly.toml b/frontend/fly.toml index 226d560..d75fa4f 100644 --- a/frontend/fly.toml +++ b/frontend/fly.toml @@ -13,7 +13,7 @@ primary_region = 'sea' PORT = '8080' API_URL = 'https://slices-api.fly.dev' DATABASE_URL = '/data/slices.db' - SLICE_URI = 'at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/3lwzmbjpqxk2q' + SLICE_URI = 'at://did:plc:bcgltzqazw5tb6k2g3ttenbj/network.slices.slice/3lymhd4jhrd2z' DENO_ENV = 'production' [http_service] diff --git a/frontend/src/client.test.ts b/frontend/src/client.test.ts index 72e868f..7434a76 100644 --- a/frontend/src/client.test.ts +++ b/frontend/src/client.test.ts @@ -75,45 +75,62 @@ class MockOAuthClient { // Mock fetch function that simulates API responses function createMockFetch() { - return async (url: string | URL | Request, init?: RequestInit): Promise => { + return async ( + url: string | URL | Request, + init?: RequestInit + ): Promise => { const requestUrl = url.toString(); const method = init?.method || "GET"; - const headers = init?.headers as Record || {}; - + const headers = (init?.headers as Record) || {}; + console.log(`Mock fetch: ${method} ${requestUrl}`); console.log(`Authorization header: ${headers["Authorization"] || "none"}`); - + // Check if request has valid authorization - const hasAuth = headers["Authorization"]?.startsWith("Bearer valid") || - headers["Authorization"]?.startsWith("Bearer refreshed"); - + const hasAuth = + headers["Authorization"]?.startsWith("Bearer valid") || + headers["Authorization"]?.startsWith("Bearer refreshed"); + if (method === "GET") { // GET requests always succeed (read-only operations) - return new Response(JSON.stringify({ - records: [ - { uri: "test://uri", cid: "test-cid", value: { name: "Test Record" } } - ] - }), { - status: 200, - headers: { "Content-Type": "application/json" } - }); + return new Response( + JSON.stringify({ + records: [ + { + uri: "test://uri", + cid: "test-cid", + value: { name: "Test Record" }, + }, + ], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); } else { // POST/PUT/DELETE require valid auth if (hasAuth) { - return new Response(JSON.stringify({ - uri: "test://created-uri", - cid: "test-created-cid" - }), { - status: 200, - headers: { "Content-Type": "application/json" } - }); + return new Response( + JSON.stringify({ + uri: "test://created-uri", + cid: "test-created-cid", + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); } else { - return new Response(JSON.stringify({ - error: "Unauthorized" - }), { - status: 401, - headers: { "Content-Type": "application/json" } - }); + return new Response( + JSON.stringify({ + error: "Unauthorized", + }), + { + status: 401, + headers: { "Content-Type": "application/json" }, + } + ); } } }; @@ -125,7 +142,7 @@ function createTestClient( const mockOAuth = new MockOAuthClient(tokenState); return new AtProtoClient( "https://test-api.example.com", - "at://did:plc:test/social.slices.slice/test", + "at://did:plc:test/network.slices.slice/test", mockOAuth as any ); } @@ -133,10 +150,10 @@ function createTestClient( Deno.test("Valid tokens - read operation should succeed", async () => { // Setup mock fetch globalThis.fetch = createMockFetch(); - + try { const client = createTestClient("valid"); - await client.social.slices.slice.getRecords(); + await client.network.slices.slice.getRecords(); console.log("Read operation succeeded as expected"); } catch (error) { throw new Error(`Read operation should have succeeded: ${error}`); @@ -149,10 +166,10 @@ Deno.test("Valid tokens - read operation should succeed", async () => { Deno.test("Valid tokens - write operation should succeed", async () => { // Setup mock fetch globalThis.fetch = createMockFetch(); - + try { const client = createTestClient("valid"); - await client.social.slices.slice.updateRecord("test", { + await client.network.slices.slice.updateRecord("test", { name: "Test Slice", createdAt: new Date().toISOString(), }); @@ -170,10 +187,10 @@ Deno.test( async () => { // Setup mock fetch globalThis.fetch = createMockFetch(); - + try { const client = createTestClient("expired"); - await client.social.slices.slice.getRecords(); + await client.network.slices.slice.getRecords(); console.log("Read operation succeeded after token refresh"); } catch (error) { throw new Error( @@ -191,10 +208,10 @@ Deno.test( async () => { // Setup mock fetch globalThis.fetch = createMockFetch(); - + try { const client = createTestClient("expired"); - await client.social.slices.slice.updateRecord("test", { + await client.network.slices.slice.updateRecord("test", { name: "Test Slice", createdAt: new Date().toISOString(), }); @@ -213,10 +230,10 @@ Deno.test( Deno.test("Token refresh fails - read operation should succeed", async () => { // Setup mock fetch globalThis.fetch = createMockFetch(); - + try { const client = createTestClient("refresh_fails"); - await client.social.slices.slice.getRecords(); + await client.network.slices.slice.getRecords(); console.log("Read operation succeeded without auth (as expected)"); } catch (error) { throw new Error( @@ -233,12 +250,12 @@ Deno.test( async () => { // Setup mock fetch globalThis.fetch = createMockFetch(); - + try { const client = createTestClient("refresh_fails"); await assertRejects( async () => { - await client.social.slices.slice.updateRecord("test", { + await client.network.slices.slice.updateRecord("test", { name: "Test Slice", createdAt: new Date().toISOString(), }); @@ -257,10 +274,10 @@ Deno.test( Deno.test("No tokens - read operation should succeed", async () => { // Setup mock fetch globalThis.fetch = createMockFetch(); - + try { const client = createTestClient("no_tokens"); - await client.social.slices.slice.getRecords(); + await client.network.slices.slice.getRecords(); console.log("Read operation succeeded without tokens (as expected)"); } catch (error) { throw new Error( @@ -277,12 +294,12 @@ Deno.test( async () => { // Setup mock fetch globalThis.fetch = createMockFetch(); - + try { const client = createTestClient("no_tokens"); await assertRejects( async () => { - await client.social.slices.slice.updateRecord("test", { + await client.network.slices.slice.updateRecord("test", { name: "Test Slice", createdAt: new Date().toISOString(), }); @@ -298,63 +315,72 @@ Deno.test( } ); -Deno.test( - "401 response triggers token refresh and retry", - async () => { - // Create a fetch that returns 401 first time, 200 second time - let callCount = 0; - const mockFetch = (url: string | URL | Request, init?: RequestInit): Promise => { - callCount++; - const requestUrl = url.toString(); - const method = init?.method || "GET"; - const headers = init?.headers as Record || {}; - - console.log(`Mock fetch call ${callCount}: ${method} ${requestUrl}`); - console.log(`Authorization header: ${headers["Authorization"] || "none"}`); - - if (method === "POST") { - if (callCount === 1) { - // First call returns 401 - console.log("First call - returning 401 Unauthorized"); - return Promise.resolve(new Response(JSON.stringify({ error: "Unauthorized" }), { +Deno.test("401 response triggers token refresh and retry", async () => { + // Create a fetch that returns 401 first time, 200 second time + let callCount = 0; + const mockFetch = ( + url: string | URL | Request, + init?: RequestInit + ): Promise => { + callCount++; + const requestUrl = url.toString(); + const method = init?.method || "GET"; + const headers = (init?.headers as Record) || {}; + + console.log(`Mock fetch call ${callCount}: ${method} ${requestUrl}`); + console.log(`Authorization header: ${headers["Authorization"] || "none"}`); + + if (method === "POST") { + if (callCount === 1) { + // First call returns 401 + console.log("First call - returning 401 Unauthorized"); + return Promise.resolve( + new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, - headers: { "Content-Type": "application/json" } - })); - } else { - // Second call should succeed (with refreshed token) - console.log("Second call - returning success"); - return Promise.resolve(new Response(JSON.stringify({ - uri: "test://created-uri", - cid: "test-created-cid" - }), { - status: 200, - headers: { "Content-Type": "application/json" } - })); - } - } - - return Promise.resolve(new Response("Not found", { status: 404 })); - }; - - globalThis.fetch = mockFetch; - - try { - const client = createTestClient("valid"); - await client.social.slices.slice.updateRecord("test", { - name: "Test Slice", - createdAt: new Date().toISOString(), - }); - - // Should have made exactly 2 calls - console.log(`Total fetch calls made: ${callCount}`); - if (callCount !== 2) { - throw new Error(`Expected 2 fetch calls but got ${callCount}`); + headers: { "Content-Type": "application/json" }, + }) + ); + } else { + // Second call should succeed (with refreshed token) + console.log("Second call - returning success"); + return Promise.resolve( + new Response( + JSON.stringify({ + uri: "test://created-uri", + cid: "test-created-cid", + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ) + ); } - - console.log("401 retry test passed - request was retried after token refresh"); - } finally { - // Restore original fetch - globalThis.fetch = originalFetch; } + + return Promise.resolve(new Response("Not found", { status: 404 })); + }; + + globalThis.fetch = mockFetch; + + try { + const client = createTestClient("valid"); + await client.network.slices.slice.updateRecord("test", { + name: "Test Slice", + createdAt: new Date().toISOString(), + }); + + // Should have made exactly 2 calls + console.log(`Total fetch calls made: ${callCount}`); + if (callCount !== 2) { + throw new Error(`Expected 2 fetch calls but got ${callCount}`); + } + + console.log( + "401 retry test passed - request was retried after token refresh" + ); + } finally { + // Restore original fetch + globalThis.fetch = originalFetch; } -); +}); diff --git a/frontend/src/client.ts b/frontend/src/client.ts index b60b4ad..9f9c63b 100644 --- a/frontend/src/client.ts +++ b/frontend/src/client.ts @@ -1,5 +1,5 @@ // Generated TypeScript client for AT Protocol records -// Generated at: 2025-09-07 23:17:29 UTC +// Generated at: 2025-09-12 05:27:35 UTC // Lexicons: 6 /** @@ -9,7 +9,7 @@ * * const client = new AtProtoClient( * 'https://slices-api.fly.dev', - * 'at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/3lx5zq4t56s2q' + * 'at://did:plc:bcgltzqazw5tb6k2g3ttenbj/network.slices.slice/3lymhd4jhrd2z' * ); * * // Get records from the app.bsky.actor.profile collection @@ -28,14 +28,14 @@ * }); * * // Use slice-level methods for cross-collection queries with type safety - * const sliceRecords = await client.social.slices.slice.getSliceRecords({ + * const sliceRecords = await client.network.slices.slice.getSliceRecords({ * where: { * collection: { eq: 'app.bsky.actor.profile' } * } * }); * * // Search across multiple collections using union types - * const multiCollectionRecords = await client.social.slices.slice.getSliceRecords({ + * const multiCollectionRecords = await client.network.slices.slice.getSliceRecords({ * where: { * collection: { in: ['app.bsky.actor.profile', 'app.bsky.actor.profile'] }, * text: { contains: 'example search term' }, @@ -379,7 +379,7 @@ export type AppBskyActorProfileSortFields = | "description" | "displayName"; -export interface SocialSlicesSlice { +export interface NetworkSlicesSlice { /** Name of the slice */ name: string; /** Primary domain namespace for this slice (e.g. social.grain) */ @@ -388,9 +388,9 @@ export interface SocialSlicesSlice { createdAt: string; } -export type SocialSlicesSliceSortFields = "name" | "domain" | "createdAt"; +export type NetworkSlicesSliceSortFields = "name" | "domain" | "createdAt"; -export interface SocialSlicesLexicon { +export interface NetworkSlicesLexicon { /** Namespaced identifier for the lexicon */ nsid: string; /** The lexicon schema definitions as JSON */ @@ -403,14 +403,14 @@ export interface SocialSlicesLexicon { slice: string; } -export type SocialSlicesLexiconSortFields = +export type NetworkSlicesLexiconSortFields = | "nsid" | "definitions" | "createdAt" | "updatedAt" | "slice"; -export interface SocialSlicesActorProfile { +export interface NetworkSlicesActorProfile { displayName?: string; /** Free-form profile description text. */ description?: string; @@ -419,7 +419,7 @@ export interface SocialSlicesActorProfile { createdAt?: string; } -export type SocialSlicesActorProfileSortFields = +export type NetworkSlicesActorProfileSortFields = | "displayName" | "description" | "createdAt"; @@ -791,7 +791,7 @@ class AppClient extends BaseClient { } } -class SliceSlicesSocialClient extends BaseClient { +class SliceSlicesNetworkClient extends BaseClient { private readonly sliceUri: string; constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { @@ -803,13 +803,17 @@ class SliceSlicesSocialClient extends BaseClient { limit?: number; cursor?: string; where?: { - [K in SocialSlicesSliceSortFields | IndexedRecordFields]?: WhereCondition; + [K in + | NetworkSlicesSliceSortFields + | IndexedRecordFields]?: WhereCondition; }; orWhere?: { - [K in SocialSlicesSliceSortFields | IndexedRecordFields]?: WhereCondition; + [K in + | NetworkSlicesSliceSortFields + | IndexedRecordFields]?: WhereCondition; }; - sortBy?: SortField[]; - }): Promise> { + sortBy?: SortField[]; + }): Promise> { // Combine where and orWhere into the expected backend format const whereClause: any = params?.where ? { ...params.where } : {}; if (params?.orWhere) { @@ -823,7 +827,7 @@ class SliceSlicesSocialClient extends BaseClient { slice: this.sliceUri, }; const result = await this.makeRequest( - "social.slices.slice.getRecords", + "network.slices.slice.getRecords", "POST", requestParams ); @@ -833,7 +837,7 @@ class SliceSlicesSocialClient extends BaseClient { cid: record.cid, did: record.did, collection: record.collection, - value: record.value as unknown as SocialSlicesSlice, + value: record.value as unknown as NetworkSlicesSlice, indexedAt: record.indexedAt, })), cursor: result.cursor, @@ -842,10 +846,10 @@ class SliceSlicesSocialClient extends BaseClient { async getRecord( params: GetRecordParams - ): Promise> { + ): Promise> { const requestParams = { ...params, slice: this.sliceUri }; - return await this.makeRequest>( - "social.slices.slice.getRecord", + return await this.makeRequest>( + "network.slices.slice.getRecord", "GET", requestParams ); @@ -855,12 +859,16 @@ class SliceSlicesSocialClient extends BaseClient { limit?: number; cursor?: string; where?: { - [K in SocialSlicesSliceSortFields | IndexedRecordFields]?: WhereCondition; + [K in + | NetworkSlicesSliceSortFields + | IndexedRecordFields]?: WhereCondition; }; orWhere?: { - [K in SocialSlicesSliceSortFields | IndexedRecordFields]?: WhereCondition; + [K in + | NetworkSlicesSliceSortFields + | IndexedRecordFields]?: WhereCondition; }; - sortBy?: SortField[]; + sortBy?: SortField[]; }): Promise { // Combine where and orWhere into the expected backend format const whereClause: any = params?.where ? { ...params.where } : {}; @@ -875,24 +883,24 @@ class SliceSlicesSocialClient extends BaseClient { slice: this.sliceUri, }; return await this.makeRequest( - "social.slices.slice.countRecords", + "network.slices.slice.countRecords", "POST", requestParams ); } async createRecord( - record: SocialSlicesSlice, + record: NetworkSlicesSlice, useSelfRkey?: boolean ): Promise<{ uri: string; cid: string }> { - const recordValue = { $type: "social.slices.slice", ...record }; + const recordValue = { $type: "network.slices.slice", ...record }; const payload = { slice: this.sliceUri, ...(useSelfRkey ? { rkey: "self" } : {}), record: recordValue, }; return await this.makeRequest<{ uri: string; cid: string }>( - "social.slices.slice.createRecord", + "network.slices.slice.createRecord", "POST", payload ); @@ -900,16 +908,16 @@ class SliceSlicesSocialClient extends BaseClient { async updateRecord( rkey: string, - record: SocialSlicesSlice + record: NetworkSlicesSlice ): Promise<{ uri: string; cid: string }> { - const recordValue = { $type: "social.slices.slice", ...record }; + const recordValue = { $type: "network.slices.slice", ...record }; const payload = { slice: this.sliceUri, rkey, record: recordValue, }; return await this.makeRequest<{ uri: string; cid: string }>( - "social.slices.slice.updateRecord", + "network.slices.slice.updateRecord", "POST", payload ); @@ -917,7 +925,7 @@ class SliceSlicesSocialClient extends BaseClient { async deleteRecord(rkey: string): Promise { return await this.makeRequest( - "social.slices.slice.deleteRecord", + "network.slices.slice.deleteRecord", "POST", { rkey } ); @@ -925,7 +933,7 @@ class SliceSlicesSocialClient extends BaseClient { async codegen(request: CodegenXrpcRequest): Promise { return await this.makeRequest( - "social.slices.slice.codegen", + "network.slices.slice.codegen", "POST", request ); @@ -933,7 +941,7 @@ class SliceSlicesSocialClient extends BaseClient { async stats(params: SliceStatsParams): Promise { return await this.makeRequest( - "social.slices.slice.stats", + "network.slices.slice.stats", "POST", params ); @@ -955,7 +963,7 @@ class SliceSlicesSocialClient extends BaseClient { slice: this.sliceUri, }; return await this.makeRequest>( - "social.slices.slice.getSliceRecords", + "network.slices.slice.getSliceRecords", "POST", requestParams ); @@ -964,7 +972,7 @@ class SliceSlicesSocialClient extends BaseClient { async getActors(params?: GetActorsParams): Promise { const requestParams = { ...params, slice: this.sliceUri }; return await this.makeRequest( - "social.slices.slice.getActors", + "network.slices.slice.getActors", "POST", requestParams ); @@ -973,7 +981,7 @@ class SliceSlicesSocialClient extends BaseClient { async startSync(params: BulkSyncParams): Promise { const requestParams = { ...params, slice: this.sliceUri }; return await this.makeRequest( - "social.slices.slice.startSync", + "network.slices.slice.startSync", "POST", requestParams ); @@ -981,7 +989,7 @@ class SliceSlicesSocialClient extends BaseClient { async getJobStatus(params: GetJobStatusParams): Promise { return await this.makeRequest( - "social.slices.slice.getJobStatus", + "network.slices.slice.getJobStatus", "GET", params ); @@ -991,7 +999,7 @@ class SliceSlicesSocialClient extends BaseClient { params: GetJobHistoryParams ): Promise { return await this.makeRequest( - "social.slices.slice.getJobHistory", + "network.slices.slice.getJobHistory", "GET", params ); @@ -999,7 +1007,7 @@ class SliceSlicesSocialClient extends BaseClient { async getJobLogs(params: GetJobLogsParams): Promise { return await this.makeRequest( - "social.slices.slice.getJobLogs", + "network.slices.slice.getJobLogs", "GET", params ); @@ -1007,7 +1015,7 @@ class SliceSlicesSocialClient extends BaseClient { async getJetstreamStatus(): Promise { return await this.makeRequest( - "social.slices.slice.getJetstreamStatus", + "network.slices.slice.getJetstreamStatus", "GET" ); } @@ -1016,7 +1024,7 @@ class SliceSlicesSocialClient extends BaseClient { params: GetJetstreamLogsParams ): Promise { return await this.makeRequest( - "social.slices.slice.getJetstreamLogs", + "network.slices.slice.getJetstreamLogs", "GET", params ); @@ -1027,7 +1035,7 @@ class SliceSlicesSocialClient extends BaseClient { ): Promise { const requestParams = { slice: this.sliceUri, ...params }; return await this.makeRequest( - "social.slices.slice.syncUserCollections", + "network.slices.slice.syncUserCollections", "POST", requestParams ); @@ -1038,7 +1046,7 @@ class SliceSlicesSocialClient extends BaseClient { ): Promise { const requestParams = { ...params, sliceUri: this.sliceUri }; return await this.makeRequest( - "social.slices.slice.createOAuthClient", + "network.slices.slice.createOAuthClient", "POST", requestParams ); @@ -1047,7 +1055,7 @@ class SliceSlicesSocialClient extends BaseClient { async getOAuthClients(): Promise { const requestParams = { slice: this.sliceUri }; return await this.makeRequest( - "social.slices.slice.getOAuthClients", + "network.slices.slice.getOAuthClients", "GET", requestParams ); @@ -1058,7 +1066,7 @@ class SliceSlicesSocialClient extends BaseClient { ): Promise { const requestParams = { ...params, sliceUri: this.sliceUri }; return await this.makeRequest( - "social.slices.slice.updateOAuthClient", + "network.slices.slice.updateOAuthClient", "POST", requestParams ); @@ -1068,14 +1076,14 @@ class SliceSlicesSocialClient extends BaseClient { clientId: string ): Promise { return await this.makeRequest( - "social.slices.slice.deleteOAuthClient", + "network.slices.slice.deleteOAuthClient", "POST", { clientId } ); } } -class LexiconSlicesSocialClient extends BaseClient { +class LexiconSlicesNetworkClient extends BaseClient { private readonly sliceUri: string; constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { @@ -1088,16 +1096,16 @@ class LexiconSlicesSocialClient extends BaseClient { cursor?: string; where?: { [K in - | SocialSlicesLexiconSortFields + | NetworkSlicesLexiconSortFields | IndexedRecordFields]?: WhereCondition; }; orWhere?: { [K in - | SocialSlicesLexiconSortFields + | NetworkSlicesLexiconSortFields | IndexedRecordFields]?: WhereCondition; }; - sortBy?: SortField[]; - }): Promise> { + sortBy?: SortField[]; + }): Promise> { // Combine where and orWhere into the expected backend format const whereClause: any = params?.where ? { ...params.where } : {}; if (params?.orWhere) { @@ -1111,7 +1119,7 @@ class LexiconSlicesSocialClient extends BaseClient { slice: this.sliceUri, }; const result = await this.makeRequest( - "social.slices.lexicon.getRecords", + "network.slices.lexicon.getRecords", "POST", requestParams ); @@ -1121,7 +1129,7 @@ class LexiconSlicesSocialClient extends BaseClient { cid: record.cid, did: record.did, collection: record.collection, - value: record.value as unknown as SocialSlicesLexicon, + value: record.value as unknown as NetworkSlicesLexicon, indexedAt: record.indexedAt, })), cursor: result.cursor, @@ -1130,10 +1138,10 @@ class LexiconSlicesSocialClient extends BaseClient { async getRecord( params: GetRecordParams - ): Promise> { + ): Promise> { const requestParams = { ...params, slice: this.sliceUri }; - return await this.makeRequest>( - "social.slices.lexicon.getRecord", + return await this.makeRequest>( + "network.slices.lexicon.getRecord", "GET", requestParams ); @@ -1144,15 +1152,15 @@ class LexiconSlicesSocialClient extends BaseClient { cursor?: string; where?: { [K in - | SocialSlicesLexiconSortFields + | NetworkSlicesLexiconSortFields | IndexedRecordFields]?: WhereCondition; }; orWhere?: { [K in - | SocialSlicesLexiconSortFields + | NetworkSlicesLexiconSortFields | IndexedRecordFields]?: WhereCondition; }; - sortBy?: SortField[]; + sortBy?: SortField[]; }): Promise { // Combine where and orWhere into the expected backend format const whereClause: any = params?.where ? { ...params.where } : {}; @@ -1167,24 +1175,24 @@ class LexiconSlicesSocialClient extends BaseClient { slice: this.sliceUri, }; return await this.makeRequest( - "social.slices.lexicon.countRecords", + "network.slices.lexicon.countRecords", "POST", requestParams ); } async createRecord( - record: SocialSlicesLexicon, + record: NetworkSlicesLexicon, useSelfRkey?: boolean ): Promise<{ uri: string; cid: string }> { - const recordValue = { $type: "social.slices.lexicon", ...record }; + const recordValue = { $type: "network.slices.lexicon", ...record }; const payload = { slice: this.sliceUri, ...(useSelfRkey ? { rkey: "self" } : {}), record: recordValue, }; return await this.makeRequest<{ uri: string; cid: string }>( - "social.slices.lexicon.createRecord", + "network.slices.lexicon.createRecord", "POST", payload ); @@ -1192,16 +1200,16 @@ class LexiconSlicesSocialClient extends BaseClient { async updateRecord( rkey: string, - record: SocialSlicesLexicon + record: NetworkSlicesLexicon ): Promise<{ uri: string; cid: string }> { - const recordValue = { $type: "social.slices.lexicon", ...record }; + const recordValue = { $type: "network.slices.lexicon", ...record }; const payload = { slice: this.sliceUri, rkey, record: recordValue, }; return await this.makeRequest<{ uri: string; cid: string }>( - "social.slices.lexicon.updateRecord", + "network.slices.lexicon.updateRecord", "POST", payload ); @@ -1209,14 +1217,14 @@ class LexiconSlicesSocialClient extends BaseClient { async deleteRecord(rkey: string): Promise { return await this.makeRequest( - "social.slices.lexicon.deleteRecord", + "network.slices.lexicon.deleteRecord", "POST", { rkey } ); } } -class ProfileActorSlicesSocialClient extends BaseClient { +class ProfileActorSlicesNetworkClient extends BaseClient { private readonly sliceUri: string; constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { @@ -1229,16 +1237,16 @@ class ProfileActorSlicesSocialClient extends BaseClient { cursor?: string; where?: { [K in - | SocialSlicesActorProfileSortFields + | NetworkSlicesActorProfileSortFields | IndexedRecordFields]?: WhereCondition; }; orWhere?: { [K in - | SocialSlicesActorProfileSortFields + | NetworkSlicesActorProfileSortFields | IndexedRecordFields]?: WhereCondition; }; - sortBy?: SortField[]; - }): Promise> { + sortBy?: SortField[]; + }): Promise> { // Combine where and orWhere into the expected backend format const whereClause: any = params?.where ? { ...params.where } : {}; if (params?.orWhere) { @@ -1252,7 +1260,7 @@ class ProfileActorSlicesSocialClient extends BaseClient { slice: this.sliceUri, }; const result = await this.makeRequest( - "social.slices.actor.profile.getRecords", + "network.slices.actor.profile.getRecords", "POST", requestParams ); @@ -1262,7 +1270,7 @@ class ProfileActorSlicesSocialClient extends BaseClient { cid: record.cid, did: record.did, collection: record.collection, - value: record.value as unknown as SocialSlicesActorProfile, + value: record.value as unknown as NetworkSlicesActorProfile, indexedAt: record.indexedAt, })), cursor: result.cursor, @@ -1271,10 +1279,10 @@ class ProfileActorSlicesSocialClient extends BaseClient { async getRecord( params: GetRecordParams - ): Promise> { + ): Promise> { const requestParams = { ...params, slice: this.sliceUri }; - return await this.makeRequest>( - "social.slices.actor.profile.getRecord", + return await this.makeRequest>( + "network.slices.actor.profile.getRecord", "GET", requestParams ); @@ -1285,15 +1293,15 @@ class ProfileActorSlicesSocialClient extends BaseClient { cursor?: string; where?: { [K in - | SocialSlicesActorProfileSortFields + | NetworkSlicesActorProfileSortFields | IndexedRecordFields]?: WhereCondition; }; orWhere?: { [K in - | SocialSlicesActorProfileSortFields + | NetworkSlicesActorProfileSortFields | IndexedRecordFields]?: WhereCondition; }; - sortBy?: SortField[]; + sortBy?: SortField[]; }): Promise { // Combine where and orWhere into the expected backend format const whereClause: any = params?.where ? { ...params.where } : {}; @@ -1308,24 +1316,24 @@ class ProfileActorSlicesSocialClient extends BaseClient { slice: this.sliceUri, }; return await this.makeRequest( - "social.slices.actor.profile.countRecords", + "network.slices.actor.profile.countRecords", "POST", requestParams ); } async createRecord( - record: SocialSlicesActorProfile, + record: NetworkSlicesActorProfile, useSelfRkey?: boolean ): Promise<{ uri: string; cid: string }> { - const recordValue = { $type: "social.slices.actor.profile", ...record }; + const recordValue = { $type: "network.slices.actor.profile", ...record }; const payload = { slice: this.sliceUri, ...(useSelfRkey ? { rkey: "self" } : {}), record: recordValue, }; return await this.makeRequest<{ uri: string; cid: string }>( - "social.slices.actor.profile.createRecord", + "network.slices.actor.profile.createRecord", "POST", payload ); @@ -1333,16 +1341,16 @@ class ProfileActorSlicesSocialClient extends BaseClient { async updateRecord( rkey: string, - record: SocialSlicesActorProfile + record: NetworkSlicesActorProfile ): Promise<{ uri: string; cid: string }> { - const recordValue = { $type: "social.slices.actor.profile", ...record }; + const recordValue = { $type: "network.slices.actor.profile", ...record }; const payload = { slice: this.sliceUri, rkey, record: recordValue, }; return await this.makeRequest<{ uri: string; cid: string }>( - "social.slices.actor.profile.updateRecord", + "network.slices.actor.profile.updateRecord", "POST", payload ); @@ -1350,21 +1358,21 @@ class ProfileActorSlicesSocialClient extends BaseClient { async deleteRecord(rkey: string): Promise { return await this.makeRequest( - "social.slices.actor.profile.deleteRecord", + "network.slices.actor.profile.deleteRecord", "POST", { rkey } ); } } -class ActorSlicesSocialClient extends BaseClient { - readonly profile: ProfileActorSlicesSocialClient; +class ActorSlicesNetworkClient extends BaseClient { + readonly profile: ProfileActorSlicesNetworkClient; private readonly sliceUri: string; constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { super(baseUrl, oauthClient); this.sliceUri = sliceUri; - this.profile = new ProfileActorSlicesSocialClient( + this.profile = new ProfileActorSlicesNetworkClient( baseUrl, sliceUri, oauthClient @@ -1372,39 +1380,39 @@ class ActorSlicesSocialClient extends BaseClient { } } -class SlicesSocialClient extends BaseClient { - readonly slice: SliceSlicesSocialClient; - readonly lexicon: LexiconSlicesSocialClient; - readonly actor: ActorSlicesSocialClient; +class SlicesNetworkClient extends BaseClient { + readonly slice: SliceSlicesNetworkClient; + readonly lexicon: LexiconSlicesNetworkClient; + readonly actor: ActorSlicesNetworkClient; private readonly sliceUri: string; constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { super(baseUrl, oauthClient); this.sliceUri = sliceUri; - this.slice = new SliceSlicesSocialClient(baseUrl, sliceUri, oauthClient); - this.lexicon = new LexiconSlicesSocialClient( + this.slice = new SliceSlicesNetworkClient(baseUrl, sliceUri, oauthClient); + this.lexicon = new LexiconSlicesNetworkClient( baseUrl, sliceUri, oauthClient ); - this.actor = new ActorSlicesSocialClient(baseUrl, sliceUri, oauthClient); + this.actor = new ActorSlicesNetworkClient(baseUrl, sliceUri, oauthClient); } } -class SocialClient extends BaseClient { - readonly slices: SlicesSocialClient; +class NetworkClient extends BaseClient { + readonly slices: SlicesNetworkClient; private readonly sliceUri: string; constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) { super(baseUrl, oauthClient); this.sliceUri = sliceUri; - this.slices = new SlicesSocialClient(baseUrl, sliceUri, oauthClient); + this.slices = new SlicesNetworkClient(baseUrl, sliceUri, oauthClient); } } export class AtProtoClient extends BaseClient { readonly app: AppClient; - readonly social: SocialClient; + readonly network: NetworkClient; readonly oauth?: OAuthClient; private readonly sliceUri: string; @@ -1412,14 +1420,14 @@ export class AtProtoClient extends BaseClient { super(baseUrl, oauthClient); this.sliceUri = sliceUri; this.app = new AppClient(baseUrl, sliceUri, oauthClient); - this.social = new SocialClient(baseUrl, sliceUri, oauthClient); + this.network = new NetworkClient(baseUrl, sliceUri, oauthClient); this.oauth = this.oauthClient; } async getActors(params: GetActorsParams): Promise { const requestParams = { ...params, slice: this.sliceUri }; return await this.makeRequest( - "social.slices.slice.getActors", + "network.slices.slice.getActors", "POST", requestParams ); @@ -1441,7 +1449,7 @@ export class AtProtoClient extends BaseClient { slice: this.sliceUri, }; return await this.makeRequest>( - "social.slices.slice.getSliceRecords", + "network.slices.slice.getSliceRecords", "POST", requestParams ); diff --git a/frontend/src/features/auth/handlers.tsx b/frontend/src/features/auth/handlers.tsx index 8b4fac5..2835c4e 100644 --- a/frontend/src/features/auth/handlers.tsx +++ b/frontend/src/features/auth/handlers.tsx @@ -132,7 +132,7 @@ async function handleOAuthCallback(req: Request): Promise { // If we can't find existing records, sync them if (!profileCheck.records || profileCheck.records.length === 0) { console.log("No existing external collections found, syncing..."); - await atprotoClient.social.slices.slice.syncUserCollections(); + await atprotoClient.network.slices.slice.syncUserCollections(); } else { console.log("External collections already synced, skipping sync"); } diff --git a/frontend/src/features/dashboard/handlers.tsx b/frontend/src/features/dashboard/handlers.tsx index 67ec163..9b491af 100644 --- a/frontend/src/features/dashboard/handlers.tsx +++ b/frontend/src/features/dashboard/handlers.tsx @@ -11,7 +11,10 @@ interface Slice extends SocialSlicesSlice { id: string; } -async function handleProfilePage(req: Request, params?: URLPatternResult): Promise { +async function handleProfilePage( + req: Request, + params?: URLPatternResult +): Promise { const context = await withAuth(req); const authResponse = requireAuth(context); if (authResponse) return authResponse; @@ -22,13 +25,13 @@ async function handleProfilePage(req: Request, params?: URLPatternResult): Promi let profileDid: string; try { const actors = await atprotoClient.getActors({ - where: { handle: { eq: handle } } + where: { handle: { eq: handle } }, }); - + if (actors.actors.length === 0) { return new Response("Profile not found", { status: 404 }); } - + profileDid = actors.actors[0].did; } catch (error) { console.error("Failed to get actor:", error); @@ -38,9 +41,10 @@ async function handleProfilePage(req: Request, params?: URLPatternResult): Promi // Fetch profile record using the DID let _profileRecord; try { - const profileResponse = await atprotoClient.app.bsky.actor.profile.getRecords({ - where: { did: { eq: profileDid } } - }); + const profileResponse = + await atprotoClient.app.bsky.actor.profile.getRecords({ + where: { did: { eq: profileDid } }, + }); _profileRecord = profileResponse.records[0]; } catch (error) { console.error("Failed to fetch profile:", error); @@ -50,7 +54,7 @@ async function handleProfilePage(req: Request, params?: URLPatternResult): Promi try { // Fetch slices for this DID - const sliceRecords = await atprotoClient.social.slices.slice.getRecords({ + const sliceRecords = await atprotoClient.network.slices.slice.getRecords({ where: { did: { eq: profileDid } }, sortBy: [{ field: "createdAt", direction: "desc" }], }); @@ -117,7 +121,7 @@ async function handleCreateSlice(req: Request): Promise { createdAt: new Date().toISOString(), }; - const result = await atprotoClient.social.slices.slice.createRecord( + const result = await atprotoClient.network.slices.slice.createRecord( recordData ); @@ -135,9 +139,7 @@ async function handleCreateSlice(req: Request): Promise { ); } } catch (_error) { - return renderHTML( - - ); + return renderHTML(); } } @@ -165,4 +167,4 @@ export const dashboardRoutes: Route[] = [ pattern: new URLPattern({ pathname: "/dialogs/create-slice" }), handler: handleCreateSliceDialog, }, -]; \ No newline at end of file +]; diff --git a/frontend/src/features/settings/handlers.tsx b/frontend/src/features/settings/handlers.tsx index 7e485eb..e5a0b47 100644 --- a/frontend/src/features/settings/handlers.tsx +++ b/frontend/src/features/settings/handlers.tsx @@ -28,10 +28,10 @@ async function handleSettingsPage(req: Request): Promise { try { const profileRecord = - await atprotoClient.social.slices.actor.profile.getRecord({ + await atprotoClient.network.slices.actor.profile.getRecord({ uri: buildAtUri({ did: context.currentUser.sub!, - collection: "social.slices.actor.profile", + collection: "network.slices.actor.profile", rkey: "self", }), }); @@ -47,8 +47,8 @@ async function handleSettingsPage(req: Request): Promise { } return renderHTML( - { } const existingProfile = - await atprotoClient.social.slices.actor.profile.getRecord({ + await atprotoClient.network.slices.actor.profile.getRecord({ uri: buildAtUri({ did: context.currentUser.sub, - collection: "social.slices.actor.profile", + collection: "network.slices.actor.profile", rkey: "self", }), }); if (existingProfile) { - await atprotoClient.social.slices.actor.profile.updateRecord("self", { + await atprotoClient.network.slices.actor.profile.updateRecord("self", { ...profileData, createdAt: existingProfile.value.createdAt, }); } else { - await atprotoClient.social.slices.actor.profile.createRecord( + await atprotoClient.network.slices.actor.profile.createRecord( profileData, true ); @@ -137,4 +137,4 @@ export const settingsRoutes: Route[] = [ pattern: new URLPattern({ pathname: "/api/profile" }), handler: handleUpdateProfile, }, -]; \ No newline at end of file +]; diff --git a/frontend/src/features/slices/api-docs/handlers.tsx b/frontend/src/features/slices/api-docs/handlers.tsx index cc6af4b..43eb6ca 100644 --- a/frontend/src/features/slices/api-docs/handlers.tsx +++ b/frontend/src/features/slices/api-docs/handlers.tsx @@ -37,11 +37,11 @@ async function handleSliceApiDocsPage( try { const sliceUri = buildAtUri({ did: context.currentUser.sub!, - collection: "social.slices.slice", + collection: "network.slices.slice", rkey: sliceId, }); - const sliceRecord = await atprotoClient.social.slices.slice.getRecord({ + const sliceRecord = await atprotoClient.network.slices.slice.getRecord({ uri: sliceUri, }); @@ -67,4 +67,4 @@ export const apiDocsRoutes: Route[] = [ pattern: new URLPattern({ pathname: "/slices/:id/api-docs" }), handler: handleSliceApiDocsPage, }, -]; \ No newline at end of file +]; diff --git a/frontend/src/features/slices/api-docs/templates/SliceApiDocsPage.tsx b/frontend/src/features/slices/api-docs/templates/SliceApiDocsPage.tsx index 4611d69..ce5f491 100644 --- a/frontend/src/features/slices/api-docs/templates/SliceApiDocsPage.tsx +++ b/frontend/src/features/slices/api-docs/templates/SliceApiDocsPage.tsx @@ -14,8 +14,8 @@ export function SliceApiDocsPage(props: SliceApiDocsPageProps) { const baseUrl = Deno.env.get("API_URL") || "http://localhost:3000"; // Build the slice URI - const sliceUri = `at://${currentUser.sub}/social.slices.slice/${sliceId}`; - const openApiUrl = `${baseUrl}/xrpc/social.slices.slice.openapi?slice=${encodeURIComponent( + const sliceUri = `at://${currentUser.sub}/network.slices.slice/${sliceId}`; + const openApiUrl = `${baseUrl}/xrpc/network.slices.slice.openapi?slice=${encodeURIComponent( sliceUri )}`; @@ -28,38 +28,40 @@ export function SliceApiDocsPage(props: SliceApiDocsPageProps) { - {/* Header with back button */} -
-
-
- - +
+ -
-

API Documentation

-

- Interactive OpenAPI docs for your slice -

+ + + + Back to {sliceName} + +
+
+

+ API Documentation +

+

+ Interactive OpenAPI docs for your slice +

+
-
{/* Info bar */}
@@ -85,16 +87,16 @@ export function SliceApiDocsPage(props: SliceApiDocsPageProps) {
- {/* Load Scalar API Reference */} - + {/* Load Scalar API Reference */} + - {/* Initialize Scalar when the script loads */} -