Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,11 @@ jobs:

- name: Make target directories
if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
run: mkdir -p sbt-plugin/target cli/target core/target traits/target project/target
run: mkdir -p sbt-plugin/target api/target cli/target core/target traits/target project/target

- name: Compress target directories
if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
run: tar cf targets.tar sbt-plugin/target cli/target core/target traits/target project/target
run: tar cf targets.tar sbt-plugin/target api/target cli/target core/target traits/target project/target

- name: Upload target directories
if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
Expand Down
8 changes: 8 additions & 0 deletions .mergify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ pull_request_rules:
- status-success=Test (ubuntu-22.04, 3, temurin@11, rootJVM)
actions:
merge: {}
- name: Label api PRs
conditions:
- files~=^api/
actions:
label:
add:
- api
remove: []
- name: Label cli PRs
conditions:
- files~=^cli/
Expand Down
68 changes: 66 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ version affects the codegen. Settings:
- `tsCodegenSmithyDirs` — dirs scanned for `*.smithy` / `*.json` (default `src/main/smithy`).
- `tsCodegenOutputFile` — where to write the TypeScript (default `target/generated.ts`).
- `tsCodegenExcludeServices` — service shape ids to skip (see above).
- `tsCodegenExtensions` — artifacts to put on the forked codegen's classpath, for
[extension](#extensions) implementations (default none). `%%` resolves against the codegen's
Scala version, not your project's.
- `tsCodegenVersion` — override the codegen version to resolve (defaults to the plugin's own).

### Programmatically (`TsCodegenPlugin.generate`)
Expand All @@ -103,6 +106,66 @@ Main <smithyDirs (path-separator-joined)> <outFile> [<excludeServices (comma-joi

This is what the sbt plugin forks; the smithy-build plugin is discovered via the SPI.

## Extensions

Some codegen decisions depend on conventions the model does not describe. The most common is a
service mounted under a path prefix that appears nowhere in the model — a server framework that
derives one from a trait, or a reverse proxy — after which every generated request misses the
prefix and 404s.

That prefix cannot be a plain setting: one codegen run can emit several services, and they need
not share one. And it should not be a trait the generator knows about, because that bakes one
organization's conventions into it. So it is an interface you implement instead. Depend on
`smithy-ts-codegen-api` (just `smithy-model`, not the generator):

```scala
libraryDependencies += "org.polyvariant" %% "smithy-ts-codegen-api" % "<version>"
```

```scala
import org.polyvariant.smithy.ts.api.{PathSegment, TsCodegenExtension}
import software.amazon.smithy.model.shapes.{OperationShape, ServiceShape}

class InternalPrefix extends TsCodegenExtension {
override def transformPath(
service: ServiceShape,
operation: OperationShape,
path: List[PathSegment],
): List[PathSegment] =
if (service.hasTrait(classOf[ApiInternalTrait]))
PathSegment.Literal("internal") :: PathSegment.Literal(service.getVersion) :: path
else
path
}
```

Extensions are discovered with `java.util.ServiceLoader`, so list the implementation in
`META-INF/services/org.polyvariant.smithy.ts.api.TsCodegenExtension` and put its artifact on the
codegen's classpath. From sbt that is `tsCodegenExtensions`:

```scala
tsCodegenExtensions := Seq("myorg" %% "my-extension" % "1.0.0")
```

which resolves the artifact with coursier and adds it to the forked codegen's classpath. Under
smithy-build, add it to the same classpath as the plugin itself.

Notes:

- **`transformPath` replaces the whole path**, so an extension can prepend, reorder or drop
segments — not only prefix. Returning `Nil` means `/`.
- **A path is segments, not a string.** The client interpolates a `Label` as
`${encodeURIComponent(…)}` while the Storybook mock router matches it as a wildcard, so the two
have to stay distinguishable. A `Literal` may not contain `/` — return several segments.
- **Both consumers resolve through the same call**, so the generated client and the generated
mocks cannot drift apart on a route.
- **A `Label` must name a member bound with `@httpLabel`.** Inventing one fails codegen with an
error naming it, rather than emitting a client that references a field that isn't there.
- **Every method has a no-op default**, so an implementation overrides only what it needs and
keeps compiling as methods are added.
- Several extensions apply in an unspecified order, each seeing the previous one's result. Put
competing rules in one extension rather than relying on classpath order.

## Streaming

Operations of an [`org.polyvariant.ndjson#ndjsonRestJson`](https://github.com/polyvariant/smithy4s-ndjson)
Expand Down Expand Up @@ -395,8 +458,9 @@ rights on the `@polyvariant` scope.

`smithy-build`, `smithy-codegen-core`, `smithy-model`, `alloy-core`, `smithy4s-protocol`,
`smithy4s-ndjson-protocol` (the trait definition only — nothing Scala-specific from
smithy4s-ndjson is needed, since the codegen keys off `@streaming` members), and
`smithy-ts-codegen-traits` (this project's own codegen-controlling traits).
smithy4s-ndjson is needed, since the codegen keys off `@streaming` members), `smithy-ts-codegen-traits` (this project's own codegen-controlling traits), and
`smithy-ts-codegen-api` (the extension interface — see [Extensions](#extensions); it depends on
`smithy-model` alone, so an implementor does not take the generator).

## License

Expand Down
46 changes: 46 additions & 0 deletions api/src/main/scala/org/polyvariant/smithy/ts/api/PathSegment.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* Copyright 2026 Polyvariant
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.polyvariant.smithy.ts.api

/** One segment of an operation's URI path.
*
* This is the structured form of an `@http` URI: `/things/{id}/tags` is
* `Literal("things") :: Label("id") :: Literal("tags") :: Nil`. Extensions see and return this
* rather than a rendered string, because the two consumers of a path need to tell the two apart —
* the client interpolates a label as `${encodeURIComponent(...)}`, while the Storybook mock router
* matches it as a wildcard. A `String => String` hook would force both to re-parse the result, and
* would let an extension hand back something that no longer parses at all.
*
* Neither case carries a leading or trailing slash; the generator joins them.
*/
sealed trait PathSegment extends Product with Serializable

object PathSegment {

/** A fixed segment, matched verbatim. The value must not contain `/` — return several segments
* rather than one containing a slash, or the mock router will never match it.
*/
final case class Literal(value: String) extends PathSegment

/** A capture, bound to the input member of the same name via `@httpLabel`.
*
* An extension may reorder or drop a label, but must not invent one: the name has to resolve to
* a member actually bound with `@httpLabel`, or codegen fails with an error naming it.
*/
final case class Label(name: String) extends PathSegment

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* Copyright 2026 Polyvariant
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.polyvariant.smithy.ts.api

import software.amazon.smithy.model.shapes.OperationShape
import software.amazon.smithy.model.shapes.ServiceShape

/** A hook into codegen decisions that depend on conventions the Smithy model does not itself
* describe.
*
* The motivating case is a service mounted under a path prefix that is nowhere in the model: a
* server framework that derives one from a trait, or a reverse proxy. The prefix cannot be plain
* configuration, because one codegen run can emit several services and they need not share a
* prefix — and it should not be a trait the generator knows, because that would mean baking one
* organization's conventions into it. An extension inspects whatever the model does carry (traits,
* the service `version`, the shape id) and decides.
*
* Implementations are discovered with `java.util.ServiceLoader`, so an implementation must be
* public, have a no-argument constructor, and be listed in
* `META-INF/services/org.polyvariant.smithy.ts.api.TsCodegenExtension` on the codegen's classpath:
*
* {{{
* class InternalPrefix extends TsCodegenExtension {
* override def transformPath(
* service: ServiceShape,
* operation: OperationShape,
* path: List[PathSegment],
* ): List[PathSegment] =
* if (service.hasTrait(classOf[ApiInternalTrait]))
* PathSegment.Literal("internal") ::
* PathSegment.Literal(service.getVersion) ::
* path
* else
* path
* }
* }}}
*
* Every method has a default that changes nothing, so an implementation overrides only what it
* cares about and stays source-compatible as methods are added.
*
* When several extensions are present they are applied in an unspecified order, each receiving the
* previous one's result. Two extensions that both rewrite the same thing will therefore compose in
* a way that depends on classpath order — put competing rules in one extension instead.
*/
trait TsCodegenExtension {

/** Rewrite an operation's URI path.
*
* `path` is the operation's `@http` URI in structured form, and the return value replaces it, in
* both the generated client and the generated Storybook mocks — so the two cannot drift apart.
* The default returns it unchanged.
*
* Returning `Nil` means the root path (`/`).
*
* Only the path is rewritable here. Query parameters, headers and the method come from the
* `@http` trait and the operation's members, which the model does describe.
*/
// The default ignores everything but `path`; the names are still part of the
// documented signature an implementor overrides, so they stay.
@annotation.nowarn("msg=unused explicit parameter")
def transformPath(
service: ServiceShape,
operation: OperationShape,
path: List[PathSegment],
): List[PathSegment] = path

}
31 changes: 26 additions & 5 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
ThisBuild / tlBaseVersion := "0.4"
ThisBuild / tlBaseVersion := "0.5"
ThisBuild / organization := "org.polyvariant"
ThisBuild / organizationName := "Polyvariant"
ThisBuild / startYear := Some(2026)
Expand Down Expand Up @@ -85,13 +85,28 @@ lazy val traits = project
tlMimaPreviousVersions := Set.empty,
)

// The extension SPI: the interface a third party implements to steer codegen
// decisions that the model itself does not describe (currently URI paths).
// Kept separate from [[core]] for the same reason as [[traits]] — an
// implementor needs only `smithy-model` to inspect shapes, not the generator
// and its smithy-build/alloy/codegen-core dependencies.
lazy val api = project
.in(file("api"))
.settings(
name := "smithy-ts-codegen-api",
commonSettings,
libraryDependencies ++= Seq(
"software.amazon.smithy" % "smithy-model" % smithyVersion
),
)

// A standalone smithy-build plugin that emits a single `generated.ts` of zod
// schemas + TypeScript types + typed HTTP clients (and Storybook mock stubs)
// for a `simpleRestJson` smithy model. JVM-only: it is discovered by
// smithy-build via the SPI file under `META-INF/services`.
lazy val core = project
.in(file("core"))
.dependsOn(traits)
.dependsOn(traits, api)
.settings(
name := "smithy-ts-codegen",
commonSettings,
Expand Down Expand Up @@ -144,9 +159,15 @@ lazy val sbtPlugin = project
scriptedBufferLog := false,
// Scripted resolves the CLI artifact at the plugin's version from the local
// Ivy repo, so publish it and everything it depends on there first — `core`,
// and `traits` for the trait definitions `core` resolves the model against.
// `traits` for the trait definitions `core` resolves the model against, and
// `api` for the extension the `extensions` test compiles against.
scripted := scripted
.dependsOn(cli / publishLocal, core / publishLocal, traits / publishLocal)
.dependsOn(
cli / publishLocal,
core / publishLocal,
traits / publishLocal,
api / publishLocal,
)
.evaluated,
)

Expand Down Expand Up @@ -195,4 +216,4 @@ tsCodegenSampleCheck := Def.taskDyn {
}
}.value

lazy val root = tlCrossRootProject.aggregate(traits, core, cli, sbtPlugin)
lazy val root = tlCrossRootProject.aggregate(traits, api, core, cli, sbtPlugin)
Loading
Loading