From 4782784fdcd663693f72fd9ab22e758b546191d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Wed, 2 Sep 2026 19:52:07 +0200 Subject: [PATCH 1/3] Add a codegen extension SPI, with a transformPath hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated client builds each URL from the 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 never mentions — a server framework that derives one from a trait, or a reverse proxy — and then every generated request misses it and 404s. A setting cannot express that: one codegen run can emit several services, and they need not share a prefix (`excludeServices` exists for exactly that multi-service case). Nor should it be a trait the generator knows, which would bake one organization's conventions in. So it is an interface to implement: class InternalPrefix extends TsCodegenExtension { override def transformPath(service, operation, path) = PathSegment.Literal("internal") :: PathSegment.Literal(service.getVersion) :: path } listed in META-INF/services and discovered with ServiceLoader. A path is a List[PathSegment], not a String. It has two consumers that must tell literals from labels — the client interpolates a label as ${encodeURIComponent(...)}, the Storybook mock router matches it as a wildcard. A String => String hook would make both re-parse the result and would let an extension return something that no longer parses. Both consumers now resolve through one PathResolver call, so they cannot disagree about a route. The hook replaces the whole path rather than prefixing it, so an extension can also reorder or drop segments; Nil means `/`. What comes back is validated: a label must name a member bound with @httpLabel, and a literal may not contain `/` (which would read as one segment to the client and never match the mock router). Both fail codegen naming the operation, rather than emitting a client that quietly cannot work. The interface lives in a new `smithy-ts-codegen-api` module, kept separate from `core` for the same reason as `traits`: an implementor needs `smithy-model` to inspect shapes, not the generator and its smithy-build/alloy/codegen-core dependencies. `tsCodegenExtensions` puts extension artifacts on the forked codegen's classpath, which is where ServiceLoader looks; without it the SPI would be unreachable from the recommended entry point. They resolve in the same coursier Fetch as the CLI, so a shared dependency lands once. `%%` resolves against the codegen's Scala version, not the enclosing project's — nothing on that classpath runs on the latter. With no extensions this changes nothing: the committed sample is byte-identical. A new scripted test publishes a real extension artifact and asserts the rewritten path in both the client and the mocks, exercising the full sbt -> forked CLI -> ServiceLoader path. --- README.md | 68 +++++- .../smithy/ts/api/PathSegment.scala | 46 ++++ .../smithy/ts/api/TsCodegenExtension.scala | 81 +++++++ build.sbt | 29 ++- .../smithy/ts/TsCodegenPlugin.scala | 171 ++++++++++---- .../smithy/ts/TsCodegenPluginTest.scala | 221 +++++++++++++++++- .../smithy/ts/sbt/SmithyTsCodegenPlugin.scala | 57 ++++- .../app/src/main/smithy/test.smithy | 25 ++ .../smithy-ts-codegen/extensions/build.sbt | 50 ++++ ...lyvariant.smithy.ts.api.TsCodegenExtension | 1 + .../ext/src/main/scala/MyExtension.scala | 22 ++ .../extensions/project/plugins.sbt | 1 + .../smithy-ts-codegen/extensions/test | 5 + 13 files changed, 706 insertions(+), 71 deletions(-) create mode 100644 api/src/main/scala/org/polyvariant/smithy/ts/api/PathSegment.scala create mode 100644 api/src/main/scala/org/polyvariant/smithy/ts/api/TsCodegenExtension.scala create mode 100644 sbt-plugin/src/sbt-test/smithy-ts-codegen/extensions/app/src/main/smithy/test.smithy create mode 100644 sbt-plugin/src/sbt-test/smithy-ts-codegen/extensions/build.sbt create mode 100644 sbt-plugin/src/sbt-test/smithy-ts-codegen/extensions/ext/src/main/resources/META-INF/services/org.polyvariant.smithy.ts.api.TsCodegenExtension create mode 100644 sbt-plugin/src/sbt-test/smithy-ts-codegen/extensions/ext/src/main/scala/MyExtension.scala create mode 100644 sbt-plugin/src/sbt-test/smithy-ts-codegen/extensions/project/plugins.sbt create mode 100644 sbt-plugin/src/sbt-test/smithy-ts-codegen/extensions/test diff --git a/README.md b/README.md index 0ed6ba0..993a799 100644 --- a/README.md +++ b/README.md @@ -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`) @@ -103,6 +106,66 @@ Main [" +``` + +```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) @@ -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 diff --git a/api/src/main/scala/org/polyvariant/smithy/ts/api/PathSegment.scala b/api/src/main/scala/org/polyvariant/smithy/ts/api/PathSegment.scala new file mode 100644 index 0000000..a47b31e --- /dev/null +++ b/api/src/main/scala/org/polyvariant/smithy/ts/api/PathSegment.scala @@ -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 + +} diff --git a/api/src/main/scala/org/polyvariant/smithy/ts/api/TsCodegenExtension.scala b/api/src/main/scala/org/polyvariant/smithy/ts/api/TsCodegenExtension.scala new file mode 100644 index 0000000..37973e2 --- /dev/null +++ b/api/src/main/scala/org/polyvariant/smithy/ts/api/TsCodegenExtension.scala @@ -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 + +} diff --git a/build.sbt b/build.sbt index 958f9b1..ddad392 100644 --- a/build.sbt +++ b/build.sbt @@ -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, @@ -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, ) @@ -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) diff --git a/core/src/main/scala/org/polyvariant/smithy/ts/TsCodegenPlugin.scala b/core/src/main/scala/org/polyvariant/smithy/ts/TsCodegenPlugin.scala index 16cadf6..30e1ccf 100644 --- a/core/src/main/scala/org/polyvariant/smithy/ts/TsCodegenPlugin.scala +++ b/core/src/main/scala/org/polyvariant/smithy/ts/TsCodegenPlugin.scala @@ -20,6 +20,8 @@ import alloy.DiscriminatedUnionTrait import alloy.JsonUnknownTrait import alloy.NullableTrait import alloy.OpenEnumTrait +import org.polyvariant.smithy.ts.api.PathSegment +import org.polyvariant.smithy.ts.api.TsCodegenExtension import software.amazon.smithy.build.PluginContext import software.amazon.smithy.build.SmithyBuildPlugin import software.amazon.smithy.codegen.core.ImportContainer @@ -251,7 +253,24 @@ object TsCodegenPlugin { } - def generate(model: Model, excludeServices: Set[String]): String = { + /** Loads the `TsCodegenExtension` implementations visible on the current classpath via + * `ServiceLoader`. This is what [[generate]] uses when not given an explicit list. + */ + def loadExtensions(): List[TsCodegenExtension] = + java + .util + .ServiceLoader + .load(classOf[TsCodegenExtension]) + .iterator + .asScala + .toList + + def generate( + model: Model, + excludeServices: Set[String], + extensions: List[TsCodegenExtension] = loadExtensions(), + ): String = { + val paths = new PathResolver(extensions) // `model.shapes` iterates a hash map keyed by ShapeId, so its order varies // between environments (JVM, classpath, filesystem). Sort by shape id to give // the topological sort below a deterministic starting order — otherwise the @@ -299,12 +318,12 @@ object TsCodegenPlugin { w.line("") w.line("// --- Service clients ---") w.line("") - servicesToEmit.foreach(svc => writeClient(w, model, svc)) + servicesToEmit.foreach(svc => writeClient(w, model, svc, paths)) w.line("") w.line("// --- Storybook mock server ---") w.line("") writeMockRuntime(w, servicesToEmit.exists(hasStreamingOperation(w, model, _))) - servicesToEmit.foreach(svc => writeMockService(w, model, svc)) + servicesToEmit.foreach(svc => writeMockService(w, model, svc, paths)) } w.toString @@ -1048,7 +1067,12 @@ object TsCodegenPlugin { // Service clients // -------------------------------------------------------------------------- - private def writeClient(w: TsWriter, model: Model, service: ServiceShape): Unit = { + private def writeClient( + w: TsWriter, + model: Model, + service: ServiceShape, + paths: PathResolver, + ): Unit = { val svcName = service.getId.getName val ops = serviceOperations(model, service) // Each half of the transport is taken only when some operation actually @@ -1080,13 +1104,18 @@ object TsCodegenPlugin { if (streaming) w.line("this.streamTransport = streamTransport") } - ops.foreach(op => writeOperation(w, model, op, service)) + ops.foreach(op => writeOperation(w, model, op, service, paths)) } w.line("") } - private def writeOperation(w: TsWriter, model: Model, op: OperationShape, service: ServiceShape) - : Unit = { + private def writeOperation( + w: TsWriter, + model: Model, + op: OperationShape, + service: ServiceShape, + paths: PathResolver, + ): Unit = { val svcName = service.getId.getName val opName = op.getId.getName val methodName = lowerFirst(opName) @@ -1159,7 +1188,10 @@ object TsCodegenPlugin { // urlExpression returns a TS template literal containing `${...}`; route // through the $L (literal) formatter so smithy's `$`-parser doesn't try // to interpret it. - w.line("const url = $L", urlExpression(http.getUri, labelMembers.map(_._1).toSet)) + w.line( + "const url = $L", + urlExpression(paths.resolve(service, op, http.getUri, labelMembers.map(_._1).toSet)), + ) if (queryMembers.nonEmpty) { w.line("const query: Record = {}") @@ -1264,33 +1296,71 @@ object TsCodegenPlugin { } } - private def urlExpression( - uri: software.amazon.smithy.model.pattern.UriPattern, - labelNames: Set[String], - ): String = { - val segments = uri.getSegments.asScala - val rendered = - segments - .iterator + /** Resolves an operation's final URI path: parses the `@http` URI into [[PathSegment]]s, runs the + * extensions over it, and validates what they hand back. + * + * Both the client and the Storybook mocks resolve through this one place, so an extension cannot + * make them disagree about a route. + */ + private[ts] final class PathResolver(extensions: List[TsCodegenExtension]) { + + /** The `@http` URI as segments, before any extension sees it. */ + private def parse(uri: software.amazon.smithy.model.pattern.UriPattern): List[PathSegment] = + uri + .getSegments + .asScala + .toList .map { seg => if (seg.isLiteral) - "/" + seg.getContent - else { - val name = seg.getContent - if (!labelNames.contains(name)) - sys.error( - s"URI references label {$name} but no input member is bound with @httpLabel" - ) - s"/$${encodeURIComponent(String(input.$name))}" - } + PathSegment.Literal(seg.getContent) + else + PathSegment.Label(seg.getContent) } - .mkString - val final_ = + + /** Applies every extension in turn, then checks the result is renderable: each label must be + * bound with `@httpLabel`, and a literal must not smuggle in a `/` (which would read as one + * segment to the client but never match the mock router's segment-by-segment comparison). + */ + def resolve( + service: ServiceShape, + operation: OperationShape, + uri: software.amazon.smithy.model.pattern.UriPattern, + labelNames: Set[String], + ): List[PathSegment] = { + val out = + extensions.foldLeft(parse(uri))((path, ext) => ext.transformPath(service, operation, path)) + out.foreach { + case PathSegment.Label(name) => + if (!labelNames.contains(name)) + sys.error( + s"URI references label {$name} but no input member is bound with @httpLabel" + ) + case PathSegment.Literal(value) => + if (value.contains("/")) + sys.error( + s"path segment '$value' for ${operation.getId} contains '/': " + + "return several Literal segments instead of one with a slash in it" + ) + } + out + } + + } + + /** The client's URL as a TS template literal, e.g. `` `/things/${encodeURIComponent(...)}` ``. */ + private def urlExpression(path: List[PathSegment]): String = { + val rendered = + path.map { + case PathSegment.Literal(value) => "/" + value + case PathSegment.Label(name) => s"/$${encodeURIComponent(String(input.$name))}" + }.mkString + // An empty path — an extension returned Nil, or the URI was just `/` — is the root. + val body = if (rendered.isEmpty) "/" else rendered - "`" + final_ + "`" + "`" + body + "`" } /** Member names of `shape` that are bound to a non-body HTTP location (`@httpHeader`, @@ -1643,7 +1713,12 @@ object TsCodegenPlugin { w.line("") } - private def writeMockService(w: TsWriter, model: Model, service: ServiceShape): Unit = { + private def writeMockService( + w: TsWriter, + model: Model, + service: ServiceShape, + paths: PathResolver, + ): Unit = { val svcName = service.getId.getName val ops = serviceOperations(model, service) @@ -1679,13 +1754,19 @@ object TsCodegenPlugin { w.block(s"export const ${svcName}Mock: MockServiceDescriptor<${svcName}Handlers> = {", "}") { w.line(s"serviceName: ${jsString(svcName)},") w.block("operations: [", "],") { - ops.foreach(op => writeMockOperation(w, model, op)) + ops.foreach(op => writeMockOperation(w, model, op, service, paths)) } } w.line("") } - private def writeMockOperation(w: TsWriter, model: Model, op: OperationShape): Unit = { + private def writeMockOperation( + w: TsWriter, + model: Model, + op: OperationShape, + service: ServiceShape, + paths: PathResolver, + ): Unit = { val opName = op.getId.getName val methodName = lowerFirst(opName) val http = op @@ -1716,7 +1797,10 @@ object TsCodegenPlugin { w.block("{", "},") { w.line(s"key: ${jsString(methodName)},") w.line(s"method: ${jsString(http.getMethod)},") - w.line("segments: $L,", segmentsArrayExpr(http.getUri, labelMembers.map(_._1).toSet)) + w.line( + "segments: $L,", + segmentsArrayExpr(paths.resolve(service, op, http.getUri, labelMembers.map(_._1).toSet)), + ) writeMockDecodeInput( w, model, @@ -1913,24 +1997,11 @@ object TsCodegenPlugin { /** Renders the operation's URI as a `MockUriSegment[]` array literal: literals become * `{ literal: '...' }`, `@httpLabel` captures `{ label: '...' }`. */ - private def segmentsArrayExpr( - uri: software.amazon.smithy.model.pattern.UriPattern, - labelNames: Set[String], - ): String = { - val segs = uri - .getSegments - .asScala - .toList - .map { seg => - if (seg.isLiteral) - s"{ literal: ${jsString(seg.getContent)} }" - else { - val name = seg.getContent - if (!labelNames.contains(name)) - sys.error(s"URI references label {$name} but no input member is bound with @httpLabel") - s"{ label: ${jsString(name)} }" - } - } + private def segmentsArrayExpr(path: List[PathSegment]): String = { + val segs = path.map { + case PathSegment.Literal(value) => s"{ literal: ${jsString(value)} }" + case PathSegment.Label(name) => s"{ label: ${jsString(name)} }" + } "[" + segs.mkString(", ") + "]" } diff --git a/core/src/test/scala/org/polyvariant/smithy/ts/TsCodegenPluginTest.scala b/core/src/test/scala/org/polyvariant/smithy/ts/TsCodegenPluginTest.scala index 14937da..ee7ff80 100644 --- a/core/src/test/scala/org/polyvariant/smithy/ts/TsCodegenPluginTest.scala +++ b/core/src/test/scala/org/polyvariant/smithy/ts/TsCodegenPluginTest.scala @@ -16,7 +16,11 @@ package org.polyvariant.smithy.ts +import org.polyvariant.smithy.ts.api.PathSegment +import org.polyvariant.smithy.ts.api.TsCodegenExtension import software.amazon.smithy.model.Model +import software.amazon.smithy.model.shapes.OperationShape +import software.amazon.smithy.model.shapes.ServiceShape import scala.jdk.CollectionConverters.* @@ -30,8 +34,12 @@ class TsCodegenPluginTest extends munit.FunSuite { .assemble() .unwrap() - private def generate(smithy: String, exclude: Set[String] = Set.empty): String = - TsCodegenPlugin.generate(model(smithy), exclude) + private def generate( + smithy: String, + exclude: Set[String] = Set.empty, + extensions: List[TsCodegenExtension] = Nil, + ): String = + TsCodegenPlugin.generate(model(smithy), exclude, extensions) /** Several model files at once — the only way to put two namespaces in one model, since a * `.smithy` file declares exactly one. @@ -41,7 +49,7 @@ class TsCodegenPluginTest extends munit.FunSuite { sources.zipWithIndex.foreach { case (src, i) => val _ = assembler.addUnparsedModel(s"test-$i.smithy", src) } - TsCodegenPlugin.generate(assembler.assemble().unwrap(), Set.empty) + TsCodegenPlugin.generate(assembler.assemble().unwrap(), Set.empty, Nil) } /** A service streaming a union out, over the ndjson protocol. */ @@ -1098,4 +1106,211 @@ class TsCodegenPluginTest extends munit.FunSuite { assert(out.contains("brand<'com_example_b_ProfileId'>()")) } + // --- transformPath --------------------------------------------------------- + + /** An extension built from a plain function, so a test can state just the rewrite. */ + private def pathExt( + f: (ServiceShape, OperationShape, List[PathSegment]) => List[PathSegment] + ): List[TsCodegenExtension] = + List( + new TsCodegenExtension { + override def transformPath( + service: ServiceShape, + operation: OperationShape, + path: List[PathSegment], + ): List[PathSegment] = f(service, operation, path) + } + ) + + /** Prepends literal segments to every operation, ignoring which service it belongs to. */ + private def prefixExt(segments: String*): List[TsCodegenExtension] = pathExt((_, _, path) => + segments.map(PathSegment.Literal(_)).toList ++ path + ) + + private val prefixModel = + """|$version: "2" + |namespace com.example + | + |service Svc { + | version: "v1" + | operations: [GetThing] + |} + | + |@http(method: "GET", uri: "/things/{id}") + |operation GetThing { + | input := { + | @required + | @httpLabel + | id: String + | } + | output := { + | name: String + | } + |} + |""".stripMargin + + private val twoServiceModel = + """|$version: "2" + |namespace com.example + | + |service Public { + | version: "v1" + | operations: [GetThing] + |} + | + |service Internal { + | version: "v2" + | operations: [GetOther] + |} + | + |@http(method: "GET", uri: "/things/{id}") + |operation GetThing { + | input := { + | @required + | @httpLabel + | id: String + | } + | output := { + | name: String + | } + |} + | + |@http(method: "GET", uri: "/others") + |operation GetOther { + | output := { + | name: String + | } + |} + |""".stripMargin + + test("no extensions leave the @http uri untouched") { + val out = generate(prefixModel) + assert(clue(out).contains("const url = `/things/${encodeURIComponent(String(input.id))}`")) + } + + test("an extension can prepend literal segments to the uri") { + val out = generate(prefixModel, extensions = prefixExt("internal", "v1")) + assert( + clue(out).contains( + "const url = `/internal/v1/things/${encodeURIComponent(String(input.id))}`" + ) + ) + } + + test("a rewritten path reaches the mock router as segments, so mocks still match") { + val out = generate(prefixModel, extensions = prefixExt("internal", "v1")) + assert( + clue(out).contains( + "segments: [{ literal: 'internal' }, { literal: 'v1' }, { literal: 'things' }, { label: 'id' }]," + ) + ) + } + + test("an extension sees the service, so two services in one run can differ") { + // The reason this is an extension and not a setting: one codegen run emits + // both services, and they are mounted differently. + val out = generate( + twoServiceModel, + extensions = pathExt((service, _, path) => + if (service.getId.getName == "Internal") + PathSegment.Literal("internal") :: PathSegment.Literal(service.getVersion) :: path + else + path + ), + ) + assert(clue(out).contains("const url = `/things/${encodeURIComponent(String(input.id))}`")) + assert(clue(out).contains("const url = `/internal/v2/others`")) + } + + test("an extension sees the operation") { + val out = generate( + prefixModel, + extensions = pathExt((_, operation, path) => + PathSegment.Literal(operation.getId.getName) :: path + ), + ) + assert(clue(out).contains("const url = `/GetThing/things/")) + } + + test("an extension can rewrite an operation whose uri is just /") { + val out = generate( + """|$version: "2" + |namespace com.example + | + |service Svc { + | version: "v1" + | operations: [Ping] + |} + | + |@http(method: "GET", uri: "/") + |operation Ping { + | output := { + | ok: Boolean + | } + |} + |""".stripMargin, + extensions = prefixExt("internal"), + ) + assert(clue(out).contains("const url = `/internal`")) + } + + test("an extension returning Nil means the root path") { + val out = generate( + """|$version: "2" + |namespace com.example + | + |service Svc { + | version: "v1" + | operations: [Ping] + |} + | + |@http(method: "GET", uri: "/ping") + |operation Ping { + | output := { + | ok: Boolean + | } + |} + |""".stripMargin, + extensions = pathExt((_, _, _) => Nil), + ) + assert(clue(out).contains("const url = `/`")) + assert(clue(out).contains("segments: [],")) + } + + test("an extension can reorder and drop segments") { + val out = generate( + prefixModel, + extensions = pathExt((_, _, path) => path.reverse), + ) + assert(clue(out).contains("const url = `/${encodeURIComponent(String(input.id))}/things`")) + assert(clue(out).contains("segments: [{ label: 'id' }, { literal: 'things' }],")) + } + + test("extensions compose, each seeing the previous one's result") { + val out = generate( + prefixModel, + extensions = prefixExt("v1") ++ prefixExt("internal"), + ) + assert(clue(out).contains("const url = `/internal/v1/things/")) + } + + test("a label an extension invents is rejected, naming it") { + val e = intercept[Exception]( + generate( + prefixModel, + extensions = pathExt((_, _, path) => PathSegment.Label("nope") :: path), + ) + ) + assert(clue(e.getMessage).contains("nope")) + assert(clue(e.getMessage).contains("@httpLabel")) + } + + test("a literal containing a slash is rejected — the mock router would never match it") { + val e = intercept[Exception]( + generate(prefixModel, extensions = prefixExt("internal/v1")) + ) + assert(clue(e.getMessage).contains("internal/v1")) + assert(clue(e.getMessage).contains("GetThing")) + } + } diff --git a/sbt-plugin/src/main/scala/org/polyvariant/smithy/ts/sbt/SmithyTsCodegenPlugin.scala b/sbt-plugin/src/main/scala/org/polyvariant/smithy/ts/sbt/SmithyTsCodegenPlugin.scala index f5d0c71..d5bd5b3 100644 --- a/sbt-plugin/src/main/scala/org/polyvariant/smithy/ts/sbt/SmithyTsCodegenPlugin.scala +++ b/sbt-plugin/src/main/scala/org/polyvariant/smithy/ts/sbt/SmithyTsCodegenPlugin.scala @@ -46,6 +46,9 @@ import scala.collection.JavaConverters._ * }}} * * then run `tsCodegen`. + * + * `tsCodegenExtensions` puts extra artifacts on the forked classpath, which is how a + * `TsCodegenExtension` reaches the codegen — `ServiceLoader` finds it there. */ object SmithyTsCodegenPlugin extends AutoPlugin { @@ -70,6 +73,12 @@ object SmithyTsCodegenPlugin extends AutoPlugin { val tsCodegenVersion = settingKey[String]("Version of the smithy-ts-codegen-cli artifact to resolve and run") + + val tsCodegenExtensions = settingKey[Seq[ModuleID]]( + "Artifacts to add to the forked codegen's classpath, for TsCodegenExtension " + + "implementations discovered via ServiceLoader" + ) + } import autoImport._ @@ -78,6 +87,7 @@ object SmithyTsCodegenPlugin extends AutoPlugin { Seq( tsCodegenVersion := BuildInfo.smithyTsCodegenVersion, tsCodegenExcludeServices := Seq.empty, + tsCodegenExtensions := Seq.empty, tsCodegenSmithyDirs := Seq((Compile / sourceDirectory).value / "smithy"), tsCodegenOutputFile := (Compile / target).value / "generated.ts", tsCodegen := tsCodegenTask.value, @@ -85,18 +95,23 @@ object SmithyTsCodegenPlugin extends AutoPlugin { private val MainClass = "org.polyvariant.smithy.ts.cli.Main" - /** Resolve the CLI artifact + its transitive deps via coursier. `Dependency.of(org, name, - * version)` takes the coordinates verbatim (the artifact already carries its Scala 3 suffix), - * and `Fetch` runs its own resolution independent of any enclosing sbt/project state — so - * nothing rewrites the Scala library version onto the forked classpath. + /** Resolve the CLI artifact + its transitive deps via coursier, plus any extension artifacts. + * `Dependency.of(org, name, version)` takes the coordinates verbatim (the artifact already + * carries its Scala 3 suffix), and `Fetch` runs its own resolution independent of any enclosing + * sbt/project state — so nothing rewrites the Scala library version onto the forked classpath. + * + * Extensions are resolved in the same `Fetch` as the CLI rather than appended afterwards, so a + * dependency they share with the codegen (`smithy-model`, `smithy-ts-codegen-api`) is reconciled + * to one version instead of landing on the classpath twice. */ - private def resolveCliClasspath(version: String): Seq[File] = { - val dep = Dependency.of( + private def resolveCliClasspath(version: String, extensions: Seq[ModuleID]): Seq[File] = { + val cli = Dependency.of( BuildInfo.smithyTsCodegenOrganization, s"smithy-ts-codegen-cli_${BuildInfo.smithyTsCodegenScalaBinaryVersion}", version, ) - Fetch + val extensionDeps = extensions.map(coursierDependency) + val fetch = Fetch .create() .addRepositories( // Snapshot versions live on Central Snapshots; the local Ivy repo backs @@ -108,10 +123,26 @@ object SmithyTsCodegenPlugin extends AutoPlugin { "[revision]/[type]s/[artifact](-[classifier]).[ext]" ), ) - .addDependencies(dep) - .fetch() - .asScala - .toVector + .addDependencies(cli) + extensionDeps.foreach(d => fetch.addDependencies(d)) + fetch.fetch().asScala.toVector + } + + /** An sbt `ModuleID` as a coursier `Dependency`. + * + * An extension is a plain JVM artifact, so `%` (no suffix) and `%%` (the codegen's own Scala 3 + * suffix) are both meaningful — but the enclosing project's `scalaVersion` is not, since nothing + * here runs on it. So `%%` is resolved against the *codegen's* binary version, which is what an + * extension compiled against `smithy-ts-codegen-api` actually carries. + */ + private def coursierDependency(m: ModuleID): Dependency = { + val name = + m.crossVersion match { + case _: librarymanagement.Binary => + s"${m.name}_${BuildInfo.smithyTsCodegenScalaBinaryVersion}" + case _ => m.name + } + Dependency.of(m.organization, name, m.revision) } private val tsCodegenTask: Def.Initialize[Task[File]] = Def.task { @@ -122,7 +153,9 @@ object SmithyTsCodegenPlugin extends AutoPlugin { val version = tsCodegenVersion.value val cacheDir = streams.value.cacheDirectory / "smithy-ts-codegen" - val classpath = resolveCliClasspath(version) + // The extension jars land in the same classpath the fork runs with, which is + // where ServiceLoader looks for them. + val classpath = resolveCliClasspath(version, tsCodegenExtensions.value) val smithyInputs = smithyDirs diff --git a/sbt-plugin/src/sbt-test/smithy-ts-codegen/extensions/app/src/main/smithy/test.smithy b/sbt-plugin/src/sbt-test/smithy-ts-codegen/extensions/app/src/main/smithy/test.smithy new file mode 100644 index 0000000..8aa5f2c --- /dev/null +++ b/sbt-plugin/src/sbt-test/smithy-ts-codegen/extensions/app/src/main/smithy/test.smithy @@ -0,0 +1,25 @@ +$version: "2" + +namespace test + +use alloy#simpleRestJson + +@simpleRestJson +service Greeter { + version: "v1" + operations: [Greet] +} + +@http(method: "POST", uri: "/greet/{name}") +operation Greet { + input := { + @required + @httpLabel + name: String + greeting: String + } + output := { + @required + message: String + } +} diff --git a/sbt-plugin/src/sbt-test/smithy-ts-codegen/extensions/build.sbt b/sbt-plugin/src/sbt-test/smithy-ts-codegen/extensions/build.sbt new file mode 100644 index 0000000..9a5f20c --- /dev/null +++ b/sbt-plugin/src/sbt-test/smithy-ts-codegen/extensions/build.sbt @@ -0,0 +1,50 @@ +// A TsCodegenExtension, discovered by the forked codegen via ServiceLoader. +// +// The extension is a real published artifact rather than a classes dir, because +// that is how a user ships one and it is what `tsCodegenExtensions` resolves. + +val codegenVersion = sys.props("plugin.version") + +ThisBuild / organization := "myorg" +ThisBuild / version := codegenVersion + +lazy val ext = project + .in(file("ext")) + .settings( + name := "my-extension", + // Scala 3, to match the api artifact the codegen publishes. + scalaVersion := "3.3.8", + libraryDependencies += "org.polyvariant" %% "smithy-ts-codegen-api" % codegenVersion, + resolvers += Resolver.sonatypeCentralSnapshots, + ) + +lazy val app = project + .in(file("app")) + .enablePlugins(SmithyTsCodegenPlugin) + .settings( + scalaVersion := "2.12.21", + resolvers += Resolver.sonatypeCentralSnapshots, + tsCodegenSmithyDirs := Seq(baseDirectory.value / "src" / "main" / "smithy"), + tsCodegenOutputFile := baseDirectory.value / "target" / "generated.ts", + // `%%` is resolved against the codegen's Scala version, not this project's + // 2.12 — nothing on the forked classpath runs on 2.12. + tsCodegenExtensions := Seq("myorg" %% "my-extension" % codegenVersion), + TaskKey[Unit]("checkOutput") := { + val f = tsCodegenOutputFile.value + assert(f.exists, s"expected $f to exist") + val contents = IO.read(f) + def require(sub: String): Unit = + assert(contents.contains(sub), s"expected generated.ts to contain: $sub") + + // The extension ran in the forked JVM: the prefix it derived from the + // service's `version` is in the client URL... + require( + "const url = `/internal/v1/greet/${encodeURIComponent(String(input.name))}`" + ) + // ...and in the mock router, as separate literal segments, so a mocked + // route still matches the client that calls it. + require( + "segments: [{ literal: 'internal' }, { literal: 'v1' }, { literal: 'greet' }, { label: 'name' }]," + ) + }, + ) diff --git a/sbt-plugin/src/sbt-test/smithy-ts-codegen/extensions/ext/src/main/resources/META-INF/services/org.polyvariant.smithy.ts.api.TsCodegenExtension b/sbt-plugin/src/sbt-test/smithy-ts-codegen/extensions/ext/src/main/resources/META-INF/services/org.polyvariant.smithy.ts.api.TsCodegenExtension new file mode 100644 index 0000000..fcc7412 --- /dev/null +++ b/sbt-plugin/src/sbt-test/smithy-ts-codegen/extensions/ext/src/main/resources/META-INF/services/org.polyvariant.smithy.ts.api.TsCodegenExtension @@ -0,0 +1 @@ +myorg.MyExtension diff --git a/sbt-plugin/src/sbt-test/smithy-ts-codegen/extensions/ext/src/main/scala/MyExtension.scala b/sbt-plugin/src/sbt-test/smithy-ts-codegen/extensions/ext/src/main/scala/MyExtension.scala new file mode 100644 index 0000000..bd13b68 --- /dev/null +++ b/sbt-plugin/src/sbt-test/smithy-ts-codegen/extensions/ext/src/main/scala/MyExtension.scala @@ -0,0 +1,22 @@ +package myorg + +import org.polyvariant.smithy.ts.api.PathSegment +import org.polyvariant.smithy.ts.api.TsCodegenExtension +import software.amazon.smithy.model.shapes.OperationShape +import software.amazon.smithy.model.shapes.ServiceShape + +/** Mounts each service under `/internal/` — a prefix the model does not describe, + * derived from what it does. + */ +class MyExtension extends TsCodegenExtension { + + override def transformPath( + service: ServiceShape, + operation: OperationShape, + path: List[PathSegment], + ): List[PathSegment] = + PathSegment.Literal("internal") :: + PathSegment.Literal(service.getVersion) :: + path + +} diff --git a/sbt-plugin/src/sbt-test/smithy-ts-codegen/extensions/project/plugins.sbt b/sbt-plugin/src/sbt-test/smithy-ts-codegen/extensions/project/plugins.sbt new file mode 100644 index 0000000..924b272 --- /dev/null +++ b/sbt-plugin/src/sbt-test/smithy-ts-codegen/extensions/project/plugins.sbt @@ -0,0 +1 @@ +addSbtPlugin("org.polyvariant" % "sbt-smithy-ts-codegen" % sys.props("plugin.version")) diff --git a/sbt-plugin/src/sbt-test/smithy-ts-codegen/extensions/test b/sbt-plugin/src/sbt-test/smithy-ts-codegen/extensions/test new file mode 100644 index 0000000..2c4093f --- /dev/null +++ b/sbt-plugin/src/sbt-test/smithy-ts-codegen/extensions/test @@ -0,0 +1,5 @@ +# Publish the extension so the forked codegen can resolve it, then generate and +# assert the rewritten path reached both the client and the mocks. +> ext/publishLocal +> app/tsCodegen +> app/checkOutput From d7ee0bd1396c41aa4a9794308f87ed993e7b5665 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Wed, 2 Sep 2026 19:52:54 +0200 Subject: [PATCH 2/3] Bump the base version to 0.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `generate` gains an `extensions` parameter. The default keeps it source-compatible, but a defaulted parameter still changes the JVM signature, so the two-argument overload disappears and MiMa catches it against 0.4.0: * static method generate(Model,Set)String in class TsCodegenPlugin does not have a correspondent in current version A bump rather than a ProblemFilters.exclude: `generate` is the documented programmatic entry point, so the break is genuinely user-visible — unlike the TsWriter filter dropped when 0.4 opened, which covered a class no caller could reach. The reasoning there was that a fresh baseline starts clean and a lingering filter would hide a future break of the same shape; adding one here would do exactly that. Needs a v0.5.0 tag when it ships — sbt-typelevel checks the release tag against tlBaseVersion. --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index ddad392..363e014 100644 --- a/build.sbt +++ b/build.sbt @@ -1,4 +1,4 @@ -ThisBuild / tlBaseVersion := "0.4" +ThisBuild / tlBaseVersion := "0.5" ThisBuild / organization := "org.polyvariant" ThisBuild / organizationName := "Polyvariant" ThisBuild / startYear := Some(2026) From 81882b0ef8abf62654b4a9a4cb8864d7efb240ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Wed, 2 Sep 2026 20:00:25 +0200 Subject: [PATCH 3/3] Regenerate ci.yml and .mergify.yml for the new api project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both files are generated from the build definition, and adding `api` to the root aggregate changes both: `api/target` joins the target directories CI tars up and uploads, and sbt-typelevel-mergify derives a per-project label rule from the aggregate, so it wants a "Label api PRs" entry. Output of `githubWorkflowGenerate` and `mergifyGenerate`; no hand edits. `githubWorkflowCheck` and `mergifyCheck` are what the "Check that workflows are up to date" CI step runs, and they pass now — I had run `test` and `scripted` locally but not these, which is why CI caught it and I did not. --- .github/workflows/ci.yml | 4 ++-- .mergify.yml | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a493d82..d98089e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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')) diff --git a/.mergify.yml b/.mergify.yml index 19c9d50..75be2aa 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -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/