Skip to content

refactor(donors): declarative route table [3/8] - #343

Merged
nourshoreibah merged 16 commits into
mainfrom
refactor/lambda-donors
Aug 23, 2026
Merged

refactor(donors): declarative route table [3/8]#343
nourshoreibah merged 16 commits into
mainfrom
refactor/lambda-donors

Conversation

@nourshoreibah

Copy link
Copy Markdown
Collaborator

Stack 3/8. Base is #342, not main. Pure reorganization of the donors lambda; no behaviour change.

handler.ts drops from 349 lines to 4. The six routes, in the order the if chain tested them:

Method Pattern Controller
GET /donors donors.getDonors
GET /donors/donations donations.getDonations
POST /donors/donations donations.createDonation
POST /donors donors.createDonor
DELETE /donors/:id donors.deleteDonor
DELETE /donors/donations/:id donations.deleteDonation

Two controller files, split by resource: controllers/donors.ts and controllers/donations.ts. No services/ layer — this lambda is thin enough that one would be ceremony. The local json() is deleted in favour of the shared one.

Worth noting what the patterns replace: DELETE /donors/:id was /^\/[^\/]+$/.test(normalizedPath) plus normalizedPath.split('/')[1], and DELETE /donors/donations/:id was normalizedPath.startsWith('/donations/') && normalizedPath.split('/').length === 3 plus split('/')[2]. Their relative order is preserved.

Auth left inline, deliberately

This lambda does not adopt the shared requireAuth/createAuthGuard. Every route ran an identical inline isAuthenticated check, but the follow-on authorization uses domain-specific messages — 'Only admins can delete donors', 'You must be a member' — that checkAuthorization's generic reasons would have replaced. Keeping the original conditionals guarantees identical status codes and message strings. Consolidating those messages is a reasonable follow-up, but it is a behaviour change and does not belong in a refactor PR.

Tests

51 passing, 1 pre-existing failure, tsc --noEmit clean. Adds a precedence test that GET /donors/donations reaches the donations controller rather than being swallowed by /donors/:id.

The failure is test/donors.test.tsDonor API with data › health test 🌞, which does a real fetch("http://localhost:3000/donors/health") and fails identically on unmodified main without the donors container running. Not caused by this PR.

tsconfig.json's include gains controllers/**/*.ts. Typechecking already reached the new files transitively through imports; this just makes it explicit and matches the other lambdas in the stack.

Noticed, not fixed

  • donors/openapi.yaml is missing the GET /donations path, which the handler implements and the tests cover. Pre-existing spec gap.
  • The duplicate-key problem in donors/openapi.yaml that closed PR fix: route lambda sub-paths via API Gateway proxy + shared dispatcher #257 claimed to fix is not present in the current file — either already fixed or it was never quite as described.

Stack-wide note: unmatched paths now 404 instead of 401

Previously the top-level auth check ran before route matching, so an unauthenticated request to a nonexistent path got 401. dispatch returns 404 centrally without running auth, so an unauthenticated caller can now distinguish "route exists" (401) from "route doesn't" (404). Accepted deliberately — the route inventory is already in the repo's openapi.yaml files, and auth-before-routing is exactly what forces each lambda to hand-roll public-route exemptions. Flagged in every stack PR so it is a decision rather than an accident.

🤖 Generated with Claude Code

nourshoreibah and others added 3 commits August 22, 2026 13:48
No lambda is converted yet -- this only lands the package the conversions
build on, so it is a pure addition.

The six handlers each route with a chain of `if (normalizedPath === ...)`
statements that test two or three path spellings per route, because API
Gateway's {proxy+} forwards the full path (/projects/7) while the shared
dev-server strips the first segment (/7). Params come out of hand-rolled
`split('/')[2]` and regex tests, correctness depends on `if` ordering that
nothing enforces, and `json()` is defined six times over with `requireAuth`
three times.

@branch/lambda-http replaces that with a declarative route table:

- dispatch({ prefix, routes }) canonicalizes the path to the prefixed shape so
  one table serves both callers, matches `:param` segments, and centralizes
  OPTIONS preflight, /<prefix>/health, 404 and 500.
- json() with CORS headers, parseBody(), requireAuth() and a createAuthGuard()
  factory that binds a service's db-scoped authenticateRequest.
- 28 unit tests, including route precedence and both path shapes.

The dispatch/match/response/types modules are recovered from the closed PR
#257; the auth and body helpers are new. No infrastructure change is needed --
{proxy+} and ANY already landed on main via PR #279.

