Skip to content

API Configuration, better support via runtime env vars#3380

Merged
ChristopherChudzicki merged 28 commits into
mainfrom
cc/api-configuration
Jun 1, 2026
Merged

API Configuration, better support via runtime env vars#3380
ChristopherChudzicki merged 28 commits into
mainfrom
cc/api-configuration

Conversation

@ChristopherChudzicki
Copy link
Copy Markdown
Contributor

@ChristopherChudzicki ChristopherChudzicki commented May 28, 2026

What are the relevant tickets?

Description (What does it do?)

This PR changes how we initialize API clients. Previously they were initialized within the api workspace. Now they are initialized in main. More importantly, this sets up the mechanism for us to switch to runtime initialization in #3364

Previously, API clients were initialized in the api workspace reading environment variables directly at build time. After #3364, all environment variables must be read at runtime, not buildtime.

Now clients are configureed:

  • in instrumentation.ts, for server-side rendering and server routes (like the sitemap)
  • in instrumentation-client.ts for client-side hydration (the process by which React attaches itself to pre-rendered HTML).

How can this be tested?

  1. Start Learn as usual.
  2. Check pages that use Learn APIs: Homepage, search page, etc. They should load AND the initial HTML should be meaningful. That's how you know SSR is still working.
  3. Check pages that use MITxOnline APIs (the dashboard, course/program pages like http://open.odl.local:8062/courses/READABLE_ID. They should load and you should see meaningful initial HTML.
  4. Try data-mutating requests to ensure CSRF etc is still working with both clients:
    • bookmarking a resource (Learn CSRF)
    • enrolling/unenrolling in a course (MITxOnline CSRF)

Notes

AI Added a lot of new tests, which seem fine if not overly likely to break. The production code diff is about +300 / -100.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 28, 2026

OpenAPI Changes

No changes detected

View full changelog

Unexpected changes? Ensure your branch is up-to-date with main (consider rebasing).

Comment thread frontends/api/src/runtime/index.ts Fixed
@ChristopherChudzicki ChristopherChudzicki force-pushed the cc/api-configuration branch 3 times, most recently from 39317cf to bffc27f Compare May 29, 2026 13:11
@ChristopherChudzicki ChristopherChudzicki marked this pull request as ready for review May 29, 2026 13:15
Copilot AI review requested due to automatic review settings May 29, 2026 13:15
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR moves Learn and MITx Online API clients toward runtime configuration so frontend/API requests can use environment values available after deployment rather than values captured during build.

Changes:

  • Adds configurable axios singletons and runtime API client setup for Learn and MITx Online.
  • Updates Next instrumentation and Jest setup to configure API clients before use.
  • Moves basket replacement redirect behavior into main and normalizes paginated next URLs to use configured axios baseURL.

Reviewed changes

Copilot reviewed 37 out of 37 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
frontends/main/src/bootstrap/api.ts Adds runtime API client bootstrap logic.
frontends/main/src/bootstrap/api.test.ts Tests bootstrap URL/credential configuration behavior.
frontends/main/src/instrumentation.ts Configures server-side API clients during instrumentation registration.
frontends/main/src/instrumentation-client.ts Configures browser API clients before hydration.
frontends/main/src/test-utils/setupJest.tsx Configures API clients for frontend Jest tests.
frontends/main/src/common/mitxonline/index.ts Improves missing legacy MITx URL invariant message.
frontends/main/src/common/mitxonline/useReplaceBasketItem.ts Adds app-level basket replacement hook with cart redirect.
frontends/main/src/common/mitxonline/useReplaceBasketItem.test.tsx Tests basket replacement and redirect behavior.
frontends/main/src/app-pages/ProductPages/CourseEnrollmentButton.tsx Uses the new app-level basket replacement hook.
frontends/main/src/app-pages/ProductPages/ProgramEnrollmentButton.tsx Uses the new app-level basket replacement hook.
frontends/main/src/page-components/EnrollmentDialogs/CourseEnrollmentDialog.tsx Uses the new app-level basket replacement hook.
frontends/main/src/page-components/EnrollmentDialogs/ProgramEnrollmentDialog.tsx Uses the new app-level basket replacement hook.
frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/DashboardCard.tsx Uses the new app-level basket replacement hook.
frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ModuleCard.tsx Uses the new app-level basket replacement hook.
frontends/api/package.json Exports the new runtime configuration module.
frontends/api/src/configurableAxios.ts Adds globally shared configurable axios singleton helper.
frontends/api/src/axios.ts Converts Learn axios to configurable runtime client.
frontends/api/src/mitxonline/axios.ts Adds configurable MITx Online axios client.
frontends/api/src/runtime/index.ts Adds API client configuration/reset APIs.
frontends/api/src/runtime/index.test.ts Tests runtime client configuration behavior.
frontends/api/src/runtime/urls.ts Adds helper to strip origins from paginated API URLs.
frontends/api/src/runtime/urls.test.ts Tests URL origin stripping.
frontends/api/src/clients.ts Uses axios baseURL instead of generated client base path.
frontends/api/src/mitxonline/clients.ts Uses configurable MITx axios and empty generated base path.
frontends/api/src/mitxonline/hooks/baskets/index.ts Removes redirecting basket replacement from API hook layer.
frontends/api/src/hooks/learningResources/queries.ts Normalizes infinite-query next URLs.
frontends/api/src/hooks/learningResources/index.test.ts Tests normalized next-page requests.
frontends/api/src/hooks/learningPaths/queries.ts Normalizes infinite-query next URLs.
frontends/api/src/hooks/learningPaths/index.test.ts Updates next-page normalization test coverage.
frontends/api/src/hooks/userLists/queries.ts Normalizes infinite-query next URLs.
frontends/api/src/hooks/userLists/index.test.ts Tests normalized next-page requests.
frontends/api/src/test-utils/setupJest.ts Configures API clients for API workspace tests.
frontends/api/src/test-utils/urls.ts Builds Learn test URLs from configured axios base URL.
frontends/api/src/test-utils/urls.test.ts Tests Learn test URL runtime configuration.
frontends/api/src/mitxonline/test-utils/urls.ts Builds MITx test URLs from configured axios base URL.
frontends/api/src/mitxonline/test-utils/urls.test.ts Tests MITx test URL runtime configuration.
frontends/api/src/test-utils/mock-requests-example.test.ts Adds coverage for resolving relative axios URLs against baseURL.

Comment thread frontends/main/src/bootstrap/api.ts
Comment on lines 16 to +20
const NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL =
process.env.NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL
invariant(NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL)
invariant(
NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL,
"NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL must be set",
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Env vars are still read at build time. This just moves them out of the api workspace, so they can be easily changed to runtime in #3364

Your suggestion is completely unrelated to your comment, but is worthwhile.

@gumaerc gumaerc self-assigned this May 29, 2026
@gumaerc gumaerc added the Needs Review An open Pull Request that is ready for review label May 29, 2026
Copy link
Copy Markdown
Contributor

@gumaerc gumaerc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This all looks good and is working for me, just one very minor question:

Comment thread frontends/api/src/configurableAxios.ts Outdated
instance.defaults.withCredentials = config.withCredentials
}

const isConfigured = (): boolean => Boolean(instance.defaults.baseURL)
Copy link
Copy Markdown
Contributor

@gumaerc gumaerc May 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The xsrfCookieName and withCredentials props are also set when running applyConfig. Do we want to ensure those are also set to for this to be true, or is it more about the bare minimum needed for Axios to technically function?

ChristopherChudzicki and others added 17 commits June 1, 2026 13:27
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace non-null assertions in bootstrapApiClients with a requireEnv helper
backed by tiny-invariant. Errors now name the missing env var instead of
the downstream config field. configureApiClients keeps its strict signature
so callers retain accurate type hints and compile-time checks.

Also document why bootstrapApiClients() must run at module scope in
providers.tsx — child components fire React Query during render, before
any effect would run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
configureApiClients now throws on any second call, eliminating the
hand-maintained (or isEqual-based) deep equality check and its
maintenance footgun. First-wins idempotency moves to a single explicit
gate in bootstrapApiClients(), using a new isApiClientsConfigured()
predicate. resetApiClientsForTests remains the test escape hatch.

This separates concerns: configureApiClients writes config once,
isApiClientsConfigured answers "have we?", and bootstrapApiClients
owns the "exactly once across triggers" policy in one obvious place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
configureApiClients's signature already guarantees ApiClientsConfig.
The isRecord/typeof guards re-checked what the type promises and the
tests that exercised them had to cast through 'as unknown as' to even
reach the runtime branch — defending JS-level inputs that the typed
surface won't produce.

What stays: normalizeBaseUrl still throws when the post-normalize value
is empty (the slash-only case), which is a real invariant (an empty
baseURL would silently break the test mock's URL matching).

Also: useReplaceBasketItem.test.tsx now restores process.env in
beforeEach so the 'throws at module load' test is robust to reordering
instead of relying on Jest source order.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Filename suggested coverage of queries.ts but the file only re-tested
toRelativeApiUrl, which runtime/urls.test.ts already covers. The
jest.resetModules + dynamic import + process.env mutation scaffolding
was solving a problem that doesn't exist: toRelativeApiUrl is a pure
function and never reads env.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
runtime/index.test.ts: drop Object.isFrozen and Reflect.set return-value
assertions from the frozen-config test. They were verifying JS engine
semantics around Object.freeze. The post-mutation read-back is the
behavioral guard that actually catches freezeConfig regressions: if
freeze were removed, the Reflect.set calls would succeed and the
read-back would observe the mutated values.

bootstrap/api.test.ts: drop the mitxonline propagation block from the
server-URL test. The browser-URL test already asserts the full config
shape; the server test now narrows to the Learn server-fallback URL,
which is the only thing that varies between the two cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ChristopherChudzicki and others added 11 commits June 1, 2026 13:28
- Delete frontends/api/src/clients.test.ts — tautological test that
  asserted a hardcoded constant; flagged by jest/no-export rule.
- Drop the orphaned BASE_PATH export from clients.ts; it was widened
  only to feed the deleted test.
- Convert stray require("../runtime") in api setupJest.ts to a static
  import; flagged by @typescript-eslint/no-require-imports.
- Add an eslint-disable for the legitimate require() inside
  jest.isolateModules in useReplaceBasketItem.test.tsx — the callback
  must be synchronous, so require is the only way to load the module.

The remaining warning in useContractDashboardData.ts is pre-existing
and not in scope for this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The cc/mock-axios-adapter mock changed makeRequest from positional
(method, url, body) to a single object arg ({ method, url, body }).
Three new tests added on this branch (the pagination tests for
learningResources and userLists, and the resolved-URL example in
mock-requests-example) still used the positional form; migrate them
to expect.objectContaining({ method, url }) to match the adapter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- normalizeBaseUrl: replace the trailing-slash regex /\/+$/ with a linear
  index scan. CodeQL flagged the regex as polynomial ReDoS — it backtracks
  O(n^2) on inputs with many trailing slashes, and baseUrl is env-derived
  (uncontrolled per static analysis). Behavior is unchanged: strip trailing
  slashes, reject an empty result.
- Prettier: wrap two long template-literal arrows in the test URL builders
  (the getApiBaseUrl() prefix pushed them past print width).
- Add an eslint-disable for the jest.isolateModules require() in
  instrumentation-client.test.ts (sync callback; require is required).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- runtime config: assert configureApiClients propagates xsrfCookieName and
  withCredentials (not just baseURL) to both axios singletons — the
  CSRF/credentials wiring this branch establishes was previously unverified.
- runtime config: drop the freeze test's axios-default assertions; they could
  not fail for the freeze invariant (axios holds an independent copy) and the
  propagation is now covered above.
- useReplaceBasketItem: add failure-path tests proving no add + no redirect
  when the sync clear never succeeds or the async clear rejects. The sync case
  also pins the clear->add->redirect nesting.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- instrumentation-client: run bootstrapApiClients() after Sentry.init so a
  missing-env failure during bootstrap is reported to Sentry instead of
  crashing before error monitoring is armed. Still runs at module load,
  before hydration, so React Query hooks remain safe on first paint.
- bootstrap/api.ts: correct the first-wins comment — providers.tsx no longer
  calls bootstrap, so the server configures once at startup (instrumentation.ts)
  and the browser once (instrumentation-client.ts, re-entrant under Fast Refresh).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collapse the near-identical Learn and MITx axios scaffolds into a single
createConfigurableAxios(registryKey) factory: axios.create + the
fail-loud 'not configured' request interceptor + apply/reset helpers.

Park the instance on globalThis under Symbol.for(registryKey) so it is a
true per-process singleton. Next.js evaluates the instrumentation runtime
and the server render runtime as separate module graphs; without sharing,
configureApiClients() from instrumentation would mutate only
instrumentation's copy and leave the render graph unconfigured. The global
symbol registry gives every module graph the same slot.

The configured-state guard now derives from instance.defaults.baseURL
presence rather than a separate boolean, removing one of the duplicated
sources of truth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make configurableAxios the sole owner of the globalThis singleton mechanism
and have the runtime config derive all state through it, so the configured
instances and the "configured?" signal can no longer disagree across Next.js's
separate module graphs.

- Extract the globalThis/Symbol.for helper into configurableAxios and drop the
  standalone globalSingleton module; runtime no longer keeps its own
  module-local config state (which was reset per graph / per Fast Refresh while
  the instances persisted).
- Derive isApiClientsConfigured() from the shared instances; configureApiClients
  returns void.
- Remove getApiClientsConfig / client getConfig: their only consumers were the
  url test-utils, which only need the base URL. They now read it straight off
  the configured axios instance (the single source of truth).
- Fix bootstrap/api.ts requireEnv: read each NEXT_PUBLIC_* var as a static
  literal so Next inlines it client-side. Dynamic process.env[name] lookups
  yield undefined in the browser, throwing even when the var is set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- bootstrap: coalesce empty NEXT_SERVER_MITOL_API_BASE_URL with `||` so a
  blank value (env/codespaces.env) falls back to the public URL instead of
  throwing during normalization; add regression test
- runtime: collapse identical LearnApiConfig/MitxOnlineApiConfig into the
  canonical ConfigurableAxiosConfig
- runtime/urls: restore hq#10999 rationale as a docstring on toRelativeApiUrl
- remove tautological instrumentation-client.test.ts
- move lodash back to devDependencies (test-utils only)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The three infinite-hook tests (learningPaths, learningResources, userLists)
were near-identical copy-paste asserting the same toRelativeApiUrl wiring.
Extract the body into assertNormalizesPaginationNext in hooks/test-utils, so
each hook keeps a thin co-located test (drift protection) without the
boilerplate. Also allow devDependencies in **/test-utils.tsx (the glob only
listed the .ts variant).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
isConfigured previously inferred "configured" from defaults.baseURL, which
conflates "configure ran" with "a default happens to be set". Track it
explicitly with a Symbol.for-keyed flag on the (already-singleton) instance —
cross-graph visible without a separate globalThis entry — and give
createConfigurableAxios an explicit ConfigurableAxiosClient return type so the
internal marker doesn't leak.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ChristopherChudzicki ChristopherChudzicki merged commit 6f992b4 into main Jun 1, 2026
13 checks passed
@ChristopherChudzicki ChristopherChudzicki deleted the cc/api-configuration branch June 1, 2026 18:01
@odlbot odlbot mentioned this pull request Jun 1, 2026
5 tasks
ChristopherChudzicki added a commit that referenced this pull request Jun 1, 2026
Following the rebase onto main (#3380 API runtime config), finish moving
every NEXT_PUBLIC_* read off build-time inlining and onto the runtime env()
helper, and align test setup with the runtime model.

Runtime-env correctness:
- bootstrap/api.ts: read NEXT_PUBLIC_* via env()/requiredEnv() instead of
  build-inlined process.env. Without this the standalone image crash-loops at
  boot (client + server) because configureApiClients relied on values that are
  empty in an env-less build.
- OnboardingPage, YouTubeIframePlayer, urls.ts: migrate the remaining reads to
  env()/requiredEnv() at call sites (no module-scope invariants).
- env.ts: guard the client process.env fallback (typeof process check) so an
  absent optional NEXT_PUBLIC_* var can't throw ReferenceError in the browser.
- backgroundImages.ts: drop the redundant NEXT_PUBLIC_OPTIMIZE_IMAGES check;
  getImageProps already respects images.unoptimized.

Test & validation hygiene:
- Move NEXT_PUBLIC_* test values out of the shared jest setup into main's setup
  (only main app code reads them); api configures clients with hardcoded test
  URLs; leaf packages need none.
- validateEnv: require NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL at startup
  (relocates #3380's module-load invariant) and drop the now-obsolete
  "throws at module load" test.
- Remove dead NEXT_PUBLIC_OPTIMIZE_IMAGES from env/frontend.env.

No build-inlined NEXT_PUBLIC_* reads remain in app code.
Verified: typecheck, eslint, and the full frontend test suites pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ChristopherChudzicki added a commit that referenced this pull request Jun 2, 2026
Following the rebase onto main (#3380 API runtime config), finish moving
every NEXT_PUBLIC_* read off build-time inlining and onto the runtime env()
helper, and align test setup with the runtime model.

Runtime-env correctness:
- bootstrap/api.ts: read NEXT_PUBLIC_* via env()/requiredEnv() instead of
  build-inlined process.env. Without this the standalone image crash-loops at
  boot (client + server) because configureApiClients relied on values that are
  empty in an env-less build.
- OnboardingPage, YouTubeIframePlayer, urls.ts: migrate the remaining reads to
  env()/requiredEnv() at call sites (no module-scope invariants).
- env.ts: guard the client process.env fallback (typeof process check) so an
  absent optional NEXT_PUBLIC_* var can't throw ReferenceError in the browser.
- backgroundImages.ts: drop the redundant NEXT_PUBLIC_OPTIMIZE_IMAGES check;
  getImageProps already respects images.unoptimized.

Test & validation hygiene:
- Move NEXT_PUBLIC_* test values out of the shared jest setup into main's setup
  (only main app code reads them); api configures clients with hardcoded test
  URLs; leaf packages need none.
- validateEnv: require NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL at startup
  (relocates #3380's module-load invariant) and drop the now-obsolete
  "throws at module load" test.
- Remove dead NEXT_PUBLIC_OPTIMIZE_IMAGES from env/frontend.env.

No build-inlined NEXT_PUBLIC_* reads remain in app code.
Verified: typecheck, eslint, and the full frontend test suites pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Needs Review An open Pull Request that is ready for review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants