Skip to content

Commit 1275f84

Browse files
authored
Improve public API docs SEO and Mintlify metadata (#2811)
* fix(docs): improve questionnaire API SEO metadata * docs: add Mintlify API overview and metadata layer * docs(api): broaden public API SEO metadata * docs(api): harden public OpenAPI SEO surface
1 parent 9e37a75 commit 1275f84

24 files changed

Lines changed: 8855 additions & 20251 deletions

apps/api/packages/docs/openapi.json

Lines changed: 0 additions & 11109 deletions
This file was deleted.

apps/api/src/gen-openapi.spec.ts

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Script-style Jest spec: generates packages/docs/openapi.json using the same
22
// mocks as openapi-docs.spec.ts (no live DB or env vars needed).
33
// Skipped by default to avoid side effects in CI.
4-
// Run manually with: cd apps/api && GEN_OPENAPI=1 npx jest src/gen-openapi.spec.ts
4+
// Run manually with: cd apps/api && GEN_OPENAPI=1 bunx jest src/gen-openapi.spec.ts
55

66
// Mock better-auth ESM-only modules so Jest (CJS) can import AppModule's transitive AuthModule.
77
jest.mock('./auth/auth.server', () => ({
@@ -21,7 +21,12 @@ jest.mock('@thallesp/nestjs-better-auth', () => {
2121
@Module({})
2222
class AuthModuleStub {
2323
static forRoot() {
24-
return { module: AuthModuleStub, imports: [], providers: [], exports: [] };
24+
return {
25+
module: AuthModuleStub,
26+
imports: [],
27+
providers: [],
28+
exports: [],
29+
};
2530
}
2631
}
2732
return { AuthModule: AuthModuleStub };
@@ -72,6 +77,7 @@ jest.mock('@db', () => {
7277
organization: { findFirst: jest.fn(), findMany: jest.fn() },
7378
auditLog: { create: jest.fn() },
7479
trust: { findMany: jest.fn().mockResolvedValue([]) },
80+
dynamicIntegration: { findMany: jest.fn().mockResolvedValue([]) },
7581
apiKey: { findFirst: jest.fn() },
7682
session: { findFirst: jest.fn() },
7783
member: { findFirst: jest.fn() },
@@ -99,6 +105,13 @@ import { Test } from '@nestjs/testing';
99105
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
100106
import { INestApplication, VersioningType } from '@nestjs/common';
101107
import { AppModule } from './app.module';
108+
import {
109+
applyPublicOpenApiMetadata,
110+
PUBLIC_OPENAPI_DESCRIPTION,
111+
PUBLIC_OPENAPI_TITLE,
112+
PUBLIC_SERVER_URL,
113+
} from './openapi/public-docs-metadata';
114+
import { collectPublicOpenApiIssues } from './openapi/public-docs-quality';
102115

103116
const shouldRun = process.env.GEN_OPENAPI === '1';
104117
const maybeDescribe = shouldRun ? describe : describe.skip;
@@ -121,18 +134,9 @@ maybeDescribe('Generate openapi.json', () => {
121134
});
122135

123136
it('writes openapi.json without excluded paths', () => {
124-
const baseUrl = process.env.BASE_URL ?? 'http://localhost:3333';
125-
const serverDescription = baseUrl.includes('api.staging.trycomp.ai')
126-
? 'Staging API Server'
127-
: baseUrl.includes('api.trycomp.ai')
128-
? 'Production API Server'
129-
: baseUrl.startsWith('http://localhost')
130-
? 'Local API Server'
131-
: 'API Server';
132-
133137
const config = new DocumentBuilder()
134-
.setTitle('API Documentation')
135-
.setDescription('The API documentation for this application')
138+
.setTitle(PUBLIC_OPENAPI_TITLE)
139+
.setDescription(PUBLIC_OPENAPI_DESCRIPTION)
136140
.setVersion('1.0')
137141
.addApiKey(
138142
{
@@ -143,10 +147,10 @@ maybeDescribe('Generate openapi.json', () => {
143147
},
144148
'apikey',
145149
)
146-
.addServer(baseUrl, serverDescription)
147150
.build();
148151

149152
const document = SwaggerModule.createDocument(app, config);
153+
applyPublicOpenApiMetadata(document);
150154

151155
const openapiPath = path.join(
152156
__dirname,
@@ -161,13 +165,16 @@ maybeDescribe('Generate openapi.json', () => {
161165
writeFileSync(openapiPath, JSON.stringify(document, null, 2));
162166
console.log(`OpenAPI documentation written to ${openapiPath}`);
163167

164-
// Verify excluded paths are absent
165-
const hiddenPrefixes = ['/v1/auth', '/v1/admin', '/v1/internal'];
166-
for (const prefix of hiddenPrefixes) {
167-
const exposed = Object.keys(document.paths).filter((p) =>
168-
p.startsWith(prefix),
169-
);
170-
expect(exposed).toEqual([]);
171-
}
168+
expect(document.servers).toEqual([
169+
{
170+
url: PUBLIC_SERVER_URL,
171+
description: 'Production API Server',
172+
},
173+
]);
174+
175+
const issues = collectPublicOpenApiIssues(document);
176+
expect(issues.excludedPaths).toEqual([]);
177+
expect(issues.exposedTags).toEqual([]);
178+
expect(issues.sensitiveSchemaDetails).toEqual([]);
172179
});
173180
});

apps/api/src/main.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ import * as express from 'express';
88
import helmet from 'helmet';
99
import path from 'path';
1010
import { AppModule } from './app.module';
11+
import {
12+
applyPublicOpenApiMetadata,
13+
PUBLIC_OPENAPI_DESCRIPTION,
14+
PUBLIC_OPENAPI_TITLE,
15+
} from './openapi/public-docs-metadata';
1116
import { isTrustedOrigin } from './auth/auth.server';
1217
import { adminAuthRateLimiter } from './auth/admin-rate-limit.middleware';
1318
import { originCheckMiddleware } from './auth/origin-check.middleware';
@@ -153,8 +158,8 @@ async function bootstrap(): Promise<void> {
153158
const serverDescription = describeServer(baseUrl);
154159

155160
const config = new DocumentBuilder()
156-
.setTitle('API Documentation')
157-
.setDescription('The API documentation for this application')
161+
.setTitle(PUBLIC_OPENAPI_TITLE)
162+
.setDescription(PUBLIC_OPENAPI_DESCRIPTION)
158163
.setVersion('1.0')
159164
.addApiKey(
160165
{
@@ -169,6 +174,8 @@ async function bootstrap(): Promise<void> {
169174
.build();
170175
const document: OpenAPIObject = SwaggerModule.createDocument(app, config);
171176

177+
applyPublicOpenApiMetadata(document);
178+
172179
// Setup Swagger UI at /api/docs
173180
SwaggerModule.setup('api/docs', app, document, {
174181
raw: ['json'],

apps/api/src/openapi-docs.spec.ts

Lines changed: 73 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
// Mock better-auth ESM-only modules so Jest (CJS) can import AppModule's transitive AuthModule.
22
// These must appear before any imports so that Jest hoists them before module evaluation.
33

4-
// Stub the auth instance so auth.server.ts never runs its top-level side effects
5-
// (validateSecurityConfig, betterAuth(), Redis connection, etc.)
64
jest.mock('./auth/auth.server', () => ({
75
auth: {
86
api: {},
@@ -14,20 +12,23 @@ jest.mock('./auth/auth.server', () => ({
1412
isStaticTrustedOrigin: () => false,
1513
}));
1614

17-
// Stub the NestJS better-auth integration module
1815
jest.mock('@thallesp/nestjs-better-auth', () => {
1916
// eslint-disable-next-line @typescript-eslint/no-var-requires
2017
const { Module } = require('@nestjs/common');
2118
@Module({})
2219
class AuthModuleStub {
2320
static forRoot() {
24-
return { module: AuthModuleStub, imports: [], providers: [], exports: [] };
21+
return {
22+
module: AuthModuleStub,
23+
imports: [],
24+
providers: [],
25+
exports: [],
26+
};
2527
}
2628
}
2729
return { AuthModule: AuthModuleStub };
2830
});
2931

30-
// Stub better-auth ESM-only packages (loaded by @trycompai/auth package)
3132
jest.mock('better-auth/plugins/access', () => ({
3233
createAccessControl: () => ({
3334
newRole: () => ({}),
@@ -76,6 +77,7 @@ jest.mock('@db', () => {
7677
organization: { findFirst: jest.fn(), findMany: jest.fn() },
7778
auditLog: { create: jest.fn() },
7879
trust: { findMany: jest.fn().mockResolvedValue([]) },
80+
dynamicIntegration: { findMany: jest.fn().mockResolvedValue([]) },
7981
apiKey: { findFirst: jest.fn() },
8082
session: { findFirst: jest.fn() },
8183
member: { findFirst: jest.fn() },
@@ -101,9 +103,20 @@ process.env.APP_AWS_BUCKET_NAME = 'test-bucket';
101103
process.env.APP_AWS_REGION = 'us-east-1';
102104

103105
import { Test } from '@nestjs/testing';
104-
import { DocumentBuilder, SwaggerModule, type OpenAPIObject } from '@nestjs/swagger';
106+
import {
107+
DocumentBuilder,
108+
SwaggerModule,
109+
type OpenAPIObject,
110+
} from '@nestjs/swagger';
105111
import { INestApplication, VersioningType } from '@nestjs/common';
106112
import { AppModule } from './app.module';
113+
import {
114+
applyPublicOpenApiMetadata,
115+
PUBLIC_OPENAPI_DESCRIPTION,
116+
PUBLIC_OPENAPI_TITLE,
117+
PUBLIC_SERVER_URL,
118+
} from './openapi/public-docs-metadata';
119+
import { collectPublicOpenApiIssues } from './openapi/public-docs-quality';
107120

108121
describe('OpenAPI document', () => {
109122
let app: INestApplication;
@@ -119,37 +132,73 @@ describe('OpenAPI document', () => {
119132
await app.init();
120133

121134
const config = new DocumentBuilder()
122-
.setTitle('Test')
135+
.setTitle(PUBLIC_OPENAPI_TITLE)
136+
.setDescription(PUBLIC_OPENAPI_DESCRIPTION)
123137
.setVersion('1.0')
124138
.build();
125139
document = SwaggerModule.createDocument(app, config);
140+
applyPublicOpenApiMetadata(document);
126141
});
127142

128143
afterAll(async () => {
129144
if (app) await app.close();
130145
});
131146

132-
const hiddenPrefixes = ['/v1/auth', '/v1/admin', '/v1/internal'];
147+
describe('public metadata', () => {
148+
it('uses production API servers in the generated Mintlify spec', () => {
149+
expect(document.info.title).toBe(PUBLIC_OPENAPI_TITLE);
150+
expect(document.info.description).toBe(PUBLIC_OPENAPI_DESCRIPTION);
151+
expect(document.servers).toEqual([
152+
{
153+
url: PUBLIC_SERVER_URL,
154+
description: 'Production API Server',
155+
},
156+
]);
157+
});
158+
159+
it('keeps the public spec complete, SEO-ready, and free of private surfaces', () => {
160+
const issues = collectPublicOpenApiIssues(document);
133161

134-
for (const prefix of hiddenPrefixes) {
135-
it(`does not expose any path starting with ${prefix}`, () => {
136-
const exposed = Object.keys(document.paths).filter((p) => p.startsWith(prefix));
137-
expect(exposed).toEqual([]);
162+
expect(issues.excludedPaths).toEqual([]);
163+
expect(issues.exposedTags).toEqual([]);
164+
expect(issues.invalidSeo).toEqual([]);
165+
expect(issues.missingMetadata).toEqual([]);
166+
expect(issues.missingSummaries).toEqual([]);
167+
expect(issues.sensitiveSchemaDetails).toEqual([]);
138168
});
139-
}
140169

141-
describe('summaries', () => {
142-
it('every public operation declares a non-empty summary', () => {
143-
const missing: string[] = [];
144-
for (const [routePath, methods] of Object.entries(document.paths)) {
145-
for (const [method, op] of Object.entries(methods as Record<string, { summary?: string }>)) {
146-
if (typeof op !== 'object' || !op) continue;
147-
if (!op.summary || op.summary.trim() === '') {
148-
missing.push(`${method.toUpperCase()} ${routePath}`);
170+
it('curates high-value API pages with operation-specific SEO copy', () => {
171+
expect(
172+
document.paths['/v1/questionnaire/parse/upload/token'],
173+
).toBeUndefined();
174+
175+
const upload = document.paths['/v1/questionnaire/parse/upload']?.post as
176+
| {
177+
summary?: string;
178+
description?: string;
179+
'x-mint'?: { href?: string; metadata?: { title?: string } };
180+
}
181+
| undefined;
182+
183+
expect(upload?.summary).toBe('Auto-answer uploaded questionnaire');
184+
expect(upload?.description).toContain('approved organization evidence');
185+
expect(upload?.['x-mint']?.href).toBe(
186+
'/api-reference/questionnaire/upload-a-questionnaire-file-and-auto-answer-with-export',
187+
);
188+
189+
const policies = document.paths['/v1/policies']?.get as
190+
| {
191+
summary?: string;
192+
description?: string;
193+
'x-mint'?: { metadata?: { title?: string } };
149194
}
150-
}
151-
}
152-
expect(missing).toEqual([]);
195+
| undefined;
196+
197+
expect(policies?.summary).toBe('List compliance policies');
198+
expect(policies?.description).toContain('SOC 2');
199+
expect(policies?.['x-mint']?.metadata?.title).toBe(
200+
'List compliance policies | Comp AI API',
201+
);
153202
});
154203
});
155204
});

0 commit comments

Comments
 (0)