Skip to content

Commit 0283eb4

Browse files
committed
Massive upgrade to the API Swagger UI @ api.compassmeet.com
1 parent f483ae4 commit 0283eb4

File tree

8 files changed

+387
-142
lines changed

8 files changed

+387
-142
lines changed

backend/api/openapi.json

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,7 @@
22
"openapi": "3.0.0",
33
"info": {
44
"title": "Compass API",
5-
"version": "1.0.0"
5+
"version": "dynamically set in app.ts"
66
},
7-
"paths": {
8-
"/health": {
9-
"get": {
10-
"summary": "Health",
11-
"responses": {
12-
"200": {
13-
"description": "OK"
14-
}
15-
}
16-
}
17-
},
18-
"/get-profiles": {
19-
"get": {
20-
"summary": "List profiles",
21-
"responses": {
22-
"200": {
23-
"description": "OK"
24-
}
25-
}
26-
}
27-
}
28-
}
7+
"paths": {}
298
}

backend/api/src/app.ts

Lines changed: 182 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import {getLikesAndShips} from './get-likes-and-ships'
2020
import {getProfileAnswers} from './get-profile-answers'
2121
import {getProfiles} from './get-profiles'
2222
import {getSupabaseToken} from './get-supabase-token'
23-
import {getDisplayUser, getUser} from './get-user'
2423
import {getMe} from './get-me'
2524
import {hasFreeLike} from './has-free-like'
2625
import {health} from './health'
@@ -53,7 +52,6 @@ import {getNotifications} from './get-notifications'
5352
import {updateNotifSettings} from './update-notif-setting'
5453
import {setLastOnlineTime} from './set-last-online-time'
5554
import swaggerUi from "swagger-ui-express"
56-
import * as fs from "fs"
5755
import {sendSearchNotifications} from "api/send-search-notifications";
5856
import {sendDiscordMessage} from "common/discord/core";
5957
import {getMessagesCount} from "api/get-messages-count";
@@ -63,6 +61,10 @@ import {contact} from "api/contact";
6361
import {saveSubscription} from "api/save-subscription";
6462
import {createBookmarkedSearch} from './create-bookmarked-search'
6563
import {deleteBookmarkedSearch} from './delete-bookmarked-search'
64+
import {OpenAPIV3} from 'openapi-types';
65+
import {version as pkgVersion} from './../package.json'
66+
import {z, ZodFirstPartyTypeKind, ZodTypeAny} from "zod";
67+
import {getUser} from "api/get-user";
6668

6769
// const corsOptions: CorsOptions = {
6870
// origin: ['*'], // Only allow requests from this domain
@@ -117,17 +119,182 @@ const apiErrorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
117119
export const app = express()
118120
app.use(requestMonitoring)
119121

120-
const swaggerDocument = JSON.parse(fs.readFileSync("./openapi.json", "utf-8"))
121-
swaggerDocument.info = {
122-
...swaggerDocument.info,
123-
description: "Compass is a free, open-source platform to help people form deep, meaningful, and lasting connections — whether platonic, romantic, or collaborative. It’s made possible by contributions from the community, including code, ideas, feedback, and donations. Unlike typical apps, Compass prioritizes values, interests, and personality over swipes and ads, giving you full control over who you discover and how you connect.",
124-
version: "1.0.0",
125-
contact: {
126-
name: "Compass",
127-
128-
url: "https://compassmeet.com"
122+
const schemaCache = new WeakMap<ZodTypeAny, any>();
123+
124+
export function zodToOpenApiSchema(
125+
zodObj: ZodTypeAny,
126+
nameHint?: string
127+
): any { // Prevent infinite recursion
128+
if (schemaCache.has(zodObj)) {
129+
return schemaCache.get(zodObj);
129130
}
130-
};
131+
132+
const def: any = (zodObj as any)._def;
133+
const typeName = def.typeName as ZodFirstPartyTypeKind;
134+
135+
// Placeholder so recursive references can point here
136+
const placeholder: any = {};
137+
schemaCache.set(zodObj, placeholder);
138+
139+
let schema: any;
140+
141+
switch (typeName) {
142+
case 'ZodString':
143+
schema = { type: 'string' };
144+
break;
145+
case 'ZodNumber':
146+
schema = { type: 'number' };
147+
break;
148+
case 'ZodBoolean':
149+
schema = { type: 'boolean' };
150+
break;
151+
case 'ZodEnum':
152+
schema = { type: 'string', enum: def.values };
153+
break;
154+
case 'ZodArray':
155+
schema = { type: 'array', items: zodToOpenApiSchema(def.type) };
156+
break;
157+
case 'ZodObject': {
158+
const shape = def.shape();
159+
const properties: Record<string, any> = {};
160+
const required: string[] = [];
161+
162+
for (const key in shape) {
163+
const child = shape[key];
164+
properties[key] = zodToOpenApiSchema(child, key);
165+
if (!child.isOptional()) required.push(key);
166+
}
167+
168+
schema = {
169+
type: 'object',
170+
properties,
171+
...(required.length ? { required } : {}),
172+
};
173+
break;
174+
}
175+
case 'ZodRecord':
176+
schema = {
177+
type: 'object',
178+
additionalProperties: zodToOpenApiSchema(def.valueType),
179+
};
180+
break;
181+
case 'ZodIntersection': {
182+
const left = zodToOpenApiSchema(def.left);
183+
const right = zodToOpenApiSchema(def.right);
184+
schema = { allOf: [left, right] };
185+
break;
186+
}
187+
case 'ZodLazy':
188+
// Recursive schema: use a $ref placeholder name
189+
schema = {
190+
$ref: `#/components/schemas/${nameHint ?? 'RecursiveType'}`,
191+
};
192+
break;
193+
case 'ZodUnion':
194+
schema = {
195+
oneOf: def.options.map((opt: ZodTypeAny) => zodToOpenApiSchema(opt)),
196+
};
197+
break;
198+
default:
199+
schema = { type: 'string' }; // fallback for unhandled
200+
}
201+
202+
Object.assign(placeholder, schema);
203+
return schema;
204+
}
205+
206+
function generateSwaggerPaths(api: typeof API) {
207+
const paths: Record<string, any> = {};
208+
209+
for (const [route, config] of Object.entries(api)) {
210+
const pathKey = '/' + route.replace(/_/g, '-'); // optional: convert underscores to dashes
211+
const method = config.method.toLowerCase();
212+
const summary = (config as any).summary ?? route;
213+
214+
// Include props in request body for POST/PUT
215+
const operation: any = {
216+
summary,
217+
tags: [(config as any).tag ?? 'API'],
218+
responses: {
219+
200: {
220+
description: 'OK',
221+
content: {
222+
'application/json': {
223+
schema: {type: 'object'}, // could be improved by introspecting returns
224+
},
225+
},
226+
},
227+
},
228+
};
229+
230+
// Include props in request body for POST/PUT
231+
if (config.props && ['post', 'put', 'patch'].includes(method)) {
232+
operation.requestBody = {
233+
required: true,
234+
content: {
235+
'application/json': {
236+
schema: zodToOpenApiSchema(config.props),
237+
},
238+
},
239+
};
240+
}
241+
242+
// Include props as query parameters for GET/DELETE
243+
if (config.props && ['get', 'delete'].includes(method)) {
244+
const shape = (config.props as z.ZodObject<any>)._def.shape();
245+
operation.parameters = Object.entries(shape).map(([key, zodType]) => {
246+
const typeMap: Record<string, string> = {
247+
ZodString: 'string',
248+
ZodNumber: 'number',
249+
ZodBoolean: 'boolean',
250+
};
251+
const t = zodType as z.ZodTypeAny; // assert type to ZodTypeAny
252+
return {
253+
name: key,
254+
in: 'query',
255+
required: !(t.isOptional ?? false),
256+
schema: {type: typeMap[t._def.typeName] ?? 'string'},
257+
};
258+
});
259+
}
260+
261+
paths[pathKey] = {
262+
[method]: operation,
263+
}
264+
265+
if (config.authed) {
266+
operation.security = [{BearerAuth: []}];
267+
}
268+
}
269+
270+
return paths;
271+
}
272+
273+
274+
const swaggerDocument: OpenAPIV3.Document = {
275+
openapi: "3.0.0",
276+
info: {
277+
title: "Compass API",
278+
description: "Compass is a free, open-source platform to help people form deep, meaningful, and lasting connections — whether platonic, romantic, or collaborative. It’s made possible by contributions from the community, including code, ideas, feedback, and donations. Unlike typical apps, Compass prioritizes values, interests, and personality over swipes and ads, giving you full control over who you discover and how you connect.",
279+
version: pkgVersion,
280+
contact: {
281+
name: "Compass",
282+
283+
url: "https://compassmeet.com"
284+
}
285+
},
286+
paths: generateSwaggerPaths(API),
287+
components: {
288+
securitySchemes: {
289+
BearerAuth: {
290+
type: 'http',
291+
scheme: 'bearer',
292+
bearerFormat: 'JWT',
293+
},
294+
},
295+
}
296+
} as OpenAPIV3.Document;
297+
131298

132299
const rootPath = pathWithPrefix("/")
133300
app.get(rootPath, swaggerUi.setup(swaggerDocument))
@@ -142,10 +309,10 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
142309
'get-supabase-token': getSupabaseToken,
143310
'get-notifications': getNotifications,
144311
'mark-all-notifs-read': markAllNotifsRead,
145-
'user/:username': getUser,
146-
'user/:username/lite': getDisplayUser,
312+
// 'user/:username': getUser,
313+
// 'user/:username/lite': getDisplayUser,
147314
'user/by-id/:id': getUser,
148-
'user/by-id/:id/lite': getDisplayUser,
315+
// 'user/by-id/:id/lite': getDisplayUser,
149316
'user/by-id/:id/block': blockUser,
150317
'user/by-id/:id/unblock': unblockUser,
151318
'search-users': searchUsers,
@@ -218,8 +385,6 @@ Object.entries(handlers).forEach(([path, handler]) => {
218385
}
219386
})
220387

