Skip to content

Commit 424a42a

Browse files
authored
Merge pull request #3 from listee-dev/feat/api-response-contract
feat(api): align response contract with shared packages
2 parents f6f15ac + ebb4ba8 commit 424a42a

13 files changed

Lines changed: 429 additions & 29 deletions

File tree

README.md

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,73 @@
11
# listee-api
2-
API server for Listee
2+
3+
listee-api exposes Listee's HTTP interface. It packages `@listee/api` inside a Next.js App Router host so the CLI and web clients share the same business logic, validation, and database access.
4+
5+
## Overview
6+
- Next.js 15 application that forwards requests to `createFetchHandler` from `@listee/api`.
7+
- Supabase supplies authentication (JWT) and the Postgres database.
8+
- Shared models and utilities come from the `@listee/*` packages (auth, db, types, api).
9+
10+
## Architecture
11+
- `src/app/api/handler.ts` is the single hand-off into `@listee/api`.
12+
- `@listee/api` (Hono + Drizzle ORM) defines routes, validation, and service orchestration.
13+
- `@listee/db` provides Drizzle schema definitions and Postgres connection management.
14+
- Authentication is header-based via `@listee/auth`.
15+
16+
## Environment Variables
17+
Configure these values in `.env.local` for development and in production:
18+
- `POSTGRES_URL` – Supabase Postgres connection string.
19+
- `SUPABASE_URL` – Supabase project base URL (e.g. `https://your-project.supabase.co`).
20+
- `SUPABASE_PUBLISHABLE_KEY` – Supabase publishable (anon) key used to call Auth endpoints.
21+
- `SUPABASE_JWT_AUDIENCE` – optional; audience value to enforce.
22+
- `SUPABASE_JWT_REQUIRED_ROLE` – optional; enforce a specific `role` claim (e.g. `authenticated`).
23+
- `SUPABASE_JWT_ISSUER` – optional; override the expected issuer. Defaults to `${SUPABASE_URL}/auth/v1`.
24+
- `SUPABASE_JWKS_PATH` – optional; override the JWKS endpoint path. Defaults to `/auth/v1/.well-known/jwks.json`.
25+
- `SUPABASE_JWT_CLOCK_TOLERANCE_SECONDS` – optional; non-negative integer clock skew tolerance.
26+
27+
## Response Contract
28+
- Success responses always return JSON with a top-level `data` property. DELETE operations respond with `{ "data": null }`.
29+
- Error responses return `{ "error": "message" }` plus the appropriate HTTP status code (`400` validation, `401/403` auth, `404` missing resources, `500` unexpected failures).
30+
31+
## API Surface
32+
| Method | Path | Description |
33+
| ------ | ---- | ----------- |
34+
| POST | `/api/auth/signup` | Forward email/password signups to Supabase Auth |
35+
| POST | `/api/auth/login` | Exchange email/password for Supabase access + refresh tokens |
36+
| POST | `/api/auth/token` | Refresh Supabase access tokens using a stored refresh token |
37+
| GET | `/api/users/:userId/categories` | List categories for the authenticated user |
38+
| POST | `/api/users/:userId/categories` | Create a new category |
39+
| GET | `/api/categories/:categoryId` | Fetch category details |
40+
| PATCH | `/api/categories/:categoryId` | Update a category name |
41+
| DELETE | `/api/categories/:categoryId` | Delete a category owned by the user |
42+
| GET | `/api/categories/:categoryId/tasks` | List tasks in a category |
43+
| POST | `/api/categories/:categoryId/tasks` | Create a task inside the category |
44+
| GET | `/api/tasks/:taskId` | Fetch task details |
45+
| PATCH | `/api/tasks/:taskId` | Update task name, description, or status |
46+
| DELETE | `/api/tasks/:taskId` | Delete a task owned by the user |
47+
| GET | `/api/healthz` | Database connectivity probe |
48+
49+
All endpoints expect `Authorization: Bearer <token>` where `<token>` is a Supabase JWT access token issued for the authenticated user.
50+
51+
## Local Development
52+
1. Install dependencies: `bun install`.
53+
2. Provide environment variables in `.env.local`.
54+
3. Run the dev server: `bun run dev` (Next.js on port 3000).
55+
4. Lint the project: `bun run lint`.
56+
5. Build for production verification: `bun run build`.
57+
58+
### Database Migrations
59+
- Schema definitions live in `@listee/db`. Do not hand-edit generated SQL.
60+
- Generate migrations with `bun run db:generate` after schema changes.
61+
- Apply migrations with `bun run db:migrate` (uses `POSTGRES_URL`).
62+
63+
## Testing
64+
Automated tests are not yet in place. Use CLI smoke tests (e.g. `listee categories update`, `listee tasks delete`) to verify JSON contracts until formal integration tests land.
65+
66+
## Deployment Notes
67+
- `bun run build` produces the Next.js bundle for production. Deploy on Vercel or any Node 20+ platform capable of running Next.js 15.
68+
- Confirm environment variables for each target environment before deploy.
69+
- Monitor `/api/healthz` after rollout to confirm database access.
70+
71+
## Conventions
72+
- Keep repository documentation and comments in English.
73+
- Follow Listee org standards: Bun 1.3.x, Biome linting, Drizzle migrations, and semantic versioning via Changesets when publishing packages.

