create-nwire 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/scaffold.test.d.ts +8 -3
- package/dist/__tests__/scaffold.test.d.ts.map +1 -1
- package/dist/__tests__/scaffold.test.js +49 -22
- package/dist/__tests__/scaffold.test.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +22 -8
- package/dist/index.js.map +1 -1
- package/package.json +4 -2
- package/templates/L1/README.md +34 -3
- package/templates/L1/__tests__/hello.test.ts +54 -0
- package/templates/L1/app/api.ts +17 -13
- package/templates/L1/app/main.ts +11 -1
- package/templates/L1/app/routes/hello.ts +29 -0
- package/templates/L1/tsconfig.json +1 -1
- package/templates/L2/README.md +33 -13
- package/templates/L2/__tests__/todo-api.test.ts +115 -0
- package/templates/L2/app/api.ts +20 -115
- package/templates/L2/app/{errors.ts → errors/todo-errors.ts} +6 -6
- package/templates/L2/app/main.ts +7 -7
- package/templates/L2/app/middleware/require-user.ts +29 -0
- package/templates/L2/app/{model.ts → resources/todo.ts} +21 -5
- package/templates/L2/app/routes/complete-todo.ts +36 -0
- package/templates/L2/app/routes/create-todo.ts +27 -0
- package/templates/L2/app/routes/delete-todo.ts +31 -0
- package/templates/L2/app/routes/list-todos.ts +28 -0
- package/templates/L2/app/{store.ts → store/todo-store.ts} +13 -5
- package/templates/L2/tsconfig.json +1 -1
- package/templates/L4/README.md +67 -17
- package/templates/L4/__tests__/auto-moderate.test.ts +96 -0
- package/templates/L4/__tests__/submit-flow.test.ts +80 -0
- package/templates/L4/app/api.ts +19 -109
- package/templates/L4/app/app.ts +4 -4
- package/templates/L4/app/main.ts +11 -12
- package/templates/L4/modules/posts/actions/approve-post.ts +28 -0
- package/templates/L4/modules/posts/actions/reject-post.ts +29 -0
- package/templates/L4/modules/posts/actions/submit-post.ts +36 -0
- package/templates/L4/modules/posts/events/post-was-approved.ts +22 -0
- package/templates/L4/modules/posts/events/post-was-rejected.ts +20 -0
- package/templates/L4/modules/posts/events/post-was-submitted.ts +27 -0
- package/templates/L4/modules/posts/posts.module.ts +35 -0
- package/templates/L4/{app/queue.projection.ts → modules/posts/projections/queue-dashboard.ts} +18 -30
- package/templates/L4/modules/posts/routes/approve-post.ts +26 -0
- package/templates/L4/modules/posts/routes/get-post.ts +20 -0
- package/templates/L4/modules/posts/routes/list-queue.ts +22 -0
- package/templates/L4/modules/posts/routes/reject-post.ts +29 -0
- package/templates/L4/modules/posts/routes/submit-post.ts +28 -0
- package/templates/L4/{app/auto-moderate.workflow.ts → modules/posts/workflows/auto-moderate.ts} +13 -20
- package/templates/L4/tsconfig.json +1 -1
- package/templates/L2/app/middleware.ts +0 -32
- package/templates/L4/app/actions.ts +0 -81
- package/templates/L4/app/events.ts +0 -53
- package/templates/L4/app/moderation.module.ts +0 -28
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke test — the four CRUD operations + the requireUser middleware.
|
|
3
|
+
*
|
|
4
|
+
* Boots the api against a real Node http server, registers a fresh
|
|
5
|
+
* in-memory TodoStore on a container, and dispatches fetch requests
|
|
6
|
+
* exactly the way clients will.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
10
|
+
import http from "node:http";
|
|
11
|
+
import type { Server } from "node:http";
|
|
12
|
+
import { InMemoryContainer } from "@nwire/container";
|
|
13
|
+
import { TodoStore } from "../app/store/todo-store";
|
|
14
|
+
import { api } from "../app/api";
|
|
15
|
+
|
|
16
|
+
let server: Server;
|
|
17
|
+
let url: string;
|
|
18
|
+
|
|
19
|
+
beforeAll(async () => {
|
|
20
|
+
const container = new InMemoryContainer();
|
|
21
|
+
container.register("todos", new TodoStore());
|
|
22
|
+
api.provide(container);
|
|
23
|
+
|
|
24
|
+
server = http.createServer(api.compile());
|
|
25
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
26
|
+
const addr = server.address() as { port: number };
|
|
27
|
+
url = `http://127.0.0.1:${addr.port}`;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterAll(async () => {
|
|
31
|
+
await new Promise<void>((resolve, reject) =>
|
|
32
|
+
server.close((err) => (err ? reject(err) : resolve())),
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
async function req(
|
|
37
|
+
path: string,
|
|
38
|
+
init: RequestInit & { user?: string } = {},
|
|
39
|
+
): Promise<{ status: number; body: unknown }> {
|
|
40
|
+
const headers: Record<string, string> = {
|
|
41
|
+
"content-type": "application/json",
|
|
42
|
+
...((init.headers as Record<string, string>) ?? {}),
|
|
43
|
+
};
|
|
44
|
+
if (init.user) headers["x-user-id"] = init.user;
|
|
45
|
+
const res = await fetch(`${url}${path}`, { ...init, headers });
|
|
46
|
+
const text = await res.text();
|
|
47
|
+
let body: unknown = text;
|
|
48
|
+
try {
|
|
49
|
+
body = JSON.parse(text);
|
|
50
|
+
} catch {
|
|
51
|
+
/* keep as text */
|
|
52
|
+
}
|
|
53
|
+
return { status: res.status, body };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("todo api", () => {
|
|
57
|
+
it("requires x-user-id (requireUser middleware)", async () => {
|
|
58
|
+
const r = await req("/api/todos", { method: "GET" });
|
|
59
|
+
expect(r.status).toBe(401);
|
|
60
|
+
expect((r.body as { error: { code: string } }).error.code).toBe("MISSING_USER_ID");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("creates, lists, completes, deletes", async () => {
|
|
64
|
+
// create
|
|
65
|
+
const created = await req("/api/todos", {
|
|
66
|
+
method: "POST",
|
|
67
|
+
body: JSON.stringify({ text: "buy milk" }),
|
|
68
|
+
user: "alice",
|
|
69
|
+
});
|
|
70
|
+
expect(created.status).toBe(201);
|
|
71
|
+
const todo = created.body as { id: string; text: string; status: string };
|
|
72
|
+
expect(todo.text).toBe("buy milk");
|
|
73
|
+
expect(todo.status).toBe("open");
|
|
74
|
+
|
|
75
|
+
// list
|
|
76
|
+
const listed = await req("/api/todos", { method: "GET", user: "alice" });
|
|
77
|
+
expect(listed.status).toBe(200);
|
|
78
|
+
const list = listed.body as { items: Array<{ id: string }>; total: number };
|
|
79
|
+
expect(list.total).toBe(1);
|
|
80
|
+
expect(list.items[0]!.id).toBe(todo.id);
|
|
81
|
+
|
|
82
|
+
// complete
|
|
83
|
+
const completed = await req(`/api/todos/${todo.id}/complete`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
user: "alice",
|
|
86
|
+
});
|
|
87
|
+
expect(completed.status).toBe(200);
|
|
88
|
+
expect((completed.body as { status: string }).status).toBe("completed");
|
|
89
|
+
|
|
90
|
+
// double-complete → 409
|
|
91
|
+
const again = await req(`/api/todos/${todo.id}/complete`, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
user: "alice",
|
|
94
|
+
});
|
|
95
|
+
expect(again.status).toBe(409);
|
|
96
|
+
|
|
97
|
+
// delete
|
|
98
|
+
const removed = await req(`/api/todos/${todo.id}`, {
|
|
99
|
+
method: "DELETE",
|
|
100
|
+
user: "alice",
|
|
101
|
+
});
|
|
102
|
+
expect(removed.status).toBe(204);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("isolates todos per user (alice doesn't see bob's)", async () => {
|
|
106
|
+
await req("/api/todos", {
|
|
107
|
+
method: "POST",
|
|
108
|
+
body: JSON.stringify({ text: "bob's task" }),
|
|
109
|
+
user: "bob",
|
|
110
|
+
});
|
|
111
|
+
const aliceList = await req("/api/todos", { method: "GET", user: "alice" });
|
|
112
|
+
const items = (aliceList.body as { items: Array<{ text: string }> }).items;
|
|
113
|
+
expect(items.find((i) => i.text === "bob's task")).toBeUndefined();
|
|
114
|
+
});
|
|
115
|
+
});
|
package/templates/L2/app/api.ts
CHANGED
|
@@ -1,125 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* HTTP interface — the surface this app exposes.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* plus the resolved request context.
|
|
4
|
+
* One `.wire(Route, Handler)` line per route. To add a new route, drop a
|
|
5
|
+
* file under `./routes/` exporting `{name}Route + {name}Handler`, then
|
|
6
|
+
* `.wire()` it in here.
|
|
8
7
|
*
|
|
9
|
-
*
|
|
8
|
+
* Each route's verb + path + schema + handler live in one file under
|
|
9
|
+
* `./routes/`. The interface stays a 1-line-per-route reducer — easy to
|
|
10
|
+
* audit, easy to grep, no second config file.
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
* - `
|
|
13
|
-
* - `
|
|
14
|
-
*
|
|
15
|
-
* The middleware chain `requireUser` runs before EVERY route — adding it
|
|
16
|
-
* once at the interface level instead of repeating per-route. Per-route
|
|
17
|
-
* middleware would go on individual `.wire(...)` calls if needed.
|
|
18
|
-
*
|
|
19
|
-
* Response shapes use `Todo.project(record)` so the public field list
|
|
20
|
-
* defined on the model controls what ships. No hand-mapping; no risk
|
|
21
|
-
* of an internal field leaking.
|
|
12
|
+
* Cross-cutting:
|
|
13
|
+
* - `requireUser` middleware runs before EVERY route (chained once here).
|
|
14
|
+
* - `.provide(container)` is wired by `main.ts` with the todo store.
|
|
22
15
|
*/
|
|
23
16
|
|
|
24
|
-
import { httpInterface
|
|
25
|
-
import {
|
|
26
|
-
import { Todo, type TodoRecord } from "./model";
|
|
27
|
-
import { TodoStore } from "./store";
|
|
28
|
-
import { TodoNotFound, TodoAlreadyCompleted } from "./errors";
|
|
29
|
-
import { requireUser } from "./middleware";
|
|
17
|
+
import { httpInterface } from "@nwire/http";
|
|
18
|
+
import { requireUser } from "./middleware/require-user";
|
|
30
19
|
|
|
31
|
-
|
|
20
|
+
import { listTodosRoute, listTodosHandler } from "./routes/list-todos";
|
|
21
|
+
import { createTodoRoute, createTodoHandler } from "./routes/create-todo";
|
|
22
|
+
import { completeTodoRoute, completeTodoHandler } from "./routes/complete-todo";
|
|
23
|
+
import { deleteTodoRoute, deleteTodoHandler } from "./routes/delete-todo";
|
|
32
24
|
|
|
33
25
|
export const api = httpInterface({ prefix: "/api" })
|
|
34
26
|
.use(requireUser)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
.wire(
|
|
38
|
-
|
|
39
|
-
body: z.object({ text: z.string().min(1).max(500) }),
|
|
40
|
-
}),
|
|
41
|
-
async ({ input, resolve, _raw }) => {
|
|
42
|
-
const { text } = input as { text: string };
|
|
43
|
-
const store = resolve<TodoStore>("todos");
|
|
44
|
-
const userId = _raw.state.userId as string;
|
|
45
|
-
const todo = store.add({ userId, text });
|
|
46
|
-
return { $status: 201, body: project(todo) };
|
|
47
|
-
},
|
|
48
|
-
)
|
|
49
|
-
|
|
50
|
-
// GET /api/todos?status=open|completed — list
|
|
51
|
-
.wire(
|
|
52
|
-
get("/todos", {
|
|
53
|
-
query: z.object({
|
|
54
|
-
status: z.enum(["open", "completed"]).optional(),
|
|
55
|
-
}),
|
|
56
|
-
}),
|
|
57
|
-
async ({ input, resolve, _raw }) => {
|
|
58
|
-
const { status } = input as { status?: "open" | "completed" };
|
|
59
|
-
const store = resolve<TodoStore>("todos");
|
|
60
|
-
const userId = _raw.state.userId as string;
|
|
61
|
-
const list = store.listByUser(userId, status);
|
|
62
|
-
return { items: list.map(project), total: list.length };
|
|
63
|
-
},
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
// POST /api/todos/:id/complete — mark complete
|
|
67
|
-
.wire(
|
|
68
|
-
post("/todos/:id/complete", {
|
|
69
|
-
params: z.object({ id: z.string() }),
|
|
70
|
-
}),
|
|
71
|
-
async ({ input, resolve, _raw }) => {
|
|
72
|
-
const { id } = input as { id: string };
|
|
73
|
-
const store = resolve<TodoStore>("todos");
|
|
74
|
-
const userId = _raw.state.userId as string;
|
|
75
|
-
|
|
76
|
-
const existing = store.get(id);
|
|
77
|
-
if (!existing || existing.userId !== userId) throw TodoNotFound;
|
|
78
|
-
if (existing.status === "completed") throw TodoAlreadyCompleted;
|
|
79
|
-
|
|
80
|
-
const updated = store.complete(id);
|
|
81
|
-
return project(updated!);
|
|
82
|
-
},
|
|
83
|
-
)
|
|
84
|
-
|
|
85
|
-
// DELETE /api/todos/:id — remove
|
|
86
|
-
.wire(
|
|
87
|
-
del("/todos/:id", {
|
|
88
|
-
params: z.object({ id: z.string() }),
|
|
89
|
-
}),
|
|
90
|
-
async ({ input, resolve, _raw }) => {
|
|
91
|
-
const { id } = input as { id: string };
|
|
92
|
-
const store = resolve<TodoStore>("todos");
|
|
93
|
-
const userId = _raw.state.userId as string;
|
|
94
|
-
|
|
95
|
-
const existing = store.get(id);
|
|
96
|
-
if (!existing || existing.userId !== userId) throw TodoNotFound;
|
|
97
|
-
|
|
98
|
-
store.remove(id);
|
|
99
|
-
return null; // → 204 No Content
|
|
100
|
-
},
|
|
101
|
-
);
|
|
102
|
-
|
|
103
|
-
// ─── Helpers ──────────────────────────────────────────────────────
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* `project()` shapes the internal record onto the public Todo model.
|
|
107
|
-
* In a richer setup with `response.list(Todo)` this happens automatically.
|
|
108
|
-
* Here we call it explicitly so the example reads top-to-bottom.
|
|
109
|
-
*/
|
|
110
|
-
function project(t: TodoRecord) {
|
|
111
|
-
return {
|
|
112
|
-
id: t.id,
|
|
113
|
-
userId: t.userId,
|
|
114
|
-
text: t.text,
|
|
115
|
-
status: t.status,
|
|
116
|
-
addedAt: t.addedAt,
|
|
117
|
-
completedAt: t.completedAt,
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// Reference Todo so the import isn't tree-shaken — the model is the
|
|
122
|
-
// canonical declaration of the public field set, even when we project
|
|
123
|
-
// by hand. Future: switch to response.list(Todo) once the new builder
|
|
124
|
-
// has the project() helper wired.
|
|
125
|
-
void Todo;
|
|
27
|
+
.wire(listTodosRoute, listTodosHandler)
|
|
28
|
+
.wire(createTodoRoute, createTodoHandler)
|
|
29
|
+
.wire(completeTodoRoute, completeTodoHandler)
|
|
30
|
+
.wire(deleteTodoRoute, deleteTodoHandler);
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
* Typed errors — throwable values, rendered per transport.
|
|
3
3
|
*
|
|
4
4
|
* `defineError` returns an Error subclass with `code` + `status` baked
|
|
5
|
-
* in. In a
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* in. In a route handler you `throw TodoNotFound` and the HTTP transport
|
|
6
|
+
* renders it as JSON `{ error: { code: "TODO_NOT_FOUND" } }` with status
|
|
7
|
+
* 404. GraphQL would render it as a GQL error with the same extensions.
|
|
8
|
+
* CLI would print + exit with the right code. One error declaration,
|
|
9
|
+
* every transport does the right thing.
|
|
10
10
|
*
|
|
11
11
|
* Bonus: clients can switch on `error.code` to render UI per-case
|
|
12
12
|
* (toast "Already done!" vs full 404 page). The shape is part of the
|
|
@@ -44,5 +44,5 @@ export const NoUserId = defineError({
|
|
|
44
44
|
summary: "Caller must provide x-user-id header",
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
-
// Re-export common framework errors so
|
|
47
|
+
// Re-export common framework errors so handlers import from one place.
|
|
48
48
|
export { Unauthorized, Forbidden, NotFound, Gone } from "@nwire/forge";
|
package/templates/L2/app/main.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Entry point — wire the store into the http interface, then run.
|
|
3
3
|
*
|
|
4
|
-
* The shape for Level
|
|
4
|
+
* The shape for Level 2:
|
|
5
5
|
*
|
|
6
6
|
* 1. Build an in-memory container, register the store on it.
|
|
7
|
-
* 2. Hand it to the http interface via `.provide()` —
|
|
8
|
-
* `resolve("todos")
|
|
7
|
+
* 2. Hand it to the http interface via `.provide()` — route handlers
|
|
8
|
+
* read it with `resolve<TodoStore>("todos")`.
|
|
9
9
|
* 3. Wrap in `endpoint()` for graceful shutdown + K8s probes.
|
|
10
10
|
*
|
|
11
11
|
* No `createApp` here. With ONE binding and zero plugin lifecycle work,
|
|
12
|
-
* createApp adds ceremony without value. The
|
|
13
|
-
*
|
|
14
|
-
* framework events;
|
|
12
|
+
* createApp adds ceremony without value. The L4 template (and the
|
|
13
|
+
* station-management example) show the full createApp + plugin shape
|
|
14
|
+
* with many bindings and framework events; L2 doesn't need it.
|
|
15
15
|
*
|
|
16
16
|
* Run: pnpm dev
|
|
17
17
|
* Try: curl -X POST http://localhost:3000/api/todos \
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
import { endpoint } from "@nwire/endpoint";
|
|
26
26
|
import { InMemoryContainer } from "@nwire/container";
|
|
27
|
-
import { TodoStore } from "./store";
|
|
27
|
+
import { TodoStore } from "./store/todo-store";
|
|
28
28
|
import { api } from "./api";
|
|
29
29
|
|
|
30
30
|
const container = new InMemoryContainer();
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `requireUser` — tiny auth shim that proves the middleware-chain pattern.
|
|
3
|
+
*
|
|
4
|
+
* A real app would use `@nwire/auth` + an IdP adapter (Logto, better-auth,
|
|
5
|
+
* etc.) to extract `user` from a JWT in the Authorization header. For this
|
|
6
|
+
* template we trust the `x-user-id` header so the test suite stays focused
|
|
7
|
+
* on the framework shape, not on JWT handling.
|
|
8
|
+
*
|
|
9
|
+
* The middleware does two things:
|
|
10
|
+
*
|
|
11
|
+
* 1. Throws `NoUserId` if the header is absent — surfaces as 401 JSON.
|
|
12
|
+
* 2. Attaches the user id to Koa state so handlers can read it without
|
|
13
|
+
* re-parsing the request.
|
|
14
|
+
*
|
|
15
|
+
* Routes chain it once via `.use(requireUser)` in `api.ts`; auth lives at
|
|
16
|
+
* the interface seam, not inside business logic.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { HttpMiddleware } from "@nwire/http";
|
|
20
|
+
import { NoUserId } from "../errors/todo-errors";
|
|
21
|
+
|
|
22
|
+
export const requireUser: HttpMiddleware = async (ctx, next) => {
|
|
23
|
+
const userId = ctx.request.headers["x-user-id"];
|
|
24
|
+
if (typeof userId !== "string" || userId.length === 0) {
|
|
25
|
+
throw NoUserId;
|
|
26
|
+
}
|
|
27
|
+
ctx.state.userId = userId;
|
|
28
|
+
await next();
|
|
29
|
+
};
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
* the strip is explicit, and reviewers can see what the API exposes by
|
|
10
10
|
* scanning one array.
|
|
11
11
|
*
|
|
12
|
-
* The same `Todo`
|
|
13
|
-
* single, list, paginated. `response.list(Todo)` and
|
|
14
|
-
*
|
|
15
|
-
* apply once, declared in one place.
|
|
12
|
+
* The same `Todo` resource is used by every route handler that returns
|
|
13
|
+
* todos — single, list, paginated. Future: `response.list(Todo)` and
|
|
14
|
+
* `response.ok(Todo)` will call `Todo.project()` on each record so the
|
|
15
|
+
* public field rules apply once, declared in one place.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { z } from "zod";
|
|
@@ -56,5 +56,21 @@ export const Todo = defineResource("Todo", {
|
|
|
56
56
|
},
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
-
/** Inferred TS type — what handlers work with internally. */
|
|
59
|
+
/** Inferred TS type — what handlers + the store work with internally. */
|
|
60
60
|
export type TodoRecord = z.infer<typeof Todo.schema>;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `project()` shapes the internal record onto the public Todo shape.
|
|
64
|
+
* Today this just hand-picks fields; future versions will call
|
|
65
|
+
* `Todo.project(record)` once the helper lands on `defineResource`.
|
|
66
|
+
*/
|
|
67
|
+
export function project(t: TodoRecord) {
|
|
68
|
+
return {
|
|
69
|
+
id: t.id,
|
|
70
|
+
userId: t.userId,
|
|
71
|
+
text: t.text,
|
|
72
|
+
status: t.status,
|
|
73
|
+
addedAt: t.addedAt,
|
|
74
|
+
completedAt: t.completedAt,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/todos/:id/complete — mark a todo done.
|
|
3
|
+
*
|
|
4
|
+
* Two negative cases mapped to typed errors:
|
|
5
|
+
*
|
|
6
|
+
* - Todo doesn't exist OR belongs to another user → `TodoNotFound` (404)
|
|
7
|
+
* - Todo already completed → `TodoAlreadyCompleted` (409)
|
|
8
|
+
*
|
|
9
|
+
* Throwing typed errors keeps the handler readable; transport translates.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { post, type HttpHandler } from "@nwire/http";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { TodoStore } from "../store/todo-store";
|
|
15
|
+
import { project } from "../resources/todo";
|
|
16
|
+
import { TodoNotFound, TodoAlreadyCompleted } from "../errors/todo-errors";
|
|
17
|
+
|
|
18
|
+
const Params = z.object({ id: z.string() });
|
|
19
|
+
|
|
20
|
+
export const completeTodoRoute = post("/todos/:id/complete", { params: Params });
|
|
21
|
+
|
|
22
|
+
export const completeTodoHandler: HttpHandler<z.infer<typeof Params>> = async ({
|
|
23
|
+
input,
|
|
24
|
+
resolve,
|
|
25
|
+
_raw,
|
|
26
|
+
}) => {
|
|
27
|
+
const store = resolve<TodoStore>("todos");
|
|
28
|
+
const userId = _raw.state.userId as string;
|
|
29
|
+
|
|
30
|
+
const existing = store.get(input.id);
|
|
31
|
+
if (!existing || existing.userId !== userId) throw TodoNotFound;
|
|
32
|
+
if (existing.status === "completed") throw TodoAlreadyCompleted;
|
|
33
|
+
|
|
34
|
+
const updated = store.complete(input.id);
|
|
35
|
+
return project(updated!);
|
|
36
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/todos — add a new todo to the caller's list.
|
|
3
|
+
*
|
|
4
|
+
* Returns 201 + the new todo. The handler reads `userId` from Koa state
|
|
5
|
+
* (set by `requireUser` middleware) and the store from the request-scoped
|
|
6
|
+
* container.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { post, type HttpHandler } from "@nwire/http";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { TodoStore } from "../store/todo-store";
|
|
12
|
+
import { project } from "../resources/todo";
|
|
13
|
+
|
|
14
|
+
const Body = z.object({ text: z.string().min(1).max(500) });
|
|
15
|
+
|
|
16
|
+
export const createTodoRoute = post("/todos", { body: Body });
|
|
17
|
+
|
|
18
|
+
export const createTodoHandler: HttpHandler<z.infer<typeof Body>> = async ({
|
|
19
|
+
input,
|
|
20
|
+
resolve,
|
|
21
|
+
_raw,
|
|
22
|
+
}) => {
|
|
23
|
+
const store = resolve<TodoStore>("todos");
|
|
24
|
+
const userId = _raw.state.userId as string;
|
|
25
|
+
const todo = store.add({ userId, text: input.text });
|
|
26
|
+
return { $status: 201, body: project(todo) };
|
|
27
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DELETE /api/todos/:id — remove a todo from the caller's list.
|
|
3
|
+
*
|
|
4
|
+
* Returns 204 (no content). Throws `TodoNotFound` if the id is unknown
|
|
5
|
+
* OR belongs to another user — never leak "exists but not yours" vs
|
|
6
|
+
* "doesn't exist", that's an information-disclosure vector.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { del, type HttpHandler } from "@nwire/http";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { TodoStore } from "../store/todo-store";
|
|
12
|
+
import { TodoNotFound } from "../errors/todo-errors";
|
|
13
|
+
|
|
14
|
+
const Params = z.object({ id: z.string() });
|
|
15
|
+
|
|
16
|
+
export const deleteTodoRoute = del("/todos/:id", { params: Params });
|
|
17
|
+
|
|
18
|
+
export const deleteTodoHandler: HttpHandler<z.infer<typeof Params>> = async ({
|
|
19
|
+
input,
|
|
20
|
+
resolve,
|
|
21
|
+
_raw,
|
|
22
|
+
}) => {
|
|
23
|
+
const store = resolve<TodoStore>("todos");
|
|
24
|
+
const userId = _raw.state.userId as string;
|
|
25
|
+
|
|
26
|
+
const existing = store.get(input.id);
|
|
27
|
+
if (!existing || existing.userId !== userId) throw TodoNotFound;
|
|
28
|
+
|
|
29
|
+
store.remove(input.id);
|
|
30
|
+
return null; // → 204 No Content
|
|
31
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/todos — list the caller's todos, optionally filtered by status.
|
|
3
|
+
*
|
|
4
|
+
* Returns `{ items, total }`. The store sorts so open-first by added-at;
|
|
5
|
+
* the handler just projects the records and counts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { get, type HttpHandler } from "@nwire/http";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { TodoStore } from "../store/todo-store";
|
|
11
|
+
import { project } from "../resources/todo";
|
|
12
|
+
|
|
13
|
+
const Query = z.object({
|
|
14
|
+
status: z.enum(["open", "completed"]).optional(),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const listTodosRoute = get("/todos", { query: Query });
|
|
18
|
+
|
|
19
|
+
export const listTodosHandler: HttpHandler<z.infer<typeof Query>> = async ({
|
|
20
|
+
input,
|
|
21
|
+
resolve,
|
|
22
|
+
_raw,
|
|
23
|
+
}) => {
|
|
24
|
+
const store = resolve<TodoStore>("todos");
|
|
25
|
+
const userId = _raw.state.userId as string;
|
|
26
|
+
const list = store.listByUser(userId, input.status);
|
|
27
|
+
return { items: list.map(project), total: list.length };
|
|
28
|
+
};
|
|
@@ -11,13 +11,17 @@
|
|
|
11
11
|
*
|
|
12
12
|
* In a real app you'd swap the store for `@nwire/data-drizzle` against
|
|
13
13
|
* Postgres or `@nwire/store-mongo` against Mongo. The PLUGIN shape stays
|
|
14
|
-
* the same — `provide("todos", { boot, shutdown })`.
|
|
15
|
-
* `
|
|
14
|
+
* the same — `provide("todos", { boot, shutdown })`. Handlers always do
|
|
15
|
+
* `resolve("todos")` and get whatever the plugin booted.
|
|
16
|
+
*
|
|
17
|
+
* The L2 template uses the simpler container-register-then-provide path
|
|
18
|
+
* from `main.ts` (no createApp). The plugin form is exported so it's
|
|
19
|
+
* one rename away once you graduate to L4.
|
|
16
20
|
*/
|
|
17
21
|
|
|
18
22
|
import { randomUUID } from "node:crypto";
|
|
19
23
|
import { definePlugin } from "@nwire/app";
|
|
20
|
-
import type { TodoRecord } from "
|
|
24
|
+
import type { TodoRecord } from "../resources/todo";
|
|
21
25
|
|
|
22
26
|
// ─── The store itself ────────────────────────────────────────────
|
|
23
27
|
|
|
@@ -92,18 +96,22 @@ export class TodoStore {
|
|
|
92
96
|
}
|
|
93
97
|
}
|
|
94
98
|
|
|
95
|
-
// ─── The plugin
|
|
99
|
+
// ─── The plugin (optional — used when you graduate to L4) ────────
|
|
96
100
|
|
|
97
101
|
/**
|
|
98
102
|
* Plugin shape: `provide("todos", { boot, shutdown })`.
|
|
99
103
|
*
|
|
100
104
|
* The framework calls `boot()` during app start, registers the result
|
|
101
105
|
* on the container under the name we chose, and calls `shutdown(store)`
|
|
102
|
-
* during graceful drain. From any
|
|
106
|
+
* during graceful drain. From any handler: `resolve("todos")`.
|
|
103
107
|
*
|
|
104
108
|
* This same shape works whether the store is in-memory (here), a Pool
|
|
105
109
|
* (Postgres via Drizzle), or a MongoClient. The plugin wrapper handles
|
|
106
110
|
* the lifecycle; the store implementation handles its own concerns.
|
|
111
|
+
*
|
|
112
|
+
* L2 main.ts uses the simpler `container.register(...)` path because we
|
|
113
|
+
* don't have a forge app yet. The plugin is exported so L4 graduation
|
|
114
|
+
* is `import { todoStorePlugin } from "./store/todo-store"`.
|
|
107
115
|
*/
|
|
108
116
|
export const todoStorePlugin = definePlugin("todos", ({ provide }) => {
|
|
109
117
|
provide("todos", {
|