diff --git a/README.md b/README.md index 0ed6ba0..406f315 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,8 @@ Add the artifact to your smithy-build classpath and reference the plugin by name "plugins": { "ts-codegen": { "outFile": "generated.ts", - "excludeServices": ["myorg.auth#AuthService"] + "excludeServices": ["myorg.auth#AuthService"], + "pathPrefix": "/internal/v1" } } } @@ -51,6 +52,12 @@ Settings: - `excludeServices` — fully-qualified service shape ids (`namespace#Name`) to skip. Their referenced data shapes are still emitted; only the operations and the client class are dropped. Use this for services you hand-roll (streaming, custom routing, …). +- `pathPrefix` — path prepended to every operation's `@http` URI (default: none). For a service + mounted under a prefix that the model itself does not describe — a server framework that + derives one from a trait, or a reverse proxy. A leading slash is optional and a trailing one + is ignored, so `"internal/v1"`, `"/internal/v1"` and `"/internal/v1/"` are equivalent. The + prefix applies to the generated Storybook mocks too, so mocked routes keep matching the + client. ### From sbt (recommended) @@ -68,6 +75,7 @@ enablePlugins(SmithyTsCodegenPlugin) tsCodegenSmithyDirs := Seq(baseDirectory.value / "src" / "main" / "smithy") tsCodegenOutputFile := baseDirectory.value / "src" / "generated.ts" tsCodegenExcludeServices := Seq("myorg.auth#AuthService") +tsCodegenPathPrefix := "/internal/v1" ``` then run `tsCodegen`. The plugin resolves the `smithy-ts-codegen-cli` artifact (at the plugin's @@ -77,6 +85,7 @@ 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). +- `tsCodegenPathPrefix` — path prepended to every `@http` URI (see above; default none). - `tsCodegenVersion` — override the codegen version to resolve (defaults to the plugin's own). ### Programmatically (`TsCodegenPlugin.generate`) @@ -88,7 +97,7 @@ import org.polyvariant.smithy.ts.TsCodegenPlugin import software.amazon.smithy.model.Model val model: Model = ??? -val ts: String = TsCodegenPlugin.generate(model, excludeServices = Set.empty) +val ts: String = TsCodegenPlugin.generate(model, excludeServices = Set.empty, pathPrefix = "") ``` ### From any build (forked JVM) @@ -98,7 +107,7 @@ from `.smithy`/`.json` sources and writes the output file. Run it with the CLI ( the classpath: ``` -Main [] +Main [ []] ``` This is what the sbt plugin forks; the smithy-build plugin is discovered via the SPI. diff --git a/build.sbt b/build.sbt index cdf9026..5401e3c 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) diff --git a/cli/src/main/scala/org/polyvariant/smithy/ts/cli/Main.scala b/cli/src/main/scala/org/polyvariant/smithy/ts/cli/Main.scala index c310f01..ca4599f 100644 --- a/cli/src/main/scala/org/polyvariant/smithy/ts/cli/Main.scala +++ b/cli/src/main/scala/org/polyvariant/smithy/ts/cli/Main.scala @@ -31,7 +31,7 @@ import scala.jdk.CollectionConverters.* * from a build via a forked `java` process so the plugin lives on the JVM's classpath (and is * discovered via the smithy-build SPI). * - * Usage: `Main []` + * Usage: `Main [ []]` * * - `smithyDirs` — `File.pathSeparator`-joined list of directories containing `.smithy` sources. * Files matching `*.smithy` or `*.json` are loaded into one model. @@ -39,7 +39,9 @@ import scala.jdk.CollectionConverters.* * necessary. * - `excludeServices` — optional `,`-joined list of fully-qualified service shape ids * (`namespace#Name`) that the plugin should not emit clients for. Their referenced data shapes - * are still emitted. + * are still emitted. Pass `""` to skip it while still supplying `pathPrefix`. + * - `pathPrefix` — optional path prepended to every operation's `@http` URI, for a service + * mounted under a prefix the model does not describe. */ object Main { @@ -48,21 +50,24 @@ object Main { case smithyDirsArg :: outFileArg :: rest => val dirs = smithyDirsArg.split(java.io.File.pathSeparatorChar).toList.map(Paths.get(_)) val excluded = - rest match { - case head :: Nil if head.nonEmpty => head.split(',').toList - case _ => Nil - } - run(dirs, Paths.get(outFileArg), excluded) + rest.headOption.filter(_.nonEmpty).map(_.split(',').toList).getOrElse(Nil) + val pathPrefix = rest.drop(1).headOption.getOrElse("") + run(dirs, Paths.get(outFileArg), excluded, pathPrefix) case _ => Console .err .println( - "usage: Main []" + "usage: Main [ []]" ) sys.exit(2) } - private def run(smithyDirs: List[Path], outFile: Path, excludeServices: List[String]): Unit = { + private def run( + smithyDirs: List[Path], + outFile: Path, + excludeServices: List[String], + pathPrefix: String, + ): Unit = { val tmp = Files.createTempDirectory("smithy-ts-codegen") try { val pluginSettings = Node @@ -71,6 +76,7 @@ object Main { "excludeServices", Node.fromStrings(excludeServices.asJava), ) + .withMember("pathPrefix", pathPrefix) .build() val configNode = Node .objectNodeBuilder() 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..d64c0b7 100644 --- a/core/src/main/scala/org/polyvariant/smithy/ts/TsCodegenPlugin.scala +++ b/core/src/main/scala/org/polyvariant/smithy/ts/TsCodegenPlugin.scala @@ -75,7 +75,10 @@ class TsCodegenPlugin extends SmithyBuildPlugin { .map(_.getElementsAs(classOf[software.amazon.smithy.model.node.StringNode])) .map(_.asScala.iterator.map(_.getValue).toSet) .getOrElse(Set.empty[String]) - val rendered = TsCodegenPlugin.generate(ctx.getModel, excludeServices) + val pathPrefix = Option(settings.getStringMember("pathPrefix").orElse(null)) + .map(_.getValue) + .getOrElse("") + val rendered = TsCodegenPlugin.generate(ctx.getModel, excludeServices, pathPrefix) val _ = ctx.getFileManifest.writeFile(Paths.get(outFile), rendered) } @@ -251,7 +254,12 @@ object TsCodegenPlugin { } - def generate(model: Model, excludeServices: Set[String]): String = { + def generate( + model: Model, + excludeServices: Set[String], + pathPrefix: String = "", + ): String = { + val prefix = normalizePathPrefix(pathPrefix) // `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 +307,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, prefix)) 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, prefix)) } w.toString @@ -1048,7 +1056,12 @@ object TsCodegenPlugin { // Service clients // -------------------------------------------------------------------------- - private def writeClient(w: TsWriter, model: Model, service: ServiceShape): Unit = { + private def writeClient( + w: TsWriter, + model: Model, + service: ServiceShape, + pathPrefix: String, + ): 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 +1093,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, pathPrefix)) } 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, + pathPrefix: String, + ): Unit = { val svcName = service.getId.getName val opName = op.getId.getName val methodName = lowerFirst(opName) @@ -1159,7 +1177,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(http.getUri, labelMembers.map(_._1).toSet, pathPrefix), + ) if (queryMembers.nonEmpty) { w.line("const query: Record = {}") @@ -1264,9 +1285,24 @@ object TsCodegenPlugin { } } + /** Normalizes a configured `pathPrefix` to either `""` or a string with a leading and no trailing + * slash, so it composes with an `@http` URI (which always starts with `/`) by plain + * concatenation. `"/"` alone normalizes to `""`: it would otherwise double the URI's own slash. + */ + private[ts] def normalizePathPrefix(pathPrefix: String): String = { + val trimmed = pathPrefix.trim.stripSuffix("/") + if (trimmed.isEmpty) + "" + else if (trimmed.startsWith("/")) + trimmed + else + "/" + trimmed + } + private def urlExpression( uri: software.amazon.smithy.model.pattern.UriPattern, labelNames: Set[String], + pathPrefix: String, ): String = { val segments = uri.getSegments.asScala val rendered = @@ -1286,10 +1322,10 @@ object TsCodegenPlugin { } .mkString val final_ = - if (rendered.isEmpty) + if (rendered.isEmpty && pathPrefix.isEmpty) "/" else - rendered + pathPrefix + rendered "`" + final_ + "`" } @@ -1643,7 +1679,12 @@ object TsCodegenPlugin { w.line("") } - private def writeMockService(w: TsWriter, model: Model, service: ServiceShape): Unit = { + private def writeMockService( + w: TsWriter, + model: Model, + service: ServiceShape, + pathPrefix: String, + ): Unit = { val svcName = service.getId.getName val ops = serviceOperations(model, service) @@ -1679,13 +1720,18 @@ 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, pathPrefix)) } } w.line("") } - private def writeMockOperation(w: TsWriter, model: Model, op: OperationShape): Unit = { + private def writeMockOperation( + w: TsWriter, + model: Model, + op: OperationShape, + pathPrefix: String, + ): Unit = { val opName = op.getId.getName val methodName = lowerFirst(opName) val http = op @@ -1716,7 +1762,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(http.getUri, labelMembers.map(_._1).toSet, pathPrefix), + ) writeMockDecodeInput( w, model, @@ -1916,7 +1965,17 @@ object TsCodegenPlugin { private def segmentsArrayExpr( uri: software.amazon.smithy.model.pattern.UriPattern, labelNames: Set[String], + pathPrefix: String, ): String = { + // The mock router matches path segments one by one, so a prefix has to arrive + // as literal segments here rather than as one concatenated string. + val prefixSegs = + pathPrefix + .split('/') + .iterator + .filter(_.nonEmpty) + .map(seg => s"{ literal: ${jsString(seg)} }") + .toList val segs = uri .getSegments .asScala @@ -1931,7 +1990,7 @@ object TsCodegenPlugin { s"{ label: ${jsString(name)} }" } } - "[" + segs.mkString(", ") + "]" + "[" + (prefixSegs ++ 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..0d067cd 100644 --- a/core/src/test/scala/org/polyvariant/smithy/ts/TsCodegenPluginTest.scala +++ b/core/src/test/scala/org/polyvariant/smithy/ts/TsCodegenPluginTest.scala @@ -30,8 +30,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, + pathPrefix: String = "", + ): String = + TsCodegenPlugin.generate(model(smithy), exclude, pathPrefix) /** Several model files at once — the only way to put two namespaces in one model, since a * `.smithy` file declares exactly one. @@ -1098,4 +1102,102 @@ class TsCodegenPluginTest extends munit.FunSuite { assert(out.contains("brand<'com_example_b_ProfileId'>()")) } + // --- pathPrefix ------------------------------------------------------------ + + 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 + + test("no pathPrefix leaves the @http uri untouched") { + val out = generate(prefixModel) + assert(clue(out).contains("const url = `/things/${encodeURIComponent(String(input.id))}`")) + } + + test("pathPrefix is prepended to the @http uri") { + val out = generate(prefixModel, pathPrefix = "/internal/v1") + assert( + clue(out).contains( + "const url = `/internal/v1/things/${encodeURIComponent(String(input.id))}`" + ) + ) + } + + test("pathPrefix reaches the mock router as literal segments, so mocks still match") { + val out = generate(prefixModel, pathPrefix = "/internal/v1") + assert( + clue(out).contains( + "segments: [{ literal: 'internal' }, { literal: 'v1' }, { literal: 'things' }, { label: 'id' }]," + ) + ) + } + + test("a pathPrefix without a leading slash is still prepended as a path") { + val out = generate(prefixModel, pathPrefix = "internal/v1") + assert(clue(out).contains("const url = `/internal/v1/things/")) + } + + test("a trailing slash on pathPrefix does not double the uri's own slash") { + val out = generate(prefixModel, pathPrefix = "/internal/v1/") + assert(clue(out).contains("const url = `/internal/v1/things/")) + assert(!out.contains("v1//things")) + } + + test("a bare slash pathPrefix is the same as none") { + assertEquals(generate(prefixModel, pathPrefix = "/"), generate(prefixModel)) + } + + test("whitespace-only pathPrefix is the same as none") { + assertEquals(generate(prefixModel, pathPrefix = " "), generate(prefixModel)) + } + + test("pathPrefix applies to 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, + pathPrefix = "/internal", + ) + assert(clue(out).contains("const url = `/internal`")) + } + + test("normalizePathPrefix") { + assertEquals(TsCodegenPlugin.normalizePathPrefix(""), "") + assertEquals(TsCodegenPlugin.normalizePathPrefix("/"), "") + assertEquals(TsCodegenPlugin.normalizePathPrefix(" "), "") + assertEquals(TsCodegenPlugin.normalizePathPrefix("/internal"), "/internal") + assertEquals(TsCodegenPlugin.normalizePathPrefix("internal"), "/internal") + assertEquals(TsCodegenPlugin.normalizePathPrefix("/internal/"), "/internal") + assertEquals(TsCodegenPlugin.normalizePathPrefix("internal/v1/"), "/internal/v1") + } + } 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..bcd57d3 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 @@ -43,6 +43,7 @@ import scala.collection.JavaConverters._ * tsCodegenSmithyDirs := Seq(baseDirectory.value / "src" / "main" / "smithy") * tsCodegenOutputFile := baseDirectory.value / "generated.ts" * tsCodegenExcludeServices := Seq("myorg.auth#AuthService") + * tsCodegenPathPrefix := "/internal/v1" * }}} * * then run `tsCodegen`. @@ -68,6 +69,11 @@ object SmithyTsCodegenPlugin extends AutoPlugin { "their referenced data shapes are still emitted" ) + val tsCodegenPathPrefix = settingKey[String]( + "Path prepended to every operation's @http URI, for a service mounted under a prefix " + + "the model does not describe (e.g. \"/internal/v1\")" + ) + val tsCodegenVersion = settingKey[String]("Version of the smithy-ts-codegen-cli artifact to resolve and run") } @@ -78,6 +84,7 @@ object SmithyTsCodegenPlugin extends AutoPlugin { Seq( tsCodegenVersion := BuildInfo.smithyTsCodegenVersion, tsCodegenExcludeServices := Seq.empty, + tsCodegenPathPrefix := "", tsCodegenSmithyDirs := Seq((Compile / sourceDirectory).value / "smithy"), tsCodegenOutputFile := (Compile / target).value / "generated.ts", tsCodegen := tsCodegenTask.value, @@ -119,6 +126,7 @@ object SmithyTsCodegenPlugin extends AutoPlugin { val smithyDirs = tsCodegenSmithyDirs.value val outFile = tsCodegenOutputFile.value val excludeServices = tsCodegenExcludeServices.value + val pathPrefix = tsCodegenPathPrefix.value val version = tsCodegenVersion.value val cacheDir = streams.value.cacheDirectory / "smithy-ts-codegen" @@ -129,7 +137,7 @@ object SmithyTsCodegenPlugin extends AutoPlugin { .flatMap(d => (d ** ("*.smithy" || "*.json")).get) .toSet val configInput = cacheDir / "exclude-services.txt" - IO.write(configInput, excludeServices.mkString("\n")) + IO.write(configInput, (excludeServices :+ s"pathPrefix=$pathPrefix").mkString("\n")) val cached = FileFunction.cached( @@ -144,6 +152,7 @@ object SmithyTsCodegenPlugin extends AutoPlugin { classpath = classpath, outFile = outFile, excludeServices = excludeServices, + pathPrefix = pathPrefix, log = log, ) Set(outFile) diff --git a/sbt-plugin/src/main/scala/org/polyvariant/smithy/ts/sbt/TsCodegenRunner.scala b/sbt-plugin/src/main/scala/org/polyvariant/smithy/ts/sbt/TsCodegenRunner.scala index af845d7..a74a8ed 100644 --- a/sbt-plugin/src/main/scala/org/polyvariant/smithy/ts/sbt/TsCodegenRunner.scala +++ b/sbt-plugin/src/main/scala/org/polyvariant/smithy/ts/sbt/TsCodegenRunner.scala @@ -32,6 +32,7 @@ private[sbt] object TsCodegenRunner { classpath: Seq[File], outFile: File, excludeServices: Seq[String], + pathPrefix: String, log: Logger, ): Unit = { val cpString = classpath.map(_.getAbsolutePath).mkString(File.pathSeparator) @@ -45,7 +46,9 @@ private[sbt] object TsCodegenRunner { outFile.getAbsolutePath, ) val cmd = - if (excludeServices.nonEmpty) + if (pathPrefix.nonEmpty) + baseCmd ++ Seq(excludeServices.mkString(","), pathPrefix) + else if (excludeServices.nonEmpty) baseCmd :+ excludeServices.mkString(",") else baseCmd diff --git a/sbt-plugin/src/sbt-test/smithy-ts-codegen/basic/build.sbt b/sbt-plugin/src/sbt-test/smithy-ts-codegen/basic/build.sbt index 5226ab5..69b66c5 100644 --- a/sbt-plugin/src/sbt-test/smithy-ts-codegen/basic/build.sbt +++ b/sbt-plugin/src/sbt-test/smithy-ts-codegen/basic/build.sbt @@ -9,6 +9,7 @@ resolvers += Resolver.sonatypeCentralSnapshots tsCodegenSmithyDirs := Seq(baseDirectory.value / "src" / "main" / "smithy") tsCodegenOutputFile := baseDirectory.value / "target" / "generated.ts" tsCodegenExcludeServices := Seq("test#HiddenService") +tsCodegenPathPrefix := "/internal/v1" TaskKey[Unit]("checkOutput") := { val f = tsCodegenOutputFile.value @@ -17,6 +18,13 @@ TaskKey[Unit]("checkOutput") := { def require(sub: String): Unit = assert(contents.contains(sub), s"expected generated.ts to contain: $sub") + // pathPrefix reaches the client through the forked CLI, where it is passed + // positionally after excludeServices — so this covers that wiring too. + require("const url = `/internal/v1/greet/${encodeURIComponent(String(input.name))}`") + // ...and the mock router gets it as literal segments, so mocks still match. + require("segments: [{ literal: 'internal' }, { literal: 'v1' }, { literal: 'greet' }") + assert(!contents.contains("const url = `/greet/"), "the un-prefixed url must not survive") + require("export const PersonSchema = z.object({") require("export type Person = z.infer") require("export class GreeterClient {")