Skip to content

Implements a public REST API for SplitPro using @trpc/openapi, with OpenAPI 3.1 spec generation, interactive documentation (Scalar), and offset-based pagination - #707

Open
kushpvo wants to merge 15 commits into
oss-apps:mainfrom
kushpvo:feat/trcp-openapi

Conversation

@kushpvo

@kushpvo kushpvo commented Jul 17, 2026

Copy link
Copy Markdown

Description

Closes #615 — implements a public REST API for SplitPro using @trpc/openapi, with
OpenAPI 3.1 spec generation, interactive documentation (Scalar), and offset-based
pagination.

API surface (/api/v1/*)

All endpoints authenticate via API key: Authorization: Bearer spro_<key>.

Endpoint Method Description
user.me GET Authenticated user profile
user.getFriends GET List of friends with balances
user.getOwnExpenses GET Expenses paid by the user (paginated)
user.getBalancesWithFriend GET Per-group balances with a specific friend
user.getFriend GET Friend profile + default split config
group.getAllGroups GET All groups the user belongs to
group.getGroupDetails GET Single group with members, balances, default split
expense.getBalances GET Per-friend balances across all groups
expense.getAllExpenses GET All expenses the user participates in (paginated)
expense.getExpensesWithFriend GET Expense history with a specific friend (paginated)
expense.getGroupExpenses GET All expenses in a group (paginated)
expense.getExpenseDetails GET Full expense detail (participants, notes, recurrence)
expense.addOrEditExpense POST Create or update expenses (array body)

Input formats

All GET endpoints accept three equivalent input formats:

  • Individual query params: ?groupId=150&limit=20
  • Plain JSON: ?input={"groupId":150}
  • Native superjson: ?input={"json":{"groupId":150}}

POST bodies are accepted as plain JSON (the API transformer handles superjson
transparently for both directions).

Pagination

Four list endpoints use offset-based pagination:

  • Input: ?limit=20&offset=0 (defaults: limit=20, offset=0, max limit=100)
  • Response: { items: [...], pagination: { total, limit, offset, hasMore } }
  • Defaults are emitted in the OpenAPI spec and shown in the Scalar UI
  • Unpaginated procedure variants continue to serve the cookie-auth app (no UI breakage)

OpenAPI docs

  • Interactive docs (Scalar): /api/docs
  • Raw spec: /api/openapi.json
  • Generated at build time via pnpm gen:openapi
  • Request body schemas inferred from Zod input schemas
  • Bearer auth declared globally

Architecture

  • src/server/api/apiRouter.ts — curated public API router (separate from appRouter)
  • src/pages/api/v1/[trpc].ts — handler with API-key context + query param bridge
  • src/server/api/pagination.ts — shared offset-pagination helpers
  • scripts/generate-openapi.ts — build-time spec generation + post-processing (injects defaults)
  • Procedures are exported standalone from router files and imported by both appRouter and apiRouter

Demo

Checklist

  • I have read CONTRIBUTING.md in its entirety
  • I have performed a self-review of my own code
  • I have added unit tests to cover my changes
  • The last commit successfully passed pre-commit checks
  • Any AI code was thoroughly reviewed by me

kvora32 and others added 14 commits July 15, 2026 13:35
Adds an isolated public REST surface generated by @trpc/openapi (alpha):
- apiRouter (curated subset) served at /api/v1 with API-key auth via createApiContext
- ApiKey model (sha256-hashed, spro_ prefix) + create/list/revoke procedures + Account UI
- OpenAPI spec at /api/openapi.json and Scalar docs at /api/docs
- first exposed endpoint: user.me

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Add shared pagination module (paginationInput schema + paginatedResult helper)
- Create API-specific paginated variants for getAllExpenses, getExpensesWithFriend,
  getGroupExpenses, and getOwnExpenses (app procedures unchanged)
- All four endpoints return { items, pagination: { total, limit, offset, hasMore } }
- Defaults: limit=20, offset=0, max limit=100
- Post-process generated OpenAPI spec to inject default values for limit/offset
@ml4den

ml4den commented Aug 13, 2026

Copy link
Copy Markdown

Would love to get an API for SplitPro 👍

@krokosik krokosik left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tremendous work! Thank you and apologies for taking so long with the review. I've left some comments

Comment on lines +7 to +8
// Static spec generation needs only router types; env validation is disabled here so build/CI can run without app secrets.
process.env.SKIP_ENV_VALIDATION ||= 'true';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be set in the script invocation not in the source code. You can use something like crossenv in package.json

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you setup a git push hook that rebuilds this file, making sure we have it always in sync?

>
<div className="mt-4 flex flex-col gap-6">
<p className="text-sm text-gray-400">
Use API keys to access the SplitPro API. Send it as{' '}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While it is only required to add the english version of the text, it needs to be still used via i18n to support internationalization

Comment on lines +19 to +32
const isPlainValue = (v: string): string | number | boolean => {
if ('true' === v) {
return true;
}
if ('false' === v) {
return false;
}
const n = Number(v);
if (!Number.isNaN(n) && String(n) === v) {
return n;
}

return v;
};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This belongs in a util file, we already have an api.ts one


for (const op of Object.values(paths)) {
for (const operation of Object.values(op as Record<string, unknown>)) {
// Biome-ignore lint/suspicious/noExplicitAny: post-processing untyped OpenAPI JSON

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Biome?

Comment thread src/pages/account.tsx
<ApiKeys>
<AccountButton>
<KeyRound className="size-5 text-amber-500" />
API keys

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where is i18n?

serializeDefaultSplit,
} from '~/lib/defaultSplit';

export const getAllGroupsProcedure = protectedProcedure.query(async ({ ctx }) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dang, is there no way to process the built router? I would like to minimize the change surface of this PR and future changes needed as much as possible

const session: Session | null = user
? {
user: toSessionUser(user),
expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be configurable, preferably in the Api Key input

Comment on lines +30 to +50
const _apiRouter = createTRPCRouter({
user: createTRPCRouter({
me: meProcedure,
getFriends: getFriendsProcedure,
getOwnExpenses: getOwnExpensesApiProcedure,
getBalancesWithFriend: getBalancesWithFriendProcedure,
getFriend: getFriendProcedure,
}),
group: createTRPCRouter({
getAllGroups: getAllGroupsProcedure,
getGroupDetails: getGroupDetailsProcedure,
}),
expense: createTRPCRouter({
getAllExpenses: getAllExpensesApiProcedure,
getBalances: getBalancesProcedure,
getExpenseDetails: getExpenseDetailsProcedure,
getExpensesWithFriend: getExpensesWithFriendApiProcedure,
getGroupExpenses: getGroupExpensesApiProcedure,
addOrEditExpense: addOrEditExpenseApiProcedure,
}),
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like I said before, please evaluate if processing the build router is feasible. If that is too much work, consider extracting the createTRPCRouter input as rawMethods or something

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Minimal API

3 participants