a cache for slack profile pictures and emojis
1/**
2 * Generates Swagger/OpenAPI specifications from typed route definitions
3 */
4
5import { version } from "../../package.json";
6import type {
7 HttpMethod,
8 RouteDefinition,
9 RouteMetadata,
10 RouteParam,
11} from "../types/routes";
12
13interface SecurityScheme {
14 type: string;
15 scheme: string;
16}
17
18interface SwaggerSpec {
19 openapi: string;
20 info: {
21 title: string;
22 version: string;
23 description: string;
24 contact: {
25 name: string;
26 email: string;
27 };
28 license: {
29 name: string;
30 url: string;
31 };
32 };
33 paths: Record<string, Record<string, unknown>>;
34 components?: {
35 securitySchemes?: Record<string, SecurityScheme>;
36 };
37}
38
39export class SwaggerGenerator {
40 private spec: SwaggerSpec;
41
42 constructor() {
43 this.spec = {
44 openapi: "3.0.0",
45 info: {
46 title: "Cachet",
47 version: version,
48 description:
49 "A high-performance cache and proxy for Slack profile pictures and emojis with comprehensive analytics.",
50 contact: {
51 name: "Kieran Klukas",
52 email: "me@dunkirk.sh",
53 },
54 license: {
55 name: "AGPL 3.0",
56 url: "https://github.com/taciturnaxolotl/cachet/blob/main/LICENSE.md",
57 },
58 },
59 paths: {},
60 components: {
61 securitySchemes: {
62 bearerAuth: {
63 type: "http",
64 scheme: "bearer",
65 },
66 },
67 },
68 };
69 }
70
71 /**
72 * Add routes to the Swagger specification
73 */
74 addRoutes(routes: Record<string, RouteDefinition>) {
75 Object.entries(routes).forEach(([path, routeConfig]) => {
76 // Skip non-API routes
77 if (
78 typeof routeConfig === "function" ||
79 path.includes("dashboard") ||
80 path.includes("swagger") ||
81 path.includes("favicon")
82 ) {
83 return;
84 }
85
86 this.addRoute(path, routeConfig);
87 });
88 }
89
90 /**
91 * Add a single route to the specification
92 */
93 private addRoute(path: string, routeConfig: RouteDefinition) {
94 const swaggerPath = this.convertPathToSwagger(path);
95
96 if (!this.spec.paths[swaggerPath]) {
97 this.spec.paths[swaggerPath] = {};
98 }
99
100 // Process each HTTP method
101 Object.entries(routeConfig).forEach(([method, typedRoute]) => {
102 if (
103 typeof typedRoute === "object" &&
104 "handler" in typedRoute &&
105 "metadata" in typedRoute
106 ) {
107 const swaggerMethod = method.toLowerCase();
108 const methodSpec = this.buildMethodSpec(
109 method as HttpMethod,
110 typedRoute.metadata,
111 );
112 // Ensure spec.paths is properly initialized before adding method
113 if (this.spec.paths[swaggerPath]) {
114 this.spec.paths[swaggerPath][swaggerMethod] = methodSpec;
115 }
116 }
117 });
118 }
119
120 /**
121 * Convert Express-style path to Swagger format
122 * /users/:id -> /users/{id}
123 */
124 private convertPathToSwagger(path: string): string {
125 return path.replace(/:([^/]+)/g, "{$1}");
126 }
127
128 /**
129 * Build Swagger specification for a single method
130 */
131 private buildMethodSpec(method: HttpMethod, metadata: RouteMetadata) {
132 const spec: Record<string, unknown> = {
133 summary: metadata.summary,
134 description: metadata.description,
135 tags: metadata.tags || ["API"],
136 responses: {},
137 };
138
139 // Add parameters
140 if (metadata.parameters) {
141 spec.parameters = [] as Record<string, unknown>[];
142
143 // Path parameters
144 if (metadata.parameters.path) {
145 metadata.parameters.path.forEach((param) => {
146 (spec.parameters as Record<string, unknown>[]).push(
147 this.buildParameterSpec(param, "path"),
148 );
149 });
150 }
151
152 // Query parameters
153 if (metadata.parameters.query) {
154 metadata.parameters.query.forEach((param) => {
155 (spec.parameters as Record<string, unknown>[]).push(
156 this.buildParameterSpec(param, "query"),
157 );
158 });
159 }
160
161 // Request body
162 if (
163 metadata.parameters.body &&
164 ["POST", "PUT", "PATCH"].includes(method)
165 ) {
166 spec.requestBody = {
167 required: true,
168 content: {
169 "application/json": {
170 schema: metadata.parameters.body,
171 },
172 },
173 };
174 }
175 }
176
177 // Add responses
178 Object.entries(metadata.responses).forEach(([status, response]) => {
179 (spec.responses as Record<string, unknown>)[status] = {
180 description: response.description,
181 ...(response.schema && {
182 content: {
183 "application/json": {
184 schema: response.schema,
185 },
186 },
187 }),
188 };
189 });
190
191 // Add security if required
192 if (metadata.requiresAuth) {
193 spec.security = [{ bearerAuth: [] }];
194 }
195
196 return spec;
197 }
198
199 /**
200 * Build parameter specification
201 */
202 private buildParameterSpec(param: RouteParam, location: "path" | "query") {
203 const schema: Record<string, unknown> = { type: param.type };
204 if (param.example !== undefined) {
205 schema.example = param.example;
206 }
207
208 return {
209 name: param.name,
210 in: location,
211 required: param.required,
212 description: param.description,
213 schema,
214 };
215 }
216
217 /**
218 * Get the complete Swagger specification
219 */
220 getSpec(): SwaggerSpec {
221 return this.spec;
222 }
223
224 /**
225 * Generate JSON string of the specification
226 */
227 toJSON(): string {
228 return JSON.stringify(this.spec, null, 2);
229 }
230}
231
232// Export singleton instance
233export const swaggerGenerator = new SwaggerGenerator();