g1t/apps/api/src/openapi.ts

232 lines7,359 bytesCodeBlame
1import { operationsByName } from "./operations";
2
3/** What the OpenAPI document needs to know about a REST route. */
4export type RouteDoc = {
5 method: "GET" | "POST";
6 path: string;
7 operation: string;
8 tag: string;
9};
10
11const ERROR = { $ref: "#/components/schemas/Error" };
12
13function errorResponse(description: string) {
14 return {
15 description,
16 content: { "application/json": { schema: ERROR } },
17 };
18}
19
20/** A short title from an operation name: `open_intent` is "Open intent". */
21function title(operation: string): string {
22 if (operation === "whoami") return "Get the current user";
23 const words = operation.split("_").join(" ");
24 return words[0].toUpperCase() + words.slice(1);
25}
26
27/** Hono's `:name` path parameters as OpenAPI's `{name}`. */
28function openApiPath(path: string): string {
29 return path.replace(/:([a-z_]+)/g, "{$1}");
30}
31
32/** Hand-written entries for the two onboarding routes, which are not operations. */
33const ONBOARDING = {
34 "/v1/register": {
35 post: {
36 operationId: "register",
37 tags: ["Accounts"],
38 summary: "Register",
39 description: "Create an account. Sends a confirmation email.",
40 security: [],
41 requestBody: {
42 required: true,
43 content: {
44 "application/json": {
45 schema: {
46 type: "object",
47 required: ["username", "email", "password"],
48 properties: {
49 username: {
50 type: "string",
51 description: "Lowercase letters, digits and single hyphens; at most 39 characters.",
52 },
53 email: { type: "string", format: "email" },
54 password: { type: "string", minLength: 10 },
55 },
56 },
57 },
58 },
59 },
60 responses: {
61 "201": { description: "The account was created; a confirmation link was emailed." },
62 "409": errorResponse("The username or email is already registered."),
63 "422": errorResponse("The input is not valid."),
64 },
65 },
66 },
67 "/v1/tokens": {
68 post: {
69 operationId: "create_token",
70 tags: ["Accounts"],
71 summary: "Create token",
72 description: "Create an access token from a username and password.",
73 security: [],
74 requestBody: {
75 required: true,
76 content: {
77 "application/json": {
78 schema: {
79 type: "object",
80 required: ["username", "password"],
81 properties: {
82 username: { type: "string" },
83 password: { type: "string" },
84 name: { type: "string", description: "A label for the token." },
85 },
86 },
87 },
88 },
89 },
90 responses: {
91 "201": {
92 description: "The token, shown once.",
93 content: {
94 "application/json": {
95 schema: {
96 type: "object",
97 properties: {
98 token: { type: "string" },
99 verified: {
100 type: "boolean",
101 description: "Whether the account's email is confirmed.",
102 },
103 },
104 },
105 },
106 },
107 },
108 "401": errorResponse("Incorrect username or password."),
109 },
110 },
111 },
112};
113
114/** The OpenAPI document, generated from the same list the routes are. */
115export function openApiDocument(routes: RouteDoc[]) {
116 const paths: Record<string, Record<string, unknown>> = { ...ONBOARDING };
117 for (const route of routes) {
118 const operation = operationsByName.get(route.operation)!;
119 const pathParams = [...route.path.matchAll(/:([a-z_]+)/g)].map((match) => match[1]);
120 // `owner` and `name` in the path stand for the operation's `repo` input.
121 const covered = new Set([...pathParams, "repo"]);
122 const inputs = Object.entries(operation.input.properties).filter(
123 ([name]) => !covered.has(name),
124 );
125 const required = (operation.input.required ?? []).filter(
126 (name) => !covered.has(name),
127 );
128
129 const parameters: unknown[] = pathParams.map((name) => ({
130 name,
131 in: "path",
132 required: true,
133 schema: { type: name === "number" ? "integer" : "string" },
134 }));
135 let requestBody: unknown;
136 if (route.method === "GET") {
137 for (const [name, schema] of inputs) {
138 parameters.push({
139 // The repository search parameter is `q` on the wire.
140 name: route.operation === "list_repos" && name === "query" ? "q" : name,
141 in: "query",
142 required: false,
143 schema,
144 });
145 }
146 } else if (inputs.length > 0) {
147 requestBody = {
148 required: required.length > 0,
149 content: {
150 "application/json": {
151 schema: {
152 type: "object",
153 properties: Object.fromEntries(inputs),
154 ...(required.length > 0 ? { required } : {}),
155 },
156 },
157 },
158 };
159 }
160
161 const path = openApiPath(route.path);
162 paths[path] ??= {};
163 paths[path][route.method.toLowerCase()] = {
164 operationId: route.operation,
165 tags: [route.tag],
166 summary: title(route.operation),
167 description: operation.description,
168 parameters,
169 ...(requestBody ? { requestBody } : {}),
170 responses: {
171 "200": {
172 description: "Success.",
173 content: { "application/json": { schema: {} } },
174 },
175 "401": errorResponse("A token is required, or the one sent is not valid."),
176 "403": errorResponse("Signed in, but not allowed to do this."),
177 "404": errorResponse("It does not exist, or you cannot see it."),
178 "409": errorResponse("The request conflicts with the current state."),
179 "422": errorResponse("The input is not valid."),
180 },
181 };
182 }
183
184 return {
185 openapi: "3.1.0",
186 info: {
187 title: "g1t API",
188 version: "1",
189 description:
190 "The REST API for g1t, a git forge built for agents. The same operations are available to agents as MCP tools at https://mcp.g1t.sh.",
191 license: { name: "MIT", identifier: "MIT" },
192 },
193 servers: [{ url: "https://api.g1t.sh" }],
194 security: [{ token: [] }, {}],
195 tags: [
196 { name: "Accounts", description: "Registering and getting a token." },
197 { name: "Repositories" },
198 { name: "Intents", description: "Goals stated against a repository." },
199 { name: "Attempts", description: "An agent's or person's run at an intent." },
200 { name: "Sessions", description: "The record of how an attempt was made." },
201 ],
202 paths,
203 components: {
204 securitySchemes: {
205 token: {
206 type: "http",
207 scheme: "bearer",
208 description: "An access token, `g1t_…`. Public data needs none.",
209 },
210 },
211 schemas: {
212 Error: {
213 type: "object",
214 required: ["error"],
215 properties: {
216 error: {
217 type: "object",
218 required: ["code", "message"],
219 properties: {
220 code: {
221 type: "string",
222 enum: ["unauthenticated", "forbidden", "not_found", "conflict", "invalid"],
223 },
224 message: { type: "string" },
225 },
226 },
227 },
228 },
229 },
230 },
231 };
232}