Skip to content

Commit 2b89229

Browse files
feat: CI job to detect breaking api v2 changes (#24028)
* refactor: move swagger to src/swagger * refactor: standalone swagger generation script * refactor: generate only 1 swagger file * feat: github action checking breaking changes * chore: ensure openapi file is formatted * chore: run breaking change check on label * chore: have only 1 swagger file * fix: run breaking changes check on workflow call * refactor: pr breaking jobs dependency * fix: copy swagger module * refactor: add check-label as dep * refactor: breaking changes check part of v2 e2e workflow * refactor: run breaking changes before e2e * chore: add vapid env keys to workflow * chore: add CI_JWT_SECRET to e2e api v2 * chore: add NODE_ENV --------- Co-authored-by: cal.com <[email protected]>
1 parent f796802 commit 2b89229

File tree

8 files changed

+161
-30278
lines changed

8 files changed

+161
-30278
lines changed

.github/workflows/e2e-api-v2.yml

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: E2E
1+
name: Check breaking changes and run E2E
22
on:
33
workflow_call:
44
env:
@@ -23,6 +23,10 @@ env:
2323
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
2424
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
2525
SLOTS_CACHE_TTL: ${{ secrets.CI_SLOTS_CACHE_TTL }}
26+
NEXT_PUBLIC_VAPID_PUBLIC_KEY: ${{ secrets.NEXT_PUBLIC_VAPID_PUBLIC_KEY }}
27+
VAPID_PRIVATE_KEY: ${{ secrets.VAPID_PRIVATE_KEY }}
28+
JWT_SECRET: ${{ secrets.CI_JWT_SECRET }}
29+
NODE_ENV: ${{ vars.CI_NODE_ENV }}
2630
jobs:
2731
e2e:
2832
timeout-minutes: 20
@@ -68,13 +72,26 @@ jobs:
6872
- uses: ./.github/actions/dangerous-git-checkout
6973
- uses: ./.github/actions/yarn-install
7074
- uses: ./.github/actions/cache-db
75+
76+
- name: Generate Swagger
77+
working-directory: apps/api/v2
78+
run: yarn generate-swagger
79+
80+
- name: Check breaking changes
81+
uses: oasdiff/oasdiff-action/breaking@main
82+
with:
83+
base: https://raw.githubusercontent.com/calcom/cal.com/refs/heads/main/docs/api-reference/v2/openapi.json
84+
revision: docs/api-reference/v2/openapi.json
85+
fail-on: WARN
86+
7187
- name: Run Tests
7288
working-directory: apps/api/v2
7389
run: |
7490
yarn workspace @calcom/platform-libraries build && yarn test:e2e
7591
EXIT_CODE=$?
7692
echo "yarn workspace @calcom/platform-libraries build && yarn test:e2e command exit code: $EXIT_CODE"
7793
exit $EXIT_CODE
94+
7895
- name: Upload Test Results
7996
if: ${{ always() }}
8097
uses: actions/upload-artifact@v4

.prettierignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,4 @@ public
1616
packages/prisma/zod
1717
packages/prisma/enums
1818
apps/web/public/embed
19-
apps/api/v2/swagger/documentation.json
2019
packages/ui/components/icon/dynamicIconImports.tsx

apps/api/v2/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"_dev:build:watch:enums": "yarn workspace @calcom/platform-enums build:watch",
1717
"_dev:build:watch:utils": "yarn workspace @calcom/platform-utils build:watch",
1818
"_dev:build:watch:types": "yarn workspace @calcom/platform-types build:watch",
19-
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-enums build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build",
19+
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-enums build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build && yarn workspace @calcom/platform-libraries build",
2020
"dev": "yarn dev:build && ts-node scripts/docker-start.ts && yarn copy-swagger-module && yarn start --watch",
2121
"dev:no-docker": "yarn dev:build && yarn copy-swagger-module && yarn start --watch",
2222
"start:debug": "nest start --debug --watch",
@@ -30,7 +30,8 @@
3030
"test:e2e:watch": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.ts --watch",
3131
"prisma": "yarn workspace @calcom/prisma prisma",
3232
"generate-schemas": "yarn prisma generate && yarn prisma format",
33-
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts",
33+
"copy-swagger-module": "ts-node -r tsconfig-paths/register src/swagger/copy-swagger-module.ts",
34+
"generate-swagger": "yarn copy-swagger-module && yarn build && node ./dist/apps/api/v2/src/swagger/generate-swagger-script.js",
3435
"prepare": "yarn run snyk-protect",
3536
"snyk-protect": "snyk-protect"
3637
},

apps/api/v2/src/main.ts

Lines changed: 12 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,29 @@
11
import type { AppConfig } from "@/config/type";
2-
import { getEnv } from "@/env";
32
import { Logger } from "@nestjs/common";
43
import { ConfigService } from "@nestjs/config";
54
import { NestFactory } from "@nestjs/core";
65
import type { NestExpressApplication } from "@nestjs/platform-express";
7-
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
8-
import {
9-
PathItemObject,
10-
PathsObject,
11-
OperationObject,
12-
TagObject,
13-
} from "@nestjs/swagger/dist/interfaces/open-api-spec.interface";
146
import "dotenv/config";
15-
import * as fs from "fs";
16-
import { Server } from "http";
177
import { WinstonModule } from "nest-winston";
188

199
import { bootstrap } from "./app";
2010
import { AppModule } from "./app.module";
2111
import { loggerConfig } from "./lib/logger";
12+
import { generateSwaggerForApp } from "./swagger/generate-swagger";
2213

23-
const HttpMethods: (keyof PathItemObject)[] = ["get", "post", "put", "delete", "patch", "options", "head"];
24-
25-
const run = async () => {
26-
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
27-
logger: WinstonModule.createLogger(loggerConfig()),
28-
bodyParser: false,
29-
});
14+
run().catch((error: Error) => {
15+
console.error("Failed to start Cal Platform API", { error: error.stack });
16+
process.exit(1);
17+
});
3018

19+
async function run() {
20+
const app = await createNestApp();
3121
const logger = new Logger("App");
3222

3323
try {
3424
bootstrap(app);
3525
const port = app.get(ConfigService<AppConfig, true>).get("api.port", { infer: true });
36-
void generateSwagger(app);
26+
generateSwaggerForApp(app);
3727
await app.listen(port);
3828
logger.log(`Application started on port: ${port}`);
3929
} catch (error) {
@@ -42,97 +32,11 @@ const run = async () => {
4232
error,
4333
});
4434
}
45-
};
46-
47-
function customTagSort(a: string, b: string): number {
48-
const platformPrefix = "Platform";
49-
const orgsPrefix = "Orgs";
50-
51-
if (a.startsWith(platformPrefix) && !b.startsWith(platformPrefix)) {
52-
return -1;
53-
}
54-
if (!a.startsWith(platformPrefix) && b.startsWith(platformPrefix)) {
55-
return 1;
56-
}
57-
58-
if (a.startsWith(orgsPrefix) && !b.startsWith(orgsPrefix)) {
59-
return -1;
60-
}
61-
if (!a.startsWith(orgsPrefix) && b.startsWith(orgsPrefix)) {
62-
return 1;
63-
}
64-
65-
return a.localeCompare(b);
6635
}
6736

68-
function isOperationObject(obj: any): obj is OperationObject {
69-
return obj && typeof obj === "object" && "tags" in obj;
70-
}
71-
72-
function groupAndSortPathsByFirstTag(paths: PathsObject): PathsObject {
73-
const groupedPaths: { [key: string]: PathsObject } = {};
74-
75-
Object.keys(paths).forEach((pathKey) => {
76-
const pathItem = paths[pathKey];
77-
78-
HttpMethods.forEach((method) => {
79-
const operation = pathItem[method];
80-
81-
if (isOperationObject(operation) && operation.tags && operation.tags.length > 0) {
82-
const firstTag = operation.tags[0];
83-
84-
if (!groupedPaths[firstTag]) {
85-
groupedPaths[firstTag] = {};
86-
}
87-
88-
groupedPaths[firstTag][pathKey] = pathItem;
89-
}
90-
});
91-
});
92-
93-
const sortedTags = Object.keys(groupedPaths).sort(customTagSort);
94-
const sortedPaths: PathsObject = {};
95-
96-
sortedTags.forEach((tag) => {
97-
Object.assign(sortedPaths, groupedPaths[tag]);
37+
export async function createNestApp() {
38+
return NestFactory.create<NestExpressApplication>(AppModule, {
39+
logger: WinstonModule.createLogger(loggerConfig()),
40+
bodyParser: false,
9841
});
99-
100-
return sortedPaths;
101-
}
102-
103-
async function generateSwagger(app: NestExpressApplication<Server>) {
104-
const logger = new Logger("App");
105-
logger.log(`Generating Swagger documentation...\n`);
106-
107-
const config = new DocumentBuilder().setTitle("Cal.com API v2").build();
108-
const document = SwaggerModule.createDocument(app, config);
109-
document.paths = groupAndSortPathsByFirstTag(document.paths);
110-
111-
const swaggerOutputFile = "./swagger/documentation.json";
112-
const docsOutputFile = "../../../docs/api-reference/v2/openapi.json";
113-
const stringifiedContents = JSON.stringify(document, null, 2);
114-
115-
if (fs.existsSync(swaggerOutputFile)) {
116-
fs.unlinkSync(swaggerOutputFile);
117-
}
118-
119-
fs.writeFileSync(swaggerOutputFile, stringifiedContents, { encoding: "utf8" });
120-
121-
if (fs.existsSync(docsOutputFile) && getEnv("NODE_ENV") === "development") {
122-
fs.unlinkSync(docsOutputFile);
123-
fs.writeFileSync(docsOutputFile, stringifiedContents, { encoding: "utf8" });
124-
}
125-
126-
if (!process.env.DOCS_URL) {
127-
SwaggerModule.setup("docs", app, document, {
128-
customCss: ".swagger-ui .topbar { display: none }",
129-
});
130-
131-
logger.log(`Swagger documentation available in the "/docs" endpoint\n`);
132-
}
13342
}
134-
135-
run().catch((error: Error) => {
136-
console.error("Failed to start Cal Platform API", { error: error.stack });
137-
process.exit(1);
138-
});

apps/api/v2/swagger/copy-swagger-module.ts renamed to apps/api/v2/src/swagger/copy-swagger-module.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import * as path from "path";
88
// "nest-cli" with the "nest-cli.json" file, and for nest cli to be loaded with plugins correctly the "@nestjs/swagger"
99
// should reside in the project's node_modules already before the "nest start" command is executed.
1010
async function copyNestSwagger() {
11-
const monorepoRoot = path.resolve(__dirname, "../../../../");
12-
const nodeModulesNestjs = path.resolve(__dirname, "../node_modules/@nestjs");
11+
const monorepoRoot = path.resolve(__dirname, "../../../../../");
12+
const nodeModulesNestjs = path.resolve(__dirname, "../../node_modules/@nestjs");
1313
const swaggerModulePath = "@nestjs/swagger";
1414

1515
const sourceDir = path.join(monorepoRoot, "node_modules", swaggerModulePath);
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import "dotenv/config";
2+
3+
import { bootstrap } from "../app";
4+
import { createNestApp } from "../main";
5+
import { generateSwaggerForApp } from "../swagger/generate-swagger";
6+
7+
generateSwagger()
8+
.then(() => {
9+
console.log("✅ Swagger generation completed successfully");
10+
process.exit(0);
11+
})
12+
.catch((error: Error) => {
13+
console.error("❌ Failed to generate swagger", { error: error.stack });
14+
process.exit(1);
15+
});
16+
17+
async function generateSwagger() {
18+
const app = await createNestApp();
19+
20+
try {
21+
bootstrap(app);
22+
await generateSwaggerForApp(app);
23+
} catch (error) {
24+
console.error(error);
25+
throw error;
26+
} finally {
27+
await app.close();
28+
}
29+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { getEnv } from "@/env";
2+
import { Logger } from "@nestjs/common";
3+
import type { NestExpressApplication } from "@nestjs/platform-express";
4+
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
5+
import {
6+
PathItemObject,
7+
PathsObject,
8+
OperationObject,
9+
} from "@nestjs/swagger/dist/interfaces/open-api-spec.interface";
10+
import "dotenv/config";
11+
import * as fs from "fs";
12+
import { Server } from "http";
13+
import { spawnSync } from "node:child_process";
14+
15+
const HttpMethods: (keyof PathItemObject)[] = ["get", "post", "put", "delete", "patch", "options", "head"];
16+
17+
export async function generateSwaggerForApp(app: NestExpressApplication<Server>) {
18+
const logger = new Logger("App");
19+
logger.log(`Generating Swagger documentation...\n`);
20+
21+
const config = new DocumentBuilder().setTitle("Cal.com API v2").build();
22+
const document = SwaggerModule.createDocument(app, config);
23+
document.paths = groupAndSortPathsByFirstTag(document.paths);
24+
25+
const docsOutputFile = "../../../docs/api-reference/v2/openapi.json";
26+
const stringifiedContents = JSON.stringify(document, null, 2);
27+
28+
if (fs.existsSync(docsOutputFile) && getEnv("NODE_ENV") === "development") {
29+
fs.unlinkSync(docsOutputFile);
30+
fs.writeFileSync(docsOutputFile, stringifiedContents, { encoding: "utf8" });
31+
spawnSync("npx", ["prettier", docsOutputFile, "--write"], { stdio: "inherit" });
32+
}
33+
34+
if (!process.env.DOCS_URL) {
35+
SwaggerModule.setup("docs", app, document, {
36+
customCss: ".swagger-ui .topbar { display: none }",
37+
});
38+
39+
logger.log(`Swagger documentation available in the "/docs" endpoint\n`);
40+
}
41+
}
42+
43+
function groupAndSortPathsByFirstTag(paths: PathsObject): PathsObject {
44+
const groupedPaths: { [key: string]: PathsObject } = {};
45+
46+
Object.keys(paths).forEach((pathKey) => {
47+
const pathItem = paths[pathKey];
48+
49+
HttpMethods.forEach((method) => {
50+
const operation = pathItem[method];
51+
52+
if (isOperationObject(operation) && operation.tags && operation.tags.length > 0) {
53+
const firstTag = operation.tags[0];
54+
55+
if (!groupedPaths[firstTag]) {
56+
groupedPaths[firstTag] = {};
57+
}
58+
59+
groupedPaths[firstTag][pathKey] = pathItem;
60+
}
61+
});
62+
});
63+
64+
const sortedTags = Object.keys(groupedPaths).sort(customTagSort);
65+
const sortedPaths: PathsObject = {};
66+
67+
sortedTags.forEach((tag) => {
68+
Object.assign(sortedPaths, groupedPaths[tag]);
69+
});
70+
71+
return sortedPaths;
72+
}
73+
74+
function customTagSort(a: string, b: string): number {
75+
const platformPrefix = "Platform";
76+
const orgsPrefix = "Orgs";
77+
78+
if (a.startsWith(platformPrefix) && !b.startsWith(platformPrefix)) {
79+
return -1;
80+
}
81+
if (!a.startsWith(platformPrefix) && b.startsWith(platformPrefix)) {
82+
return 1;
83+
}
84+
85+
if (a.startsWith(orgsPrefix) && !b.startsWith(orgsPrefix)) {
86+
return -1;
87+
}
88+
if (!a.startsWith(orgsPrefix) && b.startsWith(orgsPrefix)) {
89+
return 1;
90+
}
91+
92+
return a.localeCompare(b);
93+
}
94+
95+
function isOperationObject(obj: any): obj is OperationObject {
96+
return obj && typeof obj === "object" && "tags" in obj;
97+
}

0 commit comments

Comments
 (0)