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.