diff --git a/.github/workflows/release-prod.yml b/.github/workflows/release-prod.yml index 2ec99863c..72c7a5c37 100644 --- a/.github/workflows/release-prod.yml +++ b/.github/workflows/release-prod.yml @@ -126,6 +126,17 @@ jobs: echo "Updated version.ts with version v$VERSION" cat packages/shared/src/version.ts + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.x' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Generate OpenAPI docs + run: yarn openapi:generate + - name: Configure git run: | git config user.name "github-actions[bot]" @@ -133,7 +144,7 @@ jobs: - name: Commit changes run: | - git add CHANGELOG.md packages/shared/src/version.ts + git add CHANGELOG.md packages/shared/src/version.ts docs/api-reference/sourcebot-public.openapi.json git commit -m "[skip ci] Release v$VERSION" - name: Push to temporary branch diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f300ad81..d6efbcb9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Require explicit invocation of ask_codebase tool in MCP [#995](https://github.com/sourcebot-dev/sourcebot/pull/995) - Gate MCP API behind authentication when Ask GitHub is enabled. [#994](https://github.com/sourcebot-dev/sourcebot/pull/994) +- Added generated OpenAPI documentation for the public search, repo, and file browsing API surface. [#101](https://github.com/sourcebot-dev/sourcebot/issues/101) ## [4.15.4] - 2026-03-11 diff --git a/Dockerfile b/Dockerfile index 742143735..66bc3489c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -220,6 +220,9 @@ RUN chown -R sourcebot:sourcebot /app # Copy zoekt proto files (needed for gRPC client at runtime) COPY --chown=sourcebot:sourcebot vendor/zoekt/grpc/protos /app/vendor/zoekt/grpc/protos +# Copy OpenAPI docs +COPY --chown=sourcebot:sourcebot docs/api-reference/sourcebot-public.openapi.json /app/docs/api-reference/sourcebot-public.openapi.json + # Copy all of the things COPY --chown=sourcebot:sourcebot --from=web-builder /app/packages/web/public ./packages/web/public COPY --chown=sourcebot:sourcebot --from=web-builder /app/packages/web/.next/standalone ./ diff --git a/docs/api-reference/sourcebot-public.openapi.json b/docs/api-reference/sourcebot-public.openapi.json new file mode 100644 index 000000000..1aa7dcbf7 --- /dev/null +++ b/docs/api-reference/sourcebot-public.openapi.json @@ -0,0 +1,1105 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Sourcebot Public API", + "version": "v4.15.5", + "description": "OpenAPI description for the public Sourcebot REST endpoints used for search, repository listing, and file browsing." + }, + "tags": [ + { + "name": "Search", + "description": "Code search endpoints." + }, + { + "name": "Repositories", + "description": "Repository listing and metadata endpoints." + }, + { + "name": "Files", + "description": "File tree, file listing, and file content endpoints." + }, + { + "name": "Misc", + "description": "Miscellaneous public API endpoints." + } + ], + "components": { + "schemas": { + "PublicSearchResponse": { + "type": "object", + "properties": { + "stats": { + "type": "object", + "properties": { + "actualMatchCount": { + "type": "number" + }, + "totalMatchCount": { + "type": "number" + }, + "duration": { + "type": "number" + }, + "fileCount": { + "type": "number" + }, + "filesSkipped": { + "type": "number" + }, + "contentBytesLoaded": { + "type": "number" + }, + "indexBytesLoaded": { + "type": "number" + }, + "crashes": { + "type": "number" + }, + "shardFilesConsidered": { + "type": "number" + }, + "filesConsidered": { + "type": "number" + }, + "filesLoaded": { + "type": "number" + }, + "shardsScanned": { + "type": "number" + }, + "shardsSkipped": { + "type": "number" + }, + "shardsSkippedFilter": { + "type": "number" + }, + "ngramMatches": { + "type": "number" + }, + "ngramLookups": { + "type": "number" + }, + "wait": { + "type": "number" + }, + "matchTreeConstruction": { + "type": "number" + }, + "matchTreeSearch": { + "type": "number" + }, + "regexpsConsidered": { + "type": "number" + }, + "flushReason": { + "type": "string" + } + }, + "required": [ + "actualMatchCount", + "totalMatchCount", + "duration", + "fileCount", + "filesSkipped", + "contentBytesLoaded", + "indexBytesLoaded", + "crashes", + "shardFilesConsidered", + "filesConsidered", + "filesLoaded", + "shardsScanned", + "shardsSkipped", + "shardsSkippedFilter", + "ngramMatches", + "ngramLookups", + "wait", + "matchTreeConstruction", + "matchTreeSearch", + "regexpsConsidered", + "flushReason" + ] + }, + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "fileName": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "matchRanges": { + "type": "array", + "items": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "byteOffset": { + "type": "number" + }, + "lineNumber": { + "type": "number" + }, + "column": { + "type": "number" + } + }, + "required": [ + "byteOffset", + "lineNumber", + "column" + ] + }, + "end": { + "type": "object", + "properties": { + "byteOffset": { + "type": "number" + }, + "lineNumber": { + "type": "number" + }, + "column": { + "type": "number" + } + }, + "required": [ + "byteOffset", + "lineNumber", + "column" + ] + } + }, + "required": [ + "start", + "end" + ] + } + } + }, + "required": [ + "text", + "matchRanges" + ] + }, + "webUrl": { + "type": "string" + }, + "externalWebUrl": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "repositoryId": { + "type": "number" + }, + "language": { + "type": "string" + }, + "chunks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "matchRanges": { + "type": "array", + "items": { + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "byteOffset": { + "type": "number" + }, + "lineNumber": { + "type": "number" + }, + "column": { + "type": "number" + } + }, + "required": [ + "byteOffset", + "lineNumber", + "column" + ] + }, + "end": { + "type": "object", + "properties": { + "byteOffset": { + "type": "number" + }, + "lineNumber": { + "type": "number" + }, + "column": { + "type": "number" + } + }, + "required": [ + "byteOffset", + "lineNumber", + "column" + ] + } + }, + "required": [ + "start", + "end" + ] + } + }, + "contentStart": { + "type": "object", + "properties": { + "byteOffset": { + "type": "number" + }, + "lineNumber": { + "type": "number" + }, + "column": { + "type": "number" + } + }, + "required": [ + "byteOffset", + "lineNumber", + "column" + ] + }, + "symbols": { + "type": "array", + "items": { + "type": "object", + "properties": { + "symbol": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "parent": { + "type": "object", + "properties": { + "symbol": { + "type": "string" + }, + "kind": { + "type": "string" + } + }, + "required": [ + "symbol", + "kind" + ] + } + }, + "required": [ + "symbol", + "kind" + ] + } + } + }, + "required": [ + "content", + "matchRanges", + "contentStart" + ] + } + }, + "branches": { + "type": "array", + "items": { + "type": "string" + } + }, + "content": { + "type": "string" + } + }, + "required": [ + "fileName", + "webUrl", + "repository", + "repositoryId", + "language", + "chunks" + ] + } + }, + "repositoryInfo": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "number" + }, + "codeHostType": { + "type": "string", + "enum": [ + "github", + "gitlab", + "gitea", + "gerrit", + "bitbucketServer", + "bitbucketCloud", + "genericGitHost", + "azuredevops" + ] + }, + "name": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "webUrl": { + "type": "string" + } + }, + "required": [ + "id", + "codeHostType", + "name" + ] + } + }, + "isSearchExhaustive": { + "type": "boolean" + } + }, + "required": [ + "stats", + "files", + "repositoryInfo", + "isSearchExhaustive" + ] + }, + "PublicApiServiceError": { + "type": "object", + "properties": { + "statusCode": { + "type": "number" + }, + "errorCode": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "statusCode", + "errorCode", + "message" + ], + "description": "Structured error response returned by Sourcebot public API endpoints." + }, + "PublicSearchRequest": { + "type": "object", + "properties": { + "query": { + "type": "string" + }, + "matches": { + "type": "number" + }, + "contextLines": { + "type": "number" + }, + "whole": { + "type": "boolean" + }, + "isRegexEnabled": { + "type": "boolean" + }, + "isCaseSensitivityEnabled": { + "type": "boolean" + } + }, + "required": [ + "query", + "matches" + ] + }, + "PublicStreamSearchSse": { + "type": "string", + "description": "Server-sent event stream. Each data frame contains one JSON object representing either a chunk update, a final summary, or an error.", + "example": "data: {\"type\":\"chunk\",\"stats\":{\"actualMatchCount\":1}}\n\n" + }, + "PublicRepository": { + "type": "object", + "properties": { + "codeHostType": { + "type": "string", + "enum": [ + "github", + "gitlab", + "gitea", + "gerrit", + "bitbucketServer", + "bitbucketCloud", + "genericGitHost", + "azuredevops" + ] + }, + "repoId": { + "type": "number" + }, + "repoName": { + "type": "string" + }, + "webUrl": { + "type": "string" + }, + "repoDisplayName": { + "type": "string" + }, + "externalWebUrl": { + "type": "string" + }, + "imageUrl": { + "type": "string" + }, + "indexedAt": { + "type": "string", + "format": "date-time" + }, + "pushedAt": { + "type": "string", + "format": "date-time" + }, + "defaultBranch": { + "type": "string" + }, + "isFork": { + "type": "boolean" + }, + "isArchived": { + "type": "boolean" + } + }, + "required": [ + "codeHostType", + "repoId", + "repoName", + "webUrl", + "isFork", + "isArchived" + ] + }, + "PublicListReposResponse": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicRepository" + } + }, + "PublicVersionResponse": { + "type": "object", + "properties": { + "version": { + "type": "string", + "description": "Running Sourcebot version.", + "example": "v4.15.2" + } + }, + "required": [ + "version" + ] + }, + "PublicFileSourceResponse": { + "type": "object", + "properties": { + "source": { + "type": "string" + }, + "language": { + "type": "string" + }, + "path": { + "type": "string" + }, + "repo": { + "type": "string" + }, + "repoCodeHostType": { + "type": "string", + "enum": [ + "github", + "gitlab", + "gitea", + "gerrit", + "bitbucketServer", + "bitbucketCloud", + "genericGitHost", + "azuredevops" + ] + }, + "repoDisplayName": { + "type": "string" + }, + "repoExternalWebUrl": { + "type": "string" + }, + "webUrl": { + "type": "string" + }, + "externalWebUrl": { + "type": "string" + } + }, + "required": [ + "source", + "language", + "path", + "repo", + "repoCodeHostType", + "webUrl" + ] + }, + "PublicGetTreeRequest": { + "type": "object", + "properties": { + "repoName": { + "type": "string" + }, + "revisionName": { + "type": "string" + }, + "paths": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "repoName", + "revisionName", + "paths" + ] + }, + "PublicGetFilesResponse": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "path": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "type", + "path", + "name" + ] + } + }, + "PublicGetFilesRequest": { + "type": "object", + "properties": { + "repoName": { + "type": "string" + }, + "revisionName": { + "type": "string" + } + }, + "required": [ + "repoName", + "revisionName" + ] + }, + "PublicFileTreeNode": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "path": { + "type": "string" + }, + "name": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicFileTreeNode" + } + } + }, + "required": [ + "type", + "path", + "name", + "children" + ], + "additionalProperties": false + } + }, + "parameters": {} + }, + "paths": { + "/api/search": { + "post": { + "tags": [ + "Search" + ], + "summary": "Run a blocking code search", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicSearchRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Search results.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicSearchResponse" + } + } + } + }, + "400": { + "description": "Invalid request body.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + }, + "500": { + "description": "Unexpected search failure.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + } + } + } + }, + "/api/stream_search": { + "post": { + "tags": [ + "Search" + ], + "summary": "Run a streaming code search", + "description": "Returns a server-sent event stream. Each event data payload is a JSON object describing either a chunk, final summary, or error.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicSearchRequest" + } + } + } + }, + "responses": { + "200": { + "description": "SSE stream of search results.", + "content": { + "text/event-stream": { + "schema": { + "$ref": "#/components/schemas/PublicStreamSearchSse" + } + } + } + }, + "400": { + "description": "Invalid request body.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + }, + "500": { + "description": "Unexpected search failure.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + } + } + } + }, + "/api/repos": { + "get": { + "tags": [ + "Repositories" + ], + "summary": "List repositories", + "parameters": [ + { + "schema": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "default": 1 + }, + "required": false, + "name": "page", + "in": "query" + }, + { + "schema": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "maximum": 100, + "default": 30 + }, + "required": false, + "name": "perPage", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "name", + "pushed" + ], + "default": "name" + }, + "required": false, + "name": "sort", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "default": "asc" + }, + "required": false, + "name": "direction", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "query", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Paginated repository list.", + "headers": { + "X-Total-Count": { + "description": "Total number of repositories matching the query across all pages.", + "schema": { + "type": "integer", + "example": 137 + } + }, + "Link": { + "description": "Pagination links formatted per RFC 8288. Includes `rel=\"next\"`, `rel=\"prev\"`, `rel=\"first\"`, and `rel=\"last\"` when applicable.", + "schema": { + "type": "string", + "example": "; rel=\"next\", ; rel=\"last\"" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicListReposResponse" + } + } + } + }, + "400": { + "description": "Invalid query parameters.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + }, + "500": { + "description": "Unexpected repository listing failure.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + } + } + } + }, + "/api/version": { + "get": { + "tags": [ + "Misc" + ], + "summary": "Get Sourcebot version", + "responses": { + "200": { + "description": "Current Sourcebot version.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicVersionResponse" + } + } + } + } + } + } + }, + "/api/source": { + "get": { + "tags": [ + "Files" + ], + "summary": "Get file contents", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "path", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "query" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "ref", + "in": "query" + } + ], + "responses": { + "200": { + "description": "File source and metadata.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicFileSourceResponse" + } + } + } + }, + "400": { + "description": "Invalid query parameters or git ref.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + }, + "404": { + "description": "Repository or file not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + }, + "500": { + "description": "Unexpected file retrieval failure.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + } + } + } + }, + "/api/tree": { + "post": { + "tags": [ + "Files" + ], + "summary": "Get a file tree", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicGetTreeRequest" + } + } + } + }, + "responses": { + "200": { + "description": "File tree for the requested repository revision.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tree": { + "$ref": "#/components/schemas/PublicFileTreeNode" + } + }, + "required": [ + "tree" + ], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "Invalid request body or git ref.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + }, + "404": { + "description": "Repository or path not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + }, + "500": { + "description": "Unexpected tree retrieval failure.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + } + } + } + }, + "/api/files": { + "post": { + "tags": [ + "Files" + ], + "summary": "List files in a repository revision", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicGetFilesRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Flat list of files in the requested repository revision.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicGetFilesResponse" + } + } + } + }, + "400": { + "description": "Invalid request body.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + }, + "404": { + "description": "Repository not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + }, + "500": { + "description": "Unexpected file listing failure.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicApiServiceError" + } + } + } + } + } + } + } + } +} diff --git a/docs/docs.json b/docs/docs.json index 0b0a90eb5..853465f31 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -66,6 +66,17 @@ } ] }, + { + "group": "API Reference", + "pages": [ + "docs/api-reference/overview", + { + "group": "Public API", + "openapi": "api-reference/sourcebot-public.openapi.json", + "directory": "docs/api-reference/public-api" + } + ] + }, { "group": "Configuration", "pages": [ @@ -160,4 +171,4 @@ "default": "dark", "strict": false } -} \ No newline at end of file +} diff --git a/docs/docs/api-reference/overview.mdx b/docs/docs/api-reference/overview.mdx new file mode 100644 index 000000000..994ea3174 --- /dev/null +++ b/docs/docs/api-reference/overview.mdx @@ -0,0 +1,24 @@ +--- +title: API Reference +sidebarTitle: API Reference +--- + +You can fetch the OpenAPI document for your Sourcebot instance from `/api/openapi.json`. + +This API reference is generated from the web app's Zod schemas and OpenAPI registry. Mintlify renders it from the checked-in spec at [`/api-reference/sourcebot-public.openapi.json`](/api-reference/sourcebot-public.openapi.json). + +The first documented endpoints include: + +- `/api/search` +- `/api/stream_search` +- `/api/repos` +- `/api/version` +- `/api/source` +- `/api/tree` +- `/api/files` + +To refresh the spec after you change those contracts: + +```bash +yarn openapi:generate +``` diff --git a/package.json b/package.json index 086bf23fa..f5b4cf4aa 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "dev:prisma:migrate:reset": "yarn with-env yarn workspace @sourcebot/db prisma:migrate:reset", "dev:prisma:db:push": "yarn with-env yarn workspace @sourcebot/db prisma:db:push", "build:deps": "yarn workspaces foreach --recursive --topological --from '{@sourcebot/schemas,@sourcebot/db,@sourcebot/shared,@sourcebot/query-language}' run build", + "openapi:generate": "yarn workspace @sourcebot/web openapi:generate", "tool:decrypt-jwe": "yarn with-env yarn workspace @sourcebot/web tool:decrypt-jwe" }, "devDependencies": { diff --git a/packages/backend/src/api.ts b/packages/backend/src/api.ts index be602c6fb..0df9ee20a 100644 --- a/packages/backend/src/api.ts +++ b/packages/backend/src/api.ts @@ -3,7 +3,6 @@ import { createLogger, env, hasEntitlement, PERMISSION_SYNC_SUPPORTED_IDENTITY_P import express, { Request, Response } from 'express'; import 'express-async-errors'; import * as http from "http"; -import z from 'zod'; import { ConnectionManager } from './connectionManager.js'; import { AccountPermissionSyncer } from './ee/accountPermissionSyncer.js'; import { PromClient } from './promClient.js'; @@ -11,6 +10,7 @@ import { RepoIndexManager } from './repoIndexManager.js'; import { createGitHubRepoRecord } from './repoCompileUtils.js'; import { Octokit } from '@octokit/rest'; import { SINGLE_TENANT_ORG_ID } from './constants.js'; +import z from 'zod'; const logger = createLogger('api'); const PORT = 3060; @@ -183,4 +183,4 @@ export class Api { }); }); } -} \ No newline at end of file +} diff --git a/packages/web/package.json b/packages/web/package.json index 468df87a4..040577ce6 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -8,6 +8,7 @@ "start": "next start", "lint": "cross-env SKIP_ENV_VALIDATION=1 eslint .", "test": "cross-env SKIP_ENV_VALIDATION=1 vitest", + "openapi:generate": "tsx tools/generateOpenApi.ts", "generate:protos": "proto-loader-gen-types --includeComments --longs=Number --enums=String --defaults --oneofs --grpcLib=@grpc/grpc-js --keepCase --includeDirs=../../vendor/zoekt/grpc/protos --outDir=src/proto zoekt/webserver/v1/webserver.proto zoekt/webserver/v1/query.proto", "dev:emails": "email dev --dir ./src/emails", "stripe:listen": "stripe listen --forward-to http://localhost:3000/api/stripe", @@ -194,6 +195,7 @@ "zod-to-json-schema": "^3.24.5" }, "devDependencies": { + "@asteasolutions/zod-to-openapi": "7.3.4", "@eslint/eslintrc": "^3", "@react-email/preview-server": "5.2.8", "@react-grab/mcp": "^0.1.23", diff --git a/packages/web/src/app/api/(server)/openapi.json/route.ts b/packages/web/src/app/api/(server)/openapi.json/route.ts new file mode 100644 index 000000000..6d44fd0e6 --- /dev/null +++ b/packages/web/src/app/api/(server)/openapi.json/route.ts @@ -0,0 +1,20 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { apiHandler } from '@/lib/apiHandler'; + +export const dynamic = 'force-dynamic'; + +async function loadOpenApiDocument() { + const openApiPath = path.resolve(process.cwd(), '../../docs/api-reference/sourcebot-public.openapi.json'); + return JSON.parse(await fs.readFile(openApiPath, 'utf8')); +} + +export const GET = apiHandler(async () => { + const document = await loadOpenApiDocument(); + + return Response.json(document, { + headers: { + 'Content-Type': 'application/vnd.oai.openapi+json;version=3.0.3', + }, + }); +}, { track: false }); diff --git a/packages/web/src/app/api/(server)/source/route.ts b/packages/web/src/app/api/(server)/source/route.ts index 4e629aa09..7c1cf7809 100644 --- a/packages/web/src/app/api/(server)/source/route.ts +++ b/packages/web/src/app/api/(server)/source/route.ts @@ -1,26 +1,20 @@ 'use server'; import { getFileSource } from '@/features/git'; +import { fileSourceRequestSchema } from '@/features/git/schemas'; import { apiHandler } from "@/lib/apiHandler"; import { queryParamsSchemaValidationError, serviceErrorResponse } from "@/lib/serviceError"; import { isServiceError } from "@/lib/utils"; import { NextRequest } from "next/server"; -import { z } from "zod"; - -const querySchema = z.object({ - repo: z.string(), - path: z.string(), - ref: z.string().optional(), -}); export const GET = apiHandler(async (request: NextRequest) => { const rawParams = Object.fromEntries( - Object.keys(querySchema.shape).map(key => [ + Object.keys(fileSourceRequestSchema.shape).map(key => [ key, request.nextUrl.searchParams.get(key) ?? undefined ]) ); - const parsed = querySchema.safeParse(rawParams); + const parsed = fileSourceRequestSchema.safeParse(rawParams); if (!parsed.success) { return serviceErrorResponse( diff --git a/packages/web/src/features/git/getFileSourceApi.ts b/packages/web/src/features/git/getFileSourceApi.ts index e5b425475..03ecbaef2 100644 --- a/packages/web/src/features/git/getFileSourceApi.ts +++ b/packages/web/src/features/git/getFileSourceApi.ts @@ -9,28 +9,12 @@ import { withOptionalAuthV2 } from '@/withAuthV2'; import { getRepoPath } from '@sourcebot/shared'; import { headers } from 'next/headers'; import simpleGit from 'simple-git'; -import z from 'zod'; +import type z from 'zod'; import { isGitRefValid, isPathValid } from './utils'; -import { CodeHostType } from '@sourcebot/db'; +import { fileSourceRequestSchema, fileSourceResponseSchema } from './schemas'; -export const fileSourceRequestSchema = z.object({ - path: z.string(), - repo: z.string(), - ref: z.string().optional(), -}); +export { fileSourceRequestSchema, fileSourceResponseSchema } from './schemas'; export type FileSourceRequest = z.infer; - -export const fileSourceResponseSchema = z.object({ - source: z.string(), - language: z.string(), - path: z.string(), - repo: z.string(), - repoCodeHostType: z.nativeEnum(CodeHostType), - repoDisplayName: z.string().optional(), - repoExternalWebUrl: z.string().optional(), - webUrl: z.string(), - externalWebUrl: z.string().optional(), -}); export type FileSourceResponse = z.infer; export const getFileSource = async ({ path: filePath, repo: repoName, ref }: FileSourceRequest, { source }: { source?: string } = {}): Promise => sew(() => withOptionalAuthV2(async ({ org, prisma, user }) => { diff --git a/packages/web/src/features/git/getFilesApi.ts b/packages/web/src/features/git/getFilesApi.ts index 344fc8b51..7c6e4b4a9 100644 --- a/packages/web/src/features/git/getFilesApi.ts +++ b/packages/web/src/features/git/getFilesApi.ts @@ -1,19 +1,15 @@ import { sew } from '@/actions'; -import { FileTreeItem, fileTreeItemSchema } from "./types"; +import { FileTreeItem } from "./types"; import { notFound, ServiceError, unexpectedError } from '@/lib/serviceError'; import { withOptionalAuthV2 } from "@/withAuthV2"; import { getRepoPath } from '@sourcebot/shared'; import simpleGit from 'simple-git'; -import z from 'zod'; +import type z from 'zod'; +import { getFilesRequestSchema, getFilesResponseSchema } from './schemas'; import { logger } from './utils'; -export const getFilesRequestSchema = z.object({ - repoName: z.string(), - revisionName: z.string(), -}); +export { getFilesRequestSchema, getFilesResponseSchema } from './schemas'; export type GetFilesRequest = z.infer; - -export const getFilesResponseSchema = z.array(fileTreeItemSchema); export type GetFilesResponse = z.infer; export const getFiles = async ({ repoName, revisionName }: GetFilesRequest): Promise => sew(() => diff --git a/packages/web/src/features/git/getTreeApi.ts b/packages/web/src/features/git/getTreeApi.ts index 5a634f405..887379d19 100644 --- a/packages/web/src/features/git/getTreeApi.ts +++ b/packages/web/src/features/git/getTreeApi.ts @@ -5,20 +5,12 @@ import { withOptionalAuthV2 } from "@/withAuthV2"; import { getRepoPath } from '@sourcebot/shared'; import { headers } from 'next/headers'; import simpleGit from 'simple-git'; -import z from 'zod'; -import { fileTreeNodeSchema } from './types'; +import type z from 'zod'; +import { getTreeRequestSchema, getTreeResponseSchema } from './schemas'; import { buildFileTree, isGitRefValid, isPathValid, logger, normalizePath } from './utils'; -export const getTreeRequestSchema = z.object({ - repoName: z.string(), - revisionName: z.string(), - paths: z.array(z.string()), -}); +export { getTreeRequestSchema, getTreeResponseSchema } from './schemas'; export type GetTreeRequest = z.infer; - -export const getTreeResponseSchema = z.object({ - tree: fileTreeNodeSchema, -}); export type GetTreeResponse = z.infer; /** diff --git a/packages/web/src/features/git/schemas.ts b/packages/web/src/features/git/schemas.ts new file mode 100644 index 000000000..794ce148b --- /dev/null +++ b/packages/web/src/features/git/schemas.ts @@ -0,0 +1,38 @@ +import { CodeHostType } from '@sourcebot/db'; +import z from 'zod'; +import { fileTreeItemSchema, fileTreeNodeSchema } from './types'; + +export const getTreeRequestSchema = z.object({ + repoName: z.string(), + revisionName: z.string(), + paths: z.array(z.string()), +}); + +export const getTreeResponseSchema = z.object({ + tree: fileTreeNodeSchema, +}); + +export const getFilesRequestSchema = z.object({ + repoName: z.string(), + revisionName: z.string(), +}); + +export const getFilesResponseSchema = z.array(fileTreeItemSchema); + +export const fileSourceRequestSchema = z.object({ + path: z.string(), + repo: z.string(), + ref: z.string().optional(), +}); + +export const fileSourceResponseSchema = z.object({ + source: z.string(), + language: z.string(), + path: z.string(), + repo: z.string(), + repoCodeHostType: z.nativeEnum(CodeHostType), + repoDisplayName: z.string().optional(), + repoExternalWebUrl: z.string().optional(), + webUrl: z.string(), + externalWebUrl: z.string().optional(), +}); diff --git a/packages/web/src/openapi/publicApiDocument.ts b/packages/web/src/openapi/publicApiDocument.ts new file mode 100644 index 000000000..c3a1f71db --- /dev/null +++ b/packages/web/src/openapi/publicApiDocument.ts @@ -0,0 +1,242 @@ +import { OpenAPIRegistry, OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi'; +import type { ZodTypeAny } from 'zod'; +import type { SchemaObject } from 'openapi3-ts/oas30'; +import { + publicFileSourceRequestSchema, + publicFileSourceResponseSchema, + publicGetFilesRequestSchema, + publicGetFilesResponseSchema, + publicGetTreeRequestSchema, + publicListReposQuerySchema, + publicListReposResponseSchema, + publicSearchRequestSchema, + publicSearchResponseSchema, + publicServiceErrorSchema, + publicStreamSearchSseSchema, + publicVersionResponseSchema, +} from './publicApiSchemas.js'; + +const searchTag = { name: 'Search', description: 'Code search endpoints.' }; +const reposTag = { name: 'Repositories', description: 'Repository listing and metadata endpoints.' }; +const filesTag = { name: 'Files', description: 'File tree, file listing, and file content endpoints.' }; +const miscTag = { name: 'Misc', description: 'Miscellaneous public API endpoints.' }; + +const publicFileTreeNodeSchema: SchemaObject = { + type: 'object', + properties: { + type: { type: 'string' }, + path: { type: 'string' }, + name: { type: 'string' }, + children: { + type: 'array', + items: { $ref: '#/components/schemas/PublicFileTreeNode' }, + }, + }, + required: ['type', 'path', 'name', 'children'], + additionalProperties: false, +}; + +const publicGetTreeResponseSchema: SchemaObject = { + type: 'object', + properties: { + tree: { $ref: '#/components/schemas/PublicFileTreeNode' }, + }, + required: ['tree'], + additionalProperties: false, +}; + +function jsonContent(schema: ZodTypeAny | SchemaObject) { + return { + 'application/json': { + schema, + }, + }; +} + +function errorJson(description: string) { + return { + description, + content: jsonContent(publicServiceErrorSchema), + }; +} + +export function createPublicOpenApiDocument(version: string) { + const registry = new OpenAPIRegistry(); + + registry.registerPath({ + method: 'post', + path: '/api/search', + tags: [searchTag.name], + summary: 'Run a blocking code search', + request: { + body: { + required: true, + content: jsonContent(publicSearchRequestSchema), + }, + }, + responses: { + 200: { + description: 'Search results.', + content: jsonContent(publicSearchResponseSchema), + }, + 400: errorJson('Invalid request body.'), + 500: errorJson('Unexpected search failure.'), + }, + }); + + registry.registerPath({ + method: 'post', + path: '/api/stream_search', + tags: [searchTag.name], + summary: 'Run a streaming code search', + description: 'Returns a server-sent event stream. Each event data payload is a JSON object describing either a chunk, final summary, or error.', + request: { + body: { + required: true, + content: jsonContent(publicSearchRequestSchema), + }, + }, + responses: { + 200: { + description: 'SSE stream of search results.', + content: { + 'text/event-stream': { + schema: publicStreamSearchSseSchema, + }, + }, + }, + 400: errorJson('Invalid request body.'), + 500: errorJson('Unexpected search failure.'), + }, + }); + + registry.registerPath({ + method: 'get', + path: '/api/repos', + tags: [reposTag.name], + summary: 'List repositories', + request: { + query: publicListReposQuerySchema, + }, + responses: { + 200: { + description: 'Paginated repository list.', + headers: { + 'X-Total-Count': { + description: 'Total number of repositories matching the query across all pages.', + schema: { + type: 'integer', + example: 137, + }, + }, + Link: { + description: 'Pagination links formatted per RFC 8288. Includes `rel=\"next\"`, `rel=\"prev\"`, `rel=\"first\"`, and `rel=\"last\"` when applicable.', + schema: { + type: 'string', + example: '; rel="next", ; rel="last"', + }, + }, + }, + content: jsonContent(publicListReposResponseSchema), + }, + 400: errorJson('Invalid query parameters.'), + 500: errorJson('Unexpected repository listing failure.'), + }, + }); + + registry.registerPath({ + method: 'get', + path: '/api/version', + tags: [miscTag.name], + summary: 'Get Sourcebot version', + responses: { + 200: { + description: 'Current Sourcebot version.', + content: jsonContent(publicVersionResponseSchema), + }, + }, + }); + + registry.registerPath({ + method: 'get', + path: '/api/source', + tags: [filesTag.name], + summary: 'Get file contents', + request: { + query: publicFileSourceRequestSchema, + }, + responses: { + 200: { + description: 'File source and metadata.', + content: jsonContent(publicFileSourceResponseSchema), + }, + 400: errorJson('Invalid query parameters or git ref.'), + 404: errorJson('Repository or file not found.'), + 500: errorJson('Unexpected file retrieval failure.'), + }, + }); + + registry.registerPath({ + method: 'post', + path: '/api/tree', + tags: [filesTag.name], + summary: 'Get a file tree', + request: { + body: { + required: true, + content: jsonContent(publicGetTreeRequestSchema), + }, + }, + responses: { + 200: { + description: 'File tree for the requested repository revision.', + content: jsonContent(publicGetTreeResponseSchema), + }, + 400: errorJson('Invalid request body or git ref.'), + 404: errorJson('Repository or path not found.'), + 500: errorJson('Unexpected tree retrieval failure.'), + }, + }); + + registry.registerPath({ + method: 'post', + path: '/api/files', + tags: [filesTag.name], + summary: 'List files in a repository revision', + request: { + body: { + required: true, + content: jsonContent(publicGetFilesRequestSchema), + }, + }, + responses: { + 200: { + description: 'Flat list of files in the requested repository revision.', + content: jsonContent(publicGetFilesResponseSchema), + }, + 400: errorJson('Invalid request body.'), + 404: errorJson('Repository not found.'), + 500: errorJson('Unexpected file listing failure.'), + }, + }); + + const generator = new OpenApiGeneratorV3(registry.definitions); + + const document = generator.generateDocument({ + openapi: '3.0.3', + info: { + title: 'Sourcebot Public API', + version, + description: 'OpenAPI description for the public Sourcebot REST endpoints used for search, repository listing, and file browsing.', + }, + tags: [searchTag, reposTag, filesTag, miscTag], + }); + + document.components = document.components ?? {}; + document.components.schemas = { + ...(document.components.schemas ?? {}), + PublicFileTreeNode: publicFileTreeNodeSchema, + }; + + return document; +} diff --git a/packages/web/src/openapi/publicApiSchemas.ts b/packages/web/src/openapi/publicApiSchemas.ts new file mode 100644 index 000000000..bfcb60292 --- /dev/null +++ b/packages/web/src/openapi/publicApiSchemas.ts @@ -0,0 +1,76 @@ +import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; +import { CodeHostType } from '@sourcebot/db'; +import z from 'zod'; +import { + fileSourceRequestSchema, + fileSourceResponseSchema, + getFilesRequestSchema, + getFilesResponseSchema, + getTreeRequestSchema, +} from '../features/git/schemas.js'; +import { + searchRequestSchema, + searchResponseSchema, + streamedSearchResponseSchema, +} from '../features/search/types.js'; +import { serviceErrorSchema } from '../lib/serviceError.js'; + +let hasExtendedZod = false; + +if (!hasExtendedZod) { + extendZodWithOpenApi(z); + hasExtendedZod = true; +} + +export const publicServiceErrorSchema = serviceErrorSchema.openapi('PublicApiServiceError', { + description: 'Structured error response returned by Sourcebot public API endpoints.', +}); + +export const publicSearchRequestSchema = searchRequestSchema.openapi('PublicSearchRequest'); +export const publicSearchResponseSchema = searchResponseSchema.openapi('PublicSearchResponse'); +export const publicStreamedSearchEventSchema = streamedSearchResponseSchema.openapi('PublicStreamedSearchEvent'); + +export const publicGetTreeRequestSchema = getTreeRequestSchema.openapi('PublicGetTreeRequest'); + +export const publicGetFilesRequestSchema = getFilesRequestSchema.openapi('PublicGetFilesRequest'); +export const publicGetFilesResponseSchema = getFilesResponseSchema.openapi('PublicGetFilesResponse'); + +export const publicFileSourceRequestSchema = fileSourceRequestSchema.openapi('PublicFileSourceRequest'); +export const publicFileSourceResponseSchema = fileSourceResponseSchema.openapi('PublicFileSourceResponse'); + +export const publicVersionResponseSchema = z.object({ + version: z.string().openapi({ + description: 'Running Sourcebot version.', + example: 'v4.15.2', + }), +}).openapi('PublicVersionResponse'); + +export const publicRepositorySchema = z.object({ + codeHostType: z.nativeEnum(CodeHostType), + repoId: z.number(), + repoName: z.string(), + webUrl: z.string(), + repoDisplayName: z.string().optional(), + externalWebUrl: z.string().optional(), + imageUrl: z.string().optional(), + indexedAt: z.string().datetime().optional(), + pushedAt: z.string().datetime().optional(), + defaultBranch: z.string().optional(), + isFork: z.boolean(), + isArchived: z.boolean(), +}).openapi('PublicRepository'); + +export const publicListReposQuerySchema = z.object({ + page: z.coerce.number().int().positive().default(1), + perPage: z.coerce.number().int().positive().max(100).default(30), + sort: z.enum(['name', 'pushed']).default('name'), + direction: z.enum(['asc', 'desc']).default('asc'), + query: z.string().optional(), +}).openapi('PublicListReposQuery'); + +export const publicListReposResponseSchema = z.array(publicRepositorySchema).openapi('PublicListReposResponse'); + +export const publicStreamSearchSseSchema = z.string().openapi('PublicStreamSearchSse', { + description: 'Server-sent event stream. Each data frame contains one JSON object representing either a chunk update, a final summary, or an error.', + example: 'data: {"type":"chunk","stats":{"actualMatchCount":1}}\n\n', +}); diff --git a/packages/web/tools/generateOpenApi.ts b/packages/web/tools/generateOpenApi.ts new file mode 100644 index 000000000..d71497a18 --- /dev/null +++ b/packages/web/tools/generateOpenApi.ts @@ -0,0 +1,24 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { SOURCEBOT_VERSION } from '../../shared/src/version.js'; +import { createPublicOpenApiDocument } from '../src/openapi/publicApiDocument.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, '../../..'); +const outputPath = path.join(repoRoot, 'docs', 'api-reference', 'sourcebot-public.openapi.json'); + +async function main() { + const document = createPublicOpenApiDocument(SOURCEBOT_VERSION); + + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.writeFile(outputPath, `${JSON.stringify(document, null, 2)}\n`, 'utf8'); + + process.stdout.write(`Wrote OpenAPI spec to ${outputPath}\n`); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/yarn.lock b/yarn.lock index be9416e58..4a2dfdb3f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -255,6 +255,17 @@ __metadata: languageName: node linkType: hard +"@asteasolutions/zod-to-openapi@npm:7.3.4": + version: 7.3.4 + resolution: "@asteasolutions/zod-to-openapi@npm:7.3.4" + dependencies: + openapi3-ts: "npm:^4.1.2" + peerDependencies: + zod: ^3.20.2 + checksum: 10c0/e94f63adce051f11b0ff417865c00973c8596a7c32917b05ba38c8c4a006bd325825fd8d0278b34af70836a7321f732bd6df8ce4c4e70cb11ac6c5cace7cfe5c + languageName: node + linkType: hard + "@auth/core@npm:0.41.0": version: 0.41.0 resolution: "@auth/core@npm:0.41.0" @@ -8849,6 +8860,7 @@ __metadata: "@ai-sdk/openai-compatible": "npm:^2.0.31" "@ai-sdk/react": "npm:^3.0.107" "@ai-sdk/xai": "npm:^3.0.60" + "@asteasolutions/zod-to-openapi": "npm:7.3.4" "@auth/prisma-adapter": "npm:^2.11.1" "@aws-sdk/credential-providers": "npm:^3.1000.0" "@codemirror/commands": "npm:^6.6.0" @@ -18047,6 +18059,15 @@ __metadata: languageName: node linkType: hard +"openapi3-ts@npm:^4.1.2": + version: 4.5.0 + resolution: "openapi3-ts@npm:4.5.0" + dependencies: + yaml: "npm:^2.8.0" + checksum: 10c0/97de2d24e9ceffb89e1388e137e4a6e17ee57a02dce0c938a5e98b1338ac72b31e8b2ce8dd28945ad43fae8bee2a145892cb548ba5ae60b0930f1b6b79b0747d + languageName: node + linkType: hard + "optionator@npm:^0.9.3": version: 0.9.4 resolution: "optionator@npm:0.9.4" @@ -22766,6 +22787,15 @@ __metadata: languageName: node linkType: hard +"yaml@npm:^2.8.0": + version: 2.8.2 + resolution: "yaml@npm:2.8.2" + bin: + yaml: bin.mjs + checksum: 10c0/703e4dc1e34b324aa66876d63618dcacb9ed49f7e7fe9b70f1e703645be8d640f68ab84f12b86df8ac960bac37acf5513e115de7c970940617ce0343c8c9cd96 + languageName: node + linkType: hard + "yargs-parser@npm:^21.1.1": version: 21.1.1 resolution: "yargs-parser@npm:21.1.1"