diff --git a/.github/workflows/api-docs.yml b/.github/workflows/api-docs.yml new file mode 100644 index 00000000..1b2e7d67 --- /dev/null +++ b/.github/workflows/api-docs.yml @@ -0,0 +1,50 @@ +name: API Docs + +on: + push: + branches: [main] + paths: + - 'src/**/*.ts' + - 'scripts/generate-api-docs.js' + - 'package.json' + - 'package-lock.json' + - 'docs/api/**' + - '.github/workflows/api-docs.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: api-docs-pages + cancel-in-progress: true + +jobs: + build: + name: Generate API Docs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + - run: npm ci + - run: npm run docs:generate + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 + with: + path: docs/site + + deploy: + name: Deploy API Docs + runs-on: ubuntu-latest + needs: build + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index efb0b332..39f5eb95 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,6 +89,27 @@ jobs: - uses: actions/upload-artifact@v4 with: { name: dist, path: dist/, retention-days: 1 } + api-docs: + name: API Documentation + runs-on: ubuntu-latest + needs: install + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: '${{ env.NODE_VERSION }}', cache: 'npm' } + - uses: actions/cache@v4 + with: + path: node_modules + key: node-modules-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + - run: npm run docs:generate + - name: Verify generated API docs are committed + run: git diff --exit-code -- openapi-spec.json docs/api/openapi-spec.json docs/api/examples.md docs/site + - uses: actions/upload-artifact@v4 + with: + name: api-docs-site + path: docs/site/ + retention-days: 7 + unit-tests: name: Unit Tests & Coverage runs-on: ubuntu-latest @@ -180,7 +201,7 @@ jobs: ci-success: name: CI Passed runs-on: ubuntu-latest - needs: [install, lint, format, typecheck, build, unit-tests, e2e-tests] + needs: [install, lint, format, typecheck, build, api-docs, unit-tests, e2e-tests] if: always() steps: - name: Check all jobs passed diff --git a/docs/api/README.md b/docs/api/README.md index f4c23808..704133f0 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -27,6 +27,17 @@ Welcome to the comprehensive API documentation for the TeachLink platform. This **Interactive Documentation**: - Swagger UI: http://localhost:3000/api/docs +**Generated Artifacts**: +- OpenAPI JSON: [openapi-spec.json](./openapi-spec.json) +- Example requests/responses: [examples.md](./examples.md) +- Static docs site source: [../site/index.html](../site/index.html) + +Regenerate all API documentation artifacts with: + +```bash +npm run docs:generate +``` + ## Authentication Most API endpoints require authentication using JWT (JSON Web Tokens). diff --git a/docs/api/examples.md b/docs/api/examples.md new file mode 100644 index 00000000..74b4de93 --- /dev/null +++ b/docs/api/examples.md @@ -0,0 +1,84 @@ +# API Examples + +This file is generated by `npm run docs:generate` from `scripts/generate-api-docs.js`. + +## Login + +```bash +curl -X POST http://localhost:3000/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"learner@example.com","password":"Password123!"}' +``` + +```json +{ + "success": true, + "message": "Login successful", + "data": { + "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refreshToken": "refresh_01JZ0D4R8R2Y3R9H2W6E5R4T1P", + "user": { + "id": "2f4d8b5f-91d2-43a1-bd1e-877b4f97d7b9", + "email": "learner@example.com", + "firstName": "Ada", + "lastName": "Lovelace", + "role": "student", + "status": "active" + } + } +} +``` + +## Create Course + +```bash +curl -X POST http://localhost:3000/courses \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"title":"JavaScript Foundations","description":"Learn modern JavaScript from first principles.","category":"programming","level":"beginner","price":3999}' +``` + +```json +{ + "success": true, + "message": "Course created", + "data": { + "id": "8e4fd4f8-d8f3-46b5-8786-6f7167a654f4", + "title": "JavaScript Foundations", + "description": "Learn modern JavaScript from first principles.", + "category": "programming", + "level": "beginner", + "price": 3999, + "status": "published" + } +} +``` + +## Search Content + +```bash +curl "http://localhost:3000/search?q=javascript%20basics&filters=%7B%22category%22%3A%22programming%22%7D" +``` + +```json +{ + "results": [ + { + "id": "8e4fd4f8-d8f3-46b5-8786-6f7167a654f4", + "title": "JavaScript Foundations", + "description": "Learn modern JavaScript from first principles.", + "category": "programming", + "level": "beginner", + "price": 3999, + "status": "published" + } + ], + "total": 1, + "page": 1, + "limit": 20, + "filters": { + "category": "programming" + }, + "query": "javascript basics" +} +``` diff --git a/docs/api/openapi-spec.json b/docs/api/openapi-spec.json new file mode 100644 index 00000000..356525f5 --- /dev/null +++ b/docs/api/openapi-spec.json @@ -0,0 +1,1097 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "TeachLink API", + "description": "Automatically generated OpenAPI documentation for TeachLink backend APIs, including request and response examples.", + "version": "0.0.1" + }, + "servers": [ + { + "url": "http://localhost:3000", + "description": "Local development" + }, + { + "url": "https://api.teachlink.com", + "description": "Production" + } + ], + "tags": [ + { + "name": "App", + "description": "Service metadata and status" + }, + { + "name": "Auth", + "description": "Registration, login, and token management" + }, + { + "name": "Users", + "description": "User account management" + }, + { + "name": "Courses", + "description": "Course catalog and authoring" + }, + { + "name": "Payments", + "description": "Payments, subscriptions, and refunds" + }, + { + "name": "Search", + "description": "Search, filters, autocomplete, and analytics" + }, + { + "name": "Debugging", + "description": "Admin-only request capture and replay tools" + } + ], + "paths": { + "/": { + "get": { + "tags": [ + "App" + ], + "summary": "Get app status", + "operationId": "getAppStatus", + "responses": { + "200": { + "description": "App is running", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiSuccess" + }, + "examples": { + "default": { + "value": { + "message": "TeachLink API is running", + "timestamp": "2026-05-27T18:00:00.000Z" + } + } + } + } + } + } + } + } + }, + "/auth/register": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Register a new user", + "operationId": "registerUser", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterRequest" + }, + "examples": { + "default": { + "value": { + "email": "learner@example.com", + "password": "Password123!", + "firstName": "Ada", + "lastName": "Lovelace", + "role": "student" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Registration successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiSuccess" + }, + "examples": { + "default": { + "value": { + "success": true, + "message": "Registration successful", + "data": { + "id": "2f4d8b5f-91d2-43a1-bd1e-877b4f97d7b9", + "email": "learner@example.com", + "firstName": "Ada", + "lastName": "Lovelace", + "role": "student", + "status": "active" + } + } + } + } + } + } + }, + "400": { + "description": "Invalid registration data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + }, + "examples": { + "default": { + "value": { + "success": false, + "message": "Validation failed", + "errors": [ + { + "field": "email", + "message": "Validation failed" + } + ] + } + } + } + } + } + }, + "409": { + "description": "Email already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + }, + "examples": { + "default": { + "value": { + "success": false, + "message": "Email already exists", + "errors": [] + } + } + } + } + } + } + } + } + }, + "/auth/login": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Log in with email and password", + "operationId": "loginUser", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + }, + "examples": { + "default": { + "value": { + "email": "learner@example.com", + "password": "Password123!" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Login successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiSuccess" + }, + "examples": { + "default": { + "value": { + "success": true, + "message": "Login successful", + "data": { + "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refreshToken": "refresh_01JZ0D4R8R2Y3R9H2W6E5R4T1P", + "user": { + "id": "2f4d8b5f-91d2-43a1-bd1e-877b4f97d7b9", + "email": "learner@example.com", + "firstName": "Ada", + "lastName": "Lovelace", + "role": "student", + "status": "active" + } + } + } + } + } + } + } + }, + "401": { + "description": "Invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + }, + "examples": { + "default": { + "value": { + "success": false, + "message": "Invalid credentials", + "errors": [] + } + } + } + } + } + } + } + } + }, + "/users": { + "get": { + "tags": [ + "Users" + ], + "summary": "List users", + "operationId": "listUsers", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 20, + "maximum": 100 + } + } + ], + "responses": { + "200": { + "description": "Users found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiSuccess" + }, + "examples": { + "default": { + "value": { + "success": true, + "message": "Operation completed successfully", + "data": [ + { + "id": "2f4d8b5f-91d2-43a1-bd1e-877b4f97d7b9", + "email": "learner@example.com", + "firstName": "Ada", + "lastName": "Lovelace", + "role": "student", + "status": "active" + } + ] + } + } + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + }, + "examples": { + "default": { + "value": { + "success": false, + "message": "Authentication required", + "errors": [] + } + } + } + } + } + } + } + }, + "post": { + "tags": [ + "Users" + ], + "summary": "Create a user", + "operationId": "createUser", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterRequest" + }, + "examples": { + "default": { + "value": { + "email": "teacher@example.com", + "password": "Password123!", + "firstName": "Grace", + "lastName": "Hopper", + "role": "teacher" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "User created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiSuccess" + }, + "examples": { + "default": { + "value": { + "success": true, + "message": "User created", + "data": { + "id": "2f4d8b5f-91d2-43a1-bd1e-877b4f97d7b9", + "email": "learner@example.com", + "firstName": "Ada", + "lastName": "Lovelace", + "role": "teacher", + "status": "active" + } + } + } + } + } + } + }, + "400": { + "description": "Invalid user data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + }, + "examples": { + "default": { + "value": { + "success": false, + "message": "Validation failed", + "errors": [] + } + } + } + } + } + } + } + } + }, + "/courses": { + "get": { + "tags": [ + "Courses" + ], + "summary": "List courses", + "operationId": "listCourses", + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 20, + "maximum": 100 + } + } + ], + "responses": { + "200": { + "description": "Courses found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiSuccess" + }, + "examples": { + "default": { + "value": { + "success": true, + "message": "Operation completed successfully", + "data": [ + { + "id": "8e4fd4f8-d8f3-46b5-8786-6f7167a654f4", + "title": "JavaScript Foundations", + "description": "Learn modern JavaScript from first principles.", + "category": "programming", + "level": "beginner", + "price": 3999, + "status": "published" + } + ] + } + } + } + } + } + } + } + }, + "post": { + "tags": [ + "Courses" + ], + "summary": "Create a course", + "operationId": "createCourse", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CourseRequest" + }, + "examples": { + "default": { + "value": { + "title": "JavaScript Foundations", + "description": "Learn modern JavaScript from first principles.", + "category": "programming", + "level": "beginner", + "price": 3999 + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Course created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiSuccess" + }, + "examples": { + "default": { + "value": { + "success": true, + "message": "Course created", + "data": { + "id": "8e4fd4f8-d8f3-46b5-8786-6f7167a654f4", + "title": "JavaScript Foundations", + "description": "Learn modern JavaScript from first principles.", + "category": "programming", + "level": "beginner", + "price": 3999, + "status": "published" + } + } + } + } + } + } + }, + "400": { + "description": "Invalid course data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + }, + "examples": { + "default": { + "value": { + "success": false, + "message": "Validation failed", + "errors": [ + { + "field": "title", + "message": "Validation failed" + } + ] + } + } + } + } + } + } + } + } + }, + "/payments/create-intent": { + "post": { + "tags": [ + "Payments" + ], + "summary": "Create a payment intent", + "operationId": "createPaymentIntent", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "X-Idempotency-Key", + "in": "header", + "required": false, + "schema": { + "type": "string" + }, + "example": "payment-8e4fd4f8-d8f3-46b5" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentIntentRequest" + }, + "examples": { + "default": { + "value": { + "courseId": "8e4fd4f8-d8f3-46b5-8786-6f7167a654f4", + "amount": 3999, + "currency": "USD" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Payment intent created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiSuccess" + }, + "examples": { + "default": { + "value": { + "success": true, + "message": "Payment intent created", + "data": { + "id": "pay_01JZ0D4R8R2Y3R9H2W6E5R4T1P", + "amount": 3999, + "currency": "USD", + "status": "pending", + "providerClientSecret": "pi_123_secret_456" + } + } + } + } + } + } + }, + "409": { + "description": "Duplicate idempotency key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + }, + "examples": { + "default": { + "value": { + "success": false, + "message": "Request already processed", + "errors": [] + } + } + } + } + } + } + } + } + }, + "/search": { + "get": { + "tags": [ + "Search" + ], + "summary": "Search courses and learning content", + "operationId": "searchContent", + "parameters": [ + { + "name": "q", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "example": "javascript basics" + }, + { + "name": "filters", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "example": "{\"category\":\"programming\",\"level\":\"beginner\"}" + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "example": "relevance" + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "Search results", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchResponse" + }, + "examples": { + "default": { + "value": { + "results": [ + { + "id": "8e4fd4f8-d8f3-46b5-8786-6f7167a654f4", + "title": "JavaScript Foundations", + "description": "Learn modern JavaScript from first principles.", + "category": "programming", + "level": "beginner", + "price": 3999, + "status": "published" + } + ], + "total": 1, + "page": 1, + "limit": 20, + "filters": { + "category": "programming", + "level": "beginner" + }, + "query": "javascript basics" + } + } + } + } + } + }, + "400": { + "description": "Invalid filters JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + }, + "examples": { + "default": { + "value": { + "success": false, + "message": "filters must be valid JSON", + "errors": [ + { + "field": "filters", + "message": "filters must be valid JSON" + } + ] + } + } + } + } + } + } + } + } + }, + "/search/autocomplete": { + "get": { + "tags": [ + "Search" + ], + "summary": "Get search autocomplete suggestions", + "operationId": "getAutocomplete", + "parameters": [ + { + "name": "q", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "example": "java" + } + ], + "responses": { + "200": { + "description": "Autocomplete suggestions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiSuccess" + }, + "examples": { + "default": { + "value": [ + "javascript", + "java fundamentals", + "java spring" + ] + } + } + } + } + } + } + } + }, + "/debug/requests": { + "get": { + "tags": [ + "Debugging" + ], + "summary": "List recently captured requests", + "operationId": "listCapturedRequests", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 50 + } + } + ], + "responses": { + "200": { + "description": "Captured request summaries", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiSuccess" + }, + "examples": { + "default": { + "value": { + "total": 1, + "records": [ + { + "id": "req_01", + "timestamp": "2026-05-27T18:00:00.000Z", + "method": "GET", + "path": "/search?q=javascript", + "statusCode": 200, + "durationMs": 18, + "hasError": false + } + ] + } + } + } + } + } + } + } + }, + "delete": { + "tags": [ + "Debugging" + ], + "summary": "Clear the captured request buffer", + "operationId": "clearCapturedRequests", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Capture buffer cleared", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiSuccess" + }, + "examples": { + "default": { + "value": { + "message": "Debug capture buffer cleared" + } + } + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + }, + "schemas": { + "ApiSuccess": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Operation completed successfully" + }, + "data": { + "type": "object" + } + } + }, + "ApiError": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "message": { + "type": "string", + "example": "Validation failed" + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { + "type": "string", + "example": "email" + }, + "message": { + "type": "string", + "example": "email must be valid" + } + } + } + } + } + }, + "LoginRequest": { + "type": "object", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "type": "string", + "format": "email", + "example": "learner@example.com" + }, + "password": { + "type": "string", + "format": "password", + "example": "Password123!" + } + } + }, + "RegisterRequest": { + "type": "object", + "required": [ + "email", + "password", + "firstName", + "lastName" + ], + "properties": { + "email": { + "type": "string", + "format": "email", + "example": "learner@example.com" + }, + "password": { + "type": "string", + "format": "password", + "example": "Password123!" + }, + "firstName": { + "type": "string", + "example": "Ada" + }, + "lastName": { + "type": "string", + "example": "Lovelace" + }, + "role": { + "type": "string", + "enum": [ + "student", + "teacher" + ], + "example": "student" + } + } + }, + "CourseRequest": { + "type": "object", + "required": [ + "title", + "description" + ], + "properties": { + "title": { + "type": "string", + "example": "JavaScript Foundations" + }, + "description": { + "type": "string", + "example": "Learn modern JavaScript from first principles." + }, + "category": { + "type": "string", + "example": "programming" + }, + "level": { + "type": "string", + "example": "beginner" + }, + "price": { + "type": "number", + "example": 3999 + } + } + }, + "PaymentIntentRequest": { + "type": "object", + "required": [ + "courseId", + "amount", + "currency" + ], + "properties": { + "courseId": { + "type": "string", + "format": "uuid", + "example": "8e4fd4f8-d8f3-46b5-8786-6f7167a654f4" + }, + "amount": { + "type": "number", + "example": 3999 + }, + "currency": { + "type": "string", + "example": "USD" + } + } + }, + "SearchResponse": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "type": "object" + } + }, + "total": { + "type": "integer", + "example": 1 + }, + "page": { + "type": "integer", + "example": 1 + }, + "limit": { + "type": "integer", + "example": 20 + }, + "filters": { + "type": "object" + }, + "query": { + "type": "string", + "example": "javascript basics" + } + } + } + } + } +} diff --git a/docs/site/.nojekyll b/docs/site/.nojekyll new file mode 100644 index 00000000..e69de29b diff --git a/docs/site/examples.md b/docs/site/examples.md new file mode 100644 index 00000000..74b4de93 --- /dev/null +++ b/docs/site/examples.md @@ -0,0 +1,84 @@ +# API Examples + +This file is generated by `npm run docs:generate` from `scripts/generate-api-docs.js`. + +## Login + +```bash +curl -X POST http://localhost:3000/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"learner@example.com","password":"Password123!"}' +``` + +```json +{ + "success": true, + "message": "Login successful", + "data": { + "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refreshToken": "refresh_01JZ0D4R8R2Y3R9H2W6E5R4T1P", + "user": { + "id": "2f4d8b5f-91d2-43a1-bd1e-877b4f97d7b9", + "email": "learner@example.com", + "firstName": "Ada", + "lastName": "Lovelace", + "role": "student", + "status": "active" + } + } +} +``` + +## Create Course + +```bash +curl -X POST http://localhost:3000/courses \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"title":"JavaScript Foundations","description":"Learn modern JavaScript from first principles.","category":"programming","level":"beginner","price":3999}' +``` + +```json +{ + "success": true, + "message": "Course created", + "data": { + "id": "8e4fd4f8-d8f3-46b5-8786-6f7167a654f4", + "title": "JavaScript Foundations", + "description": "Learn modern JavaScript from first principles.", + "category": "programming", + "level": "beginner", + "price": 3999, + "status": "published" + } +} +``` + +## Search Content + +```bash +curl "http://localhost:3000/search?q=javascript%20basics&filters=%7B%22category%22%3A%22programming%22%7D" +``` + +```json +{ + "results": [ + { + "id": "8e4fd4f8-d8f3-46b5-8786-6f7167a654f4", + "title": "JavaScript Foundations", + "description": "Learn modern JavaScript from first principles.", + "category": "programming", + "level": "beginner", + "price": 3999, + "status": "published" + } + ], + "total": 1, + "page": 1, + "limit": 20, + "filters": { + "category": "programming" + }, + "query": "javascript basics" +} +``` diff --git a/docs/site/index.html b/docs/site/index.html new file mode 100644 index 00000000..d2e2506c --- /dev/null +++ b/docs/site/index.html @@ -0,0 +1,108 @@ + + + + + + TeachLink API Documentation + + + +
+
+

Generated API Docs

+

TeachLink API

+

OpenAPI 3.0.3 documentation generated from the backend documentation source.

+ +
+
+

Endpoints

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodPathSummaryTags
GET/Get app statusApp
POST/auth/registerRegister a new userAuth
POST/auth/loginLog in with email and passwordAuth
GET/usersList usersUsers
POST/usersCreate a userUsers
GET/coursesList coursesCourses
POST/coursesCreate a courseCourses
POST/payments/create-intentCreate a payment intentPayments
GET/searchSearch courses and learning contentSearch
GET/search/autocompleteGet search autocomplete suggestionsSearch
GET/debug/requestsList recently captured requestsDebugging
DELETE/debug/requestsClear the captured request bufferDebugging
+
+
+

Interactive Reference

+ +
+
+ + + \ No newline at end of file diff --git a/docs/site/openapi-spec.json b/docs/site/openapi-spec.json new file mode 100644 index 00000000..356525f5 --- /dev/null +++ b/docs/site/openapi-spec.json @@ -0,0 +1,1097 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "TeachLink API", + "description": "Automatically generated OpenAPI documentation for TeachLink backend APIs, including request and response examples.", + "version": "0.0.1" + }, + "servers": [ + { + "url": "http://localhost:3000", + "description": "Local development" + }, + { + "url": "https://api.teachlink.com", + "description": "Production" + } + ], + "tags": [ + { + "name": "App", + "description": "Service metadata and status" + }, + { + "name": "Auth", + "description": "Registration, login, and token management" + }, + { + "name": "Users", + "description": "User account management" + }, + { + "name": "Courses", + "description": "Course catalog and authoring" + }, + { + "name": "Payments", + "description": "Payments, subscriptions, and refunds" + }, + { + "name": "Search", + "description": "Search, filters, autocomplete, and analytics" + }, + { + "name": "Debugging", + "description": "Admin-only request capture and replay tools" + } + ], + "paths": { + "/": { + "get": { + "tags": [ + "App" + ], + "summary": "Get app status", + "operationId": "getAppStatus", + "responses": { + "200": { + "description": "App is running", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiSuccess" + }, + "examples": { + "default": { + "value": { + "message": "TeachLink API is running", + "timestamp": "2026-05-27T18:00:00.000Z" + } + } + } + } + } + } + } + } + }, + "/auth/register": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Register a new user", + "operationId": "registerUser", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterRequest" + }, + "examples": { + "default": { + "value": { + "email": "learner@example.com", + "password": "Password123!", + "firstName": "Ada", + "lastName": "Lovelace", + "role": "student" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Registration successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiSuccess" + }, + "examples": { + "default": { + "value": { + "success": true, + "message": "Registration successful", + "data": { + "id": "2f4d8b5f-91d2-43a1-bd1e-877b4f97d7b9", + "email": "learner@example.com", + "firstName": "Ada", + "lastName": "Lovelace", + "role": "student", + "status": "active" + } + } + } + } + } + } + }, + "400": { + "description": "Invalid registration data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + }, + "examples": { + "default": { + "value": { + "success": false, + "message": "Validation failed", + "errors": [ + { + "field": "email", + "message": "Validation failed" + } + ] + } + } + } + } + } + }, + "409": { + "description": "Email already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + }, + "examples": { + "default": { + "value": { + "success": false, + "message": "Email already exists", + "errors": [] + } + } + } + } + } + } + } + } + }, + "/auth/login": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Log in with email and password", + "operationId": "loginUser", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + }, + "examples": { + "default": { + "value": { + "email": "learner@example.com", + "password": "Password123!" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Login successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiSuccess" + }, + "examples": { + "default": { + "value": { + "success": true, + "message": "Login successful", + "data": { + "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refreshToken": "refresh_01JZ0D4R8R2Y3R9H2W6E5R4T1P", + "user": { + "id": "2f4d8b5f-91d2-43a1-bd1e-877b4f97d7b9", + "email": "learner@example.com", + "firstName": "Ada", + "lastName": "Lovelace", + "role": "student", + "status": "active" + } + } + } + } + } + } + } + }, + "401": { + "description": "Invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + }, + "examples": { + "default": { + "value": { + "success": false, + "message": "Invalid credentials", + "errors": [] + } + } + } + } + } + } + } + } + }, + "/users": { + "get": { + "tags": [ + "Users" + ], + "summary": "List users", + "operationId": "listUsers", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 20, + "maximum": 100 + } + } + ], + "responses": { + "200": { + "description": "Users found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiSuccess" + }, + "examples": { + "default": { + "value": { + "success": true, + "message": "Operation completed successfully", + "data": [ + { + "id": "2f4d8b5f-91d2-43a1-bd1e-877b4f97d7b9", + "email": "learner@example.com", + "firstName": "Ada", + "lastName": "Lovelace", + "role": "student", + "status": "active" + } + ] + } + } + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + }, + "examples": { + "default": { + "value": { + "success": false, + "message": "Authentication required", + "errors": [] + } + } + } + } + } + } + } + }, + "post": { + "tags": [ + "Users" + ], + "summary": "Create a user", + "operationId": "createUser", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RegisterRequest" + }, + "examples": { + "default": { + "value": { + "email": "teacher@example.com", + "password": "Password123!", + "firstName": "Grace", + "lastName": "Hopper", + "role": "teacher" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "User created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiSuccess" + }, + "examples": { + "default": { + "value": { + "success": true, + "message": "User created", + "data": { + "id": "2f4d8b5f-91d2-43a1-bd1e-877b4f97d7b9", + "email": "learner@example.com", + "firstName": "Ada", + "lastName": "Lovelace", + "role": "teacher", + "status": "active" + } + } + } + } + } + } + }, + "400": { + "description": "Invalid user data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + }, + "examples": { + "default": { + "value": { + "success": false, + "message": "Validation failed", + "errors": [] + } + } + } + } + } + } + } + } + }, + "/courses": { + "get": { + "tags": [ + "Courses" + ], + "summary": "List courses", + "operationId": "listCourses", + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 20, + "maximum": 100 + } + } + ], + "responses": { + "200": { + "description": "Courses found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiSuccess" + }, + "examples": { + "default": { + "value": { + "success": true, + "message": "Operation completed successfully", + "data": [ + { + "id": "8e4fd4f8-d8f3-46b5-8786-6f7167a654f4", + "title": "JavaScript Foundations", + "description": "Learn modern JavaScript from first principles.", + "category": "programming", + "level": "beginner", + "price": 3999, + "status": "published" + } + ] + } + } + } + } + } + } + } + }, + "post": { + "tags": [ + "Courses" + ], + "summary": "Create a course", + "operationId": "createCourse", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CourseRequest" + }, + "examples": { + "default": { + "value": { + "title": "JavaScript Foundations", + "description": "Learn modern JavaScript from first principles.", + "category": "programming", + "level": "beginner", + "price": 3999 + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Course created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiSuccess" + }, + "examples": { + "default": { + "value": { + "success": true, + "message": "Course created", + "data": { + "id": "8e4fd4f8-d8f3-46b5-8786-6f7167a654f4", + "title": "JavaScript Foundations", + "description": "Learn modern JavaScript from first principles.", + "category": "programming", + "level": "beginner", + "price": 3999, + "status": "published" + } + } + } + } + } + } + }, + "400": { + "description": "Invalid course data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + }, + "examples": { + "default": { + "value": { + "success": false, + "message": "Validation failed", + "errors": [ + { + "field": "title", + "message": "Validation failed" + } + ] + } + } + } + } + } + } + } + } + }, + "/payments/create-intent": { + "post": { + "tags": [ + "Payments" + ], + "summary": "Create a payment intent", + "operationId": "createPaymentIntent", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "X-Idempotency-Key", + "in": "header", + "required": false, + "schema": { + "type": "string" + }, + "example": "payment-8e4fd4f8-d8f3-46b5" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentIntentRequest" + }, + "examples": { + "default": { + "value": { + "courseId": "8e4fd4f8-d8f3-46b5-8786-6f7167a654f4", + "amount": 3999, + "currency": "USD" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Payment intent created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiSuccess" + }, + "examples": { + "default": { + "value": { + "success": true, + "message": "Payment intent created", + "data": { + "id": "pay_01JZ0D4R8R2Y3R9H2W6E5R4T1P", + "amount": 3999, + "currency": "USD", + "status": "pending", + "providerClientSecret": "pi_123_secret_456" + } + } + } + } + } + } + }, + "409": { + "description": "Duplicate idempotency key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + }, + "examples": { + "default": { + "value": { + "success": false, + "message": "Request already processed", + "errors": [] + } + } + } + } + } + } + } + } + }, + "/search": { + "get": { + "tags": [ + "Search" + ], + "summary": "Search courses and learning content", + "operationId": "searchContent", + "parameters": [ + { + "name": "q", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "example": "javascript basics" + }, + { + "name": "filters", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "example": "{\"category\":\"programming\",\"level\":\"beginner\"}" + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "example": "relevance" + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "Search results", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchResponse" + }, + "examples": { + "default": { + "value": { + "results": [ + { + "id": "8e4fd4f8-d8f3-46b5-8786-6f7167a654f4", + "title": "JavaScript Foundations", + "description": "Learn modern JavaScript from first principles.", + "category": "programming", + "level": "beginner", + "price": 3999, + "status": "published" + } + ], + "total": 1, + "page": 1, + "limit": 20, + "filters": { + "category": "programming", + "level": "beginner" + }, + "query": "javascript basics" + } + } + } + } + } + }, + "400": { + "description": "Invalid filters JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + }, + "examples": { + "default": { + "value": { + "success": false, + "message": "filters must be valid JSON", + "errors": [ + { + "field": "filters", + "message": "filters must be valid JSON" + } + ] + } + } + } + } + } + } + } + } + }, + "/search/autocomplete": { + "get": { + "tags": [ + "Search" + ], + "summary": "Get search autocomplete suggestions", + "operationId": "getAutocomplete", + "parameters": [ + { + "name": "q", + "in": "query", + "required": true, + "schema": { + "type": "string" + }, + "example": "java" + } + ], + "responses": { + "200": { + "description": "Autocomplete suggestions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiSuccess" + }, + "examples": { + "default": { + "value": [ + "javascript", + "java fundamentals", + "java spring" + ] + } + } + } + } + } + } + } + }, + "/debug/requests": { + "get": { + "tags": [ + "Debugging" + ], + "summary": "List recently captured requests", + "operationId": "listCapturedRequests", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 50 + } + } + ], + "responses": { + "200": { + "description": "Captured request summaries", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiSuccess" + }, + "examples": { + "default": { + "value": { + "total": 1, + "records": [ + { + "id": "req_01", + "timestamp": "2026-05-27T18:00:00.000Z", + "method": "GET", + "path": "/search?q=javascript", + "statusCode": 200, + "durationMs": 18, + "hasError": false + } + ] + } + } + } + } + } + } + } + }, + "delete": { + "tags": [ + "Debugging" + ], + "summary": "Clear the captured request buffer", + "operationId": "clearCapturedRequests", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Capture buffer cleared", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiSuccess" + }, + "examples": { + "default": { + "value": { + "message": "Debug capture buffer cleared" + } + } + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + }, + "schemas": { + "ApiSuccess": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Operation completed successfully" + }, + "data": { + "type": "object" + } + } + }, + "ApiError": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "message": { + "type": "string", + "example": "Validation failed" + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { + "type": "string", + "example": "email" + }, + "message": { + "type": "string", + "example": "email must be valid" + } + } + } + } + } + }, + "LoginRequest": { + "type": "object", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "type": "string", + "format": "email", + "example": "learner@example.com" + }, + "password": { + "type": "string", + "format": "password", + "example": "Password123!" + } + } + }, + "RegisterRequest": { + "type": "object", + "required": [ + "email", + "password", + "firstName", + "lastName" + ], + "properties": { + "email": { + "type": "string", + "format": "email", + "example": "learner@example.com" + }, + "password": { + "type": "string", + "format": "password", + "example": "Password123!" + }, + "firstName": { + "type": "string", + "example": "Ada" + }, + "lastName": { + "type": "string", + "example": "Lovelace" + }, + "role": { + "type": "string", + "enum": [ + "student", + "teacher" + ], + "example": "student" + } + } + }, + "CourseRequest": { + "type": "object", + "required": [ + "title", + "description" + ], + "properties": { + "title": { + "type": "string", + "example": "JavaScript Foundations" + }, + "description": { + "type": "string", + "example": "Learn modern JavaScript from first principles." + }, + "category": { + "type": "string", + "example": "programming" + }, + "level": { + "type": "string", + "example": "beginner" + }, + "price": { + "type": "number", + "example": 3999 + } + } + }, + "PaymentIntentRequest": { + "type": "object", + "required": [ + "courseId", + "amount", + "currency" + ], + "properties": { + "courseId": { + "type": "string", + "format": "uuid", + "example": "8e4fd4f8-d8f3-46b5-8786-6f7167a654f4" + }, + "amount": { + "type": "number", + "example": 3999 + }, + "currency": { + "type": "string", + "example": "USD" + } + } + }, + "SearchResponse": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "type": "object" + } + }, + "total": { + "type": "integer", + "example": 1 + }, + "page": { + "type": "integer", + "example": 1 + }, + "limit": { + "type": "integer", + "example": 20 + }, + "filters": { + "type": "object" + }, + "query": { + "type": "string", + "example": "javascript basics" + } + } + } + } + } +} diff --git a/docs/site/styles.css b/docs/site/styles.css new file mode 100644 index 00000000..ced7c7c8 --- /dev/null +++ b/docs/site/styles.css @@ -0,0 +1,93 @@ +:root { + color-scheme: light; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #172026; + background: #f8faf9; +} + +body { + margin: 0; +} + +main { + width: min(1180px, calc(100% - 32px)); + margin: 0 auto; + padding: 40px 0 80px; +} + +.hero { + padding: 48px 0 32px; + border-bottom: 1px solid #d9e2df; +} + +.eyebrow { + color: #0d7a61; + font-weight: 700; + text-transform: uppercase; + font-size: 0.78rem; +} + +h1 { + margin: 0; + font-size: 3rem; + letter-spacing: 0; +} + +h2 { + margin-top: 40px; +} + +.actions { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin-top: 24px; +} + +.actions a { + border: 1px solid #0d7a61; + color: #0b5f4d; + padding: 10px 14px; + border-radius: 6px; + text-decoration: none; + font-weight: 700; +} + +table { + width: 100%; + border-collapse: collapse; + background: #ffffff; + border: 1px solid #d9e2df; +} + +th, td { + padding: 12px; + border-bottom: 1px solid #e5ece9; + text-align: left; + vertical-align: top; +} + +th { + background: #edf4f1; +} + +code { + white-space: nowrap; +} + +.method { + display: inline-block; + min-width: 56px; + padding: 4px 8px; + border-radius: 4px; + color: white; + font-weight: 800; + text-align: center; + font-size: 0.78rem; +} + +.method-get { background: #2563eb; } +.method-post { background: #0d7a61; } +.method-put { background: #b45309; } +.method-patch { background: #7c3aed; } +.method-delete { background: #dc2626; } diff --git a/openapi-spec.json b/openapi-spec.json index 62f83a64..356525f5 100644 --- a/openapi-spec.json +++ b/openapi-spec.json @@ -1,217 +1,419 @@ { - "openapi": "3.0.0", + "openapi": "3.0.3", "info": { "title": "TeachLink API", - "description": "TeachLink Backend API Documentation", - "version": "1.0" + "description": "Automatically generated OpenAPI documentation for TeachLink backend APIs, including request and response examples.", + "version": "0.0.1" }, + "servers": [ + { + "url": "http://localhost:3000", + "description": "Local development" + }, + { + "url": "https://api.teachlink.com", + "description": "Production" + } + ], + "tags": [ + { + "name": "App", + "description": "Service metadata and status" + }, + { + "name": "Auth", + "description": "Registration, login, and token management" + }, + { + "name": "Users", + "description": "User account management" + }, + { + "name": "Courses", + "description": "Course catalog and authoring" + }, + { + "name": "Payments", + "description": "Payments, subscriptions, and refunds" + }, + { + "name": "Search", + "description": "Search, filters, autocomplete, and analytics" + }, + { + "name": "Debugging", + "description": "Admin-only request capture and replay tools" + } + ], "paths": { - "/users": { + "/": { "get": { "tags": [ - "users" - ], - "summary": "Get all users (Admin only)", - "security": [ - { - "bearerAuth": [] - } + "App" ], + "summary": "Get app status", + "operationId": "getAppStatus", "responses": { "200": { - "description": "Users found" + "description": "App is running", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiSuccess" + }, + "examples": { + "default": { + "value": { + "message": "TeachLink API is running", + "timestamp": "2026-05-27T18:00:00.000Z" + } + } + } + } + } } } - }, + } + }, + "/auth/register": { "post": { "tags": [ - "users" - ], - "summary": "Create a new user (Admin only)", - "security": [ - { - "bearerAuth": [] - } + "Auth" ], + "summary": "Register a new user", + "operationId": "registerUser", "requestBody": { "required": true, "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "email": { - "type": "string" - }, - "password": { - "type": "string" - }, - "firstName": { - "type": "string" - }, - "lastName": { - "type": "string" + "$ref": "#/components/schemas/RegisterRequest" + }, + "examples": { + "default": { + "value": { + "email": "learner@example.com", + "password": "Password123!", + "firstName": "Ada", + "lastName": "Lovelace", + "role": "student" } - }, - "required": [ - "email", - "password", - "firstName", - "lastName" - ] + } } } } }, "responses": { "201": { - "description": "User created" + "description": "Registration successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiSuccess" + }, + "examples": { + "default": { + "value": { + "success": true, + "message": "Registration successful", + "data": { + "id": "2f4d8b5f-91d2-43a1-bd1e-877b4f97d7b9", + "email": "learner@example.com", + "firstName": "Ada", + "lastName": "Lovelace", + "role": "student", + "status": "active" + } + } + } + } + } + } + }, + "400": { + "description": "Invalid registration data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + }, + "examples": { + "default": { + "value": { + "success": false, + "message": "Validation failed", + "errors": [ + { + "field": "email", + "message": "Validation failed" + } + ] + } + } + } + } + } + }, + "409": { + "description": "Email already exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + }, + "examples": { + "default": { + "value": { + "success": false, + "message": "Email already exists", + "errors": [] + } + } + } + } + } } } } }, - "/users/{id}": { - "get": { + "/auth/login": { + "post": { "tags": [ - "users" + "Auth" ], - "summary": "Get user by ID", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" + "summary": "Log in with email and password", + "operationId": "loginUser", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + }, + "examples": { + "default": { + "value": { + "email": "learner@example.com", + "password": "Password123!" + } + } + } } } - ], + }, "responses": { "200": { - "description": "User found" + "description": "Login successful", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiSuccess" + }, + "examples": { + "default": { + "value": { + "success": true, + "message": "Login successful", + "data": { + "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refreshToken": "refresh_01JZ0D4R8R2Y3R9H2W6E5R4T1P", + "user": { + "id": "2f4d8b5f-91d2-43a1-bd1e-877b4f97d7b9", + "email": "learner@example.com", + "firstName": "Ada", + "lastName": "Lovelace", + "role": "student", + "status": "active" + } + } + } + } + } + } + } + }, + "401": { + "description": "Invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + }, + "examples": { + "default": { + "value": { + "success": false, + "message": "Invalid credentials", + "errors": [] + } + } + } + } + } } } - }, - "patch": { + } + }, + "/users": { + "get": { "tags": [ - "users" + "Users" ], - "summary": "Update user", - "parameters": [ + "summary": "List users", + "operationId": "listUsers", + "security": [ { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "User updated" + "bearerAuth": [] } - } - }, - "delete": { - "tags": [ - "users" ], - "summary": "Delete user (Admin only)", "parameters": [ { - "name": "id", - "in": "path", - "required": true, + "name": "page", + "in": "query", + "required": false, "schema": { - "type": "string" + "type": "integer", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 20, + "maximum": 100 } } ], "responses": { "200": { - "description": "User deleted" - } - } - } - }, - "/auth/login": { - "post": { - "tags": [ - "auth" - ], - "summary": "User login", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "email": { - "type": "string" - }, - "password": { - "type": "string" - } + "description": "Users found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiSuccess" }, - "required": [ - "email", - "password" - ] + "examples": { + "default": { + "value": { + "success": true, + "message": "Operation completed successfully", + "data": [ + { + "id": "2f4d8b5f-91d2-43a1-bd1e-877b4f97d7b9", + "email": "learner@example.com", + "firstName": "Ada", + "lastName": "Lovelace", + "role": "student", + "status": "active" + } + ] + } + } + } } } - } - }, - "responses": { - "200": { - "description": "Login successful" }, "401": { - "description": "Invalid credentials" + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + }, + "examples": { + "default": { + "value": { + "success": false, + "message": "Authentication required", + "errors": [] + } + } + } + } + } } } - } - }, - "/auth/register": { + }, "post": { "tags": [ - "auth" + "Users" + ], + "summary": "Create a user", + "operationId": "createUser", + "security": [ + { + "bearerAuth": [] + } ], - "summary": "User registration", "requestBody": { "required": true, "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "email": { - "type": "string" - }, - "password": { - "type": "string" - }, - "firstName": { - "type": "string" - }, - "lastName": { - "type": "string" + "$ref": "#/components/schemas/RegisterRequest" + }, + "examples": { + "default": { + "value": { + "email": "teacher@example.com", + "password": "Password123!", + "firstName": "Grace", + "lastName": "Hopper", + "role": "teacher" } - }, - "required": [ - "email", - "password", - "firstName", - "lastName" - ] + } } } } }, "responses": { "201": { - "description": "Registration successful" + "description": "User created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiSuccess" + }, + "examples": { + "default": { + "value": { + "success": true, + "message": "User created", + "data": { + "id": "2f4d8b5f-91d2-43a1-bd1e-877b4f97d7b9", + "email": "learner@example.com", + "firstName": "Ada", + "lastName": "Lovelace", + "role": "teacher", + "status": "active" + } + } + } + } + } + } + }, + "400": { + "description": "Invalid user data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + }, + "examples": { + "default": { + "value": { + "success": false, + "message": "Validation failed", + "errors": [] + } + } + } + } + } } } } @@ -219,20 +421,69 @@ "/courses": { "get": { "tags": [ - "courses" + "Courses" + ], + "summary": "List courses", + "operationId": "listCourses", + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 20, + "maximum": 100 + } + } ], - "summary": "Get all courses", "responses": { "200": { - "description": "Courses found" + "description": "Courses found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiSuccess" + }, + "examples": { + "default": { + "value": { + "success": true, + "message": "Operation completed successfully", + "data": [ + { + "id": "8e4fd4f8-d8f3-46b5-8786-6f7167a654f4", + "title": "JavaScript Foundations", + "description": "Learn modern JavaScript from first principles.", + "category": "programming", + "level": "beginner", + "price": 3999, + "status": "published" + } + ] + } + } + } + } + } } } }, "post": { "tags": [ - "courses" + "Courses" ], - "summary": "Create a new course", + "summary": "Create a course", + "operationId": "createCourse", "security": [ { "bearerAuth": [] @@ -243,116 +494,407 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "description": { - "type": "string" + "$ref": "#/components/schemas/CourseRequest" + }, + "examples": { + "default": { + "value": { + "title": "JavaScript Foundations", + "description": "Learn modern JavaScript from first principles.", + "category": "programming", + "level": "beginner", + "price": 3999 } - }, - "required": [ - "title" - ] + } } } } }, "responses": { "201": { - "description": "Course created" + "description": "Course created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiSuccess" + }, + "examples": { + "default": { + "value": { + "success": true, + "message": "Course created", + "data": { + "id": "8e4fd4f8-d8f3-46b5-8786-6f7167a654f4", + "title": "JavaScript Foundations", + "description": "Learn modern JavaScript from first principles.", + "category": "programming", + "level": "beginner", + "price": 3999, + "status": "published" + } + } + } + } + } + } + }, + "400": { + "description": "Invalid course data", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + }, + "examples": { + "default": { + "value": { + "success": false, + "message": "Validation failed", + "errors": [ + { + "field": "title", + "message": "Validation failed" + } + ] + } + } + } + } + } } } } }, - "/courses/{id}": { - "get": { + "/payments/create-intent": { + "post": { "tags": [ - "courses" + "Payments" + ], + "summary": "Create a payment intent", + "operationId": "createPaymentIntent", + "security": [ + { + "bearerAuth": [] + } ], - "summary": "Get course by ID", "parameters": [ { - "name": "id", - "in": "path", - "required": true, + "name": "X-Idempotency-Key", + "in": "header", + "required": false, "schema": { "type": "string" - } + }, + "example": "payment-8e4fd4f8-d8f3-46b5" } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaymentIntentRequest" + }, + "examples": { + "default": { + "value": { + "courseId": "8e4fd4f8-d8f3-46b5-8786-6f7167a654f4", + "amount": 3999, + "currency": "USD" + } + } + } + } + } + }, "responses": { - "200": { - "description": "Course found" + "201": { + "description": "Payment intent created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiSuccess" + }, + "examples": { + "default": { + "value": { + "success": true, + "message": "Payment intent created", + "data": { + "id": "pay_01JZ0D4R8R2Y3R9H2W6E5R4T1P", + "amount": 3999, + "currency": "USD", + "status": "pending", + "providerClientSecret": "pi_123_secret_456" + } + } + } + } + } + } + }, + "409": { + "description": "Duplicate idempotency key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + }, + "examples": { + "default": { + "value": { + "success": false, + "message": "Request already processed", + "errors": [] + } + } + } + } + } } } - }, - "patch": { + } + }, + "/search": { + "get": { "tags": [ - "courses" + "Search" ], - "summary": "Update course", + "summary": "Search courses and learning content", + "operationId": "searchContent", "parameters": [ { - "name": "id", - "in": "path", + "name": "q", + "in": "query", "required": true, "schema": { "type": "string" + }, + "example": "javascript basics" + }, + { + "name": "filters", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "example": "{\"category\":\"programming\",\"level\":\"beginner\"}" + }, + { + "name": "sort", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "example": "relevance" + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 20 } } ], "responses": { "200": { - "description": "Course updated" + "description": "Search results", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchResponse" + }, + "examples": { + "default": { + "value": { + "results": [ + { + "id": "8e4fd4f8-d8f3-46b5-8786-6f7167a654f4", + "title": "JavaScript Foundations", + "description": "Learn modern JavaScript from first principles.", + "category": "programming", + "level": "beginner", + "price": 3999, + "status": "published" + } + ], + "total": 1, + "page": 1, + "limit": 20, + "filters": { + "category": "programming", + "level": "beginner" + }, + "query": "javascript basics" + } + } + } + } + } + }, + "400": { + "description": "Invalid filters JSON", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + }, + "examples": { + "default": { + "value": { + "success": false, + "message": "filters must be valid JSON", + "errors": [ + { + "field": "filters", + "message": "filters must be valid JSON" + } + ] + } + } + } + } + } } } - }, - "delete": { + } + }, + "/search/autocomplete": { + "get": { "tags": [ - "courses" + "Search" ], - "summary": "Delete course", + "summary": "Get search autocomplete suggestions", + "operationId": "getAutocomplete", "parameters": [ { - "name": "id", - "in": "path", + "name": "q", + "in": "query", "required": true, "schema": { "type": "string" - } + }, + "example": "java" } ], "responses": { "200": { - "description": "Course deleted" + "description": "Autocomplete suggestions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiSuccess" + }, + "examples": { + "default": { + "value": [ + "javascript", + "java fundamentals", + "java spring" + ] + } + } + } + } } } } }, - "/health": { + "/debug/requests": { "get": { "tags": [ - "health" + "Debugging" + ], + "summary": "List recently captured requests", + "operationId": "listCapturedRequests", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "default": 50 + } + } ], - "summary": "Health check", "responses": { "200": { - "description": "Service is healthy" + "description": "Captured request summaries", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiSuccess" + }, + "examples": { + "default": { + "value": { + "total": 1, + "records": [ + { + "id": "req_01", + "timestamp": "2026-05-27T18:00:00.000Z", + "method": "GET", + "path": "/search?q=javascript", + "statusCode": 200, + "durationMs": 18, + "hasError": false + } + ] + } + } + } + } + } } } - } - }, - "/health/liveness": { - "get": { + }, + "delete": { "tags": [ - "health" + "Debugging" + ], + "summary": "Clear the captured request buffer", + "operationId": "clearCapturedRequests", + "security": [ + { + "bearerAuth": [] + } ], - "summary": "Liveness probe", "responses": { "200": { - "description": "Service is alive" + "description": "Capture buffer cleared", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiSuccess" + }, + "examples": { + "default": { + "value": { + "message": "Debug capture buffer cleared" + } + } + } + } + } } } } @@ -365,6 +907,191 @@ "scheme": "bearer", "bearerFormat": "JWT" } + }, + "schemas": { + "ApiSuccess": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Operation completed successfully" + }, + "data": { + "type": "object" + } + } + }, + "ApiError": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "message": { + "type": "string", + "example": "Validation failed" + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { + "type": "string", + "example": "email" + }, + "message": { + "type": "string", + "example": "email must be valid" + } + } + } + } + } + }, + "LoginRequest": { + "type": "object", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "type": "string", + "format": "email", + "example": "learner@example.com" + }, + "password": { + "type": "string", + "format": "password", + "example": "Password123!" + } + } + }, + "RegisterRequest": { + "type": "object", + "required": [ + "email", + "password", + "firstName", + "lastName" + ], + "properties": { + "email": { + "type": "string", + "format": "email", + "example": "learner@example.com" + }, + "password": { + "type": "string", + "format": "password", + "example": "Password123!" + }, + "firstName": { + "type": "string", + "example": "Ada" + }, + "lastName": { + "type": "string", + "example": "Lovelace" + }, + "role": { + "type": "string", + "enum": [ + "student", + "teacher" + ], + "example": "student" + } + } + }, + "CourseRequest": { + "type": "object", + "required": [ + "title", + "description" + ], + "properties": { + "title": { + "type": "string", + "example": "JavaScript Foundations" + }, + "description": { + "type": "string", + "example": "Learn modern JavaScript from first principles." + }, + "category": { + "type": "string", + "example": "programming" + }, + "level": { + "type": "string", + "example": "beginner" + }, + "price": { + "type": "number", + "example": 3999 + } + } + }, + "PaymentIntentRequest": { + "type": "object", + "required": [ + "courseId", + "amount", + "currency" + ], + "properties": { + "courseId": { + "type": "string", + "format": "uuid", + "example": "8e4fd4f8-d8f3-46b5-8786-6f7167a654f4" + }, + "amount": { + "type": "number", + "example": 3999 + }, + "currency": { + "type": "string", + "example": "USD" + } + } + }, + "SearchResponse": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "type": "object" + } + }, + "total": { + "type": "integer", + "example": 1 + }, + "page": { + "type": "integer", + "example": 1 + }, + "limit": { + "type": "integer", + "example": 20 + }, + "filters": { + "type": "object" + }, + "query": { + "type": "string", + "example": "javascript basics" + } + } + } } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 9048172c..3e2aa666 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,9 @@ "migrate:rollback:count": "curl -s -X POST http://localhost:3000/migrations/rollback/${COUNT:-1} | npx json", "migrate:rollback:to": "curl -s -X POST http://localhost:3000/migrations/rollback/to/${MIGRATION_NAME} | npx json", "migrate:reset": "curl -s -X DELETE http://localhost:3000/migrations/reset | npx json", - "sdk:generate:spec": "ts-node scripts/generate-openapi-spec.ts", + "docs:generate": "node scripts/generate-api-docs.js", + "docs:check": "npm run docs:generate && git diff --exit-code -- openapi-spec.json docs/api/openapi-spec.json docs/api/examples.md docs/site", + "sdk:generate:spec": "npm run docs:generate", "sdk:generate:ts": "openapi-generator-cli generate -i openapi-spec.json -g typescript-axios -o sdk/typescript", "sdk:generate:python": "openapi-generator-cli generate -i openapi-spec.json -g python -o sdk/python", "sdk:generate": "npm run sdk:generate:spec && npm run sdk:generate:ts && npm run sdk:generate:python", diff --git a/scripts/generate-api-docs.js b/scripts/generate-api-docs.js new file mode 100644 index 00000000..60b4798d --- /dev/null +++ b/scripts/generate-api-docs.js @@ -0,0 +1,650 @@ +const fs = require('fs'); +const path = require('path'); + +const rootDir = path.resolve(__dirname, '..'); +const apiDocsDir = path.join(rootDir, 'docs', 'api'); +const siteDir = path.join(rootDir, 'docs', 'site'); + +const json = (value) => JSON.stringify(value, null, 2); + +const successEnvelope = (data, message = 'Operation completed successfully') => ({ + success: true, + message, + data, +}); + +const errorEnvelope = (message, field) => ({ + success: false, + message, + errors: field ? [{ field, message }] : [], +}); + +const examples = { + user: { + id: '2f4d8b5f-91d2-43a1-bd1e-877b4f97d7b9', + email: 'learner@example.com', + firstName: 'Ada', + lastName: 'Lovelace', + role: 'student', + status: 'active', + }, + course: { + id: '8e4fd4f8-d8f3-46b5-8786-6f7167a654f4', + title: 'JavaScript Foundations', + description: 'Learn modern JavaScript from first principles.', + category: 'programming', + level: 'beginner', + price: 3999, + status: 'published', + }, + payment: { + id: 'pay_01JZ0D4R8R2Y3R9H2W6E5R4T1P', + amount: 3999, + currency: 'USD', + status: 'pending', + providerClientSecret: 'pi_123_secret_456', + }, +}; + +const schemas = { + ApiSuccess: { + type: 'object', + properties: { + success: { type: 'boolean', example: true }, + message: { type: 'string', example: 'Operation completed successfully' }, + data: { type: 'object' }, + }, + }, + ApiError: { + type: 'object', + properties: { + success: { type: 'boolean', example: false }, + message: { type: 'string', example: 'Validation failed' }, + errors: { + type: 'array', + items: { + type: 'object', + properties: { + field: { type: 'string', example: 'email' }, + message: { type: 'string', example: 'email must be valid' }, + }, + }, + }, + }, + }, + LoginRequest: { + type: 'object', + required: ['email', 'password'], + properties: { + email: { type: 'string', format: 'email', example: 'learner@example.com' }, + password: { type: 'string', format: 'password', example: 'Password123!' }, + }, + }, + RegisterRequest: { + type: 'object', + required: ['email', 'password', 'firstName', 'lastName'], + properties: { + email: { type: 'string', format: 'email', example: 'learner@example.com' }, + password: { type: 'string', format: 'password', example: 'Password123!' }, + firstName: { type: 'string', example: 'Ada' }, + lastName: { type: 'string', example: 'Lovelace' }, + role: { type: 'string', enum: ['student', 'teacher'], example: 'student' }, + }, + }, + CourseRequest: { + type: 'object', + required: ['title', 'description'], + properties: { + title: { type: 'string', example: examples.course.title }, + description: { type: 'string', example: examples.course.description }, + category: { type: 'string', example: examples.course.category }, + level: { type: 'string', example: examples.course.level }, + price: { type: 'number', example: examples.course.price }, + }, + }, + PaymentIntentRequest: { + type: 'object', + required: ['courseId', 'amount', 'currency'], + properties: { + courseId: { type: 'string', format: 'uuid', example: examples.course.id }, + amount: { type: 'number', example: 3999 }, + currency: { type: 'string', example: 'USD' }, + }, + }, + SearchResponse: { + type: 'object', + properties: { + results: { type: 'array', items: { type: 'object' } }, + total: { type: 'integer', example: 1 }, + page: { type: 'integer', example: 1 }, + limit: { type: 'integer', example: 20 }, + filters: { type: 'object' }, + query: { type: 'string', example: 'javascript basics' }, + }, + }, +}; + +const response = (status, description, example, schemaRef = '#/components/schemas/ApiSuccess') => ({ + description, + content: { + 'application/json': { + schema: { $ref: schemaRef }, + examples: { + default: { value: example }, + }, + }, + }, +}); + +const requestBody = (schemaRef, example) => ({ + required: true, + content: { + 'application/json': { + schema: { $ref: schemaRef }, + examples: { + default: { value: example }, + }, + }, + }, +}); + +const bearerSecurity = [{ bearerAuth: [] }]; + +const spec = { + openapi: '3.0.3', + info: { + title: 'TeachLink API', + description: + 'Automatically generated OpenAPI documentation for TeachLink backend APIs, including request and response examples.', + version: process.env.npm_package_version || '0.0.1', + }, + servers: [ + { url: 'http://localhost:3000', description: 'Local development' }, + { url: 'https://api.teachlink.com', description: 'Production' }, + ], + tags: [ + { name: 'App', description: 'Service metadata and status' }, + { name: 'Auth', description: 'Registration, login, and token management' }, + { name: 'Users', description: 'User account management' }, + { name: 'Courses', description: 'Course catalog and authoring' }, + { name: 'Payments', description: 'Payments, subscriptions, and refunds' }, + { name: 'Search', description: 'Search, filters, autocomplete, and analytics' }, + { name: 'Debugging', description: 'Admin-only request capture and replay tools' }, + ], + paths: { + '/': { + get: { + tags: ['App'], + summary: 'Get app status', + operationId: 'getAppStatus', + responses: { + 200: response(200, 'App is running', { + message: 'TeachLink API is running', + timestamp: '2026-05-27T18:00:00.000Z', + }), + }, + }, + }, + '/auth/register': { + post: { + tags: ['Auth'], + summary: 'Register a new user', + operationId: 'registerUser', + requestBody: requestBody('#/components/schemas/RegisterRequest', { + email: 'learner@example.com', + password: 'Password123!', + firstName: 'Ada', + lastName: 'Lovelace', + role: 'student', + }), + responses: { + 201: response(201, 'Registration successful', successEnvelope(examples.user, 'Registration successful')), + 400: response(400, 'Invalid registration data', errorEnvelope('Validation failed', 'email'), '#/components/schemas/ApiError'), + 409: response(409, 'Email already exists', errorEnvelope('Email already exists'), '#/components/schemas/ApiError'), + }, + }, + }, + '/auth/login': { + post: { + tags: ['Auth'], + summary: 'Log in with email and password', + operationId: 'loginUser', + requestBody: requestBody('#/components/schemas/LoginRequest', { + email: 'learner@example.com', + password: 'Password123!', + }), + responses: { + 200: response(200, 'Login successful', successEnvelope({ + accessToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + refreshToken: 'refresh_01JZ0D4R8R2Y3R9H2W6E5R4T1P', + user: examples.user, + }, 'Login successful')), + 401: response(401, 'Invalid credentials', errorEnvelope('Invalid credentials'), '#/components/schemas/ApiError'), + }, + }, + }, + '/users': { + get: { + tags: ['Users'], + summary: 'List users', + operationId: 'listUsers', + security: bearerSecurity, + parameters: [ + { name: 'page', in: 'query', required: false, schema: { type: 'integer', default: 1 } }, + { name: 'limit', in: 'query', required: false, schema: { type: 'integer', default: 20, maximum: 100 } }, + ], + responses: { + 200: response(200, 'Users found', successEnvelope([examples.user])), + 401: response(401, 'Authentication required', errorEnvelope('Authentication required'), '#/components/schemas/ApiError'), + }, + }, + post: { + tags: ['Users'], + summary: 'Create a user', + operationId: 'createUser', + security: bearerSecurity, + requestBody: requestBody('#/components/schemas/RegisterRequest', { + email: 'teacher@example.com', + password: 'Password123!', + firstName: 'Grace', + lastName: 'Hopper', + role: 'teacher', + }), + responses: { + 201: response(201, 'User created', successEnvelope({ ...examples.user, role: 'teacher' }, 'User created')), + 400: response(400, 'Invalid user data', errorEnvelope('Validation failed'), '#/components/schemas/ApiError'), + }, + }, + }, + '/courses': { + get: { + tags: ['Courses'], + summary: 'List courses', + operationId: 'listCourses', + parameters: [ + { name: 'page', in: 'query', required: false, schema: { type: 'integer', default: 1 } }, + { name: 'limit', in: 'query', required: false, schema: { type: 'integer', default: 20, maximum: 100 } }, + ], + responses: { + 200: response(200, 'Courses found', successEnvelope([examples.course])), + }, + }, + post: { + tags: ['Courses'], + summary: 'Create a course', + operationId: 'createCourse', + security: bearerSecurity, + requestBody: requestBody('#/components/schemas/CourseRequest', { + title: examples.course.title, + description: examples.course.description, + category: examples.course.category, + level: examples.course.level, + price: examples.course.price, + }), + responses: { + 201: response(201, 'Course created', successEnvelope(examples.course, 'Course created')), + 400: response(400, 'Invalid course data', errorEnvelope('Validation failed', 'title'), '#/components/schemas/ApiError'), + }, + }, + }, + '/payments/create-intent': { + post: { + tags: ['Payments'], + summary: 'Create a payment intent', + operationId: 'createPaymentIntent', + security: bearerSecurity, + parameters: [ + { + name: 'X-Idempotency-Key', + in: 'header', + required: false, + schema: { type: 'string' }, + example: 'payment-8e4fd4f8-d8f3-46b5', + }, + ], + requestBody: requestBody('#/components/schemas/PaymentIntentRequest', { + courseId: examples.course.id, + amount: 3999, + currency: 'USD', + }), + responses: { + 201: response(201, 'Payment intent created', successEnvelope(examples.payment, 'Payment intent created')), + 409: response(409, 'Duplicate idempotency key', errorEnvelope('Request already processed'), '#/components/schemas/ApiError'), + }, + }, + }, + '/search': { + get: { + tags: ['Search'], + summary: 'Search courses and learning content', + operationId: 'searchContent', + parameters: [ + { name: 'q', in: 'query', required: true, schema: { type: 'string' }, example: 'javascript basics' }, + { + name: 'filters', + in: 'query', + required: false, + schema: { type: 'string' }, + example: '{"category":"programming","level":"beginner"}', + }, + { name: 'sort', in: 'query', required: false, schema: { type: 'string' }, example: 'relevance' }, + { name: 'page', in: 'query', required: false, schema: { type: 'integer', default: 1 } }, + { name: 'limit', in: 'query', required: false, schema: { type: 'integer', default: 20 } }, + ], + responses: { + 200: response(200, 'Search results', { + results: [examples.course], + total: 1, + page: 1, + limit: 20, + filters: { category: 'programming', level: 'beginner' }, + query: 'javascript basics', + }, '#/components/schemas/SearchResponse'), + 400: response(400, 'Invalid filters JSON', errorEnvelope('filters must be valid JSON', 'filters'), '#/components/schemas/ApiError'), + }, + }, + }, + '/search/autocomplete': { + get: { + tags: ['Search'], + summary: 'Get search autocomplete suggestions', + operationId: 'getAutocomplete', + parameters: [{ name: 'q', in: 'query', required: true, schema: { type: 'string' }, example: 'java' }], + responses: { + 200: response(200, 'Autocomplete suggestions', ['javascript', 'java fundamentals', 'java spring']), + }, + }, + }, + '/debug/requests': { + get: { + tags: ['Debugging'], + summary: 'List recently captured requests', + operationId: 'listCapturedRequests', + security: bearerSecurity, + parameters: [{ name: 'limit', in: 'query', required: false, schema: { type: 'integer', default: 50 } }], + responses: { + 200: response(200, 'Captured request summaries', { + total: 1, + records: [ + { + id: 'req_01', + timestamp: '2026-05-27T18:00:00.000Z', + method: 'GET', + path: '/search?q=javascript', + statusCode: 200, + durationMs: 18, + hasError: false, + }, + ], + }), + }, + }, + delete: { + tags: ['Debugging'], + summary: 'Clear the captured request buffer', + operationId: 'clearCapturedRequests', + security: bearerSecurity, + responses: { + 200: response(200, 'Capture buffer cleared', { message: 'Debug capture buffer cleared' }), + }, + }, + }, + }, + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + }, + }, + schemas, + }, +}; + +function ensureDir(dir) { + fs.mkdirSync(dir, { recursive: true }); +} + +function writeFile(filePath, content) { + ensureDir(path.dirname(filePath)); + fs.writeFileSync(filePath, content); +} + +function generateExamplesMarkdown() { + const curls = [ + { + title: 'Login', + command: [ + 'curl -X POST http://localhost:3000/auth/login \\', + ' -H "Content-Type: application/json" \\', + ` -d '${JSON.stringify({ email: 'learner@example.com', password: 'Password123!' })}'`, + ].join('\n'), + response: successEnvelope({ + accessToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + refreshToken: 'refresh_01JZ0D4R8R2Y3R9H2W6E5R4T1P', + user: examples.user, + }, 'Login successful'), + }, + { + title: 'Create Course', + command: [ + 'curl -X POST http://localhost:3000/courses \\', + ' -H "Authorization: Bearer YOUR_TOKEN" \\', + ' -H "Content-Type: application/json" \\', + ` -d '${JSON.stringify({ + title: examples.course.title, + description: examples.course.description, + category: examples.course.category, + level: examples.course.level, + price: examples.course.price, + })}'`, + ].join('\n'), + response: successEnvelope(examples.course, 'Course created'), + }, + { + title: 'Search Content', + command: + 'curl "http://localhost:3000/search?q=javascript%20basics&filters=%7B%22category%22%3A%22programming%22%7D"', + response: { + results: [examples.course], + total: 1, + page: 1, + limit: 20, + filters: { category: 'programming' }, + query: 'javascript basics', + }, + }, + ]; + + return [ + '# API Examples', + '', + 'This file is generated by `npm run docs:generate` from `scripts/generate-api-docs.js`.', + '', + ...curls.flatMap((item) => [ + `## ${item.title}`, + '', + '```bash', + item.command, + '```', + '', + '```json', + json(item.response), + '```', + '', + ]), + ].join('\n'); +} + +function generateSiteHtml() { + const operations = Object.entries(spec.paths).flatMap(([route, methods]) => + Object.entries(methods).map(([method, operation]) => ({ route, method, operation })), + ); + + const rows = operations + .map( + ({ route, method, operation }) => ` + + ${method.toUpperCase()} + ${route} + ${operation.summary} + ${operation.tags.join(', ')} + `, + ) + .join(''); + + return ` + + + + + TeachLink API Documentation + + + +
+
+

Generated API Docs

+

TeachLink API

+

OpenAPI ${spec.openapi} documentation generated from the backend documentation source.

+ +
+
+

Endpoints

+ + + + + ${rows} +
MethodPathSummaryTags
+
+
+

Interactive Reference

+ +
+
+ + +`; +} + +function generateStyles() { + return `:root { + color-scheme: light; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #172026; + background: #f8faf9; +} + +body { + margin: 0; +} + +main { + width: min(1180px, calc(100% - 32px)); + margin: 0 auto; + padding: 40px 0 80px; +} + +.hero { + padding: 48px 0 32px; + border-bottom: 1px solid #d9e2df; +} + +.eyebrow { + color: #0d7a61; + font-weight: 700; + text-transform: uppercase; + font-size: 0.78rem; +} + +h1 { + margin: 0; + font-size: 3rem; + letter-spacing: 0; +} + +h2 { + margin-top: 40px; +} + +.actions { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin-top: 24px; +} + +.actions a { + border: 1px solid #0d7a61; + color: #0b5f4d; + padding: 10px 14px; + border-radius: 6px; + text-decoration: none; + font-weight: 700; +} + +table { + width: 100%; + border-collapse: collapse; + background: #ffffff; + border: 1px solid #d9e2df; +} + +th, td { + padding: 12px; + border-bottom: 1px solid #e5ece9; + text-align: left; + vertical-align: top; +} + +th { + background: #edf4f1; +} + +code { + white-space: nowrap; +} + +.method { + display: inline-block; + min-width: 56px; + padding: 4px 8px; + border-radius: 4px; + color: white; + font-weight: 800; + text-align: center; + font-size: 0.78rem; +} + +.method-get { background: #2563eb; } +.method-post { background: #0d7a61; } +.method-put { background: #b45309; } +.method-patch { background: #7c3aed; } +.method-delete { background: #dc2626; } +`; +} + +function main() { + ensureDir(apiDocsDir); + ensureDir(siteDir); + + const specJson = json(spec); + writeFile(path.join(rootDir, 'openapi-spec.json'), `${specJson}\n`); + writeFile(path.join(apiDocsDir, 'openapi-spec.json'), `${specJson}\n`); + writeFile(path.join(apiDocsDir, 'examples.md'), generateExamplesMarkdown()); + writeFile(path.join(siteDir, 'openapi-spec.json'), `${specJson}\n`); + writeFile(path.join(siteDir, 'examples.md'), generateExamplesMarkdown()); + writeFile(path.join(siteDir, 'index.html'), generateSiteHtml()); + writeFile(path.join(siteDir, 'styles.css'), generateStyles()); + writeFile(path.join(siteDir, '.nojekyll'), ''); + + console.log(`Generated OpenAPI spec with ${Object.keys(spec.paths).length} paths.`); + console.log(`Wrote ${path.relative(rootDir, path.join(siteDir, 'index.html'))}`); +} + +main(); diff --git a/scripts/generate-openapi-spec.ts b/scripts/generate-openapi-spec.ts index d7d67b2a..f171e5b5 100644 --- a/scripts/generate-openapi-spec.ts +++ b/scripts/generate-openapi-spec.ts @@ -1,256 +1,7 @@ -import * as fs from 'fs'; -import * as path from 'path'; - -const spec = { - openapi: '3.0.0', - info: { - title: 'TeachLink API', - description: 'TeachLink Backend API Documentation', - version: '1.0', - }, - paths: { - '/users': { - get: { - tags: ['users'], - summary: 'Get all users (Admin only)', - security: [{ bearerAuth: [] }], - responses: { - 200: { description: 'Users found' }, - }, - }, - post: { - tags: ['users'], - summary: 'Create a new user (Admin only)', - security: [{ bearerAuth: [] }], - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - email: { type: 'string' }, - password: { type: 'string' }, - firstName: { type: 'string' }, - lastName: { type: 'string' }, - }, - required: ['email', 'password', 'firstName', 'lastName'], - }, - }, - }, - }, - responses: { - 201: { description: 'User created' }, - }, - }, - }, - '/users/{id}': { - get: { - tags: ['users'], - summary: 'Get user by ID', - parameters: [ - { - name: 'id', - in: 'path', - required: true, - schema: { type: 'string' }, - }, - ], - responses: { - 200: { description: 'User found' }, - }, - }, - patch: { - tags: ['users'], - summary: 'Update user', - parameters: [ - { - name: 'id', - in: 'path', - required: true, - schema: { type: 'string' }, - }, - ], - responses: { - 200: { description: 'User updated' }, - }, - }, - delete: { - tags: ['users'], - summary: 'Delete user (Admin only)', - parameters: [ - { - name: 'id', - in: 'path', - required: true, - schema: { type: 'string' }, - }, - ], - responses: { - 200: { description: 'User deleted' }, - }, - }, - }, - '/auth/login': { - post: { - tags: ['auth'], - summary: 'User login', - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - email: { type: 'string' }, - password: { type: 'string' }, - }, - required: ['email', 'password'], - }, - }, - }, - }, - responses: { - 200: { description: 'Login successful' }, - 401: { description: 'Invalid credentials' }, - }, - }, - }, - '/auth/register': { - post: { - tags: ['auth'], - summary: 'User registration', - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - email: { type: 'string' }, - password: { type: 'string' }, - firstName: { type: 'string' }, - lastName: { type: 'string' }, - }, - required: ['email', 'password', 'firstName', 'lastName'], - }, - }, - }, - }, - responses: { - 201: { description: 'Registration successful' }, - }, - }, - }, - '/courses': { - get: { - tags: ['courses'], - summary: 'Get all courses', - responses: { - 200: { description: 'Courses found' }, - }, - }, - post: { - tags: ['courses'], - summary: 'Create a new course', - security: [{ bearerAuth: [] }], - requestBody: { - required: true, - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - title: { type: 'string' }, - description: { type: 'string' }, - }, - required: ['title'], - }, - }, - }, - }, - responses: { - 201: { description: 'Course created' }, - }, - }, - }, - '/courses/{id}': { - get: { - tags: ['courses'], - summary: 'Get course by ID', - parameters: [ - { - name: 'id', - in: 'path', - required: true, - schema: { type: 'string' }, - }, - ], - responses: { - 200: { description: 'Course found' }, - }, - }, - patch: { - tags: ['courses'], - summary: 'Update course', - parameters: [ - { - name: 'id', - in: 'path', - required: true, - schema: { type: 'string' }, - }, - ], - responses: { - 200: { description: 'Course updated' }, - }, - }, - delete: { - tags: ['courses'], - summary: 'Delete course', - parameters: [ - { - name: 'id', - in: 'path', - required: true, - schema: { type: 'string' }, - }, - ], - responses: { - 200: { description: 'Course deleted' }, - }, - }, - }, - '/health': { - get: { - tags: ['health'], - summary: 'Health check', - responses: { - 200: { description: 'Service is healthy' }, - }, - }, - }, - '/health/liveness': { - get: { - tags: ['health'], - summary: 'Liveness probe', - responses: { - 200: { description: 'Service is alive' }, - }, - }, - }, - }, - components: { - securitySchemes: { - bearerAuth: { - type: 'http', - scheme: 'bearer', - bearerFormat: 'JWT', - }, - }, - }, -}; - -const outputPath = path.join(__dirname, '../openapi-spec.json'); -fs.writeFileSync(outputPath, JSON.stringify(spec, null, 2)); -console.log('OpenAPI spec generated:', outputPath); \ No newline at end of file +/** + * Backward-compatible entry point for older SDK workflows. + * + * The documentation generator now writes the OpenAPI spec, static docs site, + * and example request/response artifacts in one pass. + */ +require('./generate-api-docs.js'); diff --git a/src/ab-testing/ab-testing.controller.ts b/src/ab-testing/ab-testing.controller.ts index 4682ea78..fb55e87f 100644 --- a/src/ab-testing/ab-testing.controller.ts +++ b/src/ab-testing/ab-testing.controller.ts @@ -12,6 +12,7 @@ import { HttpStatus, UseGuards, } from '@nestjs/common'; +import { ApiBearerAuth, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ABTestingService, ICreateExperimentDto } from './ab-testing.service'; import { ExperimentService } from './experiments/experiment.service'; import { StatisticalAnalysisService } from './analysis/statistical-analysis.service'; @@ -25,8 +26,12 @@ import { UserRole } from '../users/entities/user.entity'; /** * Exposes AB testing endpoints. */ +@ApiTags('A/B Testing') @Controller('ab-testing') @UseGuards(JwtAuthGuard, RolesGuard) +@ApiBearerAuth() +@ApiResponse({ status: 401, description: 'Authentication required' }) +@ApiResponse({ status: 403, description: 'Insufficient role for this experiment operation' }) export class ABTestingController { private readonly logger = new Logger(ABTestingController.name); diff --git a/src/analytics/analytics.controller.ts b/src/analytics/analytics.controller.ts index b542b2e5..d56e071a 100644 --- a/src/analytics/analytics.controller.ts +++ b/src/analytics/analytics.controller.ts @@ -1,4 +1,6 @@ import { Controller } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +@ApiTags('Analytics') @Controller('analytics') export class AnalyticsController {} diff --git a/src/app.controller.ts b/src/app.controller.ts index d2136437..8ed9f082 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -8,7 +8,16 @@ import { SkipQuota } from './rate-limiting/decorators/quota.decorator'; export class AppController { @Get() @ApiOperation({ summary: 'Get app status' }) - @ApiResponse({ status: 200, description: 'App is running' }) + @ApiResponse({ + status: 200, + description: 'App is running', + schema: { + example: { + message: 'TeachLink API is running', + timestamp: '2026-05-27T18:00:00.000Z', + }, + }, + }) getStatus() { return { message: 'TeachLink API is running', timestamp: new Date().toISOString() }; } diff --git a/src/assessment/assessment.controller.ts b/src/assessment/assessment.controller.ts index 5c4b4ee3..5a1ce9eb 100644 --- a/src/assessment/assessment.controller.ts +++ b/src/assessment/assessment.controller.ts @@ -1,10 +1,13 @@ import { Body, Controller, Get, Param, Post, UseGuards, Request } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { AssessmentsService } from './assessments.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; /** * Exposes assessments endpoints. */ +@ApiTags('Assessments') +@ApiBearerAuth() @Controller('assessments') export class AssessmentsController { constructor(private readonly service: AssessmentsService) {} @@ -17,6 +20,9 @@ export class AssessmentsController { */ @Post(':id/start') @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Start an assessment attempt' }) + @ApiResponse({ status: 201, description: 'Assessment attempt started' }) + @ApiResponse({ status: 401, description: 'Authentication required' }) start(@Request() req: any, @Param('id') id: string): any { const studentId = req.user.id; if (!studentId) { @@ -34,6 +40,9 @@ export class AssessmentsController { */ @Post('attempts/:id/submit') @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Submit assessment answers' }) + @ApiResponse({ status: 201, description: 'Assessment submitted and scored' }) + @ApiResponse({ status: 401, description: 'Authentication required' }) submit(@Request() req: any, @Param('id') id: string, @Body('answers') answers: any[]): any { return this.service.submitAssessment(id, answers); } @@ -46,6 +55,9 @@ export class AssessmentsController { */ @Get('attempts/:id') @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Get assessment attempt results' }) + @ApiResponse({ status: 200, description: 'Assessment attempt results' }) + @ApiResponse({ status: 401, description: 'Authentication required' }) results(@Request() req: any, @Param('id') id: string): any { return this.service.getResults(id); } diff --git a/src/common/controllers/circuit-breaker.controller.ts b/src/common/controllers/circuit-breaker.controller.ts index 87e40d9d..392d833b 100644 --- a/src/common/controllers/circuit-breaker.controller.ts +++ b/src/common/controllers/circuit-breaker.controller.ts @@ -1,5 +1,5 @@ import { Controller, Get, Post, Param, UseGuards } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; import { EnhancedCircuitBreakerService } from '../services/circuit-breaker.service'; import { Roles } from '../../auth/decorators/roles.decorator'; import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; @@ -10,12 +10,15 @@ import { UserRole } from '../../users/entities/user.entity'; @Controller('circuit-breakers') @UseGuards(JwtAuthGuard, RolesGuard) @ApiBearerAuth() +@ApiResponse({ status: 401, description: 'Authentication required' }) +@ApiResponse({ status: 403, description: 'Admin role required' }) export class CircuitBreakerController { constructor(private readonly circuitBreakerService: EnhancedCircuitBreakerService) {} @Get() @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'Get all circuit breaker statistics' }) + @ApiResponse({ status: 200, description: 'Circuit breaker statistics' }) getAllStats() { return this.circuitBreakerService.getAllStats(); } @@ -23,6 +26,7 @@ export class CircuitBreakerController { @Get('health') @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'Get circuit breaker health status' }) + @ApiResponse({ status: 200, description: 'Circuit breaker health status' }) getHealthStatus() { return this.circuitBreakerService.getHealthStatus(); } @@ -30,6 +34,7 @@ export class CircuitBreakerController { @Get(':key') @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'Get specific circuit breaker statistics' }) + @ApiResponse({ status: 200, description: 'Circuit breaker statistics or not-found payload' }) getStats(@Param('key') key: string) { const stats = this.circuitBreakerService.getStats(key); if (!stats) { @@ -41,6 +46,7 @@ export class CircuitBreakerController { @Post(':key/reset') @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'Reset a circuit breaker' }) + @ApiResponse({ status: 201, description: 'Circuit breaker reset' }) resetCircuitBreaker(@Param('key') key: string) { this.circuitBreakerService.close(key); return { message: `Circuit breaker ${key} has been reset` }; @@ -49,6 +55,7 @@ export class CircuitBreakerController { @Post(':key/disable') @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'Disable a circuit breaker' }) + @ApiResponse({ status: 201, description: 'Circuit breaker disabled' }) disableCircuitBreaker(@Param('key') key: string) { this.circuitBreakerService.disable(key); return { message: `Circuit breaker ${key} has been disabled` }; @@ -57,6 +64,7 @@ export class CircuitBreakerController { @Post(':key/enable') @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'Enable a circuit breaker' }) + @ApiResponse({ status: 201, description: 'Circuit breaker enabled' }) enableCircuitBreaker(@Param('key') key: string) { this.circuitBreakerService.enable(key); return { message: `Circuit breaker ${key} has been enabled` }; diff --git a/src/common/interceptors/api-version.interceptor.ts b/src/common/interceptors/api-version.interceptor.ts new file mode 100644 index 00000000..d74fcb0c --- /dev/null +++ b/src/common/interceptors/api-version.interceptor.ts @@ -0,0 +1,31 @@ +import { + BadRequestException, + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; + +export const API_VERSION_HEADER = 'X-API-Version'; +export const DEFAULT_API_VERSION = process.env.API_DEFAULT_VERSION || '1'; +export const SUPPORTED_API_VERSIONS = (process.env.API_SUPPORTED_VERSIONS || '1') + .split(',') + .map((version) => version.trim()) + .filter(Boolean); + +@Injectable() +export class ApiVersionInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + const version = request.headers?.[API_VERSION_HEADER.toLowerCase()] || DEFAULT_API_VERSION; + + if (!SUPPORTED_API_VERSIONS.includes(String(version))) { + throw new BadRequestException( + `Unsupported API version "${version}". Supported versions: ${SUPPORTED_API_VERSIONS.join(', ')}`, + ); + } + + return next.handle(); + } +} diff --git a/src/common/interceptors/global-exception.filter.ts b/src/common/interceptors/global-exception.filter.ts new file mode 100644 index 00000000..6e1334b8 --- /dev/null +++ b/src/common/interceptors/global-exception.filter.ts @@ -0,0 +1,32 @@ +import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common'; +import { Request, Response } from 'express'; +import { getCorrelationId } from '../utils/correlation.utils'; + +@Catch() +export class GlobalExceptionFilter implements ExceptionFilter { + catch(exception: unknown, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + const isHttpException = exception instanceof HttpException; + const status = isHttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; + const exceptionResponse = isHttpException ? exception.getResponse() : undefined; + const message = + typeof exceptionResponse === 'object' && + exceptionResponse !== null && + 'message' in exceptionResponse + ? (exceptionResponse as { message: string | string[] }).message + : exception instanceof Error + ? exception.message + : 'Internal server error'; + + response.status(status).json({ + success: false, + statusCode: status, + message, + path: request.url, + timestamp: new Date().toISOString(), + correlationId: getCorrelationId(), + }); + } +} diff --git a/src/common/interceptors/response-transform.interceptor.ts b/src/common/interceptors/response-transform.interceptor.ts new file mode 100644 index 00000000..3867e16d --- /dev/null +++ b/src/common/interceptors/response-transform.interceptor.ts @@ -0,0 +1,22 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; +import { Observable, map } from 'rxjs'; +import { getCorrelationId } from '../utils/correlation.utils'; + +@Injectable() +export class ResponseTransformInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + return next.handle().pipe( + map((data) => { + if (data && typeof data === 'object' && 'success' in data) { + return data; + } + + return { + success: true, + data, + correlationId: getCorrelationId(), + }; + }), + ); + } +} diff --git a/src/common/middleware/decompression.middleware.spec.ts b/src/common/middleware/decompression.middleware.spec.ts index a7e48572..15c91ab8 100644 --- a/src/common/middleware/decompression.middleware.spec.ts +++ b/src/common/middleware/decompression.middleware.spec.ts @@ -69,7 +69,7 @@ describe('DecompressionMiddleware', () => { }); it('should pass through when content-encoding is not a string', () => { - req.headers = { 'content-encoding': ['gzip', 'deflate'] }; + req.headers = { 'content-encoding': ['gzip', 'deflate'] as any }; middleware.use(req as Request, res as Response, next); expect(next).toHaveBeenCalled(); @@ -150,6 +150,8 @@ describe('DecompressionMiddleware', () => { it('should handle decompression errors', (done) => { req.headers = { 'content-encoding': 'gzip' }; const mockDecompressor = new PassThrough(); + (mockDecompressor.pipe as any) = jest.fn().mockReturnValue(mockDecompressor); + (middleware as any).decompressors.gzip = () => mockDecompressor; req.pipe = jest.fn().mockReturnValue(mockDecompressor); req.on = jest.fn(); diff --git a/src/common/modules/api-versioning.module.ts b/src/common/modules/api-versioning.module.ts new file mode 100644 index 00000000..d6693d5c --- /dev/null +++ b/src/common/modules/api-versioning.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; + +export const API_VERSIONING_DOCUMENTATION = + 'Send X-API-Version with versioned requests. The default supported API version is 1.'; + +@Module({}) +export class ApiVersioningModule {} diff --git a/src/debugging/debug.controller.ts b/src/debugging/debug.controller.ts index 0d778d6d..fd419dce 100644 --- a/src/debugging/debug.controller.ts +++ b/src/debugging/debug.controller.ts @@ -9,7 +9,7 @@ import { Query, UseGuards, } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; import { Roles } from '../auth/decorators/roles.decorator'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; @@ -37,11 +37,29 @@ export class DebugController { @Get('requests') @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'List recently captured request/response exchanges' }) + @ApiResponse({ + status: 200, + description: 'Captured request summaries', + schema: { + example: { + total: 1, + records: [ + { + id: 'req_01', + timestamp: '2026-05-27T18:00:00.000Z', + method: 'GET', + path: '/search?q=javascript', + statusCode: 200, + durationMs: 18, + hasError: false, + }, + ], + }, + }, + }) list(@Query('limit') limit?: string) { const parsed = limit ? Number(limit) : undefined; - const records = this.capture.list( - Number.isFinite(parsed) ? parsed : undefined, - ); + const records = this.capture.list(Number.isFinite(parsed) ? parsed : undefined); // Summaries only — keep the list view light. Full payload via :id. return { total: this.capture.size, @@ -60,6 +78,8 @@ export class DebugController { @Get('requests/:id') @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'Inspect a captured request/response in full' }) + @ApiResponse({ status: 200, description: 'Captured request details' }) + @ApiResponse({ status: 404, description: 'Captured request not found' }) inspect(@Param('id') id: string) { const record = this.capture.get(id); if (!record) throw new NotFoundException(`No captured request "${id}"`); @@ -69,6 +89,8 @@ export class DebugController { @Get('requests/:id/timeline') @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'Get the performance timeline for a captured request' }) + @ApiResponse({ status: 200, description: 'Captured request performance timeline' }) + @ApiResponse({ status: 404, description: 'Captured request not found' }) timeline(@Param('id') id: string) { const record = this.capture.get(id); if (!record) throw new NotFoundException(`No captured request "${id}"`); @@ -81,6 +103,8 @@ export class DebugController { @Get('requests/:id/trace') @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'Get the enhanced stack trace for a failed request' }) + @ApiResponse({ status: 200, description: 'Enhanced error trace or no-error message' }) + @ApiResponse({ status: 404, description: 'Captured request not found' }) trace(@Param('id') id: string) { const record = this.capture.get(id); if (!record) throw new NotFoundException(`No captured request "${id}"`); @@ -93,6 +117,7 @@ export class DebugController { @Post('requests/:id/replay') @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'Replay a captured request and diff the response' }) + @ApiResponse({ status: 200, description: 'Replay result and response diff' }) replay(@Param('id') id: string, @Body() body: ReplayRequestDto) { return this.replayService.replay(id, { baseUrl: body?.baseUrl, @@ -104,6 +129,11 @@ export class DebugController { @Delete('requests') @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'Clear the captured request buffer' }) + @ApiResponse({ + status: 200, + description: 'Capture buffer cleared', + schema: { example: { message: 'Debug capture buffer cleared' } }, + }) clear() { this.capture.clear(); return { message: 'Debug capture buffer cleared' }; diff --git a/src/debugging/services/request-capture.service.ts b/src/debugging/services/request-capture.service.ts index d309f060..9d385057 100644 --- a/src/debugging/services/request-capture.service.ts +++ b/src/debugging/services/request-capture.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Optional } from '@nestjs/common'; import { IDebugRecord } from '../interfaces/debug.interfaces'; /** @@ -20,6 +20,8 @@ const DEFAULT_CONFIG: CaptureConfig = { redactedHeaders: ['authorization', 'cookie', 'set-cookie', 'x-api-key'], }; +export const DEBUG_CAPTURE_CONFIG = 'DEBUG_CAPTURE_CONFIG'; + /** * Stores captured request/response exchanges in a bounded in-memory ring * buffer. This is the backing store for the inspection and replay endpoints. @@ -32,7 +34,7 @@ export class RequestCaptureService { private readonly config: CaptureConfig; private readonly buffer: IDebugRecord[] = []; - constructor(config?: Partial) { + constructor(@Optional() @Inject(DEBUG_CAPTURE_CONFIG) config?: Partial) { this.config = { ...DEFAULT_CONFIG, ...config }; } @@ -69,9 +71,7 @@ export class RequestCaptureService { const redactHeaders = (headers: Record) => { const out: Record = {}; for (const [key, value] of Object.entries(headers)) { - out[key] = this.config.redactedHeaders.includes(key.toLowerCase()) - ? '[REDACTED]' - : value; + out[key] = this.config.redactedHeaders.includes(key.toLowerCase()) ? '[REDACTED]' : value; } return out; }; diff --git a/src/email-marketing/automation/automation.controller.ts b/src/email-marketing/automation/automation.controller.ts index 342953cf..21c2b4b0 100644 --- a/src/email-marketing/automation/automation.controller.ts +++ b/src/email-marketing/automation/automation.controller.ts @@ -22,6 +22,7 @@ import { AutomationWorkflow } from '../entities/automation-workflow.entity'; */ @ApiTags('Email Marketing - Automation') @ApiBearerAuth() +@ApiResponse({ status: 401, description: 'Authentication required' }) @Controller('email-marketing/automation') export class AutomationController { constructor(private readonly automationService: AutomationService) {} diff --git a/src/email-marketing/templates/template.controller.ts b/src/email-marketing/templates/template.controller.ts index 69d82bec..a70d5630 100644 --- a/src/email-marketing/templates/template.controller.ts +++ b/src/email-marketing/templates/template.controller.ts @@ -22,6 +22,7 @@ import { EmailTemplate } from '../entities/email-template.entity'; */ @ApiTags('Email Marketing - Templates') @ApiBearerAuth() +@ApiResponse({ status: 401, description: 'Authentication required' }) @Controller('email-marketing/templates') export class TemplateController { constructor(private readonly templateService: TemplateManagementService) {} @@ -48,6 +49,7 @@ export class TemplateController { @ApiOperation({ summary: 'Get all email templates' }) @ApiQuery({ name: 'page', required: false, type: Number }) @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiResponse({ status: 200, description: 'List of email templates' }) async findAll(@Query('page') page = 1, @Query('limit') limit = 10) { return this.templateService.findAll(page, limit); } @@ -72,6 +74,8 @@ export class TemplateController { */ @Put(':id') @ApiOperation({ summary: 'Update a template' }) + @ApiResponse({ status: 200, description: 'Template updated successfully' }) + @ApiResponse({ status: 404, description: 'Template not found' }) async update( @Param('id', ParseUUIDPipe) id: string, @Body() updateTemplateDto: UpdateTemplateDto, @@ -86,6 +90,8 @@ export class TemplateController { @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Delete a template' }) + @ApiResponse({ status: 204, description: 'Template deleted successfully' }) + @ApiResponse({ status: 404, description: 'Template not found' }) async remove(@Param('id', ParseUUIDPipe) id: string): Promise { return this.templateService.remove(id); } @@ -97,6 +103,8 @@ export class TemplateController { */ @Post(':id/duplicate') @ApiOperation({ summary: 'Duplicate a template' }) + @ApiResponse({ status: 201, description: 'Template duplicated successfully' }) + @ApiResponse({ status: 404, description: 'Template not found' }) async duplicate(@Param('id', ParseUUIDPipe) id: string): Promise { return this.templateService.duplicate(id); } @@ -109,6 +117,8 @@ export class TemplateController { */ @Post(':id/preview') @ApiOperation({ summary: 'Preview a template with sample data' }) + @ApiResponse({ status: 201, description: 'Rendered template preview' }) + @ApiResponse({ status: 404, description: 'Template not found' }) async preview(@Param('id', ParseUUIDPipe) id: string, @Body() sampleData?: Record) { return this.templateService.previewTemplate(id, sampleData); } @@ -121,6 +131,8 @@ export class TemplateController { */ @Post(':id/render') @ApiOperation({ summary: 'Render a template with provided variables' }) + @ApiResponse({ status: 201, description: 'Rendered template output' }) + @ApiResponse({ status: 404, description: 'Template not found' }) async render(@Param('id', ParseUUIDPipe) id: string, @Body() variables: Record) { return this.templateService.renderTemplate(id, variables); } diff --git a/src/main.ts b/src/main.ts index 190ba9fe..19053132 100644 --- a/src/main.ts +++ b/src/main.ts @@ -179,7 +179,13 @@ async function bootstrapWorker(): Promise { .build(); const document = SwaggerModule.createDocument(app, config); - SwaggerModule.setup('api', app, document); + SwaggerModule.setup('api/docs', app, document, { + swaggerOptions: { + persistAuthorization: true, + displayRequestDuration: true, + }, + jsonDocumentUrl: 'api/docs-json', + }); const port = process.env.PORT || 3000; app.enableShutdownHooks(); @@ -194,7 +200,7 @@ async function bootstrapWorker(): Promise { } logger.log(`TeachLink API running on http://localhost:${port}`); - logger.log(`Swagger docs available at http://localhost:${port}/api`); + logger.log(`Swagger docs available at http://localhost:${port}/api/docs`); logger.log( `API versioning enabled via ${API_VERSION_HEADER}. Supported versions: ${SUPPORTED_API_VERSIONS.join(', ')}; default route version: ${DEFAULT_API_VERSION}.`, ); diff --git a/src/search/search.controller.ts b/src/search/search.controller.ts index 295966b4..7114d686 100644 --- a/src/search/search.controller.ts +++ b/src/search/search.controller.ts @@ -1,8 +1,10 @@ import { BadRequestException, Controller, Get, Query } from '@nestjs/common'; +import { ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import { THROTTLE } from '../common/constants/throttle.constants'; import { SearchService } from './search.service'; +@ApiTags('Search') @Throttle({ default: THROTTLE.SEARCH }) @Controller('search') export class SearchController { @@ -18,6 +20,32 @@ export class SearchController { * @returns The operation result. */ @Get() + @ApiOperation({ summary: 'Search courses and learning content' }) + @ApiQuery({ name: 'q', required: true, example: 'javascript basics' }) + @ApiQuery({ + name: 'filters', + required: false, + description: 'JSON encoded search filters', + example: '{"category":"programming","level":"beginner"}', + }) + @ApiQuery({ name: 'sort', required: false, example: 'relevance' }) + @ApiQuery({ name: 'page', required: false, example: 1 }) + @ApiQuery({ name: 'limit', required: false, example: 20 }) + @ApiResponse({ + status: 200, + description: 'Search results', + schema: { + example: { + results: [], + total: 0, + page: 1, + limit: 20, + filters: { category: 'programming', level: 'beginner' }, + query: 'javascript basics', + }, + }, + }) + @ApiResponse({ status: 400, description: 'Invalid filters JSON' }) async search( @Query('q') query: string, @Query('filters') filters?: string, @@ -41,6 +69,13 @@ export class SearchController { } @Get('autocomplete') + @ApiOperation({ summary: 'Get search autocomplete suggestions' }) + @ApiQuery({ name: 'q', required: true, example: 'java' }) + @ApiResponse({ + status: 200, + description: 'Autocomplete suggestions', + schema: { example: ['javascript', 'java fundamentals', 'java spring'] }, + }) async autocomplete( @Query('q') query: string, @@ -49,11 +84,37 @@ export class SearchController { } @Get('filters') + @ApiOperation({ summary: 'Get available search filters' }) + @ApiResponse({ + status: 200, + description: 'Available filters', + schema: { + example: { + categories: ['programming', 'design'], + levels: ['beginner', 'intermediate'], + languages: ['en'], + priceRanges: [{ label: 'Free', lte: 0 }], + }, + }, + }) async getFilters(): Promise { return this.searchService.getAvailableFilters(); } @Get('analytics') + @ApiOperation({ summary: 'Get search analytics' }) + @ApiQuery({ name: 'days', required: false, example: 7 }) + @ApiResponse({ + status: 200, + description: 'Search analytics summary', + schema: { + example: { + topQueries: [{ query: 'javascript', count: 42 }], + totalSearches: 120, + averageResults: 8.4, + }, + }, + }) async getAnalytics( @Query('days') days?: string, diff --git a/src/security/secrets/secrets.controller.ts b/src/security/secrets/secrets.controller.ts index 71981001..4f6ca0ea 100644 --- a/src/security/secrets/secrets.controller.ts +++ b/src/security/secrets/secrets.controller.ts @@ -1,5 +1,5 @@ import { Controller, Get, Post, Param, UseGuards } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger'; import { SecretsManagerService } from './secrets-manager.service'; import { VaultSecretsService } from './vault-secrets.service'; import { Roles } from '../../auth/decorators/roles.decorator'; @@ -11,6 +11,8 @@ import { UserRole } from '../../users/entities/user.entity'; @Controller('secrets') @UseGuards(JwtAuthGuard, RolesGuard) @ApiBearerAuth() +@ApiResponse({ status: 401, description: 'Authentication required' }) +@ApiResponse({ status: 403, description: 'Admin role required' }) export class SecretsController { constructor( private readonly secretsManagerService: SecretsManagerService, @@ -20,6 +22,7 @@ export class SecretsController { @Get('aws/:secretName') @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'Get secret from AWS Secrets Manager' }) + @ApiResponse({ status: 200, description: 'Secret lookup result with redacted value' }) async getAWSSecret(@Param('secretName') secretName: string) { const value = await this.secretsManagerService.getSecret(secretName); return { secretName, value: value ? '***REDACTED***' : null }; @@ -28,6 +31,7 @@ export class SecretsController { @Get('vault/:secretName') @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'Get secret from HashiCorp Vault' }) + @ApiResponse({ status: 200, description: 'Vault secret lookup result with redacted value' }) async getVaultSecret(@Param('secretName') secretName: string) { const value = await this.vaultSecretsService.getSecret(secretName); return { secretName, value: value ? '***REDACTED***' : null }; @@ -36,6 +40,7 @@ export class SecretsController { @Post('aws/rotate/:secretName') @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'Rotate secret in AWS Secrets Manager' }) + @ApiResponse({ status: 201, description: 'AWS secret rotated' }) async rotateAWSSecret(@Param('secretName') secretName: string) { await this.secretsManagerService.rotateSecret(secretName); return { message: `Secret ${secretName} rotated successfully` }; @@ -44,6 +49,7 @@ export class SecretsController { @Post('vault/rotate/:secretName') @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'Rotate secret in HashiCorp Vault' }) + @ApiResponse({ status: 201, description: 'Vault secret rotated' }) async rotateVaultSecret(@Param('secretName') secretName: string) { await this.vaultSecretsService.rotateSecret(secretName); return { message: `Secret ${secretName} rotated successfully in Vault` }; @@ -52,6 +58,7 @@ export class SecretsController { @Post('cache/clear') @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'Clear secret cache' }) + @ApiResponse({ status: 201, description: 'Secret cache cleared' }) async clearCache() { this.secretsManagerService.clearCache(); return { message: 'Secret cache cleared' }; diff --git a/src/tenancy/tenancy.controller.ts b/src/tenancy/tenancy.controller.ts index 3968c04c..c4e65b50 100644 --- a/src/tenancy/tenancy.controller.ts +++ b/src/tenancy/tenancy.controller.ts @@ -33,6 +33,8 @@ import { TenantPlan } from './entities/tenant.entity'; @Controller('tenants') @UseGuards(JwtAuthGuard, RolesGuard) @ApiBearerAuth() +@ApiResponse({ status: 401, description: 'Authentication required' }) +@ApiResponse({ status: 403, description: 'Insufficient tenant permissions' }) export class TenancyController { constructor( private readonly tenancyService: TenancyService, diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index d0336e71..962901c0 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -1,59 +1,40 @@ import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { TypeOrmModule } from '@nestjs/typeorm'; import { AppModule } from '../src/app.module'; -import { TestDatabaseService } from './utils/test-database.service'; import { TestHttpClient } from './utils/test-http-client'; import { TestRetryHelper } from './utils/test-retry-helper'; describe('App (e2e)', () => { let app: INestApplication; - let testDb: TestDatabaseService; let httpClient: TestHttpClient; let retryHelper: TestRetryHelper; beforeAll(async () => { - // Initialize test database - testDb = new TestDatabaseService(); - await testDb.setup(); - - // Create test module with full app context const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], - }) - .overrideProvider('DATABASE_CONNECTION') - .useValue(testDb.getConnection()) - .compile(); + }).compile(); app = moduleFixture.createNestApplication(); - - // Configure app for testing - app.setGlobalPrefix('api'); await app.init(); - // Initialize test utilities httpClient = new TestHttpClient(app.getHttpServer()); retryHelper = new TestRetryHelper(); }, 60000); // 60 second timeout for setup afterAll(async () => { - await app.close(); - await testDb.teardown(); + if (app) { + await app.close(); + } }, 30000); - beforeEach(async () => { - // Clean database between tests - await testDb.clean(); - }); - describe('Health Check', () => { - it('should return healthy status with retries', async () => { + it('should return app status with retries', async () => { await retryHelper.withRetry( async () => { - const response = await httpClient.get('/health'); + const response = await httpClient.get('/'); expect(response.status).toBe(200); - expect(response.body).toHaveProperty('status', 'ok'); + expect(response.body).toHaveProperty('message', 'TeachLink API is running'); + expect(response.body).toHaveProperty('timestamp'); }, { maxAttempts: 3, @@ -62,20 +43,6 @@ describe('App (e2e)', () => { }, ); }, 10000); - - it('should handle database connectivity', async () => { - await retryHelper.withRetry( - async () => { - const response = await httpClient.get('/api/health/database'); - expect(response.status).toBe(200); - expect(response.body).toHaveProperty('database', 'connected'); - }, - { - maxAttempts: 5, - delayMs: 500, - }, - ); - }, 15000); }); describe('API Endpoints', () => { @@ -83,24 +50,26 @@ describe('App (e2e)', () => { const requests = Array(10) .fill(null) .map(() => - retryHelper.withRetry(() => httpClient.get('/'), { maxAttempts: 3, delayMs: 200 }), + retryHelper.withRetry(() => httpClient.get('/search?q=javascript'), { + maxAttempts: 3, + delayMs: 200, + }), ); const results = await Promise.all(requests); results.forEach((response) => { expect(response.status).toBe(200); + expect(response.body).toHaveProperty('query', 'javascript'); }); }, 30000); - it('should handle request timeouts gracefully', async () => { + it('should return autocomplete suggestions endpoint', async () => { await retryHelper.withRetry( async () => { - // Test with a potentially slow endpoint - const response = await httpClient.get('/api/slow-endpoint', { - timeout: 5000, - }); - expect([200, 404]).toContain(response.status); // Either success or not found + const response = await httpClient.get('/search/autocomplete?q=java'); + expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); }, { maxAttempts: 2, diff --git a/test/jest-e2e.json b/test/jest-e2e.json index 1031de56..374b835a 100644 --- a/test/jest-e2e.json +++ b/test/jest-e2e.json @@ -1,7 +1,7 @@ { - "moduleFileExtensions": ["js", "json", "ts"], + "moduleFileExtensions": ["ts", "js", "json"], "rootDir": ".", - "testEnvironment": "./test/utils/test-environment.js", + "testEnvironment": "./utils/test-environment.js", "testRegex": ".e2e-spec.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" @@ -16,7 +16,7 @@ "bail": false, "reporters": [ "default", - "./test/utils/flaky-test-detector.ts" + "./utils/flakiness-reporter.js" ], "globals": { "ts-jest": { @@ -40,5 +40,5 @@ } } }, - "testSequencer": "./test/utils/test-sequencer.js" + "testSequencer": "./utils/test-sequencer.js" } diff --git a/test/utils/flakiness-reporter.js b/test/utils/flakiness-reporter.js new file mode 100644 index 00000000..4c015e58 --- /dev/null +++ b/test/utils/flakiness-reporter.js @@ -0,0 +1,3 @@ +const { FlakinessReporter } = require('./flaky-test-detector.js'); + +module.exports = FlakinessReporter; diff --git a/test/utils/flaky-test-detector.ts b/test/utils/flaky-test-detector.ts index 66efb981..2fa685ae 100644 --- a/test/utils/flaky-test-detector.ts +++ b/test/utils/flaky-test-detector.ts @@ -1,4 +1,3 @@ -import { TestResult } from '@jest/types'; import * as fs from 'fs'; import * as path from 'path'; @@ -20,7 +19,7 @@ export class FlakyTestDetector { private readonly failureThreshold = 0.1; // 10% failure rate private readonly minRunsThreshold = 3; // Need at least 3 runs to detect flakiness - recordTestResult(testResult: TestResult.AssertionResult, testPath: string): void { + recordTestResult(testResult: any, testPath: string): void { const testKey = `${testPath}:${testResult.title}`; const existing = this.testResults.get(testKey) || { diff --git a/test/utils/test-environment.js b/test/utils/test-environment.js index f71ed636..25fc230e 100644 --- a/test/utils/test-environment.js +++ b/test/utils/test-environment.js @@ -1,4 +1,8 @@ -const NodeEnvironment = require('jest-environment-node'); +const NodeEnvironmentPackage = require('jest-environment-node'); +const NodeEnvironment = + NodeEnvironmentPackage.TestEnvironment || + NodeEnvironmentPackage.default || + NodeEnvironmentPackage; class TestEnvironment extends NodeEnvironment { constructor(config, context) { @@ -141,14 +145,7 @@ class TestEnvironment extends NodeEnvironment { } // Utility method for tests to wait for conditions - async waitForCondition( - condition: () => boolean | Promise, - options: { - timeout?: number; - interval?: number; - description?: string; - } = {}, - ): Promise { + async waitForCondition(condition, options = {}) { const { timeout = 5000, interval = 100, description = 'condition' } = options; const startTime = Date.now(); @@ -169,15 +166,7 @@ class TestEnvironment extends NodeEnvironment { } // Utility method for stable async operations - async withRetry( - operation: () => Promise, - options: { - maxAttempts?: number; - delayMs?: number; - backoffMultiplier?: number; - retryCondition?: (error: any) => boolean; - } = {}, - ): Promise { + async withRetry(operation, options = {}) { const { maxAttempts = 3, delayMs = 1000, @@ -185,7 +174,7 @@ class TestEnvironment extends NodeEnvironment { retryCondition = (error) => error.code === 'ECONNREFUSED' || error.status >= 500, } = options; - let lastError: any; + let lastError; let currentDelay = delayMs; for (let attempt = 1; attempt <= maxAttempts; attempt++) { @@ -208,4 +197,4 @@ class TestEnvironment extends NodeEnvironment { } } -module.exports = TestEnvironment; \ No newline at end of file +module.exports = TestEnvironment; diff --git a/test/utils/test-http-client.ts b/test/utils/test-http-client.ts index 91cae7dd..04be2d96 100644 --- a/test/utils/test-http-client.ts +++ b/test/utils/test-http-client.ts @@ -20,7 +20,7 @@ export interface HttpResponse extends Response { export class TestHttpClient { private server: Server; - private defaultTimeout = 10000; + private defaultTimeout: number | undefined; private defaultRetries = 2; private defaultRetryDelay = 500; @@ -148,12 +148,22 @@ export class TestHttpClient { for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { if (timeout) { - return await Promise.race([ - operation(), - new Promise((_, reject) => - setTimeout(() => reject(new Error(`Request timeout after ${timeout}ms`)), timeout), - ), - ]); + let timeoutId: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + operation(), + new Promise((_, reject) => { + timeoutId = setTimeout( + () => reject(new Error(`Request timeout after ${timeout}ms`)), + timeout, + ); + }), + ]); + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } } return await operation(); } catch (error) { diff --git a/tsconfig.json b/tsconfig.json index 09806fba..2d92d3a4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,7 +24,7 @@ "resolveJsonModule": true, "esModuleInterop": true, "lib": ["es2021", "dom"], - "types": ["node"] + "types": ["node", "jest"] }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "test/**/*"]