Skip to content

Add a pathPrefix setting for services mounted under a prefix - #23

Closed
kubukoz wants to merge 2 commits into
mainfrom
path-prefix
Closed

Add a pathPrefix setting for services mounted under a prefix#23
kubukoz wants to merge 2 commits into
mainfrom
path-prefix

Conversation

@kubukoz

@kubukoz kubukoz commented Sep 2, 2026

Copy link
Copy Markdown
Member

The generated client builds its URL from each operation's @http URI verbatim. That is correct when the model describes the whole path, but a service is often mounted under a prefix the model doesn't mention — a server framework that derives one from a trait, or a reverse proxy — and then every generated request misses it and 404s.

This adds a pathPrefix setting that is prepended to each operation's URI.

{
  "plugins": {
    "ts-codegen": {
      "outFile": "generated.ts",
      "pathPrefix": "/internal/v1"
    }
  }
}
// before
const url = `/things/${encodeURIComponent(String(input.id))}`
// after
const url = `/internal/v1/things/${encodeURIComponent(String(input.id))}`

Notes on the design

It applies to the Storybook mocks too. The mock router matches path segments one at a time, so the prefix arrives there as literal segments ({ literal: 'internal' }, { literal: 'v1' }, …) rather than as one concatenated string. Without that the mocks would silently stop matching the very client they stand in for.

A plain string, not trait-derived. Which prefix a deployment sits behind isn't a property of the model, and a trait-aware version would have to bake in one framework's conventions. A caller that does have a convention (a trait, a service version field) can compute the string and pass it.

Normalization accepts what people actually write. A leading slash is optional, a trailing one is ignored, and "/" or whitespace means no prefix — so "internal/v1", "/internal/v1" and "/internal/v1/" are equivalent. Without it "/internal/v1/" would emit v1//things. There's also an edge case where a URI of just / combined with a prefix must not leave a trailing slash.

Default is "", which is a no-op: the committed typecheck/src/generated.ts sample is byte-identical.

Surfaces

All four the existing excludeServices setting covers: the smithy-build plugin, the CLI, the sbt plugin (tsCodegenPathPrefix), and the README.

Two wiring details worth a look in review:

  • The CLI takes its args positionally, so a prefix is passed as a 4th arg after excludeServices; the sbt plugin sends an empty string for excludes when only a prefix is set. The existing parse already ignored a 4th arg, so nothing changes for current callers.
  • tsCodegenPathPrefix is folded into the sbt plugin's cache key — otherwise changing only the prefix wouldn't re-run codegen.

Release note: base version moved to 0.5

generate gains a parameter. The default value keeps it source-compatible, but a defaulted parameter still changes the JVM signature, so the two-arg overload disappears — and MiMa caught it against the published 0.4.0:

* static method generate(Model,Set)String in class TsCodegenPlugin
  does not have a correspondent in current version

So the second commit bumps tlBaseVersion to 0.5. I went with the bump rather than a ProblemFilters.exclude: generate is the documented programmatic entry point, so the break is genuinely user-visible, unlike the TsWriter filter that was dropped when 0.4 opened (a class no caller could reach). Happy to switch to a filter if you would rather keep the next release a patch, but I do not think this one qualifies.

Worth flagging that this needs a v0.5.0 tag when it ships — sbt-typelevel checks the release tag against tlBaseVersion.

Verification

  • 47 core tests pass (38 pre-existing + 9 new, covering both consumers and the normalization edge cases).
  • The scripted sbt test asserts the prefixed URL and the prefixed mock segments, which exercises the real sbt-plugin → forked-CLI → SPI path including the positional-arg handoff.
  • scalafmtCheckAll, scalafmtSbtCheck, headerCheckAll, doc all green, and mimaReportBinaryIssues green with the 0.5 base version (mimaPreviousArtifacts is empty against an unpublished baseline, which is the point of the bump).
  • Sample regenerated and confirmed unchanged via git status rather than tsCodegenSampleCheck — see tsCodegenSampleCheck can never fail: it regenerates the file it is checking #22 for why that check can't be trusted for this.

The generated client uses each operation's `@http` URI verbatim. That is right
when the model describes the whole path, but a service is often mounted under a
prefix the model does not mention — a server framework deriving one from a trait,
or a reverse proxy — and then every generated request misses it.

`pathPrefix` is prepended to each operation's URI. It reaches the Storybook mocks
too, split into literal segments, since the mock router matches segment by
segment and would otherwise stop matching the client it is standing in for.

Normalization accepts what people will actually write: a leading slash is
optional, a trailing one is ignored, and `"/"` or whitespace means no prefix.
Without that, `"/internal/v1/"` would emit `v1//things`.

Deliberately a plain string rather than anything trait-derived: which prefix a
deployment sits behind is not a property of the model, and a trait-aware version
would have to encode one framework's conventions.

Note for release: `generate` gains a parameter. The default value keeps it
source-compatible, but it is a binary break, so this wants a minor bump rather
than a patch. MiMa cannot see it — the 0.4 baseline has no published artifacts
yet.
`generate` gained a `pathPrefix` parameter in the previous commit. The default
value keeps it source-compatible, but a defaulted parameter still changes the
JVM signature, so the two-argument overload disappears and MiMa reports it
against the `0.4.0` baseline:

    * static method generate(Model,Set)String in class TsCodegenPlugin
      does not have a correspondent in current version

That is a real binary break, not a false positive, so the next release is a
minor one. sbt-typelevel checks the release tag against `tlBaseVersion`, so
this has to move before `v0.5.0` can be tagged.

A filter would have been the wrong tool here. `generate` is the documented
programmatic entry point, so the break is genuinely user-visible — unlike the
`TsWriter` filter dropped when 0.4 opened, which excused a change to a class no
caller could reach.
@kubukoz

kubukoz commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

Superseded by #25. A single static setting can't express this: one codegen run can emit several services that need not share a prefix. #25 replaces it with a transformPath extension point that sees each service and operation.

@kubukoz kubukoz closed this Sep 2, 2026
@kubukoz
kubukoz deleted the path-prefix branch September 2, 2026 17:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant