Repository navigation
feat(api): public REST API with API-key auth and OpenAPI docs #784
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
9a1cebf
e32ba0b
f326a13
62b7173
cff69d7
32ea731
9edae6d
da7a4d6
42927bd
48ea509
cc2ca72
358c6e8
4824fc4
c52bf1c
a683ed4
169cf0e
22b54c7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| # Keep the committed OpenAPI spec in sync with the API router: regenerate it | ||
| # and abort the push when the regenerated file would change, so the spec can | ||
| # never go stale. Compare against both the working tree and the index so the | ||
| # check passes only after the file is actually committed. | ||
| pnpm gen:openapi | ||
| if [ -n "$(git status --porcelain -- src/server/api/openapi.generated.json)" ]; then | ||
| echo "✖ src/server/api/openapi.generated.json is out of date." | ||
| echo " Run: pnpm gen:openapi && git add src/server/api/openapi.generated.json && git commit" | ||
| exit 1 | ||
| fi |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| # SplitPro Public REST API | ||
|
|
||
| SplitPro exposes a small, versioned REST API for integrations (e.g. syncing | ||
| expenses to a budgeting tool). The API is served at `/api/v1` and documented | ||
| interactively at **`/api/docs`** (Scalar), backed by the machine-readable | ||
| OpenAPI 3.1 spec at **`/api/openapi.json`**. | ||
|
|
||
| ## Authentication | ||
|
|
||
| 1. In the app, open **Account → API keys** and create a key. Optionally set an | ||
| expiry date. The plaintext key (`spro_…`) is shown exactly once; only a | ||
| SHA-256 hash is stored server-side. | ||
| 2. Send the key with every request: | ||
|
|
||
| ``` | ||
| Authorization: Bearer spro_… | ||
| ``` | ||
|
|
||
| The `X-API-Key: spro_…` header is also accepted. Requests without a valid, | ||
| unexpired key receive `401`. | ||
|
|
||
| API keys act with your user's permissions: every endpoint only returns data | ||
| you are part of, and writes run the same permission checks as the app. | ||
|
|
||
| ## Endpoints | ||
|
|
||
| | Endpoint | Method | Description | | ||
| | ------------------------------- | ------ | ----------------------------------------------- | | ||
| | `user.me` | GET | Authenticated user profile | | ||
| | `user.getFriends` | GET | Friends with balances | | ||
| | `user.getOwnExpenses` | GET | Expenses paid by you **(paginated)** | | ||
| | `user.getBalancesWithFriend` | GET | Per-group balances with a friend | | ||
| | `user.getFriend` | GET | Friend profile + default split | | ||
| | `group.getAllGroups` | GET | All groups you belong to | | ||
| | `group.getGroupDetails` | GET | Single group with members and balances | | ||
| | `expense.getBalances` | GET | Per-friend balances across all groups | | ||
| | `expense.getAllExpenses` | GET | All expenses you participate in **(paginated)** | | ||
| | `expense.getExpensesWithFriend` | GET | Expense history with a friend **(paginated)** | | ||
| | `expense.getGroupExpenses` | GET | All expenses in a group **(paginated)** | | ||
| | `expense.getExpenseDetails` | GET | Full expense detail | | ||
| | `expense.addOrEditExpense` | POST | Create or update expenses (array body) | | ||
|
|
||
| All requests go to `https://<your-host>/api/v1/<endpoint>`, e.g. | ||
| `GET /api/v1/user.me`. | ||
|
|
||
| ### Input formats | ||
|
|
||
| GET endpoints accept individual query params (`?groupId=150&limit=20`), a | ||
| plain JSON object (`?input={"groupId":150}`), or a native superjson envelope. | ||
| POST bodies are plain JSON; a superjson envelope also works when calling from | ||
| a tRPC client. | ||
|
|
||
| Because JSON has no BigInt or Date type, monetary amounts accept numbers or | ||
| numeric strings (strings keep full precision beyond `Number.MAX_SAFE_INTEGER`) | ||
| and are returned as numbers; timestamps are ISO 8601 strings. | ||
|
|
||
| ### Pagination | ||
|
|
||
| List endpoints use offset pagination: | ||
|
|
||
| - Input: `?limit=20&offset=0` (defaults `limit=20`, `offset=0`, max `limit=100`) | ||
| - Response: `{ items: […], pagination: { total, limit, offset, hasMore } }` | ||
|
|
||
| ### Example | ||
|
|
||
| ```bash | ||
| curl -H "Authorization: Bearer spro_…" \ | ||
| 'https://splitpro.example.com/api/v1/expense.getGroupExpenses?groupId=16&limit=10' | ||
| ``` | ||
|
|
||
| ```bash | ||
| curl -X POST -H "Authorization: Bearer spro_…" -H "Content-Type: application/json" \ | ||
| -d '[{"name":"Lunch","currency":"EUR","groupId":null,"amount":4200,"paidBy":1, | ||
| "category":"general","splitType":"EQUAL", | ||
| "participants":[{"userId":1,"amount":2100},{"userId":2,"amount":-2100}], | ||
| "expenseDate":"2026-10-25T12:00:00Z"}]' \ | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| 'https://splitpro.example.com/api/v1/expense.addOrEditExpense' | ||
| ``` | ||
|
|
||
| Note the participant amounts follow the app's stored sign convention: the | ||
| payer's entry is `amount − their share` (positive), every other participant's | ||
| entry is their negated share. The example above is a `4200` expense split | ||
| equally between user 1 (payer) and user 2, so each share is `2100`. | ||
|
|
||
| ## Maintenance (for developers) | ||
|
|
||
| - The API router is `src/server/api/apiRouter.ts` — a curated subset of the | ||
| app procedures, shared via the exported method records in the router files. | ||
| - Regenerate the OpenAPI spec after changing any procedure with | ||
| `pnpm gen:openapi`. The pre-push hook fails if the committed | ||
| `src/server/api/openapi.generated.json` is out of date, and `pnpm build` | ||
| regenerates it. | ||
| - `/api/docs` loads the Scalar bundle pinned to an exact version with a | ||
| subresource-integrity hash (see `src/pages/api/docs.ts` for upgrade steps). | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,8 +4,9 @@ | |
| "private": true, | ||
| "type": "module", | ||
| "scripts": { | ||
| "build": "next build", | ||
| "build": "pnpm gen:openapi && next build", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Running gen:openapi in build makes every build depend on devDeps and on the TS analysis step. Could we keep build as next build and add a CI check that fails if the spec is stale? |
||
| "just-build": "next build", | ||
| "gen:openapi": "cross-env SKIP_ENV_VALIDATION=true tsx scripts/generate-openapi.ts", | ||
| "db:push": "prisma db push && prisma generate", | ||
| "db:studio": "prisma studio", | ||
| "db:dev": "prisma migrate dev && prisma generate", | ||
|
|
@@ -46,6 +47,7 @@ | |
| "@tanstack/react-query": "^5.79.2", | ||
| "@trpc/client": "^11.2.0", | ||
| "@trpc/next": "^11.2.0", | ||
| "@trpc/openapi": "11.18.0-alpha", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is an alpha with peer deps @trpc/server@11.18.0 and zod>=4, but we're on 11.8.0 and zod 3.25. Can we pin to a stable version, or confirm it works with our versions? Also, it's only used at build time, so it should go in devDependencies. |
||
| "@trpc/react-query": "^11.2.0", | ||
| "@trpc/server": "^11.2.0", | ||
| "boring-avatars": "^1.10.1", | ||
|
|
@@ -102,6 +104,7 @@ | |
| "@types/react-dom": "19.3.0", | ||
| "@types/web-push": "^3.6.3", | ||
| "babel-plugin-react-compiler": "^1.0.0", | ||
| "cross-env": "^10.1.0", | ||
| "esbuild": "^0.25.12", | ||
| "husky": "^9.1.7", | ||
| "jest": "30.5.2", | ||
|
|
||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| -- CreateTable | ||
| CREATE TABLE "ApiKey" ( | ||
| "id" TEXT NOT NULL, | ||
| "userId" INTEGER NOT NULL, | ||
| "name" TEXT NOT NULL, | ||
| "hashedKey" TEXT NOT NULL, | ||
| "partialKey" TEXT NOT NULL, | ||
| "lastUsedAt" TIMESTAMP(3), | ||
| "expiresAt" TIMESTAMP(3), | ||
| "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, | ||
|
|
||
| CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id") | ||
| ); | ||
|
|
||
| -- CreateIndex | ||
| CREATE UNIQUE INDEX "ApiKey_hashedKey_key" ON "ApiKey"("hashedKey"); | ||
|
|
||
| -- CreateIndex | ||
| CREATE INDEX "ApiKey_userId_idx" ON "ApiKey"("userId"); | ||
|
|
||
| -- AddForeignKey | ||
| ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Running a full type analysis on every push is slow, even for docs-only changes. Could this move to CI, or at least only run when src/server/api/** changed?