CI: both workflows build lambda-http after lambda-auth (it consumes that
package's dist), a shared-http job runs its tests and is added to the
lambda-tests gate, and lambda-deploy triggers on shared/lambda-http/**.

Note: lambda-deploy still does not trigger on shared/lambda-auth/**, a
pre-existing gap left alone here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pure reorganization, no behaviour change:
- handler.ts is now a one-liner: dispatch(event, { prefix: 'users', routes }).
- routes.ts holds the ordered Route[] table, bracketed by the ROUTES-START/
  ROUTES-END markers (moved here from handler.ts) in the same order as the
  original if-chain: GET /users, GET /users/:userId, PATCH /users/:userId,
  DELETE /users/:userId, POST /users.
- controllers/users.ts holds one RouteHandler per route, calling Kysely
  directly (no services/ layer — this lambda is thin). Auth now goes through
  createAuthGuard(authenticateRequest) from @branch/lambda-http instead of a
  handler-local requireAuth/checkAuthorization pairing; @branch/lambda-auth's
  checkAuthorization (which the shared requireAuth calls) is behaviourally
  identical to the removed local copy for every level this lambda uses.
- Local json()/requireAuth() helpers deleted in favor of the @branch/lambda-http
  exports. dev-server.ts, db.ts, auth.ts, validation-utils.ts, swagger-utils.ts
  untouched.
- Added @branch/lambda-http as a dependency, regenerated package-lock.json.
- tsconfig.json now includes controllers/**/*.ts.
- Added a route-precedence unit test (literal /users/me vs /users/:userId).

Existing suites pass unmodified.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replace the if-chain in handler.ts with a Route[] table dispatched via
@branch/lambda-http's dispatch(). handler.ts is now a one-liner; route
logic moved into controllers/donors.ts (GET/POST /donors, DELETE
/donors/:id) and controllers/donations.ts (GET/POST /donors/donations,
DELETE /donors/donations/:id), in the same order as the original if
chain. No services/ layer added, per this lambda's existing shape.

Local json() removed in favor of the shared one; auth stays manual
(authenticateRequest + custom 401/403 messages) since this lambda's
authorization messages don't match @branch/lambda-http's generic
requireAuth reasons. ROUTES-START/END markers moved into routes.ts,
now bracketing the route table entries.

Added @branch/lambda-http as a dependency and regenerated
package-lock.json. Added one test asserting GET /donors/donations
reaches the donations controller rather than a donor-id route.

No behavior change: same status codes, messages, and validation
order. Verified via tsc --noEmit and jest (--runInBand to avoid
DB contention with sibling lambda test runs): 51 passed, 1
pre-existing failure (health test requires a live dev-server on
:3000, fails identically on main).
@nourshoreibah nourshoreibah added the no-review The PR review bot won't run label Aug 22, 2026
nourshoreibah and others added 13 commits August 22, 2026 14:33
# Conflicts:
#	.github/workflows/lambda-deploy.yml
#	.github/workflows/lambda-tests.yml
# Conflicts:
#	apps/backend/lambdas/donors/handler.ts
The lambda declares @branch/lambda-http as a file: dependency, but the
Dockerfile only copied and built shared/lambda-auth, so npm install inside the
image resolved a path that was never copied and `make up` failed at build time.

Mirrors the existing lambda-auth stage, placed after it: lambda-http resolves
lambda-auth as file:../lambda-auth and consumes its dist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Preview deploys were failing at esbuild with "Could not resolve
@branch/lambda-http" while lambda-tests and lambda-deploy were green.

Three workflows each encoded their own copy of "build the shared packages a
lambda depends on before packaging it", and adding @branch/lambda-http updated
only two of them. preview-env.yml still built lambda-auth alone, so the lambda's
npm ci installed a file: dependency whose dist had never been built and the
bundle could not resolve the import.

Replaces all of it with .github/actions/build-shared-packages, used by
lambda-tests (test + shared-http), lambda-deploy (build) and preview-env
(deploy). Build order lives in one place now: lambda-http declares lambda-auth
as file:../lambda-auth and compiles against its dist, so it goes second.

The next shared package added is the actual test of this: one edit instead of
four, with no fourth copy left to forget.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nversion

The lambda-readme workflow regenerates every README and pushes the result, and
it ran the old CLI against a converted lambda: extractRoutesFromHandler parses
if-conditions out of handler.ts, which is now four lines, so it found no routes
and auto-committed a README with all five of users' endpoints deleted.

- extractRoutesFromHandler now prefers a sibling routes.ts and falls back to the
  if-chain parse, so it reads converted and unconverted lambdas alike. That
  matters inside this stack, where only some lambdas have been converted at any
  given commit.
- collectRoutes lists health once, under the service prefix for a converted
  lambda and bare otherwise, instead of hardcoding /health and duplicating a
  spec entry that spells it the other way.
- users/openapi.yaml is normalized to match: paths carry the /users prefix and
  servers is the bare host. It previously contradicted itself -- servers ended
  in /users AND the /users path was prefixed, so Swagger built /users/users,
  while /{userId} had no prefix at all.

The fuller CLI rebuild lands in 8/8; this is the subset needed for the README
workflow to stop rewriting these files as each lambda converts.

expenditures/README.md picks up a POST /expenditures row: pre-existing drift on
main that the workflow would have auto-committed anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The lambda declares @branch/lambda-http as a file: dependency, but the
Dockerfile only copied and built shared/lambda-auth, so npm install inside the
image resolved a path that was never copied and `make up` failed at build time.

Mirrors the existing lambda-auth stage, placed after it: lambda-http resolves
lambda-auth as file:../lambda-auth and consumes its dist.

README regenerated so the lambda-readme workflow has nothing to push.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nourshoreibah
nourshoreibah marked this pull request as ready for review August 23, 2026 00:38
Base automatically changed from refactor/lambda-users to main August 23, 2026 00:39
@nourshoreibah
nourshoreibah merged commit 9eae341 into main Aug 23, 2026
20 checks passed
@nourshoreibah
nourshoreibah deleted the refactor/lambda-donors branch August 23, 2026 00:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-review The PR review bot won't run

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant