Skip to content

refactor(users): declarative route table [2/8] - #342

Draft
nourshoreibah wants to merge 9 commits into
mainfrom
refactor/lambda-users
Draft

refactor(users): declarative route table [2/8]#342
nourshoreibah wants to merge 9 commits into
mainfrom
refactor/lambda-users

Conversation

@nourshoreibah

Copy link
Copy Markdown
Collaborator

Stack 2/8. Base is #341, not main — review that first. Pure reorganization of the users lambda; no behaviour change.

Before / after

handler.ts went from 308 lines to 4:

import { dispatch } from '@branch/lambda-http';
import { routes } from './routes';

export const handler = (event: any) => dispatch(event, { prefix: 'users', routes });

The five routes now live in a table, in the same order the if chain tested them:

Method Pattern Controller
GET /users listUsers
GET /users/:userId getUser
PATCH /users/:userId patchUser
DELETE /users/:userId deleteUser
POST /users createUser

Each pattern replaces a hand-rolled test — (normalizedPath === '/users' || normalizedPath === '' || normalizedPath === '/') for the list route, normalizedPath.startsWith('/') && normalizedPath.split('/').length === 2 plus split('/')[1] for the :userId ones.

Layout

  • routes.ts — the ordered Route[], with the ROUTES-START/ROUTES-END markers moved here so lambda-cli still has an injection point (PR 8/8 teaches it to use them).
  • controllers/users.ts — one RouteHandler per route, calling Kysely directly. No services/ layer: this lambda is thin and one would be ceremony.
  • The local json() and requireAuth() are deleted in favour of the shared ones.
  • db.ts, auth.ts, validation-utils.ts, swagger-utils.ts, dev-server.ts untouched.
  • tsconfig.json's include widened to ["*.ts", "controllers/**/*.ts"] so the new subdirectory typechecks.

Tests

53/53 passing, tsc --noEmit clean. Adds a route-precedence test asserting first-match-wins: a literal route ahead of /users/:userId wins for its own path while /users/42 still falls through to the param route.

Two review notes

Authz tests now hit the real checkAuthorization. The suites jest.mock('../auth'), and the old local requireAuth called the mocked checkAuthorization through that module. The shared requireAuth imports it directly from @branch/lambda-auth, which the mock does not intercept. The real implementation was checked against every test's mock for all levels used here (PUBLIC, AUTHENTICATED, ADMIN, ADMIN_OR_SELF) and is logically identical, so outcomes are unchanged — but the tests now exercise the real code path, which is arguably what you want and is worth knowing.

Stack-wide: 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 tell "route exists" (401) from "route doesn't" (404). Accepted deliberately: the route inventory is already in the repo's openapi.yaml, and auth-before-routing is exactly what forces each lambda to hand-roll public-route exemptions. Flagging it in every stack PR so it is a decision, not an accident.

🤖 Generated with Claude Code

nourshoreibah and others added 2 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>
@nourshoreibah nourshoreibah added the no-review The PR review bot won't run label Aug 22, 2026
Base automatically changed from refactor/lambda-http-package to main August 22, 2026 18:30
nourshoreibah and others added 7 commits August 22, 2026 14:33
# Conflicts:
#	.github/workflows/lambda-deploy.yml
#	.github/workflows/lambda-tests.yml
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>
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