From d86e6f31d0db23e0d7a000a5111ecf0bd23d12c8 Mon Sep 17 00:00:00 2001 From: Amir Zouerami <53701884+Amir-Zouerami@users.noreply.github.com> Date: Wed, 12 Nov 2025 20:29:17 +0330 Subject: [PATCH 1/6] SEED: new seed script for richer seed data. --- prisma/seed.ts | 176 +++++++++++++++++++++++++------------------------ 1 file changed, 90 insertions(+), 86 deletions(-) diff --git a/prisma/seed.ts b/prisma/seed.ts index fe09a8a..50fbbcb 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -128,7 +128,7 @@ async function seedProjects() { const backendTeam = await tx.team.findUniqueOrThrow({ where: { id: 'backend' } }); // ================================================================= - // --- Project 1: Project Nova --- + // --- Project 1: Project Nova (Our "Golden" Spec for Mocking) --- // ================================================================= const projectNova = await tx.project.upsert({ where: { nameNormalized: 'project nova' }, @@ -136,7 +136,7 @@ async function seedProjects() { create: { name: 'Project Nova', nameNormalized: 'project nova', - description: 'A test project for the UI team.', + description: 'A comprehensive, public-facing User & Profile API.', serverUrl: 'https://api.nova.test', creatorId: amir.id, updatedById: amir.id, @@ -158,7 +158,7 @@ async function seedProjects() { }, }); - // --- Endpoints for Project Nova (RICH DATA) --- + // --- Endpoints for Project Nova (RICH DATA for MOCKING) --- const novaUserListEndpoint = await tx.endpoint.create({ data: { path: '/users', @@ -171,21 +171,19 @@ async function seedProjects() { tags: ['Users'], summary: 'List All Users', description: 'Retrieves a paginated list of all users in the system.', - parameters: [ - { - name: 'limit', - in: 'query', - description: 'Number of users to return.', - schema: { type: 'integer', default: 20 }, - }, - { - name: 'page', - in: 'query', - description: 'Page number for pagination.', - schema: { type: 'integer', default: 1 }, + responses: { + '200': { + description: 'A paginated list of users.', + content: { + 'application/json': { + schema: { + type: 'array', + items: { $ref: '#/components/schemas/User' }, + }, + }, + }, }, - ], - responses: { '200': { description: 'A paginated list of users.' } }, + }, }, }, }); @@ -193,7 +191,8 @@ async function seedProjects() { // Add a note to the endpoint we just created await tx.note.create({ data: { - content: 'This endpoint needs to be updated to include a search parameter for Q4.', + content: + 'This endpoint needs to be updated to include a search parameter for Q4. @david.chen can you look into this?', endpointId: novaUserListEndpoint.id, authorId: brooklyn.id, }, @@ -203,7 +202,7 @@ async function seedProjects() { data: { path: '/users/{userId}', method: 'get', - status: 'IN_REVIEW', + status: 'PUBLISHED', projectId: projectNova.id, creatorId: amir.id, updatedById: amir.id, @@ -214,14 +213,61 @@ async function seedProjects() { { name: 'userId', in: 'path', required: true, schema: { type: 'string' } }, ], responses: { - '200': { description: 'User details.' }, - '404': { description: 'User not found.' }, + '200': { + description: 'User details found.', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/User' }, + }, + }, + }, + '404': { + description: 'User not found.', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ErrorResponse' }, + }, + }, + }, }, }, }, }); - // --- Reusable Schema Components for Project Nova --- + await tx.endpoint.create({ + data: { + path: '/users', + method: 'post', + status: 'PUBLISHED', + projectId: projectNova.id, + creatorId: amir.id, + updatedById: amir.id, + operation: { + tags: ['Users'], + summary: 'Create a new user', + responses: { + '201': { + description: 'User created successfully.', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/User' }, + }, + }, + }, + '409': { + description: 'User with this username already exists.', + content: { + 'application/json': { + schema: { $ref: '#/components/schemas/ErrorResponse' }, + }, + }, + }, + }, + }, + }, + }); + + // --- Reusable Schema Components for Project Nova (with x-faker) --- await tx.schemaComponent.createMany({ data: [ { @@ -230,11 +276,12 @@ async function seedProjects() { schema: { type: 'object', properties: { - id: { type: 'string', format: 'uuid', example: '...-....-....-....' }, - username: { type: 'string', example: 'jane.doe' }, - email: { type: 'string', format: 'email' }, + id: { type: 'string', format: 'uuid', xfaker: 'string.uuid' }, + firstName: { type: 'string', 'x-faker': 'person.firstName' }, + lastName: { type: 'string', 'x-faker': 'person.lastName' }, + username: { type: 'string', 'x-faker': 'internet.userName' }, }, - required: ['id', 'username', 'email'], + required: ['id', 'firstName', 'lastName', 'username'], }, projectId: projectNova.id, creatorId: amir.id, @@ -246,9 +293,9 @@ async function seedProjects() { schema: { type: 'object', properties: { - statusCode: { type: 'integer', example: 404 }, - message: { type: 'string', example: 'Resource not found.' }, - error: { type: 'string', example: 'Not Found' }, + statusCode: { type: 'integer' }, + message: { type: 'string', 'x-faker': 'lorem.sentence' }, + error: { type: 'string' }, }, required: ['statusCode', 'message'], }, @@ -292,7 +339,7 @@ async function seedProjects() { }); // ================================================================= - // --- Project 2: Project Apollo --- + // --- Project 2: Project Apollo (Internal API) --- // ================================================================= const projectApollo = await tx.project.upsert({ where: { nameNormalized: 'project apollo' }, @@ -314,7 +361,7 @@ async function seedProjects() { }, }); - // --- Endpoints for Project Apollo (RICH DATA) --- + // --- Endpoints for Project Apollo (different structure and statuses) --- await tx.endpoint.createMany({ data: [ { @@ -327,77 +374,34 @@ async function seedProjects() { operation: { tags: ['Authentication'], summary: 'Create a Session', - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - properties: { deviceId: { type: 'string' } }, - }, - }, - }, - }, responses: { '201': { description: 'Session created.' } }, }, }, { path: '/profile/settings', method: 'put', - status: 'DRAFT', + status: 'IN_REVIEW', projectId: projectApollo.id, creatorId: brooklyn.id, updatedById: brooklyn.id, operation: { tags: ['Profile'], summary: "Update User's Settings", - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - enablePushNotifications: { type: 'boolean' }, - }, - }, - }, - }, - }, responses: { '200': { description: 'Settings updated.' } }, }, }, - ], - }); - - // --- Environments & Secrets for Project Apollo --- - const apolloDev = await tx.environment.create({ - data: { name: 'Development', projectId: projectApollo.id }, - }); - const apolloQA = await tx.environment.create({ - data: { name: 'QA', projectId: projectApollo.id }, - }); - await tx.secret.createMany({ - data: [ - { - key: 'AUTH_TOKEN_SECRET', - value: encrypt('dev-secret-key-for-apollo'), - environmentId: apolloDev.id, - }, { - key: 'REDIS_URL', - value: encrypt('redis://localhost:6379'), - environmentId: apolloDev.id, - }, - { - key: 'AUTH_TOKEN_SECRET', - value: encrypt('qa-secret-key-for-apollo'), - environmentId: apolloQA.id, - }, - { - key: 'THIRD_PARTY_API_KEY', - value: encrypt('qa-key-12345'), - environmentId: apolloQA.id, + path: '/diagnostics/health', + method: 'get', + status: 'DRAFT', + projectId: projectApollo.id, + creatorId: brooklyn.id, + updatedById: brooklyn.id, + operation: { + tags: ['Diagnostics'], + summary: 'Health Check', + responses: { '200': { description: 'Service is healthy.' } }, + }, }, ], }); From 14861f52ba1393ab42a35e1d09088820c2c3a751 Mon Sep 17 00:00:00 2001 From: Amir Zouerami <53701884+Amir-Zouerami@users.noreply.github.com> Date: Wed, 12 Nov 2025 20:29:33 +0330 Subject: [PATCH 2/6] LINT: a new rule `success-response-content-defined` added. --- .spectral.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.spectral.yaml b/.spectral.yaml index 93b3ac0..b3d445e 100644 --- a/.spectral.yaml +++ b/.spectral.yaml @@ -46,3 +46,19 @@ rules: # On each matching operation object, assert that `summary` is truthy. field: summary function: truthy + + # --------------------------------------------------------------------------- + # Custom rule: "success-response-content-defined" + # Enforces that every 2xx or 3xx response has a `content` object. + # --------------------------------------------------------------------------- + success-response-content-defined: + description: Success responses must have a 'content' object. + message: 'All 2xx and 3xx responses must define a `content` object to describe the response body.' + severity: error + recommended: true + # Target all response objects whose key (the status code) starts with a '2' or '3'. + given: '$.paths.*.*.responses[?(@property.match(/^[23]/))]' + then: + # On each matching response object, assert that `content` is a truthy value. + field: content + function: truthy From 59370ad18f492dc92ff8306528373e71862d3000 Mon Sep 17 00:00:00 2001 From: Amir Zouerami <53701884+Amir-Zouerami@users.noreply.github.com> Date: Wed, 12 Nov 2025 20:30:09 +0330 Subject: [PATCH 3/6] PACKAGE: faker package for mock server installed. --- bun.lock | 20 ++++++++++++++++++++ package.json | 4 ++++ 2 files changed, 24 insertions(+) diff --git a/bun.lock b/bun.lock index c9f44df..f33ddc9 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "schematica-api", "dependencies": { "@apidevtools/swagger-parser": "^12.1.0", + "@faker-js/faker": "^10.1.0", "@fastify/cookie": "^11.0.2", "@fastify/multipart": "^9.3.0", "@fastify/passport": "^3.0.2", @@ -31,12 +32,14 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "fastify": "5.6.1", + "json-schema-faker": "^0.5.9", "nestjs-pino": "^4.4.1", "openapi-types": "^12.1.3", "passport": "^0.7.0", "passport-gitlab-ts": "^1.0.2", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", + "path-to-regexp": "^8.3.0", "pino-http": "^11.0.0", "pino-pretty": "^13.1.2", "reflect-metadata": "^0.2.2", @@ -52,6 +55,7 @@ "@stoplight/types": "^14.1.1", "@types/bcrypt": "^6.0.0", "@types/jest": "^30.0.0", + "@types/json-schema-faker": "^0.5.6", "@types/node": "22.18.13", "@types/passport": "^1.0.17", "@types/passport-jwt": "^4.0.1", @@ -202,6 +206,8 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + "@faker-js/faker": ["@faker-js/faker@10.1.0", "", {}, "sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg=="], + "@fastify/accept-negotiator": ["@fastify/accept-negotiator@2.0.1", "", {}, "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ=="], "@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.5", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0" } }, "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A=="], @@ -522,6 +528,8 @@ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/json-schema-faker": ["@types/json-schema-faker@0.5.6", "", { "dependencies": { "json-schema-faker": "*" } }, "sha512-5/ptQ1nugrGwgNlayDzpcz71xaRuUW6trh/b6SCLTwHWCcYQQpr4DhY7SPub7cY1DDf0umAGGnoAmIyf9JzTlw=="], + "@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="], "@types/luxon": ["@types/luxon@3.7.1", "", {}, "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg=="], @@ -1056,6 +1064,8 @@ "form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="], + "format-util": ["format-util@1.0.5", "", {}, "sha512-varLbTj0e0yVyRpqQhuWV+8hlePAgaoFRhNFj50BNjEIrw1/DphHSObtqwskVCPWNgzwPoQrZAbfa/SBiicNeg=="], + "formidable": ["formidable@3.5.4", "", { "dependencies": { "@paralleldrive/cuid2": "^2.2.2", "dezalgo": "^1.0.4", "once": "^1.4.0" } }, "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug=="], "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], @@ -1300,6 +1310,10 @@ "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + "json-schema-faker": ["json-schema-faker@0.5.9", "", { "dependencies": { "json-schema-ref-parser": "^6.1.0", "jsonpath-plus": "^10.3.0" }, "bin": { "jsf": "bin/gen.cjs" } }, "sha512-fNKLHgDvfGNNTX1zqIjqFMJjCLzJ2kvnJ831x4aqkAoeE4jE2TxvpJdhOnk3JU3s42vFzmXvkpbYzH5H3ncAzg=="], + + "json-schema-ref-parser": ["json-schema-ref-parser@6.1.0", "", { "dependencies": { "call-me-maybe": "^1.0.1", "js-yaml": "^3.12.1", "ono": "^4.0.11" } }, "sha512-pXe9H1m6IgIpXmE5JSb8epilNTGsmTb2iPohAXpOdhqGFbQjNeHHsZxU+C8w6T81GZxSPFLeUoqDJmzxx5IGuw=="], + "json-schema-ref-resolver": ["json-schema-ref-resolver@3.0.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A=="], "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], @@ -1458,6 +1472,8 @@ "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + "ono": ["ono@4.0.11", "", { "dependencies": { "format-util": "^1.0.3" } }, "sha512-jQ31cORBFE6td25deYeD80wxKBMj+zBmHTrVxnc6CKhx8gho6ipmWM5zj/oeoqioZ99yqBls9Z/9Nss7J26G2g=="], + "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], @@ -2026,6 +2042,8 @@ "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + "json-schema-ref-parser/js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="], + "light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="], "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], @@ -2114,6 +2132,8 @@ "jest-runtime/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "json-schema-ref-parser/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "pino-http/pino/safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], diff --git a/package.json b/package.json index 34f197a..506785b 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ }, "dependencies": { "@apidevtools/swagger-parser": "^12.1.0", + "@faker-js/faker": "^10.1.0", "@fastify/cookie": "^11.0.2", "@fastify/multipart": "^9.3.0", "@fastify/passport": "^3.0.2", @@ -48,12 +49,14 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "fastify": "5.6.1", + "json-schema-faker": "^0.5.9", "nestjs-pino": "^4.4.1", "openapi-types": "^12.1.3", "passport": "^0.7.0", "passport-gitlab-ts": "^1.0.2", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", + "path-to-regexp": "^8.3.0", "pino-http": "^11.0.0", "pino-pretty": "^13.1.2", "reflect-metadata": "^0.2.2", @@ -69,6 +72,7 @@ "@stoplight/types": "^14.1.1", "@types/bcrypt": "^6.0.0", "@types/jest": "^30.0.0", + "@types/json-schema-faker": "^0.5.6", "@types/node": "22.18.13", "@types/passport": "^1.0.17", "@types/passport-jwt": "^4.0.1", From bfffaf51b5bc0a7947e4b9e8188bf2543ce104f6 Mon Sep 17 00:00:00 2001 From: Amir Zouerami <53701884+Amir-Zouerami@users.noreply.github.com> Date: Wed, 12 Nov 2025 20:30:24 +0330 Subject: [PATCH 4/6] MOCK: mock server implemented. --- src/app.module.ts | 2 + src/auth/auth.service.spec.ts | 2 +- .../mock-endpoint-not-found.exception.ts | 12 ++ .../mock-generation-conflict.exception.ts | 12 ++ .../mock-response-not-found.exception.ts | 12 ++ src/mock-server/mock-server.controller.ts | 76 ++++++++ src/mock-server/mock-server.module.ts | 12 ++ src/mock-server/mock-server.service.spec.ts | 30 +++ src/mock-server/mock-server.service.ts | 175 ++++++++++++++++++ src/projects/endpoints/endpoints.module.ts | 3 +- src/projects/endpoints/endpoints.service.ts | 51 +++++ src/projects/projects.module.ts | 1 + 12 files changed, 386 insertions(+), 2 deletions(-) create mode 100644 src/mock-server/exceptions/mock-endpoint-not-found.exception.ts create mode 100644 src/mock-server/exceptions/mock-generation-conflict.exception.ts create mode 100644 src/mock-server/exceptions/mock-response-not-found.exception.ts create mode 100644 src/mock-server/mock-server.controller.ts create mode 100644 src/mock-server/mock-server.module.ts create mode 100644 src/mock-server/mock-server.service.spec.ts create mode 100644 src/mock-server/mock-server.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index 9f2ff9e..1f6d641 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -26,6 +26,7 @@ import fileConfig from './config/file.config'; import oauthConfig from './config/oauth.config'; import { EncryptionModule } from './encryption/encryption.module'; import { LockingModule } from './locking/locking.module'; +import { MockServerModule } from './mock-server/mock-server.module'; import { NotificationModule } from './notifications/notification.module'; import { PrismaModule } from './prisma/prisma.module'; import { ProfileModule } from './profile/profile.module'; @@ -93,6 +94,7 @@ import { UsersModule } from './users/users.module'; ApiLintingModule, LockingModule, EncryptionModule, + MockServerModule, ], controllers: [], providers: [ diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index 46bb711..dcd7f84 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -1,6 +1,6 @@ import { JwtService } from '@nestjs/jwt'; import { Test, type TestingModule } from '@nestjs/testing'; -import { PinoLogger } from 'nestjs-pino'; // <-- IMPORT +import { PinoLogger } from 'nestjs-pino'; import { PrismaService } from 'src/prisma/prisma.service'; import { AuthService } from './auth.service'; diff --git a/src/mock-server/exceptions/mock-endpoint-not-found.exception.ts b/src/mock-server/exceptions/mock-endpoint-not-found.exception.ts new file mode 100644 index 0000000..c2be8d1 --- /dev/null +++ b/src/mock-server/exceptions/mock-endpoint-not-found.exception.ts @@ -0,0 +1,12 @@ +import { HttpStatus } from '@nestjs/common'; +import { BaseAppException } from 'src/common/exceptions/base-app.exception'; + +export class MockEndpointNotFoundException extends BaseAppException { + constructor(method: string, path: string) { + super( + `Mock endpoint '${method.toUpperCase()} ${path}' not found in the project's specification.`, + HttpStatus.NOT_FOUND, + 'MOCK_ENDPOINT_NOT_FOUND', + ); + } +} diff --git a/src/mock-server/exceptions/mock-generation-conflict.exception.ts b/src/mock-server/exceptions/mock-generation-conflict.exception.ts new file mode 100644 index 0000000..371b14a --- /dev/null +++ b/src/mock-server/exceptions/mock-generation-conflict.exception.ts @@ -0,0 +1,12 @@ +import { HttpStatus } from '@nestjs/common'; +import { BaseAppException } from 'src/common/exceptions/base-app.exception'; + +export class MockGenerationConflictException extends BaseAppException { + constructor(method: string, path: string, statusCode: string) { + super( + `Cannot generate mock body. The OpenAPI specification for this project is incomplete. The success response ('${statusCode}') for endpoint '${method.toUpperCase()} ${path}' is missing a valid 'content' schema.`, + HttpStatus.CONFLICT, + 'MOCK_GENERATION_CONFLICT', + ); + } +} diff --git a/src/mock-server/exceptions/mock-response-not-found.exception.ts b/src/mock-server/exceptions/mock-response-not-found.exception.ts new file mode 100644 index 0000000..e352d3d --- /dev/null +++ b/src/mock-server/exceptions/mock-response-not-found.exception.ts @@ -0,0 +1,12 @@ +import { HttpStatus } from '@nestjs/common'; +import { BaseAppException } from 'src/common/exceptions/base-app.exception'; + +export class MockResponseNotFoundException extends BaseAppException { + constructor(method: string, path: string, statusCode: string) { + super( + `Response with status code '${statusCode}' is not defined for endpoint '${method.toUpperCase()} ${path}' in the project's specification.`, + HttpStatus.NOT_FOUND, + 'MOCK_RESPONSE_NOT_FOUND', + ); + } +} diff --git a/src/mock-server/mock-server.controller.ts b/src/mock-server/mock-server.controller.ts new file mode 100644 index 0000000..f60b373 --- /dev/null +++ b/src/mock-server/mock-server.controller.ts @@ -0,0 +1,76 @@ +import { All, Controller, Headers, Param, Req, Res, UseGuards } from '@nestjs/common'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { + ApiBearerAuth, + ApiHeader, + ApiNotFoundResponse, + ApiOkResponse, + ApiParam, + ApiTags, +} from '@nestjs/swagger'; +import { CurrentUser } from 'src/auth/decorators/current-user.decorator'; +import { UserDto } from 'src/auth/dto/user.dto'; +import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard'; +import { MockServerService } from './mock-server.service'; + +@ApiTags('Mock Server') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('mock') +export class MockServerController { + constructor(private readonly mockServerService: MockServerService) {} + + @All('*') + @ApiParam({ + name: 'projectId', + description: 'The ID of the project to generate a mock response for.', + }) + @ApiParam({ + name: '*', + description: 'The API path to mock (e.g., "users/123/profile").', + }) + @ApiHeader({ + name: 'x-mock-status-code', + description: 'An optional status code to force the mock server to return.', + required: false, + example: '404', + }) + @ApiHeader({ + name: 'x-mock-project-id', + description: 'The ID of the project whose specification should be used for mocking.', + required: true, + }) + @ApiHeader({ + name: 'x-mock-locale', + description: 'An optional locale to generate mock data in (e.g., "fa" for Persian).', + required: false, + example: 'fa', + }) + @ApiOkResponse({ + description: + "A dynamically generated mock response based on the project's OpenAPI specification.", + }) + @ApiNotFoundResponse({ + description: 'The project or the requested API path was not found in the specification.', + }) + async handleMockRequest( + @Param('*') mockPath: string, + @Headers('x-mock-project-id') projectId: string | undefined, + @Headers('x-mock-status-code') requestedStatusCode: string | undefined, + @Headers('x-mock-locale') requestedLocale: string | undefined, + @Req() req: FastifyRequest, + @Res() res: FastifyReply, + @CurrentUser() user: UserDto, + ): Promise { + const result = await this.mockServerService.generateMockResponse( + projectId, + req.method, + `/${mockPath}`, + user, + requestedStatusCode, + requestedLocale, + ); + + await res.status(result.statusCode).send(result.body); + } +} diff --git a/src/mock-server/mock-server.module.ts b/src/mock-server/mock-server.module.ts new file mode 100644 index 0000000..a2850a6 --- /dev/null +++ b/src/mock-server/mock-server.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AccessControlModule } from 'src/access-control/access-control.module'; +import { ProjectsModule } from 'src/projects/projects.module'; +import { MockServerController } from './mock-server.controller'; +import { MockServerService } from './mock-server.service'; + +@Module({ + imports: [ProjectsModule, AccessControlModule], + controllers: [MockServerController], + providers: [MockServerService], +}) +export class MockServerModule {} diff --git a/src/mock-server/mock-server.service.spec.ts b/src/mock-server/mock-server.service.spec.ts new file mode 100644 index 0000000..582994c --- /dev/null +++ b/src/mock-server/mock-server.service.spec.ts @@ -0,0 +1,30 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PinoLogger } from 'nestjs-pino'; +import { AccessControlService } from 'src/access-control/access-control.service'; +import { ProjectsService } from 'src/projects/projects.service'; +import { MockServerService } from './mock-server.service'; + +describe('MockServerService', () => { + let service: MockServerService; + + const mockPinoLogger = { setContext: jest.fn() }; + const mockProjectsService = {}; + const mockAccessControlService = {}; + + beforeEach(async (): Promise => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MockServerService, + { provide: PinoLogger, useValue: mockPinoLogger }, + { provide: ProjectsService, useValue: mockProjectsService }, + { provide: AccessControlService, useValue: mockAccessControlService }, + ], + }).compile(); + + service = module.get(MockServerService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/mock-server/mock-server.service.ts b/src/mock-server/mock-server.service.ts new file mode 100644 index 0000000..968ccee --- /dev/null +++ b/src/mock-server/mock-server.service.ts @@ -0,0 +1,175 @@ +import SwaggerParser from '@apidevtools/swagger-parser'; +import { Faker, en, fa, type LocaleDefinition } from '@faker-js/faker'; +import { BadRequestException, Injectable, InternalServerErrorException } from '@nestjs/common'; +import { JSONSchemaFaker, Schema } from 'json-schema-faker'; +import { PinoLogger } from 'nestjs-pino'; +import { OpenAPIV3 } from 'openapi-types'; +import { match } from 'path-to-regexp'; +import { AccessControlService } from 'src/access-control/access-control.service'; +import { UserDto } from 'src/auth/dto/user.dto'; +import { VALID_HTTP_METHODS } from 'src/common/constants/http.constants'; +import { ProjectNotFoundException } from 'src/common/exceptions/project-not-found.exception'; +import { ProjectsService } from 'src/projects/projects.service'; +import { MockEndpointNotFoundException } from './exceptions/mock-endpoint-not-found.exception'; +import { MockGenerationConflictException } from './exceptions/mock-generation-conflict.exception'; +import { MockResponseNotFoundException } from './exceptions/mock-response-not-found.exception'; + +interface MockResponse { + body: unknown; + statusCode: number; +} + +const SUPPORTED_LOCALES: Record = { + en, + fa, +}; + +@Injectable() +export class MockServerService { + constructor( + private readonly logger: PinoLogger, + private readonly projectsService: ProjectsService, + private readonly accessControlService: AccessControlService, + ) { + this.logger.setContext(MockServerService.name); + } + + async generateMockResponse( + projectId: string | undefined, + method: string, + path: string, + user: UserDto, + requestedStatusCode?: string, + requestedLocale?: string, + ): Promise { + if (!projectId) { + throw new BadRequestException('The "X-Mock-Project-ID" header is required.'); + } + + const canView = await this.accessControlService.canViewProject(user, projectId); + if (!canView) { + throw new ProjectNotFoundException(projectId); + } + + const assembledSpec = (await this.projectsService.getOpenApiSpec( + projectId, + user, + )) as unknown as OpenAPIV3.Document; + + const spec = (await SwaggerParser.dereference(assembledSpec)) as OpenAPIV3.Document; + + const lowerCaseMethod = method.toLowerCase(); + const matchedOperation = this.findMatchingOperation(spec, lowerCaseMethod, path); + + if (!matchedOperation?.responses) { + throw new MockEndpointNotFoundException(method, path); + } + + const { response, statusCode } = this.selectResponse( + matchedOperation.responses, + requestedStatusCode, + ); + + if (!response) { + throw new MockResponseNotFoundException(method, path, statusCode); + } + + const schema = this.getSchemaFromResponse(response); + + if (!schema && statusCode.startsWith('2') && statusCode !== '204') { + throw new MockGenerationConflictException(method, path, statusCode); + } + + try { + const faker = this.getFakerInstance(requestedLocale); + JSONSchemaFaker.extend('faker', () => faker); + + const mockBody = schema ? JSONSchemaFaker.generate(schema as Schema) : null; + + return { + body: mockBody, + statusCode: parseInt(statusCode, 10), + }; + } catch (error: unknown) { + this.logger.error({ error }, 'Failed to generate mock data from schema.'); + throw new InternalServerErrorException('Could not generate mock response body.'); + } + } + + private getFakerInstance(locale?: string): Faker { + const selectedLocale = + locale && Object.hasOwn(SUPPORTED_LOCALES, locale) ? SUPPORTED_LOCALES[locale] : en; + + return new Faker({ locale: [selectedLocale, en] }); + } + + private findMatchingOperation( + spec: OpenAPIV3.Document, + method: string, + requestPath: string, + ): OpenAPIV3.OperationObject | null { + if (!spec.paths) return null; + + for (const specPath of Object.keys(spec.paths)) { + const parsablePath = specPath.replace(/{(\w+)}/g, ':$1'); + const matcher = match(parsablePath, { decode: decodeURIComponent }); + const isMatch = matcher(requestPath); + + if (isMatch) { + const pathItem = spec.paths[specPath]; + if (!pathItem) continue; + + if (VALID_HTTP_METHODS.has(method) && method in pathItem) { + const operation = pathItem[method as OpenAPIV3.HttpMethods]; + if (operation && '$ref' in operation) continue; + if (operation) return operation; + } + } + } + + return null; + } + + private selectResponse( + responses: OpenAPIV3.ResponsesObject, + requestedStatus?: string, + ): { response: OpenAPIV3.ResponseObject | null; statusCode: string } { + if (requestedStatus) { + if (responses[requestedStatus]) { + const res = responses[requestedStatus]; + if ('$ref' in res) return { response: null, statusCode: requestedStatus }; + return { response: res, statusCode: requestedStatus }; + } + + return { response: null, statusCode: requestedStatus }; + } + + const successStatus = Object.keys(responses).find( + (code) => code.startsWith('2') && code !== '204', + ); + + if (successStatus) { + const res = responses[successStatus]; + if ('$ref' in res) return { response: null, statusCode: successStatus }; + return { response: res, statusCode: successStatus }; + } + + if (responses['204']) { + const res = responses['204']; + if ('$ref' in res) return { response: null, statusCode: '204' }; + return { response: res, statusCode: '204' }; + } + + return { response: null, statusCode: '200' }; + } + + private getSchemaFromResponse( + response: OpenAPIV3.ResponseObject, + ): OpenAPIV3.SchemaObject | null { + const content = response.content?.['application/json']?.schema; + if (!content) { + return null; + } + return content as OpenAPIV3.SchemaObject; + } +} diff --git a/src/projects/endpoints/endpoints.module.ts b/src/projects/endpoints/endpoints.module.ts index 3f119bf..8922a44 100644 --- a/src/projects/endpoints/endpoints.module.ts +++ b/src/projects/endpoints/endpoints.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { AccessControlModule } from 'src/access-control/access-control.module'; +import { ApiLintingModule } from 'src/api-linting/api-linting.module'; import { LockingModule } from 'src/locking/locking.module'; import { PrismaModule } from 'src/prisma/prisma.module'; import { GuardsModule } from '../guards/guards.module'; @@ -7,7 +8,7 @@ import { EndpointsController } from './endpoints.controller'; import { EndpointsService } from './endpoints.service'; @Module({ - imports: [PrismaModule, GuardsModule, AccessControlModule, LockingModule], + imports: [PrismaModule, GuardsModule, AccessControlModule, LockingModule, ApiLintingModule], controllers: [EndpointsController], providers: [EndpointsService], }) diff --git a/src/projects/endpoints/endpoints.service.ts b/src/projects/endpoints/endpoints.service.ts index d031ab4..70fddbf 100644 --- a/src/projects/endpoints/endpoints.service.ts +++ b/src/projects/endpoints/endpoints.service.ts @@ -5,8 +5,14 @@ import { InternalServerErrorException, } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; +import type { + OpenAPIObject, + OperationObject, + PathItemObject, +} from '@nestjs/swagger/dist/interfaces/open-api-spec.interface'; import { EndpointStatus, Prisma } from '@prisma/client'; import { PinoLogger } from 'nestjs-pino'; +import { ApiLintingService } from 'src/api-linting/api-linting.service'; import { AuditAction, AuditEvent, AuditLogEvent } from 'src/audit/audit.events'; import { UserDto } from 'src/auth/dto/user.dto'; import { @@ -15,6 +21,7 @@ import { EndpointStatusChangeEvent, EndpointUpdateChangeEvent, } from 'src/changelog/changelog.events'; +import { VALID_HTTP_METHODS } from 'src/common/constants/http.constants'; import { PrismaErrorCode } from 'src/common/constants/prisma-error-codes.constants'; import { PaginationQueryDto } from 'src/common/dto/pagination-query.dto'; import { EndpointConcurrencyException } from 'src/common/exceptions/endpoint-concurrency.exception'; @@ -22,6 +29,7 @@ import { EndpointConflictException } from 'src/common/exceptions/endpoint-confli import { EndpointNotFoundException } from 'src/common/exceptions/endpoint-not-found.exception'; import { EndpointStatusConcurrencyException } from 'src/common/exceptions/endpoint-status-concurrency.exception'; import { ProjectNotFoundException } from 'src/common/exceptions/project-not-found.exception'; +import { SpecLintingException } from 'src/common/exceptions/spec-linting.exception'; import { PaginatedServiceResponse } from 'src/common/interfaces/api-response.interface'; import { handlePrismaError } from 'src/common/utils/prisma-error.util'; import { @@ -42,6 +50,7 @@ export class EndpointsService { private readonly prisma: PrismaService, private readonly logger: PinoLogger, private readonly eventEmitter: EventEmitter2, + private readonly apiLintingService: ApiLintingService, ) { this.logger.setContext(EndpointsService.name); } @@ -115,6 +124,9 @@ export class EndpointsService { creator: UserDto, ): Promise { const { path, method, operation } = createEndpointDto; + + await this.lintOperation(operation, path, method); + const now = new Date().toISOString(); const metadata: EndpointAppMetadata = { @@ -178,6 +190,9 @@ export class EndpointsService { updater: UserDto, ): Promise { const { path, method, operation, lastKnownUpdatedAt } = updateEndpointDto; + + await this.lintOperation(operation, path, method); + const now = new Date().toISOString(); try { @@ -367,4 +382,40 @@ export class EndpointsService { throw new BadRequestException(`Cannot transition endpoint from '${from}' to '${to}'.`); } } + + private async lintOperation( + operation: OperationObject, + path: string, + method: string, + ): Promise { + type HttpMethod = keyof Omit< + PathItemObject, + 'summary' | 'description' | 'servers' | 'parameters' + >; + + const normalizedMethod = method.toLowerCase(); + if (!VALID_HTTP_METHODS.has(normalizedMethod)) { + throw new BadRequestException(`Invalid HTTP method: ${method}`); + } + + const pathItem: PathItemObject = { + [normalizedMethod as HttpMethod]: operation, + }; + + const minimalSpec: OpenAPIObject = { + openapi: '3.0.0', + info: { + title: 'Validation Spec', + version: '1.0.0', + }, + paths: { + [path]: pathItem, + }, + }; + + const issues = await this.apiLintingService.lintSpec(minimalSpec); + if (issues.length > 0) { + throw new SpecLintingException(issues); + } + } } diff --git a/src/projects/projects.module.ts b/src/projects/projects.module.ts index 660ae20..16ca747 100644 --- a/src/projects/projects.module.ts +++ b/src/projects/projects.module.ts @@ -35,5 +35,6 @@ import { AreLinksUniqueConstraint } from './validators/are-links-unique.validato AreLinksUniqueConstraint, SpecReconciliationService, ], + exports: [ProjectsService], }) export class ProjectsModule {} From f6dc29440bba63d8b9486ab1bf5c457340055625 Mon Sep 17 00:00:00 2001 From: Amir Zouerami <53701884+Amir-Zouerami@users.noreply.github.com> Date: Wed, 12 Nov 2025 20:45:10 +0330 Subject: [PATCH 5/6] TEST: fixing faker ESM/CommonJS incompatibilities in our tests. --- bun.lock | 4 ++-- package.json | 5 ++++- src/projects/endpoints/endpoints.service.spec.ts | 3 +++ test/jest-e2e.json | 3 +++ 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index f33ddc9..3c8faa9 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "name": "schematica-api", "dependencies": { "@apidevtools/swagger-parser": "^12.1.0", - "@faker-js/faker": "^10.1.0", + "@faker-js/faker": "~9.0.0", "@fastify/cookie": "^11.0.2", "@fastify/multipart": "^9.3.0", "@fastify/passport": "^3.0.2", @@ -206,7 +206,7 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], - "@faker-js/faker": ["@faker-js/faker@10.1.0", "", {}, "sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg=="], + "@faker-js/faker": ["@faker-js/faker@9.0.3", "", {}, "sha512-lWrrK4QNlFSU+13PL9jMbMKLJYXDFu3tQfayBsMXX7KL/GiQeqfB1CzHkqD5UHBUtPAuPo6XwGbMFNdVMZObRA=="], "@fastify/accept-negotiator": ["@fastify/accept-negotiator@2.0.1", "", {}, "sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ=="], diff --git a/package.json b/package.json index 506785b..7a47fbd 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ }, "dependencies": { "@apidevtools/swagger-parser": "^12.1.0", - "@faker-js/faker": "^10.1.0", + "@faker-js/faker": "~9.0.0", "@fastify/cookie": "^11.0.2", "@fastify/multipart": "^9.3.0", "@fastify/passport": "^3.0.2", @@ -109,6 +109,9 @@ "transform": { "^.+\\.(t|j)s$": "ts-jest" }, + "transformIgnorePatterns": [ + "/node_modules/(?!(@faker-js/faker|@apidevtools/swagger-parser|json-schema-faker|path-to-regexp)/)" + ], "collectCoverageFrom": [ "src/**/*.(t|j)s" ], diff --git a/src/projects/endpoints/endpoints.service.spec.ts b/src/projects/endpoints/endpoints.service.spec.ts index 855cad8..23189e5 100644 --- a/src/projects/endpoints/endpoints.service.spec.ts +++ b/src/projects/endpoints/endpoints.service.spec.ts @@ -1,6 +1,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter'; import { Test, type TestingModule } from '@nestjs/testing'; import { PinoLogger } from 'nestjs-pino'; +import { ApiLintingService } from 'src/api-linting/api-linting.service'; import { PrismaService } from 'src/prisma/prisma.service'; import { EndpointsService } from './endpoints.service'; @@ -15,6 +16,7 @@ describe('EndpointsService', () => { const mockEventEmitter = { emit: jest.fn(), }; + const mockApiLintingService = {}; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -23,6 +25,7 @@ describe('EndpointsService', () => { { provide: PrismaService, useValue: mockPrismaService }, { provide: PinoLogger, useValue: mockPinoLogger }, { provide: EventEmitter2, useValue: mockEventEmitter }, + { provide: ApiLintingService, useValue: mockApiLintingService }, ], }).compile(); diff --git a/test/jest-e2e.json b/test/jest-e2e.json index c29daa6..2c0f093 100644 --- a/test/jest-e2e.json +++ b/test/jest-e2e.json @@ -6,6 +6,9 @@ "transform": { "^.+\\.(t|j)s$": "ts-jest" }, + "transformIgnorePatterns": [ + "/node_modules/(?!(@faker-js/faker|@apidevtools/swagger-parser|json-schema-faker|path-to-regexp)/)" + ], "moduleNameMapper": { "^src/(.*)$": "/src/$1" } From 324cdddee63bd17534d39ae80376280c957d0e7b Mon Sep 17 00:00:00 2001 From: Amir Zouerami <53701884+Amir-Zouerami@users.noreply.github.com> Date: Wed, 12 Nov 2025 22:30:01 +0330 Subject: [PATCH 6/6] FIX: fixes some issues with the mock server. --- .spectral.yaml | 13 ++++++++----- prisma/seed.ts | 2 +- src/mock-server/mock-server.controller.ts | 4 ---- src/mock-server/mock-server.service.ts | 16 ++++++++++++++-- 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/.spectral.yaml b/.spectral.yaml index b3d445e..be78adc 100644 --- a/.spectral.yaml +++ b/.spectral.yaml @@ -56,9 +56,12 @@ rules: message: 'All 2xx and 3xx responses must define a `content` object to describe the response body.' severity: error recommended: true - # Target all response objects whose key (the status code) starts with a '2' or '3'. - given: '$.paths.*.*.responses[?(@property.match(/^[23]/))]' + # FIX: Use a more precise 'given' and allow for '$ref' + given: + - '$.paths.*.*.responses[?(@property.match(/^2\d{2}$/) && @property != "204" && @property != "205")]' then: - # On each matching response object, assert that `content` is a truthy value. - field: content - function: truthy + function: or + functionOptions: + properties: + - content + - $ref diff --git a/prisma/seed.ts b/prisma/seed.ts index 50fbbcb..cede9a7 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -276,7 +276,7 @@ async function seedProjects() { schema: { type: 'object', properties: { - id: { type: 'string', format: 'uuid', xfaker: 'string.uuid' }, + id: { type: 'string', format: 'uuid', 'x-faker': 'string.uuid' }, firstName: { type: 'string', 'x-faker': 'person.firstName' }, lastName: { type: 'string', 'x-faker': 'person.lastName' }, username: { type: 'string', 'x-faker': 'internet.userName' }, diff --git a/src/mock-server/mock-server.controller.ts b/src/mock-server/mock-server.controller.ts index f60b373..5102a3f 100644 --- a/src/mock-server/mock-server.controller.ts +++ b/src/mock-server/mock-server.controller.ts @@ -21,10 +21,6 @@ export class MockServerController { constructor(private readonly mockServerService: MockServerService) {} @All('*') - @ApiParam({ - name: 'projectId', - description: 'The ID of the project to generate a mock response for.', - }) @ApiParam({ name: '*', description: 'The API path to mock (e.g., "users/123/profile").', diff --git a/src/mock-server/mock-server.service.ts b/src/mock-server/mock-server.service.ts index 968ccee..036cee7 100644 --- a/src/mock-server/mock-server.service.ts +++ b/src/mock-server/mock-server.service.ts @@ -24,6 +24,8 @@ const SUPPORTED_LOCALES: Record = { fa, }; +const THREE_DIGIT_STATUS_CODE_REGEX = /^\d{3}$/; + @Injectable() export class MockServerService { constructor( @@ -86,9 +88,13 @@ export class MockServerService { const mockBody = schema ? JSONSchemaFaker.generate(schema as Schema) : null; + const numericStatus = THREE_DIGIT_STATUS_CODE_REGEX.test(statusCode) + ? Number(statusCode) + : 200; + return { body: mockBody, - statusCode: parseInt(statusCode, 10), + statusCode: numericStatus, }; } catch (error: unknown) { this.logger.error({ error }, 'Failed to generate mock data from schema.'); @@ -111,7 +117,7 @@ export class MockServerService { if (!spec.paths) return null; for (const specPath of Object.keys(spec.paths)) { - const parsablePath = specPath.replace(/{(\w+)}/g, ':$1'); + const parsablePath = specPath.replace(/{([^}]+)}/g, ':$1'); const matcher = match(parsablePath, { decode: decodeURIComponent }); const isMatch = matcher(requestPath); @@ -160,6 +166,12 @@ export class MockServerService { return { response: res, statusCode: '204' }; } + if (responses.default) { + const res = responses.default; + if ('$ref' in res) return { response: null, statusCode: 'default' }; + return { response: res, statusCode: 'default' }; + } + return { response: null, statusCode: '200' }; }