221-
// console.debug('COMPASS_API_KEY:', process.env.COMPASS_API_KEY)
222-
223388
// Internal Endpoints
224389
app.post(pathWithPrefix("/internal/send-search-notifications"),
225390
async (req, res) => {

backend/api/src/get-user.ts

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,18 @@ export const getUser = async (props: { id: string } | { username: string }) => {
1717
return toUserAPIResponse(user)
1818
}
1919

20-
export const getDisplayUser = async (
21-
props: { id: string } | { username: string }
22-
) => {
23-
const pg = createSupabaseDirectClient()
24-
const liteUser = await pg.oneOrNone(
25-
`select ${displayUserColumns}
26-
from users
27-
where ${'id' in props ? 'id' : 'username'} = $1`,
28-
['id' in props ? props.id : props.username]
29-
)
30-
if (!liteUser) throw new APIError(404, 'User not found')
31-
32-
return removeNullOrUndefinedProps(liteUser)
33-
}
20+
// export const getDisplayUser = async (
21+
// props: { id: string } | { username: string }
22+
// ) => {
23+
// console.log('getDisplayUser', props)
24+
// const pg = createSupabaseDirectClient()
25+
// const liteUser = await pg.oneOrNone(
26+
// `select ${displayUserColumns}
27+
// from users
28+
// where ${'id' in props ? 'id' : 'username'} = $1`,
29+
// ['id' in props ? props.id : props.username]
30+
// )
31+
// if (!liteUser) throw new APIError(404, 'User not found')
32+
//
33+
// return removeNullOrUndefinedProps(liteUser)
34+
// }

backend/api/tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"tsBuildInfoFile": "lib/tsconfig.tsbuildinfo",
99
"sourceMap": true,
1010
"strict": true,
11+
"resolveJsonModule": true,
1112
"esModuleInterop": true,
1213
"allowSyntheticDefaultImports": true,
1314
"target": "esnext",

backend/shared/src/supabase/init.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export {SupabaseClient} from 'common/supabase/utils'
1111
export const pgp = pgPromise({
1212
error(err: any, e: pgPromise.IEventContext) {
1313
// Read more: https://node-postgres.com/apis/pool#error
14-
log.error('pgPromise background error', {
14+
log.error(`pgPromise background error: ${err?.detail}`, {
1515
error: err,
1616
event: e,
1717
})

0 commit comments

Comments
 (0)