Skip to content
Closed
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
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
Expand All @@ -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)

Expand All @@ -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
Expand All @@ -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`)
Expand All @@ -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)
Expand All @@ -98,7 +107,7 @@ from `.smithy`/`.json` sources and writes the output file. Run it with the CLI (
the classpath:

```
Main <smithyDirs (path-separator-joined)> <outFile> [<excludeServices (comma-joined)>]
Main <smithyDirs (path-separator-joined)> <outFile> [<excludeServices (comma-joined)> [<pathPrefix>]]
```

This is what the sbt plugin forks; the smithy-build plugin is discovered via the SPI.
Expand Down
2 changes: 1 addition & 1 deletion 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
24 changes: 15 additions & 9 deletions cli/src/main/scala/org/polyvariant/smithy/ts/cli/Main.scala
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,17 @@ 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 <smithyDirs> <outFile> [<excludeServices>]`
* Usage: `Main <smithyDirs> <outFile> [<excludeServices> [<pathPrefix>]]`
*
* - `smithyDirs` — `File.pathSeparator`-joined list of directories containing `.smithy` sources.
* Files matching `*.smithy` or `*.json` are loaded into one model.
* - `outFile` — destination path for the generated file. Parent directories are created if
* 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 {

Expand All @@ -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 <smithyDirs (path-separator-joined)> <outFile> [<excludeServices (comma-joined)>]"
"usage: Main <smithyDirs (path-separator-joined)> <outFile> [<excludeServices (comma-joined)> [<pathPrefix>]]"
)
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
Expand All @@ -71,6 +76,7 @@ object Main {
"excludeServices",
Node.fromStrings(excludeServices.asJava),
)
.withMember("pathPrefix", pathPrefix)
.build()
val configNode = Node
.objectNodeBuilder()
Expand Down
91 changes: 75 additions & 16 deletions core/src/main/scala/org/polyvariant/smithy/ts/TsCodegenPlugin.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<string, string | number | boolean | undefined> = {}")
Expand Down Expand Up @@ -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 =
Expand All @@ -1286,10 +1322,10 @@ object TsCodegenPlugin {
}
.mkString
val final_ =
if (rendered.isEmpty)
if (rendered.isEmpty && pathPrefix.isEmpty)
"/"
else
rendered
pathPrefix + rendered
"`" + final_ + "`"
}

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -1931,7 +1990,7 @@ object TsCodegenPlugin {
s"{ label: ${jsString(name)} }"
}
}
"[" + segs.mkString(", ") + "]"
"[" + (prefixSegs ++ segs).mkString(", ") + "]"
}

// --------------------------------------------------------------------------
Expand Down
Loading
Loading