Two projects that only make sense together: resume-api stores a person’s resume-shaped data — profile, work experience, education, skills, certifications, hobbies, and goals — and serves it back as JSON or Markdown. resume-app is the signed-in editor on top of it: view your assembled resume, edit every section, no admin surface, nothing but your own data.

Why Python again, not TypeScript end to end

peteshepley.com and api-console are both TypeScript, which would have kept one language across the whole portfolio. resume-api is Python instead — a deliberate choice, not a default: the API-hosting strategy is itself part of the portfolio, so different projects intentionally use different runtimes rather than standardizing on one. It’s built on AWS Lambda Powertools’ APIGatewayHttpResolver and Router, with Pydantic v2 for validation — the closest Python equivalent to a Hono + zod setup.

One DynamoDB table, one query

Every access pattern this API needs is “everything for one person” — assembling a resume means fetching a profile plus six collections for one user, always together, never joined across users. A single table keyed on pk = USER#<clerk_id> / sk = <ENTITY_TYPE>#<uuid> turns that into one Query instead of six-plus round trips. One-table-per-entity would have been simpler to read in isolation but adds six tables’ worth of ARNs and outputs for no actual access-pattern benefit.

/me everywhere — no person ID ever appears in a URL

The one hard requirement from the start was that people can only ever access their own data. Rather than /people/{personId}/... routes with a per-handler ownership check, every route is /me/..., and the Lambda derives the only person ID that matters — the caller’s own — from the verified Clerk JWT’s sub claim. There’s structurally no path where a handler could forget the check, because there’s no ID in the request to check against in the first place.

The Clerk authorizer can’t be created with a placeholder issuer

The original plan was to default clerk_issuer_url to a placeholder so the infrastructure could be applied before the Clerk app existed. First real apply proved that wrong: AWS validates a JWT authorizer’s issuer by actually fetching its /.well-known/openid-configuration at creation time, so a fake URL fails outright — there’s no “create now, point at the real issuer later.” Fixed by defaulting the issuer and audience to empty strings and gating both the authorizer and the routes on whether they’re set. With no Clerk app yet, the API simply has no routes at all — every request 404s — rather than briefly existing without auth.

resume-app: one generic component, not six near-identical forms

resume-app is TypeScript, React 19, and Vite, matching api-console’s conventions, with a few additions justified by a bigger surface: seven entities, each with add/edit/delete, across multiple pages. react-hook-form + zod handle schema-driven validation instead of seven hand-rolled forms, and src/components/Section.tsx is the one generic list-and-modal-form implementation shared by all six collections — the frontend analogue of resume-api’s own generic CRUD router factory. ResumePage.tsx instantiates it six times with different type arguments rather than hand-writing six near-identical blocks. Deliberately left out: TanStack Query. resume-api’s aggregate GET /me/resume endpoint already returns everything the dashboard needs in one call, so there’s no real cache-invalidation problem to solve — every mutation just refetches that one query.

Types aren’t hand-maintained either: openapi-typescript generates src/types/resume-api.ts straight from the published OpenAPI spec, checked in like a lockfile and regenerated by hand when the spec changes.

The one entity that broke the generic component

The first version of Section<TItem, TInput, TValues> assumed form values could be passed straight through as the API request body with no per-entity mapping — true for five of the six collections, where the Zod schema’s inferred type happens to line up field-for-field with the generated input type. Not true for skills: years_experience comes out of a number input as a string in form state, but the API expects number | null | undefined. Rather than special-case that one field with a Zod transform (which would have required a different, more complex form-typing setup just for skills), I added an explicit toInput mapping prop to Section — identity for five entities, a real conversion for one. Correct by construction now, not by coincidence.

Custom domain: two applies, not one

resume-api’s custom domain, resume.api.peteshepley.com, needed a new wildcard certificate — the existing *.peteshepley.com cert is one label short of covering a second-level subdomain like *.api.peteshepley.com. Two things made this fiddlier than the single-label subdomains (resume.peteshepley.com, test.peteshepley.com) that reuse the existing cert directly:

There’s no Terraform data source for an API Gateway v2 custom domain’s regional target, only for API Gateway v1 — so wiring the Route53 alias needed applying the API stack first, copying its target/hosted-zone outputs into variables, then applying the DNS stack second.

A brand-new certificate’s validation records also can’t be planned in the same apply that creates the certificate, since for_each can’t resolve keys from a value that’s still unknown at plan time. Worked around it with one apply excluding the validation resources, then a second plain apply. Validation itself finished in about 13 seconds once the CNAME existed — Route53 is authoritative for the zone, no propagation wait.

Verified end to end afterward: curl against the new domain with no token returns a clean 401 with WWW-Authenticate: Bearer, identical to the execute-api URL; a CORS preflight against it returns a clean 204; and the certificate presents cleanly with no TLS warnings.

What’s next

PDF export of a resume is the obvious next feature and deliberately out of scope for v1 — resume-api already renders Markdown as an intermediate format, which works either as input to a client-side PDF renderer or a future server-side format=pdf option. Worth its own design conversation rather than bolting on now.