From 7cb1ebd2657e7a773335c6d1d5ca32f1ac9a78a4 Mon Sep 17 00:00:00 2001 From: Matieu Date: Sat, 23 Aug 2025 03:00:36 -0400 Subject: [PATCH 1/3] chore(server): prisma scaffold and db check --- server/.env.example | 11 +- server/docs/Expense Tracker API.yaml | 330 +++++++++++++++++++++++++++ server/package.json | 12 +- server/prisma/schema.prisma | 11 + server/server.js | 17 ++ 5 files changed, 377 insertions(+), 4 deletions(-) create mode 100644 server/docs/Expense Tracker API.yaml create mode 100644 server/prisma/schema.prisma diff --git a/server/.env.example b/server/.env.example index cb097c5..7e35b9b 100644 --- a/server/.env.example +++ b/server/.env.example @@ -3,7 +3,16 @@ PORT=8080 NODE_ENV=development # Database Configuration -# Replace with your actual database connection string +# Prefer Vercel Postgres variables when deploying on Vercel. +# For local dev, you can still use DATABASE_URL. +# Vercel will inject these automatically when you add the Vercel Postgres integration. +# POSTGRES_URL usually points to a pooled connection (safe to use with clients that do NOT create their own pool). +# POSTGRES_URL_NON_POOLING is a direct connection string (use with clients that manage their own pool). +POSTGRES_URL= +POSTGRES_URL_NON_POOLING= +POSTGRES_PRISMA_URL= + +# Fallback local connection string (used when POSTGRES_* are not present) DATABASE_URL=postgres://:@localhost:5432/ # JWT Configuration diff --git a/server/docs/Expense Tracker API.yaml b/server/docs/Expense Tracker API.yaml new file mode 100644 index 0000000..84cf070 --- /dev/null +++ b/server/docs/Expense Tracker API.yaml @@ -0,0 +1,330 @@ +openapi: 3.0.0 +info: + title: Smart Personal Expense Tracker API + description: REST API for managing personal expenses, incomes, receipts, and categories. + version: 1.0.0 +servers: + - url: http://localhost:8080/api +paths: + /auth/signup: + post: + summary: Register a new user + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email, password] + properties: + email: + type: string + password: + type: string + responses: + '201': + description: User created + /auth/login: + post: + summary: Login and receive JWT token + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email, password] + properties: + email: + type: string + password: + type: string + responses: + '200': + description: Token returned + /expenses: + get: + summary: List all user expenses + parameters: + - in: query + name: start + schema: + type: string + description: Start date + - in: query + name: end + schema: + type: string + description: End date + - in: query + name: category + schema: + type: string + - in: query + name: type + schema: + type: string + enum: [recurring, one-time] + responses: + '200': + description: List of expenses + post: + summary: Create a new expense + requestBody: + content: + multipart/form-data: + schema: + type: object + required: [amount, date, categoryId] + properties: + amount: + type: number + date: + type: string + categoryId: + type: string + description: + type: string + type: + type: string + enum: [one-time, recurring] + startDate: + type: string + endDate: + type: string + receipt: + type: string + format: binary + responses: + '201': + description: Expense created + /expenses/{id}: + parameters: + - in: path + name: id + required: true + schema: + type: string + get: + summary: Get a single expense + responses: + '200': + description: Expense data + put: + summary: Update an expense + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + amount: + type: number + date: + type: string + categoryId: + type: string + description: + type: string + type: + type: string + enum: [one-time, recurring] + startDate: + type: string + endDate: + type: string + receipt: + type: string + format: binary + responses: + '200': + description: Expense updated + delete: + summary: Delete an expense + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + '204': + description: Expense deleted + /incomes: + get: + summary: List all incomes + parameters: + - in: query + name: start + schema: + type: string + - in: query + name: end + schema: + type: string + responses: + '200': + description: List of incomes + post: + summary: Create new income + requestBody: + content: + application/json: + schema: + type: object + required: [amount, date] + properties: + amount: + type: number + date: + type: string + source: + type: string + description: + type: string + responses: + '201': + description: Income created + /incomes/{id}: + parameters: + - in: path + name: id + required: true + schema: + type: string + get: + summary: Get an income entry + responses: + '200': + description: Income data + put: + summary: Update an income entry + requestBody: + content: + application/json: + schema: + type: object + properties: + amount: + type: number + date: + type: string + source: + type: string + description: + type: string + responses: + '200': + description: Income updated + delete: + summary: Delete an income entry + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + '204': + description: Income deleted + /categories: + get: + summary: List user categories + responses: + '200': + description: List of categories + post: + summary: Create new category + requestBody: + content: + application/json: + schema: + type: object + required: [name] + properties: + name: + type: string + responses: + '201': + description: Category created + /categories/{id}: + put: + summary: Rename a category + parameters: + - in: path + name: id + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + responses: + '200': + description: Category updated + delete: + summary: Delete a category + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + '204': + description: Category deleted + /summary/monthly: + get: + summary: Get current month summary + parameters: + - in: query + name: month + schema: + type: string + responses: + '200': + description: Monthly summary + /summary: + get: + summary: Get summary for custom range + parameters: + - in: query + name: start + schema: + type: string + - in: query + name: end + schema: + type: string + responses: + '200': + description: Summary + /summary/alerts: + get: + summary: Budget overrun alert + responses: + '200': + description: Alert info + /receipts/{id}: + get: + summary: Download/view receipt + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + '200': + description: Receipt file + /user/profile: + get: + summary: Get user profile + responses: + '200': + description: Profile info \ No newline at end of file diff --git a/server/package.json b/server/package.json index 38a6804..93b907e 100644 --- a/server/package.json +++ b/server/package.json @@ -7,7 +7,11 @@ "scripts": { "start": "node server.js", "dev": "nodemon server.js", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev", + "prisma:deploy": "prisma migrate deploy", + "prisma:studio": "prisma studio" }, "keywords": ["express", "expense-tracker", "api"], "author": "", @@ -18,9 +22,11 @@ "dotenv": "^16.3.1", "helmet": "^7.1.0", "morgan": "^1.10.0", - "pg": "^8.11.3" + "pg": "^8.11.3", + "@prisma/client": "^5.18.0" }, "devDependencies": { - "nodemon": "^3.0.2" + "nodemon": "^3.0.2", + "prisma": "^5.18.0" } } diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma new file mode 100644 index 0000000..bf32d34 --- /dev/null +++ b/server/prisma/schema.prisma @@ -0,0 +1,11 @@ +// Prisma schema for Expense Tracker (initial scaffold) +// No models yet; MCD pending. You can add models later. + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("POSTGRES_PRISMA_URL") +} diff --git a/server/server.js b/server/server.js index cafe86e..dd6bced 100644 --- a/server/server.js +++ b/server/server.js @@ -1,6 +1,7 @@ import express from 'express'; import cors from 'cors'; import dotenv from 'dotenv'; +import { PrismaClient } from '@prisma/client'; dotenv.config(); @@ -10,6 +11,9 @@ const PORT = process.env.PORT || 8080; app.use(cors()); app.use(express.json()); +// Initialize a single Prisma client instance +const prisma = new PrismaClient(); + app.get('/', (req, res) => { res.json({ message: 'Expense Tracker API', status: 'running' }); }); @@ -18,6 +22,19 @@ app.get('/api/health', (req, res) => { res.json({ status: 'OK' }); }); +// DB health check using Prisma raw query; does not require any models +app.get('/api/db-check', async (_req, res) => { + try { + const result = await prisma.$queryRaw`SELECT NOW() as now`; + // Result shape differs by driver; normalize to { now } + const now = Array.isArray(result) ? result[0]?.now ?? result[0]?.NOW ?? result[0] : result?.now ?? result; + res.json({ ok: true, now }); + } catch (err) { + console.error('DB check failed:', err); + res.status(500).json({ ok: false, error: 'DB connection failed' }); + } +}); + app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); }); From c16e604732424ceb7b6cbd17b2a87ef2c7457a85 Mon Sep 17 00:00:00 2001 From: Matieu Date: Sat, 23 Aug 2025 04:02:55 -0400 Subject: [PATCH 2/3] fix(openapi): deduplicate path param 'id' on DELETE for /expenses/{id} and /incomes/{id}; regenerate client SDK --- client/package-lock.json | 392 ++++++++++++++++++++- client/package.json | 7 +- client/src/api/core/ApiError.ts | 25 ++ client/src/api/core/ApiRequestOptions.ts | 17 + client/src/api/core/ApiResult.ts | 11 + client/src/api/core/CancelablePromise.ts | 131 +++++++ client/src/api/core/OpenAPI.ts | 32 ++ client/src/api/core/request.ts | 323 +++++++++++++++++ client/src/api/index.ts | 10 + client/src/api/services/DefaultService.ts | 408 ++++++++++++++++++++++ server/docs/Expense Tracker API.yaml | 12 - server/package-lock.json | 95 ++++- 12 files changed, 1444 insertions(+), 19 deletions(-) create mode 100644 client/src/api/core/ApiError.ts create mode 100644 client/src/api/core/ApiRequestOptions.ts create mode 100644 client/src/api/core/ApiResult.ts create mode 100644 client/src/api/core/CancelablePromise.ts create mode 100644 client/src/api/core/OpenAPI.ts create mode 100644 client/src/api/core/request.ts create mode 100644 client/src/api/index.ts create mode 100644 client/src/api/services/DefaultService.ts diff --git a/client/package-lock.json b/client/package-lock.json index 596ca8c..59efc7a 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -12,6 +12,7 @@ "@tailwindcss/vite": "^4.1.11", "@vercel/analytics": "^1.5.0", "@vercel/speed-insights": "^1.2.0", + "axios": "^1.7.7", "react": "^19.1.1", "react-dom": "^19.1.1", "tailwindcss": "^4.1.11" @@ -34,6 +35,7 @@ "eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-storybook": "^9.1.2", "globals": "^16.3.0", + "openapi-typescript-codegen": "^0.29.0", "playwright": "^1.54.2", "storybook": "^9.1.2", "typescript": "~5.8.3", @@ -62,6 +64,24 @@ "node": ">=6.0.0" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "11.9.3", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz", + "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1117,6 +1137,13 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true, + "license": "MIT" + }, "node_modules/@mdx-js/react": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.0.tgz", @@ -2962,6 +2989,12 @@ "dev": true, "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/axe-core": { "version": "4.10.3", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz", @@ -2972,6 +3005,17 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", + "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3059,6 +3103,19 @@ "node": ">=8" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3069,6 +3126,19 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001733", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001733.tgz", @@ -3187,6 +3257,28 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3275,6 +3367,15 @@ "node": ">=8" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -3314,6 +3415,20 @@ "dev": true, "license": "MIT" }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -3348,6 +3463,24 @@ "node": ">=10.13.0" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -3355,6 +3488,33 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.25.8", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz", @@ -3793,6 +3953,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -3810,6 +3990,37 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-extra": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", + "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3828,7 +4039,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -3844,6 +4054,43 @@ "node": ">=6.9.0" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -3917,6 +4164,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -3930,6 +4189,28 @@ "dev": true, "license": "MIT" }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3940,11 +4221,37 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -4623,6 +4930,15 @@ "node": ">=10" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4647,6 +4963,27 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -4758,6 +5095,13 @@ "dev": true, "license": "MIT" }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", @@ -4783,6 +5127,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi-typescript-codegen": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/openapi-typescript-codegen/-/openapi-typescript-codegen-0.29.0.tgz", + "integrity": "sha512-/wC42PkD0LGjDTEULa/XiWQbv4E9NwLjwLjsaJ/62yOsoYhwvmBR31kPttn1DzQ2OlGe5stACcF/EIkZk43M6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^11.5.4", + "camelcase": "^6.3.0", + "commander": "^12.0.0", + "fs-extra": "^11.2.0", + "handlebars": "^4.7.8" + }, + "bin": { + "openapi": "bin/index.js" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5067,6 +5428,12 @@ "node": ">= 6" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5956,6 +6323,20 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/unicorn-magic": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", @@ -6293,6 +6674,13 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", diff --git a/client/package.json b/client/package.json index 978c142..7edf5c5 100644 --- a/client/package.json +++ b/client/package.json @@ -9,13 +9,15 @@ "lint": "eslint .", "preview": "vite preview", "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "build-storybook": "storybook build", + "api:generate": "openapi --input \"../server/docs/Expense Tracker API.yaml\" --output src/api --client axios" }, "dependencies": { "@radix-ui/react-icons": "^1.3.2", "@tailwindcss/vite": "^4.1.11", "@vercel/analytics": "^1.5.0", "@vercel/speed-insights": "^1.2.0", + "axios": "^1.7.7", "react": "^19.1.1", "react-dom": "^19.1.1", "tailwindcss": "^4.1.11" @@ -43,6 +45,7 @@ "vitest": "^3.2.4", "@vitest/browser": "^3.2.4", "playwright": "^1.54.2", - "@vitest/coverage-v8": "^3.2.4" + "@vitest/coverage-v8": "^3.2.4", + "openapi-typescript-codegen": "^0.29.0" } } diff --git a/client/src/api/core/ApiError.ts b/client/src/api/core/ApiError.ts new file mode 100644 index 0000000..ec7b16a --- /dev/null +++ b/client/src/api/core/ApiError.ts @@ -0,0 +1,25 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { ApiResult } from './ApiResult'; + +export class ApiError extends Error { + public readonly url: string; + public readonly status: number; + public readonly statusText: string; + public readonly body: any; + public readonly request: ApiRequestOptions; + + constructor(request: ApiRequestOptions, response: ApiResult, message: string) { + super(message); + + this.name = 'ApiError'; + this.url = response.url; + this.status = response.status; + this.statusText = response.statusText; + this.body = response.body; + this.request = request; + } +} diff --git a/client/src/api/core/ApiRequestOptions.ts b/client/src/api/core/ApiRequestOptions.ts new file mode 100644 index 0000000..93143c3 --- /dev/null +++ b/client/src/api/core/ApiRequestOptions.ts @@ -0,0 +1,17 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type ApiRequestOptions = { + readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH'; + readonly url: string; + readonly path?: Record; + readonly cookies?: Record; + readonly headers?: Record; + readonly query?: Record; + readonly formData?: Record; + readonly body?: any; + readonly mediaType?: string; + readonly responseHeader?: string; + readonly errors?: Record; +}; diff --git a/client/src/api/core/ApiResult.ts b/client/src/api/core/ApiResult.ts new file mode 100644 index 0000000..ee1126e --- /dev/null +++ b/client/src/api/core/ApiResult.ts @@ -0,0 +1,11 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type ApiResult = { + readonly url: string; + readonly ok: boolean; + readonly status: number; + readonly statusText: string; + readonly body: any; +}; diff --git a/client/src/api/core/CancelablePromise.ts b/client/src/api/core/CancelablePromise.ts new file mode 100644 index 0000000..d70de92 --- /dev/null +++ b/client/src/api/core/CancelablePromise.ts @@ -0,0 +1,131 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export class CancelError extends Error { + + constructor(message: string) { + super(message); + this.name = 'CancelError'; + } + + public get isCancelled(): boolean { + return true; + } +} + +export interface OnCancel { + readonly isResolved: boolean; + readonly isRejected: boolean; + readonly isCancelled: boolean; + + (cancelHandler: () => void): void; +} + +export class CancelablePromise implements Promise { + #isResolved: boolean; + #isRejected: boolean; + #isCancelled: boolean; + readonly #cancelHandlers: (() => void)[]; + readonly #promise: Promise; + #resolve?: (value: T | PromiseLike) => void; + #reject?: (reason?: any) => void; + + constructor( + executor: ( + resolve: (value: T | PromiseLike) => void, + reject: (reason?: any) => void, + onCancel: OnCancel + ) => void + ) { + this.#isResolved = false; + this.#isRejected = false; + this.#isCancelled = false; + this.#cancelHandlers = []; + this.#promise = new Promise((resolve, reject) => { + this.#resolve = resolve; + this.#reject = reject; + + const onResolve = (value: T | PromiseLike): void => { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#isResolved = true; + if (this.#resolve) this.#resolve(value); + }; + + const onReject = (reason?: any): void => { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#isRejected = true; + if (this.#reject) this.#reject(reason); + }; + + const onCancel = (cancelHandler: () => void): void => { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#cancelHandlers.push(cancelHandler); + }; + + Object.defineProperty(onCancel, 'isResolved', { + get: (): boolean => this.#isResolved, + }); + + Object.defineProperty(onCancel, 'isRejected', { + get: (): boolean => this.#isRejected, + }); + + Object.defineProperty(onCancel, 'isCancelled', { + get: (): boolean => this.#isCancelled, + }); + + return executor(onResolve, onReject, onCancel as OnCancel); + }); + } + + get [Symbol.toStringTag]() { + return "Cancellable Promise"; + } + + public then( + onFulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onRejected?: ((reason: any) => TResult2 | PromiseLike) | null + ): Promise { + return this.#promise.then(onFulfilled, onRejected); + } + + public catch( + onRejected?: ((reason: any) => TResult | PromiseLike) | null + ): Promise { + return this.#promise.catch(onRejected); + } + + public finally(onFinally?: (() => void) | null): Promise { + return this.#promise.finally(onFinally); + } + + public cancel(): void { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#isCancelled = true; + if (this.#cancelHandlers.length) { + try { + for (const cancelHandler of this.#cancelHandlers) { + cancelHandler(); + } + } catch (error) { + console.warn('Cancellation threw an error', error); + return; + } + } + this.#cancelHandlers.length = 0; + if (this.#reject) this.#reject(new CancelError('Request aborted')); + } + + public get isCancelled(): boolean { + return this.#isCancelled; + } +} diff --git a/client/src/api/core/OpenAPI.ts b/client/src/api/core/OpenAPI.ts new file mode 100644 index 0000000..03da729 --- /dev/null +++ b/client/src/api/core/OpenAPI.ts @@ -0,0 +1,32 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { ApiRequestOptions } from './ApiRequestOptions'; + +type Resolver = (options: ApiRequestOptions) => Promise; +type Headers = Record; + +export type OpenAPIConfig = { + BASE: string; + VERSION: string; + WITH_CREDENTIALS: boolean; + CREDENTIALS: 'include' | 'omit' | 'same-origin'; + TOKEN?: string | Resolver | undefined; + USERNAME?: string | Resolver | undefined; + PASSWORD?: string | Resolver | undefined; + HEADERS?: Headers | Resolver | undefined; + ENCODE_PATH?: ((path: string) => string) | undefined; +}; + +export const OpenAPI: OpenAPIConfig = { + BASE: 'http://localhost:8080/api', + VERSION: '1.0.0', + WITH_CREDENTIALS: false, + CREDENTIALS: 'include', + TOKEN: undefined, + USERNAME: undefined, + PASSWORD: undefined, + HEADERS: undefined, + ENCODE_PATH: undefined, +}; diff --git a/client/src/api/core/request.ts b/client/src/api/core/request.ts new file mode 100644 index 0000000..1dc6fef --- /dev/null +++ b/client/src/api/core/request.ts @@ -0,0 +1,323 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import axios from 'axios'; +import type { AxiosError, AxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios'; +import FormData from 'form-data'; + +import { ApiError } from './ApiError'; +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { ApiResult } from './ApiResult'; +import { CancelablePromise } from './CancelablePromise'; +import type { OnCancel } from './CancelablePromise'; +import type { OpenAPIConfig } from './OpenAPI'; + +export const isDefined = (value: T | null | undefined): value is Exclude => { + return value !== undefined && value !== null; +}; + +export const isString = (value: any): value is string => { + return typeof value === 'string'; +}; + +export const isStringWithValue = (value: any): value is string => { + return isString(value) && value !== ''; +}; + +export const isBlob = (value: any): value is Blob => { + return ( + typeof value === 'object' && + typeof value.type === 'string' && + typeof value.stream === 'function' && + typeof value.arrayBuffer === 'function' && + typeof value.constructor === 'function' && + typeof value.constructor.name === 'string' && + /^(Blob|File)$/.test(value.constructor.name) && + /^(Blob|File)$/.test(value[Symbol.toStringTag]) + ); +}; + +export const isFormData = (value: any): value is FormData => { + return value instanceof FormData; +}; + +export const isSuccess = (status: number): boolean => { + return status >= 200 && status < 300; +}; + +export const base64 = (str: string): string => { + try { + return btoa(str); + } catch (err) { + // @ts-ignore + return Buffer.from(str).toString('base64'); + } +}; + +export const getQueryString = (params: Record): string => { + const qs: string[] = []; + + const append = (key: string, value: any) => { + qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + }; + + const process = (key: string, value: any) => { + if (isDefined(value)) { + if (Array.isArray(value)) { + value.forEach(v => { + process(key, v); + }); + } else if (typeof value === 'object') { + Object.entries(value).forEach(([k, v]) => { + process(`${key}[${k}]`, v); + }); + } else { + append(key, value); + } + } + }; + + Object.entries(params).forEach(([key, value]) => { + process(key, value); + }); + + if (qs.length > 0) { + return `?${qs.join('&')}`; + } + + return ''; +}; + +const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { + const encoder = config.ENCODE_PATH || encodeURI; + + const path = options.url + .replace('{api-version}', config.VERSION) + .replace(/{(.*?)}/g, (substring: string, group: string) => { + if (options.path?.hasOwnProperty(group)) { + return encoder(String(options.path[group])); + } + return substring; + }); + + const url = `${config.BASE}${path}`; + if (options.query) { + return `${url}${getQueryString(options.query)}`; + } + return url; +}; + +export const getFormData = (options: ApiRequestOptions): FormData | undefined => { + if (options.formData) { + const formData = new FormData(); + + const process = (key: string, value: any) => { + if (isString(value) || isBlob(value)) { + formData.append(key, value); + } else { + formData.append(key, JSON.stringify(value)); + } + }; + + Object.entries(options.formData) + .filter(([_, value]) => isDefined(value)) + .forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach(v => process(key, v)); + } else { + process(key, value); + } + }); + + return formData; + } + return undefined; +}; + +type Resolver = (options: ApiRequestOptions) => Promise; + +export const resolve = async (options: ApiRequestOptions, resolver?: T | Resolver): Promise => { + if (typeof resolver === 'function') { + return (resolver as Resolver)(options); + } + return resolver; +}; + +export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions, formData?: FormData): Promise> => { + const [token, username, password, additionalHeaders] = await Promise.all([ + resolve(options, config.TOKEN), + resolve(options, config.USERNAME), + resolve(options, config.PASSWORD), + resolve(options, config.HEADERS), + ]); + + const formHeaders = typeof formData?.getHeaders === 'function' && formData?.getHeaders() || {} + + const headers = Object.entries({ + Accept: 'application/json', + ...additionalHeaders, + ...options.headers, + ...formHeaders, + }) + .filter(([_, value]) => isDefined(value)) + .reduce((headers, [key, value]) => ({ + ...headers, + [key]: String(value), + }), {} as Record); + + if (isStringWithValue(token)) { + headers['Authorization'] = `Bearer ${token}`; + } + + if (isStringWithValue(username) && isStringWithValue(password)) { + const credentials = base64(`${username}:${password}`); + headers['Authorization'] = `Basic ${credentials}`; + } + + if (options.body !== undefined) { + if (options.mediaType) { + headers['Content-Type'] = options.mediaType; + } else if (isBlob(options.body)) { + headers['Content-Type'] = options.body.type || 'application/octet-stream'; + } else if (isString(options.body)) { + headers['Content-Type'] = 'text/plain'; + } else if (!isFormData(options.body)) { + headers['Content-Type'] = 'application/json'; + } + } + + return headers; +}; + +export const getRequestBody = (options: ApiRequestOptions): any => { + if (options.body) { + return options.body; + } + return undefined; +}; + +export const sendRequest = async ( + config: OpenAPIConfig, + options: ApiRequestOptions, + url: string, + body: any, + formData: FormData | undefined, + headers: Record, + onCancel: OnCancel, + axiosClient: AxiosInstance +): Promise> => { + const source = axios.CancelToken.source(); + + const requestConfig: AxiosRequestConfig = { + url, + headers, + data: body ?? formData, + method: options.method, + withCredentials: config.WITH_CREDENTIALS, + withXSRFToken: config.CREDENTIALS === 'include' ? config.WITH_CREDENTIALS : false, + cancelToken: source.token, + }; + + onCancel(() => source.cancel('The user aborted a request.')); + + try { + return await axiosClient.request(requestConfig); + } catch (error) { + const axiosError = error as AxiosError; + if (axiosError.response) { + return axiosError.response; + } + throw error; + } +}; + +export const getResponseHeader = (response: AxiosResponse, responseHeader?: string): string | undefined => { + if (responseHeader) { + const content = response.headers[responseHeader]; + if (isString(content)) { + return content; + } + } + return undefined; +}; + +export const getResponseBody = (response: AxiosResponse): any => { + if (response.status !== 204) { + return response.data; + } + return undefined; +}; + +export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => { + const errors: Record = { + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + ...options.errors, + } + + const error = errors[result.status]; + if (error) { + throw new ApiError(options, result, error); + } + + if (!result.ok) { + const errorStatus = result.status ?? 'unknown'; + const errorStatusText = result.statusText ?? 'unknown'; + const errorBody = (() => { + try { + return JSON.stringify(result.body, null, 2); + } catch (e) { + return undefined; + } + })(); + + throw new ApiError(options, result, + `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}` + ); + } +}; + +/** + * Request method + * @param config The OpenAPI configuration object + * @param options The request options from the service + * @param axiosClient The axios client instance to use + * @returns CancelablePromise + * @throws ApiError + */ +export const request = (config: OpenAPIConfig, options: ApiRequestOptions, axiosClient: AxiosInstance = axios): CancelablePromise => { + return new CancelablePromise(async (resolve, reject, onCancel) => { + try { + const url = getUrl(config, options); + const formData = getFormData(options); + const body = getRequestBody(options); + const headers = await getHeaders(config, options, formData); + + if (!onCancel.isCancelled) { + const response = await sendRequest(config, options, url, body, formData, headers, onCancel, axiosClient); + const responseBody = getResponseBody(response); + const responseHeader = getResponseHeader(response, options.responseHeader); + + const result: ApiResult = { + url, + ok: isSuccess(response.status), + status: response.status, + statusText: response.statusText, + body: responseHeader ?? responseBody, + }; + + catchErrorCodes(options, result); + + resolve(result.body); + } + } catch (error) { + reject(error); + } + }); +}; diff --git a/client/src/api/index.ts b/client/src/api/index.ts new file mode 100644 index 0000000..4e78275 --- /dev/null +++ b/client/src/api/index.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export { ApiError } from './core/ApiError'; +export { CancelablePromise, CancelError } from './core/CancelablePromise'; +export { OpenAPI } from './core/OpenAPI'; +export type { OpenAPIConfig } from './core/OpenAPI'; + +export { DefaultService } from './services/DefaultService'; diff --git a/client/src/api/services/DefaultService.ts b/client/src/api/services/DefaultService.ts new file mode 100644 index 0000000..50ff9f3 --- /dev/null +++ b/client/src/api/services/DefaultService.ts @@ -0,0 +1,408 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CancelablePromise } from '../core/CancelablePromise'; +import { OpenAPI } from '../core/OpenAPI'; +import { request as __request } from '../core/request'; +export class DefaultService { + /** + * Register a new user + * @param requestBody + * @returns any User created + * @throws ApiError + */ + public static postAuthSignup( + requestBody: { + email: string; + password: string; + }, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/auth/signup', + body: requestBody, + mediaType: 'application/json', + }); + } + /** + * Login and receive JWT token + * @param requestBody + * @returns any Token returned + * @throws ApiError + */ + public static postAuthLogin( + requestBody: { + email: string; + password: string; + }, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/auth/login', + body: requestBody, + mediaType: 'application/json', + }); + } + /** + * List all user expenses + * @param start Start date + * @param end End date + * @param category + * @param type + * @returns any List of expenses + * @throws ApiError + */ + public static getExpenses( + start?: string, + end?: string, + category?: string, + type?: 'recurring' | 'one-time', + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/expenses', + query: { + 'start': start, + 'end': end, + 'category': category, + 'type': type, + }, + }); + } + /** + * Create a new expense + * @param formData + * @returns any Expense created + * @throws ApiError + */ + public static postExpenses( + formData?: { + amount: number; + date: string; + categoryId: string; + description?: string; + type?: 'one-time' | 'recurring'; + startDate?: string; + endDate?: string; + receipt?: Blob; + }, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/expenses', + formData: formData, + mediaType: 'multipart/form-data', + }); + } + /** + * Get a single expense + * @param id + * @returns any Expense data + * @throws ApiError + */ + public static getExpenses1( + id: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/expenses/{id}', + path: { + 'id': id, + }, + }); + } + /** + * Update an expense + * @param id + * @param formData + * @returns any Expense updated + * @throws ApiError + */ + public static putExpenses( + id: string, + formData?: { + amount?: number; + date?: string; + categoryId?: string; + description?: string; + type?: 'one-time' | 'recurring'; + startDate?: string; + endDate?: string; + receipt?: Blob; + }, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/expenses/{id}', + path: { + 'id': id, + }, + formData: formData, + mediaType: 'multipart/form-data', + }); + } + /** + * Delete an expense + * @param id + * @returns void + * @throws ApiError + */ + public static deleteExpenses( + id: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/expenses/{id}', + path: { + 'id': id, + }, + }); + } + /** + * List all incomes + * @param start + * @param end + * @returns any List of incomes + * @throws ApiError + */ + public static getIncomes( + start?: string, + end?: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/incomes', + query: { + 'start': start, + 'end': end, + }, + }); + } + /** + * Create new income + * @param requestBody + * @returns any Income created + * @throws ApiError + */ + public static postIncomes( + requestBody?: { + amount: number; + date: string; + source?: string; + description?: string; + }, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/incomes', + body: requestBody, + mediaType: 'application/json', + }); + } + /** + * Get an income entry + * @param id + * @returns any Income data + * @throws ApiError + */ + public static getIncomes1( + id: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/incomes/{id}', + path: { + 'id': id, + }, + }); + } + /** + * Update an income entry + * @param id + * @param requestBody + * @returns any Income updated + * @throws ApiError + */ + public static putIncomes( + id: string, + requestBody?: { + amount?: number; + date?: string; + source?: string; + description?: string; + }, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/incomes/{id}', + path: { + 'id': id, + }, + body: requestBody, + mediaType: 'application/json', + }); + } + /** + * Delete an income entry + * @param id + * @returns void + * @throws ApiError + */ + public static deleteIncomes( + id: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/incomes/{id}', + path: { + 'id': id, + }, + }); + } + /** + * List user categories + * @returns any List of categories + * @throws ApiError + */ + public static getCategories(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/categories', + }); + } + /** + * Create new category + * @param requestBody + * @returns any Category created + * @throws ApiError + */ + public static postCategories( + requestBody?: { + name: string; + }, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/categories', + body: requestBody, + mediaType: 'application/json', + }); + } + /** + * Rename a category + * @param id + * @param requestBody + * @returns any Category updated + * @throws ApiError + */ + public static putCategories( + id: string, + requestBody?: { + name?: string; + }, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/categories/{id}', + path: { + 'id': id, + }, + body: requestBody, + mediaType: 'application/json', + }); + } + /** + * Delete a category + * @param id + * @returns void + * @throws ApiError + */ + public static deleteCategories( + id: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/categories/{id}', + path: { + 'id': id, + }, + }); + } + /** + * Get current month summary + * @param month + * @returns any Monthly summary + * @throws ApiError + */ + public static getSummaryMonthly( + month?: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/summary/monthly', + query: { + 'month': month, + }, + }); + } + /** + * Get summary for custom range + * @param start + * @param end + * @returns any Summary + * @throws ApiError + */ + public static getSummary( + start?: string, + end?: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/summary', + query: { + 'start': start, + 'end': end, + }, + }); + } + /** + * Budget overrun alert + * @returns any Alert info + * @throws ApiError + */ + public static getSummaryAlerts(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/summary/alerts', + }); + } + /** + * Download/view receipt + * @param id + * @returns any Receipt file + * @throws ApiError + */ + public static getReceipts( + id: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/receipts/{id}', + path: { + 'id': id, + }, + }); + } + /** + * Get user profile + * @returns any Profile info + * @throws ApiError + */ + public static getUserProfile(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/user/profile', + }); + } +} diff --git a/server/docs/Expense Tracker API.yaml b/server/docs/Expense Tracker API.yaml index 84cf070..10fe973 100644 --- a/server/docs/Expense Tracker API.yaml +++ b/server/docs/Expense Tracker API.yaml @@ -141,12 +141,6 @@ paths: description: Expense updated delete: summary: Delete an expense - parameters: - - in: path - name: id - required: true - schema: - type: string responses: '204': description: Expense deleted @@ -218,12 +212,6 @@ paths: description: Income updated delete: summary: Delete an income entry - parameters: - - in: path - name: id - required: true - schema: - type: string responses: '204': description: Income deleted diff --git a/server/package-lock.json b/server/package-lock.json index 07c0449..97e318d 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -9,16 +9,85 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@prisma/client": "^5.18.0", "cors": "^2.8.5", "dotenv": "^16.3.1", "express": "^4.18.2", "helmet": "^7.1.0", "morgan": "^1.10.0", - "pg": "^8.11.3", - "pg-pool": "^3.6.1" + "pg": "^8.11.3" }, "devDependencies": { - "nodemon": "^3.0.2" + "nodemon": "^3.0.2", + "prisma": "^5.18.0" + } + }, + "node_modules/@prisma/client": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", + "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", + "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", + "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/fetch-engine": "5.22.0", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", + "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", + "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", + "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0" } }, "node_modules/accepts": { @@ -1080,6 +1149,26 @@ "node": ">=0.10.0" } }, + "node_modules/prisma": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", + "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/engines": "5.22.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", From fc8af84a20dbcddc363bb87dde07967b9d6733a9 Mon Sep 17 00:00:00 2001 From: Matieu Date: Sat, 23 Aug 2025 04:06:35 -0400 Subject: [PATCH 3/3] docs(readme): document OpenAPI spec rule and client SDK generation workflow --- client/README.md | 10 ++++++++++ server/README.md | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/client/README.md b/client/README.md index 642cf3d..6f09bca 100644 --- a/client/README.md +++ b/client/README.md @@ -29,6 +29,16 @@ - preview: `npm run preview` - storybook: `npm run storybook` +

API Client SDK

+ +- Regenerate the client from the server OpenAPI spec: + +```bash +npm run api:generate +``` + +- Source spec: `server/docs/Expense Tracker API.yaml` +

UI Components Overview (client/src/ui/)

- Tooltip (`Tooltip.tsx`) diff --git a/server/README.md b/server/README.md index 77209e4..473c6a6 100644 --- a/server/README.md +++ b/server/README.md @@ -62,3 +62,13 @@ Key variables in `server/.env`: - Logging via `morgan` (dev-friendly). - Security headers via `helmet`. - CORS configured via `CORS_ORIGIN`. + +

OpenAPI Spec & Client Generation

+ +- Spec location: `server/docs/Expense Tracker API.yaml` +- To regenerate the client SDK after spec changes: + +```bash +cd ../client +npm run api:generate +```