bun.lock

Lines changed: 19 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

drizzle.config.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,29 @@
11
import { schemaPath } from "@listee/db";
22
import { loadEnvConfig } from "@next/env";
33
import { defineConfig } from "drizzle-kit";
4+
import { ZodError } from "zod";
5+
import { getEnv } from "./src/app/env";
46

57
loadEnvConfig(process.cwd());
68

7-
const databaseUrl = process.env.POSTGRES_URL;
8-
9-
if (databaseUrl === undefined || databaseUrl.length === 0) {
10-
throw new Error("POSTGRES_URL is not set.");
11-
}
9+
const databaseUrl = (() => {
10+
try {
11+
return getEnv().POSTGRES_URL;
12+
} catch (error) {
13+
if (error instanceof ZodError) {
14+
const issue = error.issues.find((entry) => {
15+
return entry.path.join(".") === "POSTGRES_URL";
16+
});
17+
if (issue !== undefined) {
18+
if (issue.code === "invalid_type") {
19+
throw new Error("POSTGRES_URL is not set.");
20+
}
21+
throw new Error(issue.message);
22+
}
23+
}
24+
throw error;
25+
}
26+
})();
1227

1328
export default defineConfig({
1429
dialect: "postgresql",

next.config.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
1+
import { createJiti } from "jiti";
12
import type { NextConfig } from "next";
23

4+
type EnvModule = typeof import("./src/app/env");
5+
6+
const jiti = createJiti(import.meta.url);
7+
8+
const loadEnvModule = async (): Promise<void> => {
9+
const envModule = await jiti.import<EnvModule>("./src/app/env");
10+
envModule.getEnv();
11+
};
12+
313
const nextConfig: NextConfig = {
414
/* config options here */
515
};
616

7-
export default nextConfig;
17+
export default async (): Promise<NextConfig> => {
18+
await loadEnvModule();
19+
return nextConfig;
20+
};

package.json

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,24 @@
1212
"db:migrate": "drizzle-kit migrate --config drizzle.config.ts"
1313
},
1414
"dependencies": {
15-
"@listee/api": "0.2.3",
16-
"@listee/auth": "0.2.3",
17-
"@listee/db": "0.2.3",
18-
"@listee/types": "0.2.3",
15+
"@listee/api": "0.3.2",
16+
"@listee/auth": "0.5.0",
17+
"@listee/db": "0.4.0",
18+
"@listee/types": "0.5.0",
19+
"@t3-oss/env-nextjs": "0.13.8",
20+
"drizzle-orm": "0.44.5",
21+
"next": "15.5.4",
1922
"react": "19.1.0",
2023
"react-dom": "19.1.0",
21-
"next": "15.5.4",
22-
"drizzle-orm": "0.44.5"
24+
"zod": "4.1.12"
2325
},
2426
"devDependencies": {
2527
"@biomejs/biome": "2.2.0",
2628
"@types/node": "^20",
2729
"@types/react": "^19",
2830
"@types/react-dom": "^19",
2931
"drizzle-kit": "^0.31.0",
32+
"jiti": "^2.6.1",
3033
"typescript": "^5"
3134
}
3235
}

src/app/api/auth/login/route.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { SupabaseAuthError } from "@listee/auth";
2+
3+
import {
4+
ApiError,
5+
handleRoute,
6+
parseJsonBody,
7+
respondWithData,
8+
} from "@/app/api/auth/utils";
9+
import { loginSchema } from "@/app/api/auth/validation";
10+
import { getSupabaseAuthClient } from "@/app/supabase-auth-client";
11+
12+
export async function POST(request: Request): Promise<Response> {
13+
return handleRoute(async () => {
14+
const input = await parseJsonBody(request, loginSchema);
15+
try {
16+
const authClient = getSupabaseAuthClient();
17+
const tokenResponse = await authClient.login(input);
18+
return respondWithData(tokenResponse, 200);
19+
} catch (error) {
20+
if (error instanceof SupabaseAuthError) {
21+
throw new ApiError(error.statusCode, error.message);
22+
}
23+
throw error;
24+
}
25+
});
26+
}

src/app/api/auth/signup/route.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { SupabaseAuthError } from "@listee/auth";
2+
3+
import {
4+
ApiError,
5+
handleRoute,
6+
parseJsonBody,
7+
respondWithData,
8+
} from "@/app/api/auth/utils";
9+
import { signupSchema } from "@/app/api/auth/validation";
10+
import { getSupabaseAuthClient } from "@/app/supabase-auth-client";
11+
12+
export async function POST(request: Request): Promise<Response> {
13+
return handleRoute(async () => {
14+
const input = await parseJsonBody(request, signupSchema);
15+
try {
16+
const authClient = getSupabaseAuthClient();
17+
await authClient.signup(input);
18+
} catch (error) {
19+
if (error instanceof SupabaseAuthError) {
20+
throw new ApiError(error.statusCode, error.message);
21+
}
22+
throw error;
23+
}
24+
return respondWithData(null, 200);
25+
});
26+
}

src/app/api/auth/token/route.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { SupabaseAuthError } from "@listee/auth";
2+
3+
import {
4+
ApiError,
5+
handleRoute,
6+
parseJsonBody,
7+
respondWithData,
8+
} from "@/app/api/auth/utils";
9+
import { tokenSchema } from "@/app/api/auth/validation";
10+
import { getSupabaseAuthClient } from "@/app/supabase-auth-client";
11+
12+
export async function POST(request: Request): Promise<Response> {
13+
return handleRoute(async () => {
14+
const input = await parseJsonBody(request, tokenSchema);
15+
try {
16+
const authClient = getSupabaseAuthClient();
17+
const tokenResponse = await authClient.refresh(input);
18+
return respondWithData(tokenResponse, 200);
19+
} catch (error) {
20+
if (error instanceof SupabaseAuthError) {
21+
throw new ApiError(error.statusCode, error.message);
22+
}
23+
throw error;
24+
}
25+
});
26+
}

src/app/api/auth/utils.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { ZodType } from "zod";
2+
3+
export class ApiError extends Error {
4+
readonly statusCode: number;
5+
6+
constructor(statusCode: number, message: string) {
7+
super(message);
8+
this.statusCode = statusCode;
9+
}
10+
}
11+
12+
export async function parseJsonBody<T>(
13+
request: Request,
14+
schema: ZodType<T>,
15+
): Promise<T> {
16+
let parsed: unknown;
17+
try {
18+
parsed = await request.json();
19+
} catch {
20+
throw new ApiError(400, "Request body must be valid JSON.");
21+
}
22+
23+
const result = schema.safeParse(parsed);
24+
if (!result.success) {
25+
const issue = result.error.issues[0];
26+
const message = issue?.message ?? "Invalid request body.";
27+
throw new ApiError(400, message);
28+
}
29+
30+
return result.data;
31+
}
32+
33+
export const respondWithData = <T>(data: T, status = 200): Response => {
34+
return Response.json({ data }, { status });
35+
};
36+
37+
export const respondWithError = (message: string, status: number): Response => {
38+
return Response.json({ error: message }, { status });
39+
};
40+
41+
export const handleRoute = async (
42+
handler: () => Promise<Response>,
43+
): Promise<Response> => {
44+
try {
45+
return await handler();
46+
} catch (error) {
47+
if (error instanceof ApiError) {
48+
return respondWithError(error.message, error.statusCode);
49+
}
50+
console.error("Unhandled auth route error:", error);
51+
return respondWithError("Internal server error.", 500);
52+
}
53+
};

0 commit comments

Comments
 (0)