From e7ef659c6b2427f397e18f49c0206b80544589b8 Mon Sep 17 00:00:00 2001 From: Bastian Eicher Date: Tue, 11 Aug 2026 01:11:38 +0200 Subject: [PATCH 1/2] Added Java and Kotlin code generators --- .github/workflows/build.yml | 5 + .gitignore | 3 + README.md | 16 +- doc/index.md | 8 + src/SmokeTest.Jvm/build.gradle.kts | 42 ++ src/SmokeTest.Jvm/settings.gradle.kts | 1 + .../net/typedrest/smoketest/UsageJava.java | 46 +++ .../src/main/kotlin/UsageKotlin.kt | 34 ++ .../Commands/Generate.cs | 8 +- src/TypedRest.CodeGeneration.Cli/README.md | 34 +- .../TypedRest.CodeGeneration.Cli.csproj | 2 + .../JavaClientGenerator.cs | 38 ++ .../JavaGeneratedFile.cs | 32 ++ .../JavaGenerationOptions.cs | 46 +++ .../JavaWriter.cs | 371 ++++++++++++++++++ .../OpenApiDocumentExtensions.cs | 56 +++ src/TypedRest.CodeGeneration.Java/README.md | 85 ++++ .../TypedRest.CodeGeneration.Java.csproj | 17 + .../Dtos/DtoBuilders.cs | 253 ++++++++++++ .../Dtos/DtoGenerator.cs | 31 ++ .../Endpoints/BuilderBase.cs | 145 +++++++ .../Endpoints/BuilderRegistry.cs | 49 +++ .../Endpoints/Builders.cs | 340 ++++++++++++++++ .../Endpoints/EndpointGenerator.cs | 87 ++++ .../Endpoints/IBuilder.cs | 65 +++ .../INamingStrategy.cs | 30 ++ .../JvmGenerationOptions.cs | 74 ++++ .../JvmSerializer.cs | 177 +++++++++ src/TypedRest.CodeGeneration.Jvm/Messages.cs | 41 ++ .../Model/JvmAnnotation.cs | 43 ++ .../Model/JvmExpression.cs | 134 +++++++ .../Model/JvmIdentifier.cs | 173 ++++++++ .../Model/JvmPackage.cs | 95 +++++ .../Model/JvmSyntax.cs | 103 +++++ .../Model/JvmTypes.cs | 269 +++++++++++++ .../Model/JvmWriter.cs | 70 ++++ .../Model/Packages.cs | 74 ++++ .../NamingStrategy.cs | 78 ++++ src/TypedRest.CodeGeneration.Jvm/README.md | 47 +++ .../TypeNameRegistry.cs | 31 ++ .../TypedRest.CodeGeneration.Jvm.csproj | 17 + .../KotlinClientGenerator.cs | 32 ++ .../KotlinGeneratedFile.cs | 29 ++ .../KotlinGenerationOptions.cs | 36 ++ .../KotlinWriter.cs | 306 +++++++++++++++ .../OpenApiDocumentExtensions.cs | 56 +++ src/TypedRest.CodeGeneration.Kotlin/README.md | 89 +++++ .../TypedRest.CodeGeneration.Kotlin.csproj | 17 + src/TypedRest.CodeGeneration.slnx | 3 + src/UnitTests/sample-nested.yml | 8 + src/test.ps1 | 19 + src/test.sh | 18 + 52 files changed, 3876 insertions(+), 7 deletions(-) create mode 100644 src/SmokeTest.Jvm/build.gradle.kts create mode 100644 src/SmokeTest.Jvm/settings.gradle.kts create mode 100644 src/SmokeTest.Jvm/src/main/java/net/typedrest/smoketest/UsageJava.java create mode 100644 src/SmokeTest.Jvm/src/main/kotlin/UsageKotlin.kt create mode 100644 src/TypedRest.CodeGeneration.Java/JavaClientGenerator.cs create mode 100644 src/TypedRest.CodeGeneration.Java/JavaGeneratedFile.cs create mode 100644 src/TypedRest.CodeGeneration.Java/JavaGenerationOptions.cs create mode 100644 src/TypedRest.CodeGeneration.Java/JavaWriter.cs create mode 100644 src/TypedRest.CodeGeneration.Java/OpenApiDocumentExtensions.cs create mode 100644 src/TypedRest.CodeGeneration.Java/README.md create mode 100644 src/TypedRest.CodeGeneration.Java/TypedRest.CodeGeneration.Java.csproj create mode 100644 src/TypedRest.CodeGeneration.Jvm/Dtos/DtoBuilders.cs create mode 100644 src/TypedRest.CodeGeneration.Jvm/Dtos/DtoGenerator.cs create mode 100644 src/TypedRest.CodeGeneration.Jvm/Endpoints/BuilderBase.cs create mode 100644 src/TypedRest.CodeGeneration.Jvm/Endpoints/BuilderRegistry.cs create mode 100644 src/TypedRest.CodeGeneration.Jvm/Endpoints/Builders.cs create mode 100644 src/TypedRest.CodeGeneration.Jvm/Endpoints/EndpointGenerator.cs create mode 100644 src/TypedRest.CodeGeneration.Jvm/Endpoints/IBuilder.cs create mode 100644 src/TypedRest.CodeGeneration.Jvm/INamingStrategy.cs create mode 100644 src/TypedRest.CodeGeneration.Jvm/JvmGenerationOptions.cs create mode 100644 src/TypedRest.CodeGeneration.Jvm/JvmSerializer.cs create mode 100644 src/TypedRest.CodeGeneration.Jvm/Messages.cs create mode 100644 src/TypedRest.CodeGeneration.Jvm/Model/JvmAnnotation.cs create mode 100644 src/TypedRest.CodeGeneration.Jvm/Model/JvmExpression.cs create mode 100644 src/TypedRest.CodeGeneration.Jvm/Model/JvmIdentifier.cs create mode 100644 src/TypedRest.CodeGeneration.Jvm/Model/JvmPackage.cs create mode 100644 src/TypedRest.CodeGeneration.Jvm/Model/JvmSyntax.cs create mode 100644 src/TypedRest.CodeGeneration.Jvm/Model/JvmTypes.cs create mode 100644 src/TypedRest.CodeGeneration.Jvm/Model/JvmWriter.cs create mode 100644 src/TypedRest.CodeGeneration.Jvm/Model/Packages.cs create mode 100644 src/TypedRest.CodeGeneration.Jvm/NamingStrategy.cs create mode 100644 src/TypedRest.CodeGeneration.Jvm/README.md create mode 100644 src/TypedRest.CodeGeneration.Jvm/TypeNameRegistry.cs create mode 100644 src/TypedRest.CodeGeneration.Jvm/TypedRest.CodeGeneration.Jvm.csproj create mode 100644 src/TypedRest.CodeGeneration.Kotlin/KotlinClientGenerator.cs create mode 100644 src/TypedRest.CodeGeneration.Kotlin/KotlinGeneratedFile.cs create mode 100644 src/TypedRest.CodeGeneration.Kotlin/KotlinGenerationOptions.cs create mode 100644 src/TypedRest.CodeGeneration.Kotlin/KotlinWriter.cs create mode 100644 src/TypedRest.CodeGeneration.Kotlin/OpenApiDocumentExtensions.cs create mode 100644 src/TypedRest.CodeGeneration.Kotlin/README.md create mode 100644 src/TypedRest.CodeGeneration.Kotlin/TypedRest.CodeGeneration.Kotlin.csproj diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3e40a1a..983022a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,6 +17,11 @@ jobs: id: gitversion - name: Add NuGet package source run: dotnet nuget add source https://nuget.pkg.github.com/${{github.repository_owner}}/index.json --name github --username ${{github.actor}} --password ${{github.token}} --store-password-in-clear-text + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 21 + - uses: gradle/actions/setup-gradle@v4 - uses: actions/setup-node@v7 with: node-version: 24 diff --git a/.gitignore b/.gitignore index 4489128..e7f5264 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ /artifacts/ **/TestResults/ /src/SmokeTest.TypeScript/generated/ +/src/SmokeTest.Jvm/generated/ +/src/SmokeTest.Jvm/.gradle/ +/src/SmokeTest.Jvm/build/ diff --git a/README.md b/README.md index 295c729..2827b58 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Build](https://github.com/TypedRest/CodeGeneration/actions/workflows/build.yml/badge.svg)](https://github.com/TypedRest/CodeGeneration/actions/workflows/build.yml) [![API documentation](https://img.shields.io/badge/api-docs-orange.svg)](https://code-generation.typedrest.net/) -Tool that automatically infers [TypedRest Endpoints](https://typedrest.net/endpoints/) from patterns in [OpenAPI/Swagger](https://swagger.io/resources/open-api/) documents and generates source code for TypedRest clients. It can generate C# and TypeScript clients. +Tool that automatically infers [TypedRest Endpoints](https://typedrest.net/endpoints/) from patterns in [OpenAPI/Swagger](https://swagger.io/resources/open-api/) documents and generates source code for TypedRest clients. It can generate C#, TypeScript, Kotlin and Java clients. Write a C# client to disk with the command-line tool: @@ -13,6 +13,11 @@ Write a TypeScript client to disk with the command-line tool: typedrest-codegen generate -l typescript -f myapi.yml -o src/myclient/ -s MyService --generate-dtos +Write a Java or Kotlin client to disk with the command-line tool: + + typedrest-codegen generate -l java -f myapi.yml -o src/main/java/ -s MyService -n com.mycompany.myservice --generate-dtos + typedrest-codegen generate -l kotlin -f myapi.yml -o src/main/kotlin/ -s MyService -n com.mycompany.myservice --generate-dtos + Or generate a C# client during compilation instead, with nothing written to disk: dotnet add package TypedRest.SourceGenerator @@ -36,6 +41,15 @@ Generates C# source code for TypedRest .NET clients from OpenAPI/Swagger documen [![TypedRest.CodeGeneration.TypeScript](https://img.shields.io/nuget/v/TypedRest.CodeGeneration.TypeScript.svg?label=TypedRest.CodeGeneration.TypeScript)](https://www.nuget.org/packages/TypedRest.CodeGeneration.TypeScript/) Generates TypeScript source code for TypedRest clients from OpenAPI/Swagger documents. +[![TypedRest.CodeGeneration.Jvm](https://img.shields.io/nuget/v/TypedRest.CodeGeneration.Jvm.svg?label=TypedRest.CodeGeneration.Jvm)](https://www.nuget.org/packages/TypedRest.CodeGeneration.Jvm/) +Shared logic for generating source code for JVM-based languages. + +[![TypedRest.CodeGeneration.Java](https://img.shields.io/nuget/v/TypedRest.CodeGeneration.Java.svg?label=TypedRest.CodeGeneration.Java)](https://www.nuget.org/packages/TypedRest.CodeGeneration.Java/) +Generates Java source code for TypedRest clients from OpenAPI/Swagger documents. [TypedRest for the JVM](https://github.com/TypedRest/TypedRest-Java) is written in Kotlin, so prefer the Kotlin generator unless your own source is Java. + +[![TypedRest.CodeGeneration.Kotlin](https://img.shields.io/nuget/v/TypedRest.CodeGeneration.Kotlin.svg?label=TypedRest.CodeGeneration.Kotlin)](https://www.nuget.org/packages/TypedRest.CodeGeneration.Kotlin/) +Generates Kotlin source code for TypedRest clients from OpenAPI/Swagger documents. + You can also [build your own generator](https://typedrest.net/code-generation/custom-code/) for more complex APIs. For the relevant types and methods take a look at the **[API documentation](https://code-generation.typedrest.net/)**. [![TypedRest.SourceGenerator](https://img.shields.io/nuget/v/TypedRest.SourceGenerator.svg?label=TypedRest.SourceGenerator)](https://www.nuget.org/packages/TypedRest.SourceGenerator/) diff --git a/doc/index.md b/doc/index.md index cc8455e..244070e 100644 --- a/doc/index.md +++ b/doc/index.md @@ -28,6 +28,9 @@ foreach (var type in doc.GenerateTypedRest(new GenerationOptions("MyService") | [TypedRest.CodeGeneration](https://www.nuget.org/packages/TypedRest.CodeGeneration/) | | Parses OpenAPI/Swagger documents and infers TypedRest Endpoints from patterns. | | [TypedRest.CodeGeneration.CSharp](https://www.nuget.org/packages/TypedRest.CodeGeneration.CSharp/) | | Generates C# source code for TypedRest .NET clients from OpenAPI/Swagger documents. | | [TypedRest.CodeGeneration.TypeScript](https://www.nuget.org/packages/TypedRest.CodeGeneration.TypeScript/) | | Generates TypeScript source code for TypedRest clients from OpenAPI/Swagger documents. | +| [TypedRest.CodeGeneration.Jvm](https://www.nuget.org/packages/TypedRest.CodeGeneration.Jvm/) | | Shared logic for generating source code for JVM-based languages. | +| [TypedRest.CodeGeneration.Kotlin](https://www.nuget.org/packages/TypedRest.CodeGeneration.Kotlin/) | | Generates Kotlin source code for TypedRest clients from OpenAPI/Swagger documents. | +| [TypedRest.CodeGeneration.Java](https://www.nuget.org/packages/TypedRest.CodeGeneration.Java/) | | Generates Java source code for TypedRest clients from OpenAPI/Swagger documents. | | [TypedRest.SourceGenerator](https://www.nuget.org/packages/TypedRest.SourceGenerator/) | | Roslyn [source generator](https://typedrest.net/code-generation/source-generator/) that builds clients during compilation. | | [typedrest-codegen](https://www.nuget.org/packages/typedrest-codegen/) | | [Command-line tool](https://typedrest.net/code-generation/cli/) that writes the generated code to disk. | @@ -36,9 +39,14 @@ foreach (var type in doc.GenerateTypedRest(new GenerationOptions("MyService") ```mermaid flowchart TD cli["typedrest-codegen"] --> csharp + cli --> java + cli --> kotlin cli --> typescript sourcegen["TypedRest.SourceGenerator"] --> csharp csharp["TypedRest.CodeGeneration.
CSharp"] --> core + jvm["TypedRest.CodeGeneration.
Jvm"] --> core + java["TypedRest.CodeGeneration.
Java"] --> jvm + kotlin["TypedRest.CodeGeneration.
Kotlin"] --> jvm typescript["TypedRest.CodeGeneration.
TypeScript"] --> core core["TypedRest.CodeGeneration"] ``` diff --git a/src/SmokeTest.Jvm/build.gradle.kts b/src/SmokeTest.Jvm/build.gradle.kts new file mode 100644 index 0000000..648674e --- /dev/null +++ b/src/SmokeTest.Jvm/build.gradle.kts @@ -0,0 +1,42 @@ +plugins { + kotlin("jvm") version "2.3.21" + kotlin("plugin.serialization") version "2.3.21" +} + +repositories { + mavenCentral() +} + +kotlin { + jvmToolchain(21) +} + +sourceSets { + main { + kotlin.srcDir("generated/kotlin") + java.srcDir("generated/java") + } +} + +dependencies { + implementation("net.typedrest:typedrest:0.33.0") + implementation("net.typedrest:typedrest-serializers-jackson:0.32.0") + + // The @Serializable and @SerialName annotations the Kotlin generator emits. TypedRest depends on + // kotlinx-serialization only as `implementation`, so it does not reach a consumer's compile classpath and has + // to be declared here; the kotlin("plugin.serialization") plugin adds the compiler plugin but no dependency. + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") + + // Carries the @Nullable annotations the Java generator emits, so Kotlin sees real nullability + compileOnly("org.jspecify:jspecify:1.0.1") +} + +tasks.withType().configureEach { + compilerOptions { + allWarningsAsErrors.set(true) + } +} + +tasks.withType().configureEach { + options.compilerArgs.addAll(listOf("-Xlint:all", "-Werror")) +} diff --git a/src/SmokeTest.Jvm/settings.gradle.kts b/src/SmokeTest.Jvm/settings.gradle.kts new file mode 100644 index 0000000..988b0ec --- /dev/null +++ b/src/SmokeTest.Jvm/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "smoketest-jvm" diff --git a/src/SmokeTest.Jvm/src/main/java/net/typedrest/smoketest/UsageJava.java b/src/SmokeTest.Jvm/src/main/java/net/typedrest/smoketest/UsageJava.java new file mode 100644 index 0000000..0500036 --- /dev/null +++ b/src/SmokeTest.Jvm/src/main/java/net/typedrest/smoketest/UsageJava.java @@ -0,0 +1,46 @@ +package net.typedrest.smoketest; + +import net.typedrest.smoketest.java.SampleClient; +import net.typedrest.smoketest.java.dtos.Contact; +import net.typedrest.smoketest.java.dtos.Note; + +import java.io.InputStream; +import java.net.URI; +import java.util.List; + +public final class UsageJava { + private UsageJava() {} + + public static SampleClient client(URI uri) { + return new SampleClient(uri); + } + + public static List readAllContacts(SampleClient client) { + return client.contacts.readAll(); + } + + public static Note readNote(SampleClient client, String id) { + return client.contacts.get(id).note.read(); + } + + public static void writeNote(SampleClient client, String id, Note note) { + client.contacts.get(id).note.set(note); + } + + public static InputStream pokeAndDownload(SampleClient client, Contact contact) { + // get() also accepts an entity, extracting its id property + var element = client.contacts.get(contact); + element.poke.invoke(); + return element.picture.download(); + } + + public static Note createContact(SampleClient client, Contact contact) { + var created = client.contacts.create(contact); + return created == null ? null : created.note.read(); + } + + public static Contact requiredProperties() { + // The generated DTO has both a no-argument constructor for the serializer and a full one + return new Contact(null, "John", "Doe"); + } +} diff --git a/src/SmokeTest.Jvm/src/main/kotlin/UsageKotlin.kt b/src/SmokeTest.Jvm/src/main/kotlin/UsageKotlin.kt new file mode 100644 index 0000000..9be1085 --- /dev/null +++ b/src/SmokeTest.Jvm/src/main/kotlin/UsageKotlin.kt @@ -0,0 +1,34 @@ +package net.typedrest.smoketest + +import net.typedrest.smoketest.kotlin.SampleClient +import net.typedrest.smoketest.kotlin.dtos.Contact +import net.typedrest.smoketest.kotlin.dtos.Note +import java.io.InputStream +import java.net.URI + +fun client(uri: URI): SampleClient = + SampleClient(uri) + +fun readAllContacts(client: SampleClient): List = + client.contacts.readAll() + +fun readNote(client: SampleClient, id: String): Note = + client.contacts[id].note.read() + +fun writeNote(client: SampleClient, id: String, note: Note) { + client.contacts[id].note.set(note) +} + +fun pokeAndDownload(client: SampleClient, contact: Contact): InputStream { + // get() also accepts an entity, extracting its id property + val element = client.contacts[contact] + element.poke.invoke() + return element.picture.download() +} + +fun createContact(client: SampleClient, contact: Contact): Note? = + client.contacts.create(contact)?.note?.read() + +fun requiredProperties(): Contact = + // firstName and lastName are required, id is not and defaults to null + Contact(firstName = "John", lastName = "Doe") diff --git a/src/TypedRest.CodeGeneration.Cli/Commands/Generate.cs b/src/TypedRest.CodeGeneration.Cli/Commands/Generate.cs index 956a7a9..775ef85 100644 --- a/src/TypedRest.CodeGeneration.Cli/Commands/Generate.cs +++ b/src/TypedRest.CodeGeneration.Cli/Commands/Generate.cs @@ -2,6 +2,8 @@ using Microsoft.CodeAnalysis.CSharp; using TypedRest.CodeGeneration.CSharp; using TypedRest.CodeGeneration.Generation; +using TypedRest.CodeGeneration.Java; +using TypedRest.CodeGeneration.Kotlin; using TypedRest.CodeGeneration.TypeScript; namespace TypedRest.CodeGeneration.Cli.Commands; @@ -15,7 +17,7 @@ public class Generate : CommandBase [Option('s', "service-name", HelpText = "The service name to use for the entry endpoint.", Required = true)] public string ServiceName { get; set; } = default!; - [Option('l', "language", Default = CSharpClientGenerator.LanguageName, HelpText = "The language to generate: 'csharp' or 'typescript'.")] + [Option('l', "language", Default = CSharpClientGenerator.LanguageName, HelpText = "The language to generate: 'csharp', 'typescript', 'kotlin' or 'java'.")] public string Language { get; set; } = CSharpClientGenerator.LanguageName; [Option('n', "namespace", HelpText = "The C# namespace for the endpoints, or the directory for TypeScript. Uses service-name if not set.")] @@ -45,7 +47,9 @@ public class Generate : CommandBase private static ClientGeneratorRegistry Generators => new ClientGeneratorRegistry() .Add(new CSharpClientGenerator()) - .Add(new TypeScriptClientGenerator()); + .Add(new TypeScriptClientGenerator()) + .Add(new KotlinClientGenerator()) + .Add(new JavaClientGenerator()); public override int Run() { diff --git a/src/TypedRest.CodeGeneration.Cli/README.md b/src/TypedRest.CodeGeneration.Cli/README.md index f69377a..581ceed 100644 --- a/src/TypedRest.CodeGeneration.Cli/README.md +++ b/src/TypedRest.CodeGeneration.Cli/README.md @@ -21,12 +21,12 @@ Generates a TypedRest client. | `-f`, `--file` (required) | The path to the Swagger or OpenAPI spec file. Use `-` to read from standard input. | | | `-o`, `--output` (required) | The directory to write the generated source code to. | | | `-s`, `--service-name` (required) | The service name to use for the entry endpoint. | | -| `-l`, `--language` | The language to generate: `csharp` or `typescript`. | `csharp` | -| `-n`, `--namespace` | The C# namespace for the endpoints, or the directory for TypeScript. | the service name | -| `--dto-namespace` | The C# namespace for the DTOs, or the directory for TypeScript. | see below | +| `-l`, `--language` | The language to generate: `csharp`, `typescript`, `kotlin` or `java`. | `csharp` | +| `-n`, `--namespace` | The namespace (C#), package (Kotlin/Java) or directory (TypeScript) for the endpoints. | the service name | +| `--dto-namespace` | The same for the DTOs. | see below | | `--generate-interfaces` | Also generate interfaces for the endpoints. **C# only.** | off | | `--generate-dtos` | Also generate DTOs for the schemas in the document. | off | -| `--generate-entry-constructor` | Give the entry endpoint a constructor taking the base URI. Pass `false` to write your own in a partial class. **C# only.** | on | +| `--generate-entry-constructor` | Give the entry endpoint a constructor taking the base URI. Pass `false` to write your own. **Not for TypeScript.** | on | | `--lang-version` | The minimum C# version the generated code must compile with, using the same values as the MSBuild `LangVersion` property. **C# only.** | `latest` | | `--serializer` | The JSON serializer the generated DTOs are annotated for. See below. | per language | @@ -63,6 +63,32 @@ Endpoints become classes deriving from the TypedRest endpoint types and exposing `--serializer` has no effect here, for the same reason: there is no serializer to choose and nothing to annotate. +### Kotlin + + typedrest-codegen generate -l kotlin -f myapi.yml -o src/main/kotlin/ -s MyService -n com.mycompany.myservice --generate-dtos + +The generated code derives from [TypedRest for the JVM](https://github.com/TypedRest/TypedRest-Java), so add `net.typedrest:typedrest` to the consuming project — plus `net.typedrest:typedrest-reactive` if the document describes any polling or streaming endpoints. + +One file per type, in a directory matching its package, so `--output` is the source root (`src/main/kotlin/`) rather than the package directory. `--namespace` is the package for the endpoints and `--dto-namespace` the one for the DTOs, defaulting to a `dtos` subpackage of the endpoints. + +Endpoints become `open class`es deriving from the TypedRest `Impl` classes and exposing their children as `val`s. DTOs become `data class`es, and schemas with an `enum` become `enum class`es. Optional properties are nullable and default to `null`; required ones get no default, so a missing value is a compile error. + +`--serializer` picks `kotlinx` (default), `jackson` or `moshi`. kotlinx.serialization is what `EntryEndpoint` itself defaults to, so a client generated for it passes no serializer at all; the others are passed explicitly. + +Generating DTOs for `kotlinx` needs both the `kotlin("plugin.serialization")` Gradle plugin **and** an explicit `org.jetbrains.kotlinx:kotlinx-serialization-json` dependency: TypedRest depends on it only as `implementation`, so it does not reach your compile classpath, and the plugin adds the compiler plugin but no dependency. + +### Java + + typedrest-codegen generate -l java -f myapi.yml -o src/main/java/ -s MyService -n com.mycompany.myservice --generate-dtos + +Prefer the Kotlin generator if you can: TypedRest for the JVM is written in Kotlin, so that is the lower-friction direction. Use this one when your own source is Java. + +The layout matches the Kotlin generator's. Endpoints expose their children as `public final` fields rather than getters, because a getter recomputing the endpoint on every call would hand out a new instance each time and throw away the response cache. DTOs become plain classes with public fields, a no-argument constructor and a full one. + +Properties the document does not require are annotated with JSpecify's `@Nullable`, so that Kotlin consumers get real null safety instead of platform types. Add `org.jspecify:jspecify` to the consuming project, or drop the annotations by generating Kotlin instead. + +`--serializer` picks `jackson` (default) or `moshi`. `kotlinx` is rejected here: kotlinx.serialization generates its serializers with a Kotlin compiler plugin and cannot handle a class written in Java, so a client generated for it would compile and then fail to deserialize anything. + ## `pattern` Runs only the inference step and writes the result back into the document as an `x-typedrest` extension, for inspecting or hand-editing what the tool infers. diff --git a/src/TypedRest.CodeGeneration.Cli/TypedRest.CodeGeneration.Cli.csproj b/src/TypedRest.CodeGeneration.Cli/TypedRest.CodeGeneration.Cli.csproj index c46e8ae..28a9f4d 100644 --- a/src/TypedRest.CodeGeneration.Cli/TypedRest.CodeGeneration.Cli.csproj +++ b/src/TypedRest.CodeGeneration.Cli/TypedRest.CodeGeneration.Cli.csproj @@ -18,6 +18,8 @@ + + diff --git a/src/TypedRest.CodeGeneration.Java/JavaClientGenerator.cs b/src/TypedRest.CodeGeneration.Java/JavaClientGenerator.cs new file mode 100644 index 0000000..2a98921 --- /dev/null +++ b/src/TypedRest.CodeGeneration.Java/JavaClientGenerator.cs @@ -0,0 +1,38 @@ +using TypedRest.CodeGeneration.Generation; +using TypedRest.CodeGeneration.Jvm; + +namespace TypedRest.CodeGeneration.Java; + +/// +/// Generates the source code of a Java TypedRest client. +/// +public class JavaClientGenerator : IClientGenerator +{ + /// + /// The name of this target language. + /// + public const string LanguageName = "java"; + + /// + public string Language => LanguageName; + + /// + public ClientGenerationOptions CreateOptions(string serviceName) + => new JavaGenerationOptions(serviceName); + + /// + public IEnumerable Generate(OpenApiDocument document, ClientGenerationOptions options, IGenerationLog? log = null) + { + var javaOptions = options as JavaGenerationOptions ?? new JavaGenerationOptions(options); + var generationLog = log ?? NullGenerationLog.Instance; + + if (options.GenerateInterfaces) generationLog.Report(Messages.InterfacesNotSupported()); + + // kotlinx.serialization needs a Kotlin compiler plugin, so it can never serialize a Java DTO. Falling back + // silently would generate a client that compiles and then fails to deserialize anything at runtime. + if (javaOptions.Serializer is {} serializer && !JvmSerializer.For(serializer).SupportsJava) + throw new ArgumentException(Messages.SerializerNotSupportedByJava(serializer).Text, nameof(options)); + + return document.GenerateTypedRestJava(javaOptions, log); + } +} diff --git a/src/TypedRest.CodeGeneration.Java/JavaGeneratedFile.cs b/src/TypedRest.CodeGeneration.Java/JavaGeneratedFile.cs new file mode 100644 index 0000000..ef90dd2 --- /dev/null +++ b/src/TypedRest.CodeGeneration.Java/JavaGeneratedFile.cs @@ -0,0 +1,32 @@ +using System.Text; +using TypedRest.CodeGeneration.Generation; +using TypedRest.CodeGeneration.Jvm.Model; + +namespace TypedRest.CodeGeneration.Java; + +/// +/// A Java source file holding one generated type. +/// +/// The type declared in the file. +/// Renders the type. +/// +/// One public type per file, in a directory matching its package. Java requires both. +/// +public sealed class JavaGeneratedFile(IJvmType type, JavaWriter writer) : IGeneratedFile +{ + /// + /// The type declared in the file. + /// + public IJvmType Type { get; } = type; + + /// + public string Path + => (Type.Identifier.Package ?? JvmPackage.External("")).FilePath(Type.Identifier.Name, JavaWriter.FileExtension); + + /// + public Encoding Encoding { get; } = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + + /// + public void WriteTo(TextWriter textWriter) + => writer.WriteFile(textWriter, Type); +} diff --git a/src/TypedRest.CodeGeneration.Java/JavaGenerationOptions.cs b/src/TypedRest.CodeGeneration.Java/JavaGenerationOptions.cs new file mode 100644 index 0000000..f15e96a --- /dev/null +++ b/src/TypedRest.CodeGeneration.Java/JavaGenerationOptions.cs @@ -0,0 +1,46 @@ +using TypedRest.CodeGeneration.Generation; +using TypedRest.CodeGeneration.Jvm; + +namespace TypedRest.CodeGeneration.Java; + +/// +/// Options controlling the generation of a Java TypedRest client. +/// +public class JavaGenerationOptions : JvmGenerationOptions +{ + /// + /// Creates new generation options. + /// + /// The service name to use for the entry endpoint. + public JavaGenerationOptions(string serviceName) + : base(serviceName) + {} + + /// + /// Creates new generation options, copying the common options from . + /// + public JavaGenerationOptions(ClientGenerationOptions other) + : base(other) + {} + + /// + /// + /// Not kotlinx.serialization, which every other part of TypedRest for the JVM defaults to: it generates its + /// serializers with a Kotlin compiler plugin and cannot handle a class written in Java. A Java client therefore + /// has to pass its serializer to the entry endpoint explicitly. + /// + protected override string DefaultSerializerName => JvmSerializer.Jackson; + + /// + public override IReadOnlyCollection SupportedSerializers + => [JvmSerializer.Jackson, JvmSerializer.Moshi]; + + /// + /// Controls whether nullable properties are annotated with JSpecify's @Nullable. + /// + /// + /// On by default. Without it Kotlin sees every generated type as a platform type and loses null safety across + /// the whole DTO surface, which defeats much of the point of consuming a Java client from Kotlin. + /// + public bool NullableAnnotations { get; set; } = true; +} diff --git a/src/TypedRest.CodeGeneration.Java/JavaWriter.cs b/src/TypedRest.CodeGeneration.Java/JavaWriter.cs new file mode 100644 index 0000000..d35c74e --- /dev/null +++ b/src/TypedRest.CodeGeneration.Java/JavaWriter.cs @@ -0,0 +1,371 @@ +using TypedRest.CodeGeneration.Generation; +using TypedRest.CodeGeneration.Jvm; +using TypedRest.CodeGeneration.Jvm.Model; + +namespace TypedRest.CodeGeneration.Java; + +/// +/// Renders the shared JVM type model as Java source code. +/// +/// Supplies the annotations carrying wire names on generated DTOs. +/// Controls whether the entry endpoint gets a generated constructor. +/// Controls whether properties are annotated with JSpecify nullability. +public sealed class JavaWriter(JvmSerializer serializer, bool entryConstructor = true, bool nullableAnnotations = true) +{ + /// + /// The file extension of Java source files. + /// + public const string FileExtension = ".java"; + + /// + /// The line length past which a class declaration is wrapped before its extends clause. + /// + private const int MaxLineLength = 120; + + private static readonly JvmPackage _jspecify = JvmPackage.External("org.jspecify.annotations"); + + /// + /// Marks a value as possibly null. + /// + private static JvmAnnotation Nullable + => new(new JvmIdentifier(_jspecify, "Nullable")); + + /// + /// Writes a file declaring . + /// + public void WriteFile(TextWriter textWriter, IJvmType type) + { + var writer = new JvmWriter(textWriter); + + var package = type.Identifier.Package; + if (package is {Name.Length: > 0}) + { + writer.WriteLine($"package {package.Name};"); + writer.WriteLine(); + } + + var imports = Imports(type).ToList(); + if (imports.Count != 0) + { + foreach (string import in imports) + writer.WriteLine($"import {import};"); + writer.WriteLine(); + } + + Write(writer, type); + } + + /// + /// Returns the sorted, deduplicated imports a file declaring needs. + /// + private IEnumerable Imports(IJvmType type) + => AllImports(type) + .Where(x => x.Package is {} package + && package.Name.Length != 0 + && !Equals(package, type.Identifier.Package) + && !Equals(package, Packages.JavaLang)) + .Select(x => x.QualifiedName) + .Distinct(StringComparer.Ordinal) + .OrderBy(x => x, StringComparer.Ordinal); + + private IEnumerable AllImports(IJvmType type) + { + foreach (var import in type.GetImports()) yield return import; + + foreach (var annotation in AnnotationsFor(type)) + { + foreach (var import in annotation.GetImports()) yield return import; + } + + if (type is JvmEndpointClass endpoint) + { + // Java has no inherited constructors, so one is always synthesized. + // The entry endpoint's takes only the base URI; every other endpoint's takes a referrer as well. + if (Equals(endpoint.BaseType, Packages.EntryEndpoint)) + { + if (entryConstructor) + { + yield return JvmIdentifier.Uri; + if (serializer.RuntimeSerializer is {} runtimeSerializer) + { + yield return runtimeSerializer; + yield return Packages.HttpCredentials; + } + } + } + else + { + yield return Packages.Endpoint; + yield return JvmIdentifier.Uri; + } + } + } + + private IEnumerable AnnotationsFor(IJvmType type) + { + switch (type) + { + case JvmDto dto: + foreach (var annotation in serializer.TypeAnnotations()) yield return annotation; + foreach (var property in dto.Properties) + { + if (serializer.PropertyName(property.WireName) is {} annotation) yield return annotation; + if (nullableAnnotations && property.Type.Nullable) yield return Nullable; + } + break; + + case JvmEnum @enum: + foreach (var annotation in serializer.EnumAnnotations()) yield return annotation; + foreach (var value in @enum.Values) + { + if (serializer.EnumMemberName(value.WireName) is {} annotation) yield return annotation; + } + break; + } + } + + private void Write(JvmWriter writer, IJvmType type) + { + switch (type) + { + case JvmEndpointClass endpoint: + WriteEndpoint(writer, endpoint); + break; + case JvmDto dto: + WriteDto(writer, dto); + break; + case JvmEnum @enum: + WriteEnum(writer, @enum); + break; + default: + throw new ArgumentException($"Cannot write a {type.GetType().Name} as Java.", nameof(type)); + } + } + + private void WriteEndpoint(JvmWriter writer, JvmEndpointClass type) + { + writer.WriteDocComment(type.Summary, type.Deprecated); + if (type.Deprecated) writer.WriteLine("@Deprecated"); + + // Every child endpoint takes its parent as the referrer, so the field initializers hand out `this` while the class is still initializing. + // That is safe here because an endpoint only reads the referrer's URI and HTTP client, both set by the super constructor. + // Suppressed on the generated class so the warning does not land in every consumer's build. + if (type.Children.Count != 0) writer.WriteLine("@SuppressWarnings(\"this-escape\")"); + + string declaration = $"public class {type.Identifier.Name}"; + string extends = $" extends {TypeExpression(type.BaseType)}"; + + if (declaration.Length + extends.Length > MaxLineLength) + { + writer.WriteLine(declaration); + using (writer.Indent()) + writer.WriteLine(extends.TrimStart() + " {"); + } + else + writer.WriteLine(declaration + extends + " {"); + + using (writer.Indent()) + { + WriteConstructor(writer, type); + + foreach (var child in type.Children) + { + writer.WriteLine(); + WriteChild(writer, child); + } + } + + writer.WriteLine("}"); + } + + /// + /// Writes the constructor of an endpoint class. + /// + private void WriteConstructor(JvmWriter writer, JvmEndpointClass type) + { + string name = type.Identifier.Name; + + if (Equals(type.BaseType, Packages.EntryEndpoint)) + { + if (!entryConstructor) return; + + // Kotlin passes the serializer as a named argument and lets the credentials parameter before it default. + // Java has neither, so the credentials have to be passed positionally, and cast because @JvmOverloads generates a (URI, OkHttpClient, Serializer) overload a bare null could also match. + string arguments = serializer.RuntimeSerializer is {} runtimeSerializer + ? $"uri, ({Packages.HttpCredentials.Name}) null, new {runtimeSerializer.Name}()" + : "uri"; + + writer.WriteDocComment($"Creates a new {name}."); + writer.WriteLine($"public {name}(URI uri) {{"); + using (writer.Indent()) + writer.WriteLine($"super({arguments});"); + writer.WriteLine("}"); + return; + } + + var constructor = type.Constructor; + var parameters = constructor?.Parameters ?? []; + var baseArguments = constructor?.BaseArguments ?? []; + + string parameterList = string.Join(", ", parameters.Select(x => $"{TypeExpression(x.Type)} {x.Name}")); + string argumentList = string.Join(", ", baseArguments.Select(Expression)); + + writer.WriteDocComment($"Creates a new {name}."); + writer.WriteLine($"public {name}({parameterList}) {{"); + using (writer.Indent()) + writer.WriteLine($"super({argumentList});"); + writer.WriteLine("}"); + } + + /// + /// Writes a child endpoint as a public final field. + /// + private void WriteChild(JvmWriter writer, JvmChildEndpoint child) + { + writer.WriteDocComment(child.Summary, child.Deprecated); + if (child.Deprecated) writer.WriteLine("@Deprecated"); + + writer.WriteLine($"public final {TypeExpression(child.Type)} {child.Name} ="); + using (writer.Indent()) + writer.WriteLine(Expression(child.Value) + ";"); + } + + /// + /// Writes a DTO as a class with public final fields and a constructor. + /// + private void WriteDto(JvmWriter writer, JvmDto type) + { + writer.WriteDocComment(type.Summary, type.Deprecated); + if (type.Deprecated) writer.WriteLine("@Deprecated"); + + foreach (var annotation in serializer.TypeAnnotations()) + writer.WriteLine(annotation.Write()); + + writer.WriteLine($"public class {type.Identifier.Name} {{"); + + using (writer.Indent()) + { + foreach (var property in type.Properties) + { + writer.WriteDocComment(property.Summary, property.Deprecated); + if (property.Deprecated) writer.WriteLine("@Deprecated"); + if (serializer.PropertyName(property.WireName) is {} annotation) + writer.WriteLine(annotation.Write()); + if (nullableAnnotations && property.Type.Nullable) + writer.WriteLine(Nullable.Write()); + + writer.WriteLine($"public {TypeExpression(property.Type)} {property.Name};"); + writer.WriteLine(); + } + + // The serializers construct the instance and then populate the fields, so they need a no-argument constructor. + // Writing it explicitly keeps it once the full constructor below removes the default one. + writer.WriteDocComment($"Creates an empty {type.Identifier.Name}."); + writer.WriteLine($"public {type.Identifier.Name}() {{}}"); + + if (type.Properties.Count != 0) + { + writer.WriteLine(); + WriteDtoConstructor(writer, type); + } + } + + writer.WriteLine("}"); + } + + private void WriteDtoConstructor(JvmWriter writer, JvmDto type) + { + string parameters = string.Join(", ", type.Properties.Select(x => $"{TypeExpression(x.Type)} {x.Name}")); + + writer.WriteDocComment($"Creates a {type.Identifier.Name} with all fields set."); + writer.WriteLine($"public {type.Identifier.Name}({parameters}) {{"); + using (writer.Indent()) + { + foreach (var property in type.Properties) + writer.WriteLine($"this.{property.Name} = {property.Name};"); + } + writer.WriteLine("}"); + } + + private void WriteEnum(JvmWriter writer, JvmEnum type) + { + writer.WriteDocComment(type.Summary, type.Deprecated); + if (type.Deprecated) writer.WriteLine("@Deprecated"); + + foreach (var annotation in serializer.EnumAnnotations()) + writer.WriteLine(annotation.Write()); + + writer.WriteLine($"public enum {type.Identifier.Name} {{"); + + using (writer.Indent()) + { + for (int i = 0; i < type.Values.Count; i++) + { + var value = type.Values[i]; + string separator = i == type.Values.Count - 1 ? ";" : ","; + + writer.WriteDocComment(value.Summary); + if (serializer.EnumMemberName(value.WireName) is {} annotation) + writer.WriteLine(annotation.Write()); + writer.WriteLine(value.Name + separator); + } + } + + writer.WriteLine("}"); + } + + /// + /// Writes a type reference, e.g. List<Contact>. + /// + public string TypeExpression(JvmIdentifier identifier) + { + string name = identifier.Kind switch + { + JvmTypeKind.Int => "Integer", + JvmTypeKind.Long => "Long", + JvmTypeKind.Double => "Double", + JvmTypeKind.Boolean => "Boolean", + JvmTypeKind.Object => "Object", + _ => identifier.Name + }; + + return identifier.TypeArguments.Count == 0 + ? name + : $"{name}<{string.Join(", ", identifier.TypeArguments.Select(TypeExpression))}>"; + } + + /// + /// Writes the type of an object creation, using the diamond operator where there are type arguments to infer. + /// + private string CreationType(JvmIdentifier identifier) + => identifier.TypeArguments.Count == 0 + ? TypeExpression(identifier) + : TypeExpression(identifier).Split('<')[0] + "<>"; + + /// + /// Writes an expression. + /// + public string Expression(JvmExpression expression) + => expression switch + { + JvmThis => "this", + JvmName name => name.Name, + JvmLiteral literal => JvmSyntax.Quote(literal.Value), + + // Not `new URI(...)`, whose checked URISyntaxException a field initializer cannot handle + JvmUriLiteral uri => $"URI.create({JvmSyntax.Quote(uri.Value)})", + + // Java's class literal is already a java.lang.Class, with no type arguments allowed on it + JvmClassLiteral classLiteral => $"{TypeExpression(classLiteral.Type.ToNonNullable()).Split('<')[0]}.class", + + // The runtime declares the factory as a Kotlin function type, which Java sees as a Function2 whose single abstract invoke method a lambda can implement + JvmElementFactory factory => + $"({JvmElementFactory.ReferrerParameter}, {JvmElementFactory.RelativeUriParameter}) -> {Expression(factory.Body)}", + + // Every generated member declares its type explicitly, so the diamond operator infers the arguments + JvmNew creation => + $"new {CreationType(creation.Type)}({string.Join(", ", creation.Arguments.Select(Expression))})", + + _ => throw new ArgumentException($"Cannot write a {expression.GetType().Name} as Java.", nameof(expression)) + }; +} diff --git a/src/TypedRest.CodeGeneration.Java/OpenApiDocumentExtensions.cs b/src/TypedRest.CodeGeneration.Java/OpenApiDocumentExtensions.cs new file mode 100644 index 0000000..1bc7910 --- /dev/null +++ b/src/TypedRest.CodeGeneration.Java/OpenApiDocumentExtensions.cs @@ -0,0 +1,56 @@ +using TypedRest.CodeGeneration.Generation; +using TypedRest.CodeGeneration.Jvm; +using TypedRest.CodeGeneration.Jvm.Dtos; +using TypedRest.CodeGeneration.Jvm.Endpoints; +using TypedRest.CodeGeneration.Jvm.Model; +using TypedRest.CodeGeneration.Patterns; + +namespace TypedRest.CodeGeneration.Java; + +/// +/// Generates Java TypedRest clients for OpenAPI/Swagger documents. +/// +public static class OpenApiDocumentExtensions +{ + /// + /// Generates the source files of a Java TypedRest client for . + /// + /// The document describing the service. + /// Options controlling the generation. + /// Collects messages about aspects of the document that Java cannot express. + /// Controls what is inferred when the document has no x-typedrest extension. + /// Controls what code is emitted for each kind of endpoint. + public static IEnumerable GenerateTypedRestJava(this OpenApiDocument doc, JavaGenerationOptions options, IGenerationLog? log = null, PatternRegistry? patterns = null, BuilderRegistry? builders = null) + { + var naming = options.NamingStrategy(); + + // Endpoints and DTOs may share a package, so they have to agree on the names they hand out + var typeNames = new TypeNameRegistry(); + + var types = doc.GenerateTypedRestJavaEndpoints(naming, log, patterns, builders, typeNames).ToList(); + if (options.GenerateDtos) + types.AddRange(doc.GenerateJavaDtos(naming, typeNames)); + + var writer = new JavaWriter(options.ResolveSerializer(), options.GenerateEntryConstructor, options.NullableAnnotations); + return types.Select(type => (IGeneratedFile)new JavaGeneratedFile(type, writer)); + } + + /// + /// Generates the endpoint types of a Java TypedRest client for , without the DTOs. + /// + public static IEnumerable GenerateTypedRestJavaEndpoints(this OpenApiDocument doc, INamingStrategy naming, IGenerationLog? log = null, PatternRegistry? patterns = null, BuilderRegistry? builders = null, TypeNameRegistry? typeNames = null) + { + var generator = new EndpointGenerator(naming, builders ?? BuilderRegistry.Default, typeNames) + { + Log = log ?? NullGenerationLog.Instance + }; + var entryEndpoint = doc.GetTypedRest() ?? doc.MatchTypedRestPatterns(patterns); + return generator.Generate(entryEndpoint); + } + + /// + /// Generates Java types for the schemas in . + /// + public static IEnumerable GenerateJavaDtos(this OpenApiDocument doc, INamingStrategy naming, TypeNameRegistry? typeNames = null) + => new DtoGenerator(naming, typeNames).Generate(doc.Components?.Schemas ?? new Dictionary()); +} diff --git a/src/TypedRest.CodeGeneration.Java/README.md b/src/TypedRest.CodeGeneration.Java/README.md new file mode 100644 index 0000000..7888ce8 --- /dev/null +++ b/src/TypedRest.CodeGeneration.Java/README.md @@ -0,0 +1,85 @@ +# ![TypedRest](https://raw.githubusercontent.com/TypedRest/TypedRest-DotNet/master/logo.svg) Code Generation for Java + +Generates Java source code for [TypedRest for the JVM](https://github.com/TypedRest/TypedRest-Java) clients from [OpenAPI/Swagger](https://swagger.io/resources/open-api/) documents. + + dotnet add package TypedRest.CodeGeneration.Java + +Use this to build your own code generator. If you just want to generate a client for your API, use the [command-line tool](https://www.nuget.org/packages/typedrest-codegen/) instead; it is built on this library. + +> **Consuming the client from Kotlin?** Generate Kotlin instead, with [TypedRest.CodeGeneration.Kotlin](https://www.nuget.org/packages/TypedRest.CodeGeneration.Kotlin/). TypedRest for the JVM is written in Kotlin, so that is the lower-friction direction: you get `data class` DTOs, real null safety and kotlinx.serialization. This package is for projects whose own source is Java. + +## Usage + +```csharp +var reader = new OpenApiStreamReader(new OpenApiReaderSettings().AddTypedRest()); +var doc = reader.Read(File.OpenRead("myapi.yml"), out _); + +foreach (var file in doc.GenerateTypedRestJava(new JavaGenerationOptions("MyService") +{ + Namespace = "com.mycompany.myservice", + GenerateDtos = true +})) + file.WriteToDirectory("src/main/java/"); +``` + +`GenerateTypedRestJava()` uses the endpoints described by the document's `x-typedrest` extension, or infers them from the paths using [TypedRest.CodeGeneration](https://www.nuget.org/packages/TypedRest.CodeGeneration/) if there is no such extension. + +The generated code needs the TypedRest artifacts plus a serializer on the classpath: + +```kotlin +dependencies { + implementation("net.typedrest:typedrest:") + implementation("net.typedrest:typedrest-serializers-jackson:") + compileOnly("org.jspecify:jspecify:") +} +``` + +Add `net.typedrest:typedrest-reactive` as well if the document describes any polling or streaming endpoints. + +## Output + +One public type per file, in a directory matching its package, as Java requires. `Namespace` is the package for the endpoints, defaulting to the service name; `DtoNamespace` is the package for the DTOs, defaulting to a `dtos` subpackage of the endpoints. + +Endpoints become classes deriving from the TypedRest `Impl` classes and exposing their children as `public final` fields. They are fields rather than getters because a getter recomputing the endpoint on every call would hand out a new instance each time and throw away the response cache `AbstractCachingEndpoint` keeps. + +DTOs become plain classes with public fields, a no-argument constructor and a full constructor — not `record`s, which need Java 16 and do not suit the serializers' construct-then-populate approach. Schemas with an `enum` become `enum`s. + +A `$ref` inside `allOf` is flattened into the type rather than becoming a base class, keeping the output identical in shape to the Kotlin generator's. + +### Nullability + +Properties the document does not mark as required are annotated with JSpecify's `@Nullable`. Without it Kotlin sees every generated type as a platform type and silently drops null safety across the whole DTO surface — exactly where it matters most, since an optional field really can be absent. Turn it off with `NullableAnnotations = false` if you would rather not take the dependency. + +## Serializers + +`Serializer` picks which annotations carry the wire names: + +| Value | Type annotation | Property annotation | Artifact | +| ------------------- | ------------------------------------ | ------------------- | --------------------------------------------- | +| `jackson` (default) | | `@JsonProperty` | `net.typedrest:typedrest-serializers-jackson` | +| `moshi` | `@JsonClass(generateAdapter = true)` | `@Json(name = ...)` | `net.typedrest:typedrest-serializers-moshi` | + +`kotlinx` is **not** available here. kotlinx.serialization generates its serializers with a Kotlin compiler plugin and cannot handle a class written in Java, so asking for it is an error rather than a silent fallback — the resulting client would compile and then fail to deserialize anything at runtime. + +Because `EntryEndpoint` defaults to kotlinx.serialization, a generated Java client always passes its serializer explicitly: + +```java +public MyServiceClient(URI uri) { + super(uri, new JacksonJsonSerializer()); +} +``` + +## Extension points + +`GenerateTypedRestJava()` takes an optional `PatternRegistry` controlling what is inferred, and an optional `BuilderRegistry` controlling what is emitted. Both live in [TypedRest.CodeGeneration.Jvm](https://www.nuget.org/packages/TypedRest.CodeGeneration.Jvm/) and are shared with the Kotlin generator, because both languages target the same runtime types. + +## Related packages + +- [TypedRest.CodeGeneration.Jvm](https://www.nuget.org/packages/TypedRest.CodeGeneration.Jvm/) is the basis of this library and holds everything shared with the Kotlin generator. +- [TypedRest.CodeGeneration.Kotlin](https://www.nuget.org/packages/TypedRest.CodeGeneration.Kotlin/) does the same for Kotlin. +- [typedrest-codegen](https://www.nuget.org/packages/typedrest-codegen/) is a command-line tool that builds on this library and writes the generated code to disk. + +## Links + +- [Code generation documentation](https://typedrest.net/code-generation/) +- [API documentation](https://code-generation.typedrest.net/) diff --git a/src/TypedRest.CodeGeneration.Java/TypedRest.CodeGeneration.Java.csproj b/src/TypedRest.CodeGeneration.Java/TypedRest.CodeGeneration.Java.csproj new file mode 100644 index 0000000..b39a393 --- /dev/null +++ b/src/TypedRest.CodeGeneration.Java/TypedRest.CodeGeneration.Java.csproj @@ -0,0 +1,17 @@ + + + + + netstandard2.0;net8.0;net10.0 + Java code generator for TypedRest clients from OpenAPI/Swagger + Generates Java source code for TypedRest clients from OpenAPI/Swagger documents. + Typed REST OpenAPI Swagger CodeGen Java JVM + ..\..\artifacts\$(Configuration)\ + + + + + + + + diff --git a/src/TypedRest.CodeGeneration.Jvm/Dtos/DtoBuilders.cs b/src/TypedRest.CodeGeneration.Jvm/Dtos/DtoBuilders.cs new file mode 100644 index 0000000..62969d6 --- /dev/null +++ b/src/TypedRest.CodeGeneration.Jvm/Dtos/DtoBuilders.cs @@ -0,0 +1,253 @@ +using TypedRest.CodeGeneration.Generation; +using TypedRest.CodeGeneration.Jvm.Model; + +namespace TypedRest.CodeGeneration.Jvm.Dtos; + +/// +/// Builds the type for one schema in an OpenAPI/Swagger document. +/// +/// The key of the schema in the document. +/// The schema to build a type for. +/// Decides what the generated type is called. +/// Keeps the generated names from colliding. +public abstract class DtoBuilder(string key, OpenApiSchema schema, INamingStrategy naming, TypeNameRegistry? typeNames = null) +{ + /// The name and package of the generated type. + protected readonly JvmIdentifier Identifier = typeNames?.Register(naming.DtoType(key)) ?? naming.DtoType(key); + + /// The schema being generated for. + protected readonly OpenApiSchema Schema = schema; + + /// Decides what the generated types are called. + protected readonly INamingStrategy Naming = naming; + + /// Keeps the names of types generated for inline schemas from colliding with other generated types. + protected readonly TypeNameRegistry? TypeNames = typeNames; + + /// Types generated for schemas inlined into this one. + protected readonly List ChildTypes = []; + + /// + /// Returns the builder for , or null if it needs no type of its own. + /// + public static DtoBuilder? For(string key, OpenApiSchema schema, INamingStrategy naming, TypeNameRegistry? typeNames = null) + => (schema.Type ?? "object") switch + { + "object" => new DtoClassBuilder(key, schema, naming, typeNames), + "string" when schema.Enum.Count != 0 => new DtoEnumBuilder(key, schema, naming, typeNames), + "integer" when schema.Enum.Count != 0 => new DtoEnumBuilder(key, schema, naming, typeNames), + _ => null + }; + + /// + /// Builds the type for the schema, followed by any types generated for schemas inlined into it. + /// + public IEnumerable BuildTypes() + { + ChildTypes.Clear(); + yield return BuildType(); + + foreach (var type in ChildTypes) + yield return type; + } + + private IJvmType BuildType() + { + var type = BuildTypeInner(); + type.Summary = Schema.Description; + type.Deprecated = Schema.Deprecated; + return type; + } + + /// + /// Builds the type itself, without the parts every kind of DTO has in common. + /// + protected abstract IJvmType BuildTypeInner(); +} + +/// +/// Builds a DTO for an object schema. +/// +public class DtoClassBuilder(string key, OpenApiSchema schema, INamingStrategy naming, TypeNameRegistry? typeNames = null) + : DtoBuilder(key, schema, naming, typeNames) +{ + /// + /// The properties of this type, including any merged in from allOf schemas. + /// + protected readonly IReadOnlyDictionary Properties = GetProperties(schema); + + /// + /// The keys of the required properties, including any from allOf schemas. + /// + protected readonly ICollection RequiredProperties = GetRequiredProperties(schema); + + /// + protected override IJvmType BuildTypeInner() + { + var type = new JvmDto(Identifier); + + foreach ((string propKey, var propSchema) in Properties) + type.Properties.Add(BuildProperty(propKey, propSchema)); + + return type; + } + + /// + /// Returns every schema contributing properties to this type. + /// + private static IEnumerable GetSources(OpenApiSchema schema) + { + yield return schema; + foreach (var source in schema.AllOf) + yield return source; + } + + private static IReadOnlyDictionary GetProperties(OpenApiSchema schema) + { + var result = new Dictionary(); + foreach (var source in GetSources(schema)) + { + foreach ((string key, var value) in source.Properties) + result[key] = value; + } + return result; + } + + private static ICollection GetRequiredProperties(OpenApiSchema schema) + => new HashSet(GetSources(schema).SelectMany(x => x.Required)); + + /// + /// Builds one property of the DTO. + /// + protected virtual JvmDtoProperty BuildProperty(string key, OpenApiSchema? schema) + { + string propertyName = Naming.Property(key); + if (propertyName == Identifier.Name) + propertyName += "Value"; + + var type = GetPropertyType(propertyName, schema); + bool required = RequiredProperties.Contains(key); + + // A property the document does not require may simply be absent from a response, so it has to be nullable. + // Collections are the exception: they default to an empty one rather than to null. + if (!required && !IsCollection(schema)) type = type.ToNullable(); + + return new JvmDtoProperty(propertyName, key, type) + { + Summary = schema?.Description, + Deprecated = schema is {Deprecated: true}, + Required = required + }; + } + + private JvmIdentifier GetPropertyType(string nameHint, OpenApiSchema? schema) + => schema switch + { + // Inline enum + {Reference: null, Type: "string" or "integer", Enum.Count: > 0} => + AddChildType(new DtoEnumBuilder(ChildKey(nameHint), schema, Naming, TypeNames)), + + // Inline object + {Reference: null, Properties.Count: > 0} => + AddChildType(new DtoClassBuilder(ChildKey(nameHint), schema, Naming, TypeNames)), + + // Array of inline enums/objects + {Type: "array", Items: {} items} when NeedsChildType(items) => + JvmIdentifier.ListOf(GetPropertyType(nameHint.Depluralize(), items)), + + // Map of inline enums/objects + {AdditionalProperties: {} values} when NeedsChildType(values) => + JvmIdentifier.MapOf(GetPropertyType(nameHint, values)), + + _ => Naming.TypeFor(schema) + }; + + /// + /// Indicates whether a schema is inlined rather than referenced and therefore needs a type generated for it. + /// + private static bool NeedsChildType(OpenApiSchema schema) + => schema is {Reference: null, Type: "string" or "integer", Enum.Count: > 0} + or {Reference: null, Properties.Count: > 0}; + + /// + /// Prefixes a child type's key with this type's name, so that e.g. two DTOs with an inline status enum + /// do not both generate a type called Status. + /// + private string ChildKey(string nameHint) + => Identifier.Name + nameHint; + + private JvmIdentifier AddChildType(DtoBuilder builder) + { + var types = builder.BuildTypes().ToList(); + ChildTypes.AddRange(types); + return types[0].Identifier; + } + + private static bool IsCollection(OpenApiSchema? schema) + => schema is {Type: "array"} or {AdditionalProperties: not null}; +} + +/// +/// Builds an enum for a schema with an enum. +/// +public class DtoEnumBuilder(string key, OpenApiSchema schema, INamingStrategy naming, TypeNameRegistry? typeNames = null) + : DtoBuilder(key, schema, naming, typeNames) +{ + /// + protected override IJvmType BuildTypeInner() + { + var type = new JvmEnum(Identifier); + var usedNames = new HashSet(); + + foreach (var value in Schema.Enum) + { + switch (value) + { + case OpenApiString str: + type.Values.Add(new JvmEnumValue(UniqueName(usedNames, ValueName(str.Value)), str.Value)); + break; + case OpenApiInteger num: + type.Values.Add(new JvmEnumValue(UniqueName(usedNames, NumericName(num.Value)), num.Value.ToString())); + break; + case OpenApiLong num: + type.Values.Add(new JvmEnumValue(UniqueName(usedNames, NumericName(num.Value)), num.Value.ToString())); + break; + } + } + + return type; + } + + /// + /// Builds the name of an enum value. + /// + private static string ValueName(string value) + { + var words = Words.Split(value); + return words.Count == 0 + ? "" + : JvmSyntax.Identifier(string.Join("_", words.Select(word => word.ToUpperInvariant()))); + } + + /// + /// Builds a name for a numeric value, avoiding the minus sign which may not appear in an identifier. + /// + private static string NumericName(long value) + => value < 0 + ? "VALUE_MINUS_" + -value + : "VALUE_" + value; + + /// + /// Ensures the is usable as an identifier and unique within the enum. + /// + private static string UniqueName(HashSet usedNames, string name) + { + // Schemas may contain an empty string as an enum value + if (name.Length == 0) name = "EMPTY"; + + string result = name; + for (int i = 2; !usedNames.Add(result); i++) + result = name + i; + return result; + } +} diff --git a/src/TypedRest.CodeGeneration.Jvm/Dtos/DtoGenerator.cs b/src/TypedRest.CodeGeneration.Jvm/Dtos/DtoGenerator.cs new file mode 100644 index 0000000..800b869 --- /dev/null +++ b/src/TypedRest.CodeGeneration.Jvm/Dtos/DtoGenerator.cs @@ -0,0 +1,31 @@ +using TypedRest.CodeGeneration.Jvm.Model; + +namespace TypedRest.CodeGeneration.Jvm.Dtos; + +/// +/// Generates types for the schemas in an OpenAPI/Swagger document. +/// +/// Decides what the generated types are called. +/// Keeps the generated names from colliding. Share this with an endpoint generator writing to the same package. +public class DtoGenerator(INamingStrategy naming, TypeNameRegistry? typeNames = null) +{ + /// + /// Generates a type for each of the that needs one. + /// + public IEnumerable Generate(IEnumerable> schemas) + { + var names = typeNames ?? new TypeNameRegistry(); + + // Create all builders first, so that types from the document claim their names before the types + // generated for inline schemas, which get a number appended if their name is already taken + var builders = schemas.Select(x => DtoBuilder.For(x.Key, x.Value, naming, names)) + .OfType() + .ToList(); + + foreach (var builder in builders) + { + foreach (var type in builder.BuildTypes()) + yield return type; + } + } +} diff --git a/src/TypedRest.CodeGeneration.Jvm/Endpoints/BuilderBase.cs b/src/TypedRest.CodeGeneration.Jvm/Endpoints/BuilderBase.cs new file mode 100644 index 0000000..42dc2f7 --- /dev/null +++ b/src/TypedRest.CodeGeneration.Jvm/Endpoints/BuilderBase.cs @@ -0,0 +1,145 @@ +using TypedRest.CodeGeneration.Endpoints; +using TypedRest.CodeGeneration.Jvm.Model; + +namespace TypedRest.CodeGeneration.Jvm.Endpoints; + +/// +/// Common base class for s. +/// +/// The type of to generate code for. +public abstract class BuilderBase : IBuilder + where TEndpoint : IEndpoint +{ + /// + public (JvmChildEndpoint child, IEnumerable types) Build(string key, IEndpoint endpoint, IEndpointGenerator generator) + => Build(key, (TEndpoint)endpoint, generator); + + /// + public (JvmChildEndpoint child, IEnumerable types) Build(string key, TEndpoint endpoint, IEndpointGenerator generator) + { + var types = new List(); + + var (baseType, additionalTypes, extraArguments) = GetBase(key, endpoint, generator); + types.AddRange(additionalTypes); + + var extras = extraArguments.ToList(); + + var inlineCreation = new JvmNew(baseType) {Arguments = {JvmThis.Instance, RelativeUri(endpoint)}}; + inlineCreation.Arguments.AddRange(extras); + + var memberType = baseType; + JvmExpression value = inlineCreation; + + if (endpoint.Children.Count > 0 || RequiresGeneratedClass) + { + var implementation = CustomImplementation(key, endpoint, baseType, extras, types, generator); + types.Add(implementation); + + memberType = implementation.Identifier; + + // A generated class bakes its own relative URI in, unless it sits in an element position + var creation = new JvmNew(memberType) {Arguments = {JvmThis.Instance}}; + if (endpoint.Uri == null) creation.Arguments.Add(RelativeUri(endpoint)); + value = creation; + } + + return ( + new JvmChildEndpoint(generator.Naming.Property(key), memberType, value) {Summary = endpoint.Description}, + types); + } + + private JvmEndpointClass CustomImplementation(string key, TEndpoint endpoint, JvmIdentifier baseType, List extraArguments, List types, IEndpointGenerator generator) + { + var implementation = new JvmEndpointClass(generator.EndpointType(key, endpoint)) + { + Summary = endpoint.Description, + BaseType = baseType, + Constructor = BuildConstructor(endpoint, extraArguments, generator) + }; + + generator.PushParent(key); + try + { + foreach ((string childKey, var childEndpoint) in endpoint.Children) + { + var (child, additionalTypes) = generator.Generate(childKey, childEndpoint); + implementation.Children.Add(child); + types.AddRange(additionalTypes); + } + } + finally + { + generator.PopParent(); + } + + return implementation; + } + + /// + /// Builds the constructor of a generated endpoint class. + /// + protected virtual JvmConstructor BuildConstructor(TEndpoint endpoint, List extraArguments, IEndpointGenerator generator) + { + bool hasUri = endpoint.Uri != null; + + var constructor = new JvmConstructor + { + Parameters = {new JvmParameter("referrer", Packages.Endpoint)}, + BaseArguments = {new JvmName("referrer"), RelativeUri(endpoint)} + }; + if (!hasUri) constructor.Parameters.Add(new JvmParameter("relativeUri", JvmIdentifier.Uri)); + constructor.BaseArguments.AddRange(extraArguments); + + return constructor; + } + + /// + /// The relative URI of the endpoint, either as a literal or as the constructor parameter it is handed in. + /// + protected JvmExpression RelativeUri(TEndpoint endpoint) + => endpoint.Uri switch + { + null => new JvmName("relativeUri"), + {} uri when RequiresUriObject => new JvmUriLiteral(uri), + {} uri => new JvmLiteral(uri) + }; + + /// + /// Returns the TypedRest type the endpoint derives from, any additional types generated along the way, and + /// any constructor arguments beyond the referrer and the relative URI. + /// + protected virtual (JvmIdentifier baseType, IEnumerable types, IEnumerable extraArguments) GetBase(string key, TEndpoint endpoint, IEndpointGenerator generator) + => (GetBaseType(endpoint, generator), [], ExtraArguments(endpoint, generator)); + + /// + /// Returns the TypedRest implementation class the endpoint derives from. + /// + protected abstract JvmIdentifier GetBaseType(TEndpoint endpoint, IEndpointGenerator generator); + + /// + /// Indicates whether this kind of endpoint needs a generated class even when it has no children. + /// + /// + /// Most kinds map to a concrete TypedRest class, which is simply constructed inline where there are no children + /// to hold. Override this where the base type cannot be instantiated. + /// + protected virtual bool RequiresGeneratedClass => false; + + /// + /// Indicates whether the base type takes the relative URI as a URI rather than a String. + /// + /// + /// Every Impl class has a secondary constructor taking a String. Override this where the base + /// type has only the URI one, so that the literal gets wrapped. + /// + protected virtual bool RequiresUriObject => false; + + /// + /// Returns the constructor arguments beyond the referrer and the relative URI. + /// + /// + /// Most JVM endpoints take a Class<T> for the entity they deserialize, because TypedRest for the JVM avoids reified type parameters so that its endpoints stay usable from Java. + /// + protected virtual IEnumerable ExtraArguments(TEndpoint endpoint, IEndpointGenerator generator) + => []; +} diff --git a/src/TypedRest.CodeGeneration.Jvm/Endpoints/BuilderRegistry.cs b/src/TypedRest.CodeGeneration.Jvm/Endpoints/BuilderRegistry.cs new file mode 100644 index 0000000..f338195 --- /dev/null +++ b/src/TypedRest.CodeGeneration.Jvm/Endpoints/BuilderRegistry.cs @@ -0,0 +1,49 @@ +using TypedRest.CodeGeneration.Endpoints; +using TypedRest.CodeGeneration.Generation; + +namespace TypedRest.CodeGeneration.Jvm.Endpoints; + +/// +/// A list of all known s. +/// +public class BuilderRegistry : BuilderRegistry +{ + /// + /// Builder registry with the built-in default s. + /// + public static BuilderRegistry Default + => new BuilderRegistry() + .Add(new DefaultBuilder()) + .Add(new ElementBuilder()) + .Add(new IndexerBuilder()) + .Add(new CollectionBuilder()) + .Add(new ActionBuilder()) + .Add(new ProducerBuilder()) + .Add(new ConsumerBuilder()) + .Add(new FunctionBuilder()) + .Add(new UploadBuilder()) + .Add(new BlobBuilder()) + .Add(new PollingBuilder()) + .Add(new StreamingBuilder()) + .Add(new SseStreamingBuilder()) + .Add(new StreamingCollectionBuilder()); + + /// + /// Creates a registry holding only the . + /// + public BuilderRegistry() + { + // Must always be registered + Add(new EntryBuilder()); + } + + /// + /// Adds to the list of known builders. + /// + public BuilderRegistry Add(IBuilder builder) + where TEndpoint : IEndpoint, new() + { + Register(builder); + return this; + } +} diff --git a/src/TypedRest.CodeGeneration.Jvm/Endpoints/Builders.cs b/src/TypedRest.CodeGeneration.Jvm/Endpoints/Builders.cs new file mode 100644 index 0000000..805b405 --- /dev/null +++ b/src/TypedRest.CodeGeneration.Jvm/Endpoints/Builders.cs @@ -0,0 +1,340 @@ +using TypedRest.CodeGeneration.Endpoints; +using TypedRest.CodeGeneration.Endpoints.Generic; +using TypedRest.CodeGeneration.Endpoints.Raw; +using TypedRest.CodeGeneration.Endpoints.Reactive; +using TypedRest.CodeGeneration.Endpoints.Rpc; +using TypedRest.CodeGeneration.Jvm.Model; + +namespace TypedRest.CodeGeneration.Jvm.Endpoints; + +/// +/// Builds code for the entry endpoint. +/// +public class EntryBuilder : BuilderBase +{ + /// + protected override JvmIdentifier GetBaseType(EntryEndpoint endpoint, IEndpointGenerator generator) + => Packages.EntryEndpoint; + + /// + protected override JvmConstructor BuildConstructor(EntryEndpoint endpoint, List extraArguments, IEndpointGenerator generator) + => new(); +} + +/// +/// Builds code for endpoints with no more specific kind. +/// +public class DefaultBuilder : BuilderBase +{ + /// + protected override JvmIdentifier GetBaseType(Endpoint endpoint, IEndpointGenerator generator) + => Packages.AbstractEndpoint; + + /// + /// + /// AbstractEndpoint is abstract and there is no EndpointImpl to instantiate instead, so a plain + /// endpoint needs a class of its own even when it has no children to hold. + /// + protected override bool RequiresGeneratedClass => true; + + /// + /// + /// AbstractEndpoint has no secondary constructor taking the relative URI as a String, unlike + /// every Impl class. + /// + protected override bool RequiresUriObject => true; +} + +/// +/// Builds code for s. +/// +public class ElementBuilder : BuilderBase +{ + /// + protected override JvmIdentifier GetBaseType(ElementEndpoint endpoint, IEndpointGenerator generator) + => ElementEndpointType(endpoint.Schema, generator); + + /// + protected override IEnumerable ExtraArguments(ElementEndpoint endpoint, IEndpointGenerator generator) + => [new JvmClassLiteral(generator.Naming.TypeFor(endpoint.Schema))]; + + internal static JvmIdentifier ElementEndpointType(OpenApiSchema? schema, IEndpointGenerator generator) + => Packages.Implementation(Packages.Generic, "ElementEndpoint") + .WithTypeArguments(generator.Naming.TypeFor(schema)); +} + +/// +/// Builds code for s. +/// +public class IndexerBuilder : BuilderBase +{ + /// + protected override (JvmIdentifier baseType, IEnumerable types, IEnumerable extraArguments) GetBase(string key, IndexerEndpoint endpoint, IEndpointGenerator generator) + { + var (child, types) = generator.Generate( + EndpointTree.ElementKey(key), + endpoint.Element ?? throw new InvalidOperationException($"Missing element for {endpoint}.")); + + return ( + Packages.Implementation(Packages.Generic, "IndexerEndpoint").WithTypeArguments(child.Type), + types, + [ElementFactory(child.Type)]); + } + + /// + protected override JvmIdentifier GetBaseType(IndexerEndpoint endpoint, IEndpointGenerator generator) + => Packages.Implementation(Packages.Generic, "IndexerEndpoint"); + + /// + /// Builds the factory the indexer and collection endpoints use to create an endpoint per element. + /// + internal static JvmExpression ElementFactory(JvmIdentifier elementType) + => new JvmElementFactory( + new JvmNew(elementType) + { + Arguments = + { + new JvmName(JvmElementFactory.ReferrerParameter), + new JvmName(JvmElementFactory.RelativeUriParameter) + } + }); +} + +/// +/// Common base class for builders for and derived types. +/// +/// The type of to generate code for. +public abstract class CollectionBuilderBase : BuilderBase + where TEndpoint : CollectionEndpoint +{ + /// + protected override (JvmIdentifier baseType, IEnumerable types, IEnumerable extraArguments) GetBase(string key, TEndpoint endpoint, IEndpointGenerator generator) + { + var entity = generator.Naming.TypeFor(endpoint.Schema ?? throw new InvalidOperationException($"Missing schema for {endpoint}.")); + var entityClass = new JvmClassLiteral(entity); + + if (endpoint.Element == null) + return (CollectionType(entity), [], [entityClass]); + + endpoint.Element.Schema ??= endpoint.Schema; + + // TElementEndpoint is constrained to ElementEndpoint, so the two schemas have to agree + var elementEntity = generator.Naming.TypeFor(endpoint.Element.Schema); + if (elementEntity.ToString() != entity.ToString()) + { + generator.Log.Report(Messages.ElementSchemaMismatch(key, entity.ToString(), elementEntity.ToString())); + endpoint.Element.Schema = endpoint.Schema; + } + + var (child, types) = generator.Generate(EndpointTree.ElementKey(key), endpoint.Element); + var elementType = child.Type; + + // A plain element endpoint needs no class of its own; the specialized collection endpoint creates it + if (IsPlainElementEndpoint(elementType, entity)) + return (CollectionType(entity), types, [entityClass]); + + return ( + GenericCollectionType(entity, elementType), + types, + [entityClass, IndexerBuilder.ElementFactory(elementType)]); + } + + /// + protected override JvmIdentifier GetBaseType(TEndpoint endpoint, IEndpointGenerator generator) + => CollectionType(generator.Naming.TypeFor(endpoint.Schema)); + + /// + /// The name of the TypedRest type creating its element endpoints itself, e.g. CollectionEndpoint. + /// + protected abstract string TypeName { get; } + + /// + /// The name of the TypedRest type taking a factory for element endpoints, e.g. GenericCollectionEndpoint. + /// + protected abstract string GenericTypeName { get; } + + /// + /// The package the two types live in. + /// + protected virtual JvmPackage Package => Packages.Generic; + + private JvmIdentifier CollectionType(JvmIdentifier entity) + => Packages.Implementation(Package, TypeName).WithTypeArguments(entity); + + private JvmIdentifier GenericCollectionType(JvmIdentifier entity, JvmIdentifier elementType) + => Packages.Implementation(Package, GenericTypeName).WithTypeArguments(entity, elementType); + + private bool IsPlainElementEndpoint(JvmIdentifier elementType, JvmIdentifier entity) + => elementType.Name == "ElementEndpointImpl" + && Equals(elementType.Package, Packages.Generic) + && elementType.TypeArguments.Count == 1 + && elementType.TypeArguments[0].ToString() == entity.ToString(); +} + +/// +/// Builds code for s. +/// +public class CollectionBuilder : CollectionBuilderBase +{ + /// + protected override string TypeName => "CollectionEndpoint"; + + /// + protected override string GenericTypeName => "GenericCollectionEndpoint"; +} + +/// +/// Builds code for s. +/// +public class StreamingCollectionBuilder : CollectionBuilderBase +{ + /// + protected override string TypeName => "StreamingCollectionEndpoint"; + + /// + protected override string GenericTypeName => "GenericStreamingCollectionEndpoint"; + + /// + protected override JvmPackage Package => Packages.Reactive; +} + +/// +/// Builds code for s. +/// +public class ActionBuilder : BuilderBase +{ + /// + protected override JvmIdentifier GetBaseType(ActionEndpoint endpoint, IEndpointGenerator generator) + => Packages.Implementation(Packages.Rpc, "ActionEndpoint"); +} + +/// +/// Builds code for s. +/// +public class ProducerBuilder : BuilderBase +{ + /// + protected override JvmIdentifier GetBaseType(ProducerEndpoint endpoint, IEndpointGenerator generator) + => Packages.Implementation(Packages.Rpc, "ProducerEndpoint") + .WithTypeArguments(generator.Naming.TypeFor(endpoint.Schema)); + + /// + protected override IEnumerable ExtraArguments(ProducerEndpoint endpoint, IEndpointGenerator generator) + => [new JvmClassLiteral(generator.Naming.TypeFor(endpoint.Schema))]; +} + +/// +/// Builds code for s. +/// +public class ConsumerBuilder : BuilderBase +{ + /// + protected override JvmIdentifier GetBaseType(ConsumerEndpoint endpoint, IEndpointGenerator generator) + => Packages.Implementation(Packages.Rpc, "ConsumerEndpoint") + .WithTypeArguments(generator.Naming.TypeFor(endpoint.Schema)); + + /// + protected override IEnumerable ExtraArguments(ConsumerEndpoint endpoint, IEndpointGenerator generator) + => [new JvmClassLiteral(generator.Naming.TypeFor(endpoint.Schema))]; +} + +/// +/// Builds code for s. +/// +public class FunctionBuilder : BuilderBase +{ + /// + protected override JvmIdentifier GetBaseType(FunctionEndpoint endpoint, IEndpointGenerator generator) + => Packages.Implementation(Packages.Rpc, "FunctionEndpoint") + .WithTypeArguments( + generator.Naming.TypeFor(endpoint.RequestSchema), + generator.Naming.TypeFor(endpoint.ResponseSchema)); + + /// + protected override IEnumerable ExtraArguments(FunctionEndpoint endpoint, IEndpointGenerator generator) + => + [ + new JvmClassLiteral(generator.Naming.TypeFor(endpoint.RequestSchema)), + new JvmClassLiteral(generator.Naming.TypeFor(endpoint.ResponseSchema)) + ]; +} + +/// +/// Builds code for s. +/// +public class BlobBuilder : BuilderBase +{ + /// + protected override JvmIdentifier GetBaseType(BlobEndpoint endpoint, IEndpointGenerator generator) + => Packages.Implementation(Packages.Raw, "BlobEndpoint"); +} + +/// +/// Builds code for s. +/// +public class UploadBuilder : BuilderBase +{ + /// + protected override JvmIdentifier GetBaseType(UploadEndpoint endpoint, IEndpointGenerator generator) + => Packages.Implementation(Packages.Raw, "UploadEndpoint"); + + /// + protected override IEnumerable ExtraArguments(UploadEndpoint endpoint, IEndpointGenerator generator) + => endpoint.FormField is {Length: > 0} field ? [new JvmLiteral(field)] : []; +} + +/// +/// Builds code for s. +/// +public class PollingBuilder : BuilderBase +{ + /// + protected override JvmIdentifier GetBaseType(PollingEndpoint endpoint, IEndpointGenerator generator) + => Packages.Implementation(Packages.Reactive, "PollingEndpoint") + .WithTypeArguments(generator.Naming.TypeFor(endpoint.Schema)); + + /// + protected override IEnumerable ExtraArguments(PollingEndpoint endpoint, IEndpointGenerator generator) + => [new JvmClassLiteral(generator.Naming.TypeFor(endpoint.Schema))]; +} + +/// +/// Builds code for s. +/// +public class StreamingBuilder : BuilderBase +{ + /// + protected override JvmIdentifier GetBaseType(StreamingEndpoint endpoint, IEndpointGenerator generator) + => Packages.Implementation(Packages.Reactive, "StreamingEndpoint") + .WithTypeArguments(generator.Naming.TypeFor(endpoint.Schema)); + + /// + protected override IEnumerable ExtraArguments(StreamingEndpoint endpoint, IEndpointGenerator generator) + { + yield return new JvmClassLiteral(generator.Naming.TypeFor(endpoint.Schema)); + + // The separator defaults to "\n", so it is only worth passing when the document asks for another one + if (endpoint.Separator is {Length: > 0} separator && separator != "\n") + yield return new JvmLiteral(separator); + } +} + +/// +/// Builds code for s. +/// +public class SseStreamingBuilder : BuilderBase +{ + /// + protected override JvmIdentifier GetBaseType(SseStreamingEndpoint endpoint, IEndpointGenerator generator) + => Packages.Implementation(Packages.Reactive, "SseStreamingEndpoint") + .WithTypeArguments(generator.Naming.TypeFor(endpoint.Schema)); + + /// + protected override IEnumerable ExtraArguments(SseStreamingEndpoint endpoint, IEndpointGenerator generator) + { + yield return new JvmClassLiteral(generator.Naming.TypeFor(endpoint.Schema)); + + if (endpoint.EventType is {Length: > 0} eventType) + yield return new JvmLiteral(eventType); + } +} diff --git a/src/TypedRest.CodeGeneration.Jvm/Endpoints/EndpointGenerator.cs b/src/TypedRest.CodeGeneration.Jvm/Endpoints/EndpointGenerator.cs new file mode 100644 index 0000000..0f75cf7 --- /dev/null +++ b/src/TypedRest.CodeGeneration.Jvm/Endpoints/EndpointGenerator.cs @@ -0,0 +1,87 @@ +using TypedRest.CodeGeneration.Endpoints; +using TypedRest.CodeGeneration.Generation; +using TypedRest.CodeGeneration.Jvm.Model; + +namespace TypedRest.CodeGeneration.Jvm.Endpoints; + +/// +/// Generates the types for a tree of s. +/// +/// Decides what the generated types and members are called. +/// Decides what code is emitted for each kind of endpoint. +/// Keeps the generated names from colliding. Share this with a DTO generator writing to the same package. +public class EndpointGenerator(INamingStrategy namingStrategy, BuilderRegistry builders, TypeNameRegistry? typeNames = null) : IEndpointGenerator +{ + /// + public INamingStrategy Naming { get; } = namingStrategy; + + /// + public IGenerationLog Log { get; set; } = NullGenerationLog.Instance; + + private HashSet _collidingKeys = []; + private readonly Stack _parentKeys = new(); + private TypeNameRegistry _typeNames = typeNames ?? new TypeNameRegistry(); + + /// + /// Generates the types for an entire client. + /// + public IEnumerable Generate(EntryEndpoint endpoint) + { + _collidingKeys = EndpointTree.FindCollidingKeys(endpoint); + _parentKeys.Clear(); + _typeNames = typeNames ?? new TypeNameRegistry(); + + var (child, generated) = Generate("entry", endpoint); + var types = generated.ToList(); + + // Endpoints are generated after the children they contain, but the entry endpoint is the most useful thing + // to read first. Its member carries the very identifier its generated class was given. + int index = types.FindIndex(x => ReferenceEquals(x.Identifier, child.Type)); + if (index > 0) + { + var entryType = types[index]; + types.RemoveAt(index); + types.Insert(0, entryType); + } + + return types; + } + + /// + public (JvmChildEndpoint child, IEnumerable types) Generate(string key, IEndpoint endpoint) + => builders.For(endpoint).Build(key, endpoint, this); + + /// + public JvmIdentifier EndpointType(string key, IEndpoint endpoint) + => _typeNames.Register(NameCandidates(key, endpoint)); + + /// + /// Returns increasingly qualified names for an endpoint: the bare key, then the key prefixed with its parent, + /// its grandparent, and so on. Keys that are known to collide skip the bare name. + /// + private List NameCandidates(string key, IEndpoint endpoint) + { + var candidates = new List(); + if (!_collidingKeys.Contains(key)) + candidates.Add(Naming.EndpointType(key, endpoint)); + + // The bottom of the stack is the entry endpoint, which would only contribute a meaningless prefix + string prefix = ""; + foreach (string parentKey in _parentKeys.Take(Math.Max(_parentKeys.Count - 1, 0))) + { + prefix = parentKey + "_" + prefix; + candidates.Add(Naming.EndpointType(key, endpoint, prefix)); + } + + if (candidates.Count == 0) + candidates.Add(Naming.EndpointType(key, endpoint)); + + return candidates; + } + + /// + public void PushParent(string key) => _parentKeys.Push(key); + + /// + public void PopParent() => _parentKeys.Pop(); +} diff --git a/src/TypedRest.CodeGeneration.Jvm/Endpoints/IBuilder.cs b/src/TypedRest.CodeGeneration.Jvm/Endpoints/IBuilder.cs new file mode 100644 index 0000000..a86f25e --- /dev/null +++ b/src/TypedRest.CodeGeneration.Jvm/Endpoints/IBuilder.cs @@ -0,0 +1,65 @@ +using TypedRest.CodeGeneration.Endpoints; +using TypedRest.CodeGeneration.Generation; +using TypedRest.CodeGeneration.Jvm.Model; + +namespace TypedRest.CodeGeneration.Jvm.Endpoints; + +/// +/// Builds the code for a specific kind of . +/// +public interface IBuilder +{ + /// + /// Builds the member exposing on its parent, plus any types needed for it. + /// + (JvmChildEndpoint child, IEnumerable types) Build(string key, IEndpoint endpoint, IEndpointGenerator generator); +} + +/// +/// Builds the code for . +/// +/// The type of to generate code for. +public interface IBuilder : IBuilder + where TEndpoint : IEndpoint +{ + /// + /// Builds the member exposing on its parent, plus any types needed for it. + /// + (JvmChildEndpoint child, IEnumerable types) Build(string key, TEndpoint endpoint, IEndpointGenerator generator); +} + +/// +/// Drives the generation of code for a tree of s. +/// +public interface IEndpointGenerator +{ + /// + /// Decides what the generated types and members are called. + /// + INamingStrategy Naming { get; } + + /// + /// Collects messages about aspects of the document the target language cannot express. + /// + IGenerationLog Log { get; } + + /// + /// Generates the member exposing an endpoint on its parent, plus any types needed for it. + /// + (JvmChildEndpoint child, IEnumerable types) Generate(string key, IEndpoint endpoint); + + /// + /// Reserves a unique name for a generated endpoint class. + /// + JvmIdentifier EndpointType(string key, IEndpoint endpoint); + + /// + /// Records that generation has descended into the children of the endpoint with this key. + /// + void PushParent(string key); + + /// + /// Records that generation has left the children of the most recently pushed endpoint. + /// + void PopParent(); +} diff --git a/src/TypedRest.CodeGeneration.Jvm/INamingStrategy.cs b/src/TypedRest.CodeGeneration.Jvm/INamingStrategy.cs new file mode 100644 index 0000000..7771d8e --- /dev/null +++ b/src/TypedRest.CodeGeneration.Jvm/INamingStrategy.cs @@ -0,0 +1,30 @@ +using TypedRest.CodeGeneration.Endpoints; +using TypedRest.CodeGeneration.Jvm.Model; + +namespace TypedRest.CodeGeneration.Jvm; + +/// +/// Decides what the types and members of a generated JVM client are called. +/// +public interface INamingStrategy +{ + /// + /// The name of the member exposing a child endpoint. + /// + string Property(string key); + + /// + /// The name and package of a generated endpoint class. + /// + JvmIdentifier EndpointType(string key, IEndpoint endpoint, string? prefix = null); + + /// + /// The name and package of a generated DTO type. + /// + JvmIdentifier DtoType(string key); + + /// + /// The JVM type a schema maps to. + /// + JvmIdentifier TypeFor(OpenApiSchema? schema); +} diff --git a/src/TypedRest.CodeGeneration.Jvm/JvmGenerationOptions.cs b/src/TypedRest.CodeGeneration.Jvm/JvmGenerationOptions.cs new file mode 100644 index 0000000..5f76779 --- /dev/null +++ b/src/TypedRest.CodeGeneration.Jvm/JvmGenerationOptions.cs @@ -0,0 +1,74 @@ +using TypedRest.CodeGeneration.Generation; +using TypedRest.CodeGeneration.Jvm.Model; + +namespace TypedRest.CodeGeneration.Jvm; + +/// +/// Options controlling the generation of a JVM TypedRest client, shared by the Java and Kotlin generators. +/// +public abstract class JvmGenerationOptions : ClientGenerationOptions +{ + /// + /// Creates new generation options. + /// + /// The service name to use for the entry endpoint. + protected JvmGenerationOptions(string serviceName) + : base(serviceName) + {} + + /// + /// Creates new generation options, copying the common options from . + /// + protected JvmGenerationOptions(ClientGenerationOptions other) + : base(other) + {} + + /// + /// The package name DTOs go into when is not set, relative + /// to the endpoint package. + /// + public const string DefaultDtoSubPackage = "dtos"; + + /// + /// The type to use for schemas that carry no usable type information. + /// + public JvmIdentifier UntypedFallback { get; set; } = JvmIdentifier.Object; + + /// + /// The name of the serializer this target language uses when none is chosen. + /// + protected abstract string DefaultSerializerName { get; } + + /// + /// Resolves to the serializer to generate for. + /// + /// The serializer is not one of . + public JvmSerializer ResolveSerializer() + => JvmSerializer.For(Serializer ?? DefaultSerializerName); + + /// + /// The package the endpoints are generated into. + /// + public string EndpointPackage + => JvmPackage.Sanitize(Namespace ?? ServiceName); + + /// + /// The package the DTOs are generated into. + /// + /// + /// DTOs default to a subpackage of the endpoints rather than sharing their package, because a DTO and an endpoint generated from the same key would otherwise be able to collide. + /// + public string DtoPackage + => DtoNamespace is {Length: > 0} dtoNamespace + ? JvmPackage.Sanitize(dtoNamespace) + : Combine(EndpointPackage, DefaultDtoSubPackage); + + /// + /// Builds a applying the package fallbacks. + /// + public NamingStrategy NamingStrategy() + => new(ServiceName, EndpointPackage, DtoPackage, UntypedFallback); + + private static string Combine(string package, string subPackage) + => package.Length == 0 ? subPackage : package + "." + subPackage; +} diff --git a/src/TypedRest.CodeGeneration.Jvm/JvmSerializer.cs b/src/TypedRest.CodeGeneration.Jvm/JvmSerializer.cs new file mode 100644 index 0000000..e9de729 --- /dev/null +++ b/src/TypedRest.CodeGeneration.Jvm/JvmSerializer.cs @@ -0,0 +1,177 @@ +using TypedRest.CodeGeneration.Jvm.Model; + +namespace TypedRest.CodeGeneration.Jvm; + +/// +/// Supplies the annotations that carry wire names on generated DTOs, for one specific JSON serializer. +/// +public abstract class JvmSerializer +{ + /// + /// kotlinx.serialization, the default of TypedRest for the JVM. Kotlin only. + /// + public const string Kotlinx = "kotlinx"; + + /// + /// Jackson, from the typedrest-serializers-jackson artifact. + /// + public const string Jackson = "jackson"; + + /// + /// Moshi, from the typedrest-serializers-moshi artifact. + /// + public const string Moshi = "moshi"; + + /// + /// Returns the serializer with . + /// + /// is not a known serializer. + public static JvmSerializer For(string name) + => name switch + { + Kotlinx => new KotlinxSerializer(), + Jackson => new JacksonSerializer(), + Moshi => new MoshiSerializer(), + _ => throw new ArgumentException($"Unknown serializer '{name}'. Expected one of: {Kotlinx}, {Jackson}, {Moshi}.", nameof(name)) + }; + + /// + /// The name of this serializer. + /// + public abstract string Name { get; } + + /// + /// Indicates whether this serializer can handle DTOs written in Java. + /// + public abstract bool SupportsJava { get; } + + /// + /// The runtime Serializer the entry endpoint has to be constructed with, or null if it is + /// already the default of EntryEndpoint and can be left out. + /// + public abstract JvmIdentifier? RuntimeSerializer { get; } + + /// + /// The Maven coordinates of the artifact providing , for the generated README. + /// + public abstract string Artifact { get; } + + /// + /// Annotations that go on a generated DTO class itself. + /// + public virtual IEnumerable TypeAnnotations() => []; + + /// + /// Annotations that go on a generated enum. + /// + public virtual IEnumerable EnumAnnotations() => []; + + /// + /// The annotation carrying the wire name of a property, or null if the name needs none. + /// + public abstract JvmAnnotation? PropertyName(string wireName); + + /// + /// The annotation carrying the wire name of an enum value, or null if the name needs none. + /// + public abstract JvmAnnotation? EnumMemberName(string wireName); +} + +/// +/// Annotates DTOs for kotlinx.serialization. +/// +/// +/// Requires the kotlin-serialization Gradle plugin in the consuming project: the @Serializable annotation is meaningless without the compiler plugin that acts on it. +/// +public sealed class KotlinxSerializer : JvmSerializer +{ + private static readonly JvmPackage _package = JvmPackage.External("kotlinx.serialization"); + + /// + public override string Name => Kotlinx; + + /// + public override bool SupportsJava => false; + + /// + public override JvmIdentifier? RuntimeSerializer => null; + + /// + public override string Artifact => "net.typedrest:typedrest"; + + /// + public override IEnumerable TypeAnnotations() + => [new(new JvmIdentifier(_package, "Serializable"))]; + + /// + public override IEnumerable EnumAnnotations() + => [new(new JvmIdentifier(_package, "Serializable"))]; + + /// + public override JvmAnnotation? PropertyName(string wireName) + => new JvmAnnotation(new JvmIdentifier(_package, "SerialName")) {Arguments = {wireName}}; + + /// + public override JvmAnnotation? EnumMemberName(string wireName) + => new JvmAnnotation(new JvmIdentifier(_package, "SerialName")) {Arguments = {wireName}}; +} + +/// +/// Annotates DTOs for Jackson. +/// +public sealed class JacksonSerializer : JvmSerializer +{ + private static readonly JvmPackage _package = JvmPackage.External("com.fasterxml.jackson.annotation"); + + /// + public override string Name => Jackson; + + /// + public override bool SupportsJava => true; + + /// + public override JvmIdentifier? RuntimeSerializer => new(Packages.Serializers, "JacksonJsonSerializer"); + + /// + public override string Artifact => "net.typedrest:typedrest-serializers-jackson"; + + /// + public override JvmAnnotation? PropertyName(string wireName) + => new JvmAnnotation(new JvmIdentifier(_package, "JsonProperty")) {Arguments = {wireName}}; + + /// + public override JvmAnnotation? EnumMemberName(string wireName) + => new JvmAnnotation(new JvmIdentifier(_package, "JsonProperty")) {Arguments = {wireName}}; +} + +/// +/// Annotates DTOs for Moshi. +/// +public sealed class MoshiSerializer : JvmSerializer +{ + private static readonly JvmPackage _package = JvmPackage.External("com.squareup.moshi"); + + /// + public override string Name => Moshi; + + /// + public override bool SupportsJava => true; + + /// + public override JvmIdentifier? RuntimeSerializer => new(Packages.Serializers, "MoshiJsonSerializer"); + + /// + public override string Artifact => "net.typedrest:typedrest-serializers-moshi"; + + /// + public override IEnumerable TypeAnnotations() + => [new JvmAnnotation(new JvmIdentifier(_package, "JsonClass")) {NamedArguments = {("generateAdapter", "true")}}]; + + /// + public override JvmAnnotation? PropertyName(string wireName) + => new JvmAnnotation(new JvmIdentifier(_package, "Json")) {NamedArguments = {("name", JvmSyntax.Quote(wireName))}}; + + /// + public override JvmAnnotation? EnumMemberName(string wireName) + => new JvmAnnotation(new JvmIdentifier(_package, "Json")) {NamedArguments = {("name", JvmSyntax.Quote(wireName))}}; +} diff --git a/src/TypedRest.CodeGeneration.Jvm/Messages.cs b/src/TypedRest.CodeGeneration.Jvm/Messages.cs new file mode 100644 index 0000000..ea57716 --- /dev/null +++ b/src/TypedRest.CodeGeneration.Jvm/Messages.cs @@ -0,0 +1,41 @@ +using TypedRest.CodeGeneration.Generation; + +namespace TypedRest.CodeGeneration.Jvm; + +/// +/// The s the JVM generators can report. +/// +public static class Messages +{ + /// + /// The element of a collection describes a different type than the collection itself, which cannot be expressed + /// because TElementEndpoint is constrained to ElementEndpoint<TEntity>. + /// + public static GenerationMessage ElementSchemaMismatch(string key, string collectionEntity, string elementEntity) + => Warning("TRCG120", key, + $"The element of collection '{key}' describes {elementEntity} while the collection describes {collectionEntity}. TypedRest for the JVM constrains both to the same type; using {collectionEntity}."); + + /// + /// TypedRest for the JVM has no endpoint interfaces to generate alongside the classes. + /// + public static GenerationMessage InterfacesNotSupported() + => Warning("TRCG121", null, + "Generating interfaces has no effect on the JVM. TypedRest for the JVM already ships an interface for every endpoint kind, and generated endpoints derive from the Impl classes behind them."); + + /// + /// The C# language version does not apply to the JVM. + /// + public static GenerationMessage LangVersionNotSupported() + => Warning("TRCG122", null, + "The C# language version has no effect on the JVM."); + + /// + /// kotlinx.serialization cannot serialize a class written in Java. + /// + public static GenerationMessage SerializerNotSupportedByJava(string serializer) + => Warning("TRCG123", null, + $"Serializer '{serializer}' generates its serializers with a Kotlin compiler plugin and cannot handle DTOs written in Java. Generate Kotlin, or pick a reflection-based serializer such as 'jackson' or 'moshi'."); + + private static GenerationMessage Warning(string code, string? endpointKey, string text) + => new(GenerationSeverity.Warning, code, text, endpointKey); +} diff --git a/src/TypedRest.CodeGeneration.Jvm/Model/JvmAnnotation.cs b/src/TypedRest.CodeGeneration.Jvm/Model/JvmAnnotation.cs new file mode 100644 index 0000000..64e9b8b --- /dev/null +++ b/src/TypedRest.CodeGeneration.Jvm/Model/JvmAnnotation.cs @@ -0,0 +1,43 @@ +namespace TypedRest.CodeGeneration.Jvm.Model; + +/// +/// An annotation on a generated type or member, e.g. @SerialName("first_name"). +/// +/// The type of the annotation. +public sealed class JvmAnnotation(JvmIdentifier identifier) +{ + /// + /// The type of the annotation. + /// + public JvmIdentifier Identifier { get; } = identifier; + + /// + /// Positional arguments, written in order before any . + /// + public List Arguments { get; } = []; + + /// + /// Named arguments. The value is written verbatim, so a string has to arrive already quoted. + /// + public List<(string name, string value)> NamedArguments { get; } = []; + + /// + /// Writes the annotation, e.g. @JsonClass(generateAdapter = true). + /// + public string Write() + { + var parts = Arguments.Select(JvmSyntax.Quote) + .Concat(NamedArguments.Select(x => $"{x.name} = {x.value}")) + .ToList(); + + return parts.Count == 0 + ? "@" + Identifier.Name + : $"@{Identifier.Name}({string.Join(", ", parts)})"; + } + + /// + /// Returns every type that has to be imported to write this annotation. + /// + public IEnumerable GetImports() + => Identifier.GetImports(); +} diff --git a/src/TypedRest.CodeGeneration.Jvm/Model/JvmExpression.cs b/src/TypedRest.CodeGeneration.Jvm/Model/JvmExpression.cs new file mode 100644 index 0000000..b3019c2 --- /dev/null +++ b/src/TypedRest.CodeGeneration.Jvm/Model/JvmExpression.cs @@ -0,0 +1,134 @@ +namespace TypedRest.CodeGeneration.Jvm.Model; + +/// +/// An expression in generated code. +/// +public abstract class JvmExpression +{ + /// + /// Returns every type that has to be imported to write this expression. + /// + public abstract IEnumerable GetImports(); +} + +/// +/// A reference to a local variable or parameter, e.g. referrer. +/// +/// The name of the variable. +public sealed class JvmName(string name) : JvmExpression +{ + /// + /// The name of the variable. + /// + public string Name { get; } = name; + + /// + public override IEnumerable GetImports() => []; +} + +/// +/// The this reference, which every child endpoint passes as its referrer. +/// +public sealed class JvmThis : JvmExpression +{ + /// + /// The singleton instance. + /// + public static readonly JvmThis Instance = new(); + + private JvmThis() {} + + /// + public override IEnumerable GetImports() => []; +} + +/// +/// A string literal, e.g. the relative URI of an endpoint. +/// +/// The value of the literal, unescaped. +public sealed class JvmLiteral(string value) : JvmExpression +{ + /// + /// The value of the literal, unescaped. + /// + public string Value { get; } = value; + + /// + public override IEnumerable GetImports() => []; +} + +/// +/// A java.net.URI built from a string literal. +/// +/// The value of the URI, unescaped. +public sealed class JvmUriLiteral(string value) : JvmExpression +{ + /// + /// The value of the URI, unescaped. + /// + public string Value { get; } = value; + + /// + public override IEnumerable GetImports() => JvmIdentifier.Uri.GetImports(); +} + +/// +/// A class literal, which the endpoints need to deserialize their entities at runtime. +/// +/// The type to take the class literal of. +public sealed class JvmClassLiteral(JvmIdentifier type) : JvmExpression +{ + /// + /// The type to take the class literal of. + /// + public JvmIdentifier Type { get; } = type; + + /// + public override IEnumerable GetImports() => Type.GetImports(); +} + +/// +/// The construction of an object, e.g. ContactElementEndpoint(this, "contacts"). +/// +/// The type to construct. +public sealed class JvmNew(JvmIdentifier type) : JvmExpression +{ + /// + /// The type to construct. + /// + public JvmIdentifier Type { get; } = type; + + /// + /// The arguments to pass to the constructor. + /// + public List Arguments { get; } = []; + + /// + public override IEnumerable GetImports() + => Type.GetImports().Concat(Arguments.SelectMany(x => x.GetImports())); +} + +/// +/// A lambda taking a referrer and a relative URI and returning an endpoint. +/// +/// The expression the lambda returns. +public sealed class JvmElementFactory(JvmExpression body) : JvmExpression +{ + /// + /// The name of the referrer parameter. + /// + public const string ReferrerParameter = "elementReferrer"; + + /// + /// The name of the relative URI parameter. + /// + public const string RelativeUriParameter = "elementUri"; + + /// + /// The expression the lambda returns, in terms of and . + /// + public JvmExpression Body { get; } = body; + + /// + public override IEnumerable GetImports() => Body.GetImports(); +} diff --git a/src/TypedRest.CodeGeneration.Jvm/Model/JvmIdentifier.cs b/src/TypedRest.CodeGeneration.Jvm/Model/JvmIdentifier.cs new file mode 100644 index 0000000..8a00721 --- /dev/null +++ b/src/TypedRest.CodeGeneration.Jvm/Model/JvmIdentifier.cs @@ -0,0 +1,173 @@ +namespace TypedRest.CodeGeneration.Jvm.Model; + +/// +/// A reference to a JVM type. +/// +public sealed class JvmIdentifier +{ + /// + /// Creates a new type reference. + /// + /// The package the type lives in, or null for a primitive/built-in. + /// The simple name of the type. + /// Indicates whether the type can have the value null. + public JvmIdentifier(JvmPackage? package, string name, bool nullable = false) + { + Package = package; + Name = name; + Nullable = nullable; + } + + private JvmIdentifier(JvmIdentifier other, bool nullable) + : this(other.Package, other.Name, nullable) + { + Kind = other.Kind; + TypeArguments.AddRange(other.TypeArguments); + } + + /// + /// The package the type lives in, or null for a primitive/built-in. + /// + public JvmPackage? Package { get; } + + /// + /// The simple name of the type, without the package. + /// + public string Name { get; } + + /// + /// Indicates whether the type can have the value null. + /// + public bool Nullable { get; } + + /// + /// What kind of type this is, for the cases the writers have to treat specially. + /// + public JvmTypeKind Kind { get; private set; } = JvmTypeKind.Class; + + /// + /// Generic type arguments for the type. + /// + public List TypeArguments { get; } = []; + + /// + /// The fully qualified name, e.g. java.util.List. + /// + public string QualifiedName + => Package is null or {Name.Length: 0} ? Name : Package.Name + "." + Name; + + /// + /// Returns a copy of the type reference that can have the value null. + /// + public JvmIdentifier ToNullable() + => Nullable ? this : new JvmIdentifier(this, nullable: true); + + /// + /// Returns a copy of the type reference that cannot have the value null. + /// + public JvmIdentifier ToNonNullable() + => Nullable ? new JvmIdentifier(this, nullable: false) : this; + + /// + /// Returns a copy of the type reference with applied. + /// + public JvmIdentifier WithTypeArguments(params JvmIdentifier[] typeArguments) + { + var result = new JvmIdentifier(this, Nullable); + result.TypeArguments.Clear(); + result.TypeArguments.AddRange(typeArguments); + return result; + } + + /// The java.lang.String type. + public static JvmIdentifier String => new(Packages.JavaLang, "String"); + + /// The boxed java.lang.Integer type. + public static JvmIdentifier Int => new(null, "Int") {Kind = JvmTypeKind.Int}; + + /// The boxed java.lang.Long type. + public static JvmIdentifier Long => new(null, "Long") {Kind = JvmTypeKind.Long}; + + /// The boxed java.lang.Double type. + public static JvmIdentifier Double => new(null, "Double") {Kind = JvmTypeKind.Double}; + + /// The boxed java.lang.Boolean type. + public static JvmIdentifier Boolean => new(null, "Boolean") {Kind = JvmTypeKind.Boolean}; + + /// The java.net.URI type, which every endpoint constructor takes. + public static JvmIdentifier Uri => new(Packages.JavaNet, "URI"); + + /// The java.io.InputStream type, used for blob and upload endpoints. + public static JvmIdentifier InputStream => new(Packages.JavaIo, "InputStream"); + + /// The java.time.OffsetDateTime type, used for date-time formats. + public static JvmIdentifier OffsetDateTime => new(Packages.JavaTime, "OffsetDateTime"); + + /// The java.time.LocalDate type, used for date formats. + public static JvmIdentifier LocalDate => new(Packages.JavaTime, "LocalDate"); + + /// The java.util.UUID type, used for uuid formats. + public static JvmIdentifier Uuid => new(Packages.JavaUtil, "UUID"); + + /// The fallback for schemas that carry no usable type information. + public static JvmIdentifier Object => new(Packages.JavaLang, "Object") {Kind = JvmTypeKind.Object}; + + /// + /// A java.util.List of . + /// + public static JvmIdentifier ListOf(JvmIdentifier item) + => new JvmIdentifier(Packages.JavaUtil, "List") {Kind = JvmTypeKind.List}.WithTypeArguments(item); + + /// + /// A java.util.Map from String to . + /// + public static JvmIdentifier MapOf(JvmIdentifier value) + => new JvmIdentifier(Packages.JavaUtil, "Map") {Kind = JvmTypeKind.Map}.WithTypeArguments(String, value); + + /// + /// Returns every type that has to be imported to reference this type. + /// + public IEnumerable GetImports() + { + if (Package != null) yield return this; + + foreach (var import in TypeArguments.SelectMany(x => x.GetImports())) + yield return import; + } + + /// + public override string ToString() + => TypeArguments.Count == 0 + ? QualifiedName + : $"{QualifiedName}<{string.Join(", ", TypeArguments)}>"; +} + +/// +/// The kinds of type a can refer to, limited to the distinctions the writers act on. +/// +public enum JvmTypeKind +{ + /// An ordinary class or interface. + Class, + + /// A 32-bit integer, written Int in Kotlin and Integer/int in Java. + Int, + + /// A 64-bit integer, written Long in both but boxed differently in Java. + Long, + + /// A double-precision float. + Double, + + /// A boolean. + Boolean, + + /// The root type, written Any in Kotlin and Object in Java. + Object, + + /// A java.util.List. + List, + + /// A java.util.Map. + Map +} diff --git a/src/TypedRest.CodeGeneration.Jvm/Model/JvmPackage.cs b/src/TypedRest.CodeGeneration.Jvm/Model/JvmPackage.cs new file mode 100644 index 0000000..a8fca80 --- /dev/null +++ b/src/TypedRest.CodeGeneration.Jvm/Model/JvmPackage.cs @@ -0,0 +1,95 @@ +namespace TypedRest.CodeGeneration.Jvm.Model; + +/// +/// A JVM package, e.g. net.typedrest.endpoints.generic. +/// +public sealed class JvmPackage : IEquatable +{ + /// + /// Creates a package from a dotted name. + /// + /// The dotted package name. May be empty for the default package. + /// Whether this package is generated by this tool rather than coming from a library. + public JvmPackage(string name, bool generated = false) + { + Name = name; + Generated = generated; + } + + /// + /// A package belonging to the TypedRest runtime library or the JDK. + /// + public static JvmPackage External(string name) + => new(name); + + /// + /// A package holding types generated by this tool. + /// + public static JvmPackage ForGenerated(string name) + => new(name, generated: true); + + /// + /// The dotted package name. Empty for the default package. + /// + public string Name { get; } + + /// + /// Indicates whether this package holds types generated by this tool, as opposed to library types. + /// + public bool Generated { get; } + + /// + /// The directory this package's files go in, relative to the source root, using / as the separator. + /// + public string Directory + => Name.Replace('.', '/'); + + /// + /// The path of a file holding in this package, including the extension. + /// + public string FilePath(string typeName, string extension) + => Directory.Length == 0 ? typeName + extension : Directory + "/" + typeName + extension; + + /// + /// Turns a namespace-style value such as MyCompany.MyService into a valid package name. + /// + public static string Sanitize(string? value) + { + if (string.IsNullOrEmpty(value)) return ""; + + var segments = value!.Split(['.', '/', '\\'], StringSplitOptions.RemoveEmptyEntries) + .Select(SanitizeSegment) + .Where(x => x.Length != 0); + + return string.Join(".", segments); + } + + private static string SanitizeSegment(string segment) + { + var chars = segment.Where(c => char.IsLetterOrDigit(c) || c == '_').ToArray(); + if (chars.Length == 0) return ""; + + string result = new string(chars).ToLowerInvariant(); + + // A segment may not start with a digit, and must not collide with a Java keyword + if (char.IsDigit(result[0]) || JvmSyntax.IsReservedWord(result)) result = "_" + result; + + return result; + } + + /// + public bool Equals(JvmPackage? other) + => other is not null && Name == other.Name; + + /// + public override bool Equals(object? obj) + => obj is JvmPackage other && Equals(other); + + /// + public override int GetHashCode() + => Name.GetHashCode(); + + /// + public override string ToString() + => Name; +} diff --git a/src/TypedRest.CodeGeneration.Jvm/Model/JvmSyntax.cs b/src/TypedRest.CodeGeneration.Jvm/Model/JvmSyntax.cs new file mode 100644 index 0000000..ebe4ee9 --- /dev/null +++ b/src/TypedRest.CodeGeneration.Jvm/Model/JvmSyntax.cs @@ -0,0 +1,103 @@ +using System.Text; + +namespace TypedRest.CodeGeneration.Jvm.Model; + +/// +/// Syntax helpers shared by the Java and Kotlin generators. +/// +public static class JvmSyntax +{ + /// + /// The words that may not be used as an identifier in either Java or Kotlin. + /// + private static readonly HashSet _reservedWords = new(StringComparer.Ordinal) + { + // Java + "abstract", "assert", "boolean", "break", "byte", "case", "catch", "char", "class", "const", "continue", + "default", "do", "double", "else", "enum", "extends", "final", "finally", "float", "for", "goto", "if", + "implements", "import", "instanceof", "int", "interface", "long", "native", "new", "package", "private", + "protected", "public", "return", "short", "static", "strictfp", "super", "switch", "synchronized", "this", + "throw", "throws", "transient", "try", "void", "volatile", "while", + // Kotlin + "as", "fun", "in", "is", "object", "typealias", "typeof", "val", "var", "when", + // Literals, reserved in both + "true", "false", "null", "_" + }; + + /// + /// Indicates whether may not be used as an identifier in Java or Kotlin. + /// + public static bool IsReservedWord(string word) + => _reservedWords.Contains(word); + + /// + /// Returns as an identifier that is legal in both languages, suffixing reserved words. + /// + public static string Identifier(string name) + { + if (name.Length == 0) return "_"; + + var builder = new StringBuilder(name.Length); + foreach (char c in name) + { + if (char.IsLetterOrDigit(c) || c == '_') builder.Append(c); + } + + if (builder.Length == 0) return "_"; + if (char.IsDigit(builder[0])) builder.Insert(0, '_'); + + string result = builder.ToString(); + return IsReservedWord(result) ? result + "_" : result; + } + + /// + /// Returns as a double-quoted string literal, valid in both languages. + /// + public static string Quote(string value) + => Quote(value, escapeDollar: false); + + /// + /// Returns as a double-quoted string literal. + /// + /// The string to quote. + /// + /// Escapes $ as \$, which Kotlin requires to keep it from starting a string template and Java + /// rejects as an unknown escape sequence. + /// + public static string Quote(string value, bool escapeDollar) + { + var builder = new StringBuilder(value.Length + 2); + builder.Append('"'); + + foreach (char c in value) + { + switch (c) + { + case '"': + builder.Append("\\\""); + break; + case '\\': + builder.Append("\\\\"); + break; + case '\n': + builder.Append("\\n"); + break; + case '\r': + builder.Append("\\r"); + break; + case '\t': + builder.Append("\\t"); + break; + case '$' when escapeDollar: + builder.Append("\\$"); + break; + default: + builder.Append(c < ' ' ? "\\u" + ((int)c).ToString("x4") : c.ToString()); + break; + } + } + + builder.Append('"'); + return builder.ToString(); + } +} diff --git a/src/TypedRest.CodeGeneration.Jvm/Model/JvmTypes.cs b/src/TypedRest.CodeGeneration.Jvm/Model/JvmTypes.cs new file mode 100644 index 0000000..5637704 --- /dev/null +++ b/src/TypedRest.CodeGeneration.Jvm/Model/JvmTypes.cs @@ -0,0 +1,269 @@ +namespace TypedRest.CodeGeneration.Jvm.Model; + +/// +/// A type declaration that can be written to a file of its own. +/// +public interface IJvmType +{ + /// + /// The name of the type and the package it is declared in. + /// + JvmIdentifier Identifier { get; } + + /// + /// A description of the type for a JavaDoc/KDoc comment. + /// + string? Summary { get; set; } + + /// + /// Marks the type as deprecated. + /// + bool Deprecated { get; set; } + + /// + /// Returns every type that has to be imported by the file declaring this type. + /// + IEnumerable GetImports(); +} + +/// +/// A generated endpoint class, deriving from one of the TypedRest endpoint implementations. +/// +/// The name of the class and the package it is declared in. +public sealed class JvmEndpointClass(JvmIdentifier identifier) : IJvmType +{ + /// + public JvmIdentifier Identifier { get; } = identifier; + + /// + public string? Summary { get; set; } + + /// + public bool Deprecated { get; set; } + + /// + /// The TypedRest endpoint implementation this class derives from. + /// + public JvmIdentifier BaseType { get; set; } = Packages.EntryEndpoint; + + /// + /// The constructor of the class, or null to inherit the base constructor. + /// + public JvmConstructor? Constructor { get; set; } + + /// + /// The child endpoints exposed by the class. + /// + public List Children { get; } = []; + + /// + public IEnumerable GetImports() + { + foreach (var package in BaseType.GetImports()) yield return package; + + if (Constructor != null) + { + foreach (var package in Constructor.GetImports()) yield return package; + } + + foreach (var package in Children.SelectMany(x => x.GetImports())) yield return package; + } +} + +/// +/// A child endpoint exposed by a generated endpoint class. +/// +/// The name of the member. +/// The type of the member. +/// The expression the member is initialized with. +public sealed class JvmChildEndpoint(string name, JvmIdentifier type, JvmExpression value) +{ + /// + /// The name of the member. + /// + public string Name { get; } = name; + + /// + /// The type of the member. + /// + public JvmIdentifier Type { get; } = type; + + /// + /// The expression the member is initialized with. + /// + public JvmExpression Value { get; } = value; + + /// + /// A description of the member for a JavaDoc/KDoc comment. + /// + public string? Summary { get; set; } + + /// + /// Marks the member as deprecated. + /// + public bool Deprecated { get; set; } + + /// + /// Returns every type that has to be imported to declare this member. + /// + public IEnumerable GetImports() + => Type.GetImports().Concat(Value.GetImports()); +} + +/// +/// The constructor of a generated endpoint class. +/// +public sealed class JvmConstructor +{ + /// + /// The parameters of the constructor. + /// + public List Parameters { get; } = []; + + /// + /// The arguments passed on to the base constructor. + /// + public List BaseArguments { get; } = []; + + /// + /// Returns every type that has to be imported to declare this constructor. + /// + public IEnumerable GetImports() + => Parameters.SelectMany(x => x.Type.GetImports()) + .Concat(BaseArguments.SelectMany(x => x.GetImports())); +} + +/// +/// A parameter of a generated constructor. +/// +/// The name of the parameter. +/// The type of the parameter. +public sealed class JvmParameter(string name, JvmIdentifier type) +{ + /// + /// The name of the parameter. + /// + public string Name { get; } = name; + + /// + /// The type of the parameter. + /// + public JvmIdentifier Type { get; } = type; +} + +/// +/// A generated DTO. +/// +/// The name of the type and the package it is declared in. +public sealed class JvmDto(JvmIdentifier identifier) : IJvmType +{ + /// + public JvmIdentifier Identifier { get; } = identifier; + + /// + public string? Summary { get; set; } + + /// + public bool Deprecated { get; set; } + + /// + /// The properties of the DTO, in the order they were declared in the document. + /// + public List Properties { get; } = []; + + /// + public IEnumerable GetImports() + => Properties.SelectMany(x => x.GetImports()); +} + +/// +/// A property of a generated DTO. +/// +/// The name of the property in generated code. +/// The name of the property on the wire. +/// The type of the property. +public sealed class JvmDtoProperty(string name, string wireName, JvmIdentifier type) +{ + /// + /// The name of the property in generated code. + /// + public string Name { get; } = name; + + /// + /// The name of the property on the wire, which the serializer annotation carries when it differs from . + /// + public string WireName { get; } = wireName; + + /// + /// The type of the property. + /// + public JvmIdentifier Type { get; } = type; + + /// + /// A description of the property for a JavaDoc/KDoc comment. + /// + public string? Summary { get; set; } + + /// + /// Marks the property as deprecated. + /// + public bool Deprecated { get; set; } + + /// + /// Indicates whether the document marks this property as required. + /// + public bool Required { get; set; } + + /// + /// Returns every type that has to be imported to declare this property. + /// + public IEnumerable GetImports() + => Type.GetImports(); +} + +/// +/// A generated enum. +/// +/// The name of the type and the package it is declared in. +public sealed class JvmEnum(JvmIdentifier identifier) : IJvmType +{ + /// + public JvmIdentifier Identifier { get; } = identifier; + + /// + public string? Summary { get; set; } + + /// + public bool Deprecated { get; set; } + + /// + /// The values of the enum. + /// + public List Values { get; } = []; + + /// + public IEnumerable GetImports() => []; +} + +/// +/// A value of a generated enum. +/// +/// The name of the value in generated code. +/// The name of the value on the wire. +public sealed class JvmEnumValue(string name, string wireName) +{ + /// + /// The name of the value in generated code. + /// + public string Name { get; } = name; + + /// + /// The name of the value on the wire, which the serializer annotation carries when it differs from . + /// + public string WireName { get; } = wireName; + + /// + /// A description of the value for a JavaDoc/KDoc comment. + /// + public string? Summary { get; set; } +} diff --git a/src/TypedRest.CodeGeneration.Jvm/Model/JvmWriter.cs b/src/TypedRest.CodeGeneration.Jvm/Model/JvmWriter.cs new file mode 100644 index 0000000..cfbbbf5 --- /dev/null +++ b/src/TypedRest.CodeGeneration.Jvm/Model/JvmWriter.cs @@ -0,0 +1,70 @@ +namespace TypedRest.CodeGeneration.Jvm.Model; + +/// +/// Writes indented JVM source code. +/// +/// The underlying writer. +public sealed class JvmWriter(TextWriter writer) +{ + private int _level; + + /// + /// Writes a line of code at the current indentation level. + /// + public void WriteLine(string text = "") + { + if (text.Length != 0) + { + for (int i = 0; i < _level; i++) writer.Write(" "); + writer.Write(text); + } + writer.Write('\n'); + } + + /// + /// Increases the indentation level until the returned value is disposed. + /// + public IDisposable Indent() + { + _level++; + return new Dedenter(this); + } + + /// + /// Writes a JavaDoc/KDoc comment, if there is anything to write. + /// + /// The description to write, if any. + /// Adds a @deprecated tag. + public void WriteDocComment(string? summary, bool deprecated = false) + { + var lines = new List(); + if (!string.IsNullOrEmpty(summary)) + lines.AddRange(summary!.Replace("\r\n", "\n").Split('\n').Select(line => line.TrimEnd())); + if (deprecated) + { + if (lines.Count != 0) lines.Add(""); + lines.Add("@deprecated"); + } + + if (lines.Count == 0) return; + + // A doc comment may not contain the comment terminator + lines = [..lines.Select(line => line.Replace("*/", "*/"))]; + + if (lines.Count == 1) + { + WriteLine($"/** {lines[0]} */"); + return; + } + + WriteLine("/**"); + foreach (string line in lines) + WriteLine(line.Length == 0 ? " *" : " * " + line); + WriteLine(" */"); + } + + private sealed class Dedenter(JvmWriter writer) : IDisposable + { + public void Dispose() => writer._level--; + } +} diff --git a/src/TypedRest.CodeGeneration.Jvm/Model/Packages.cs b/src/TypedRest.CodeGeneration.Jvm/Model/Packages.cs new file mode 100644 index 0000000..079bffc --- /dev/null +++ b/src/TypedRest.CodeGeneration.Jvm/Model/Packages.cs @@ -0,0 +1,74 @@ +namespace TypedRest.CodeGeneration.Jvm.Model; + +/// +/// The packages generated code imports from. +/// +public static class Packages +{ + /// Holds String and Object. Never imported, but needed to qualify them. + public static JvmPackage JavaLang { get; } = JvmPackage.External("java.lang"); + + /// Holds List, Map and UUID. + public static JvmPackage JavaUtil { get; } = JvmPackage.External("java.util"); + + /// Holds URI, which every endpoint constructor takes. + public static JvmPackage JavaNet { get; } = JvmPackage.External("java.net"); + + /// Holds InputStream, used for blob and upload endpoints. + public static JvmPackage JavaIo { get; } = JvmPackage.External("java.io"); + + /// Holds OffsetDateTime and LocalDate. + public static JvmPackage JavaTime { get; } = JvmPackage.External("java.time"); + + /// Holds Endpoint and EntryEndpoint. + public static JvmPackage Endpoints { get; } = JvmPackage.External("net.typedrest.endpoints"); + + /// Holds ElementEndpoint, CollectionEndpoint, GenericCollectionEndpoint, IndexerEndpoint and their Impl classes. + public static JvmPackage Generic { get; } = JvmPackage.External("net.typedrest.endpoints.generic"); + + /// Holds ActionEndpoint, ProducerEndpoint, ConsumerEndpoint, FunctionEndpoint and their Impl classes. + public static JvmPackage Rpc { get; } = JvmPackage.External("net.typedrest.endpoints.rpc"); + + /// Holds BlobEndpoint, UploadEndpoint and their Impl classes. + public static JvmPackage Raw { get; } = JvmPackage.External("net.typedrest.endpoints.raw"); + + /// Holds the streaming and polling endpoints, from the separate typedrest-reactive artifact. + public static JvmPackage Reactive { get; } = JvmPackage.External("net.typedrest.endpoints.reactive"); + + /// Holds the Serializer implementations the entry endpoint is constructed with. + public static JvmPackage Serializers { get; } = JvmPackage.External("net.typedrest.serializers"); + + /// Holds HttpCredentials. + public static JvmPackage Http { get; } = JvmPackage.External("net.typedrest.http"); + + /// + /// The optional credentials parameter of the entry endpoint constructor. + /// + public static JvmIdentifier HttpCredentials { get; } = new(Http, "HttpCredentials"); + + /// + /// The base type of every generated entry endpoint. + /// + public static JvmIdentifier EntryEndpoint { get; } = new(Endpoints, "EntryEndpoint"); + + /// + /// The type every endpoint constructor takes as its referrer. + /// + public static JvmIdentifier Endpoint { get; } = new(Endpoints, "Endpoint"); + + /// + /// The base of an endpoint that has no more specific kind. + /// + /// + /// Endpoint itself is an interface and there is no EndpointImpl, so unlike every other kind the + /// plain endpoint has no concrete class to instantiate. AbstractEndpoint is abstract, which is why + /// always generates a class rather than constructing one inline. + /// + public static JvmIdentifier AbstractEndpoint { get; } = new(Endpoints, "AbstractEndpoint"); + + /// + /// Returns the implementation class a generated endpoint derives from. + /// + public static JvmIdentifier Implementation(JvmPackage package, string name) + => new(package, name + "Impl"); +} diff --git a/src/TypedRest.CodeGeneration.Jvm/NamingStrategy.cs b/src/TypedRest.CodeGeneration.Jvm/NamingStrategy.cs new file mode 100644 index 0000000..e78ae5e --- /dev/null +++ b/src/TypedRest.CodeGeneration.Jvm/NamingStrategy.cs @@ -0,0 +1,78 @@ +using TypedRest.CodeGeneration.Endpoints; +using TypedRest.CodeGeneration.Endpoints.Generic; +using TypedRest.CodeGeneration.Generation; +using TypedRest.CodeGeneration.Jvm.Model; + +namespace TypedRest.CodeGeneration.Jvm; + +/// +/// The default for JVM clients. +/// +/// The service name to use for the entry endpoint. +/// The package the endpoints are generated into. +/// The package the DTOs are generated into. +/// The type to use for schemas that carry no usable type information. +public class NamingStrategy(string serviceName, string endpointPackage, string dtoPackage, JvmIdentifier? untypedFallback = null) : INamingStrategy +{ + /// The service name to use for the entry endpoint. + protected readonly string ServiceName = serviceName; + + /// The package the endpoints are generated into. + protected readonly JvmPackage EndpointPackage = JvmPackage.ForGenerated(JvmPackage.Sanitize(endpointPackage)); + + /// The package the DTOs are generated into. + protected readonly JvmPackage DtoPackage = JvmPackage.ForGenerated(JvmPackage.Sanitize(dtoPackage)); + + /// The type to use for schemas that carry no usable type information. + protected readonly JvmIdentifier UntypedFallback = untypedFallback ?? JvmIdentifier.Object; + + /// + public virtual string Property(string key) + => JvmSyntax.Identifier(Words.ToCamelCase(key)); + + /// + public virtual JvmIdentifier EndpointType(string key, IEndpoint endpoint, string? prefix = null) + { + string prefixed = prefix is null ? "" : Words.ToPascalCase(prefix); + string name = endpoint switch + { + EntryEndpoint _ => ServiceName + "Client", + IndexerEndpoint _ => prefixed + Words.ToPascalCase(key.Depluralize()) + "CollectionEndpoint", + _ => prefixed + Words.ToPascalCase(key) + "Endpoint" + }; + + return new JvmIdentifier(EndpointPackage, JvmSyntax.Identifier(name)); + } + + /// + public virtual JvmIdentifier DtoType(string key) + { + var parts = key.Split(['.', '/'], StringSplitOptions.RemoveEmptyEntries); + string name = parts.Length == 0 ? key : string.Concat(parts.Select(Words.ToPascalCase)); + + return new JvmIdentifier(DtoPackage, JvmSyntax.Identifier(Words.ToPascalCase(name))); + } + + /// + public virtual JvmIdentifier TypeFor(OpenApiSchema? schema) + { + var type = (schema?.Type, schema?.Format) switch + { + ("string", "date-time") => JvmIdentifier.OffsetDateTime, + ("string", "date") => JvmIdentifier.LocalDate, + ("string", "uuid") => JvmIdentifier.Uuid, + ("string", "binary") => JvmIdentifier.InputStream, + ("string", _) => JvmIdentifier.String, + ("integer", "int64") => JvmIdentifier.Long, + ("integer", _) => JvmIdentifier.Int, + ("number", _) => JvmIdentifier.Double, + ("boolean", _) => JvmIdentifier.Boolean, + ("array", _) => JvmIdentifier.ListOf(TypeFor(schema!.Items)), + _ when schema?.Reference?.Id is {Length: > 0} id => DtoType(id), + _ when schema?.AdditionalProperties is {} props => JvmIdentifier.MapOf(TypeFor(props)), + _ => UntypedFallback + }; + + return schema is {Nullable: true} ? type.ToNullable() : type; + } +} diff --git a/src/TypedRest.CodeGeneration.Jvm/README.md b/src/TypedRest.CodeGeneration.Jvm/README.md new file mode 100644 index 0000000..bc77ad3 --- /dev/null +++ b/src/TypedRest.CodeGeneration.Jvm/README.md @@ -0,0 +1,47 @@ +# ![TypedRest](https://raw.githubusercontent.com/TypedRest/TypedRest-DotNet/master/logo.svg) Code Generation for the JVM + +Everything the [Java](https://www.nuget.org/packages/TypedRest.CodeGeneration.Java/) and [Kotlin](https://www.nuget.org/packages/TypedRest.CodeGeneration.Kotlin/) generators have in common. You only need this package directly if you are building your own JVM generator; to generate a client, use one of those two or the [command-line tool](https://www.nuget.org/packages/typedrest-codegen/). + + dotnet add package TypedRest.CodeGeneration.Jvm + +## Why a shared core + +[TypedRest for the JVM](https://github.com/TypedRest/TypedRest-Java) is written in Kotlin and published as a single set of Maven artifacts that both languages consume. A generated Java client and a generated Kotlin client therefore derive from the *same* runtime types, and every decision about which type an endpoint maps to is identical between them: + +```kotlin +val contacts: GenericCollectionEndpointImpl = + GenericCollectionEndpointImpl(this, "contacts", Contact::class.java) { r, u -> ContactElementEndpoint(r, u) } +``` +```java +public final GenericCollectionEndpointImpl contacts = + new GenericCollectionEndpointImpl<>(this, "contacts", Contact.class, ContactElementEndpoint::new); +``` + +Only the syntax differs. So this package holds the parts that do not: + +- **`Model/`** — the type model (`JvmIdentifier`, `JvmPackage`) and a syntax-free AST of what to emit (`JvmEndpointClass`, `JvmDto`, `JvmEnum`, `JvmExpression`). Nothing here knows how to write itself; each language's writer renders the tree. +- **`Packages`** — the TypedRest runtime packages and the `Impl` classes generated endpoints derive from. +- **`JvmSerializer`** — the JSON serializers and the annotations each one wants on a DTO. +- The naming strategy, type mapping and endpoint builders. + +`JvmSyntax.IsReservedWord` deliberately uses the *union* of Java's and Kotlin's reserved words. The two generators share a naming strategy, so a name legal in only one of them would make the same document produce a compiling client in one language and a broken one in the other. + +## Serializers + +| Name | Languages | Type annotation | Property annotation | Artifact | +| --------- | ------------ | ----------------------------------- | ------------------- | ------------------------------------------- | +| `kotlinx` | Kotlin only | `@Serializable` | `@SerialName` | `net.typedrest:typedrest` | +| `jackson` | Java, Kotlin | | `@JsonProperty` | `net.typedrest:typedrest-serializers-jackson` | +| `moshi` | Java, Kotlin | `@JsonClass(generateAdapter = true)` | `@Json(name = ...)` | `net.typedrest:typedrest-serializers-moshi` | + +kotlinx.serialization generates its serializers with a Kotlin compiler plugin, so it cannot serialize a class written in Java. This is why the two generators default differently: Kotlin defaults to `kotlinx`, matching the default of `EntryEndpoint` itself, while Java defaults to `jackson` and passes it to the entry endpoint explicitly. + +## Related packages + +- [TypedRest.CodeGeneration](https://www.nuget.org/packages/TypedRest.CodeGeneration/) is the basis of this library. It parses OpenAPI/Swagger documents and infers TypedRest Endpoints from patterns. +- [TypedRest.CodeGeneration.Kotlin](https://www.nuget.org/packages/TypedRest.CodeGeneration.Kotlin/) and [TypedRest.CodeGeneration.Java](https://www.nuget.org/packages/TypedRest.CodeGeneration.Java/) build on this one. + +## Links + +- [Code generation documentation](https://typedrest.net/code-generation/) +- [API documentation](https://code-generation.typedrest.net/) diff --git a/src/TypedRest.CodeGeneration.Jvm/TypeNameRegistry.cs b/src/TypedRest.CodeGeneration.Jvm/TypeNameRegistry.cs new file mode 100644 index 0000000..aa7206e --- /dev/null +++ b/src/TypedRest.CodeGeneration.Jvm/TypeNameRegistry.cs @@ -0,0 +1,31 @@ +using TypedRest.CodeGeneration.Generation; +using TypedRest.CodeGeneration.Jvm.Model; + +namespace TypedRest.CodeGeneration.Jvm; + +/// +/// Keeps track of the type names handed out during a generation run, so that no two generated types end up in the +/// same file. +/// +public class TypeNameRegistry +{ + private readonly NameRegistry _names = new( + getKey: identifier => identifier.QualifiedName, + withNumber: (identifier, number) => Renamed(identifier, identifier.Name + number)); + + /// + /// Registers a name for a type, appending a number if it is already taken. + /// + public JvmIdentifier Register(JvmIdentifier candidate) + => _names.Register(candidate); + + /// + /// Registers the first of the that is still free. + /// Falls back to appending a number to the last candidate if all of them are taken. + /// + public JvmIdentifier Register(IEnumerable candidates) + => _names.Register(candidates); + + private static JvmIdentifier Renamed(JvmIdentifier identifier, string name) + => new(identifier.Package, name); +} diff --git a/src/TypedRest.CodeGeneration.Jvm/TypedRest.CodeGeneration.Jvm.csproj b/src/TypedRest.CodeGeneration.Jvm/TypedRest.CodeGeneration.Jvm.csproj new file mode 100644 index 0000000..6ed601c --- /dev/null +++ b/src/TypedRest.CodeGeneration.Jvm/TypedRest.CodeGeneration.Jvm.csproj @@ -0,0 +1,17 @@ + + + + + netstandard2.0;net8.0;net10.0 + Shared JVM code generator core for TypedRest clients from OpenAPI/Swagger + Everything the Java and Kotlin TypedRest client generators have in common: the JVM type model, the naming strategy and the endpoint builders. + Typed REST OpenAPI Swagger CodeGen JVM Java Kotlin + ..\..\artifacts\$(Configuration)\ + + + + + + + + diff --git a/src/TypedRest.CodeGeneration.Kotlin/KotlinClientGenerator.cs b/src/TypedRest.CodeGeneration.Kotlin/KotlinClientGenerator.cs new file mode 100644 index 0000000..0ff4a3a --- /dev/null +++ b/src/TypedRest.CodeGeneration.Kotlin/KotlinClientGenerator.cs @@ -0,0 +1,32 @@ +using TypedRest.CodeGeneration.Generation; +using TypedRest.CodeGeneration.Jvm; + +namespace TypedRest.CodeGeneration.Kotlin; + +/// +/// Generates the source code of a Kotlin TypedRest client. +/// +public class KotlinClientGenerator : IClientGenerator +{ + /// + /// The name of this target language. + /// + public const string LanguageName = "kotlin"; + + /// + public string Language => LanguageName; + + /// + public ClientGenerationOptions CreateOptions(string serviceName) + => new KotlinGenerationOptions(serviceName); + + /// + public IEnumerable Generate(OpenApiDocument document, ClientGenerationOptions options, IGenerationLog? log = null) + { + if (options.GenerateInterfaces) (log ?? NullGenerationLog.Instance).Report(Messages.InterfacesNotSupported()); + + return document.GenerateTypedRestKotlin( + options as KotlinGenerationOptions ?? new KotlinGenerationOptions(options), + log); + } +} diff --git a/src/TypedRest.CodeGeneration.Kotlin/KotlinGeneratedFile.cs b/src/TypedRest.CodeGeneration.Kotlin/KotlinGeneratedFile.cs new file mode 100644 index 0000000..70e6c15 --- /dev/null +++ b/src/TypedRest.CodeGeneration.Kotlin/KotlinGeneratedFile.cs @@ -0,0 +1,29 @@ +using System.Text; +using TypedRest.CodeGeneration.Generation; +using TypedRest.CodeGeneration.Jvm.Model; + +namespace TypedRest.CodeGeneration.Kotlin; + +/// +/// A Kotlin source file holding one generated type. +/// +/// The type declared in the file. +/// Renders the type. +public sealed class KotlinGeneratedFile(IJvmType type, KotlinWriter writer) : IGeneratedFile +{ + /// + /// The type declared in the file. + /// + public IJvmType Type { get; } = type; + + /// + public string Path + => (Type.Identifier.Package ?? JvmPackage.External("")).FilePath(Type.Identifier.Name, KotlinWriter.FileExtension); + + /// + public Encoding Encoding { get; } = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + + /// + public void WriteTo(TextWriter textWriter) + => writer.WriteFile(textWriter, Type); +} diff --git a/src/TypedRest.CodeGeneration.Kotlin/KotlinGenerationOptions.cs b/src/TypedRest.CodeGeneration.Kotlin/KotlinGenerationOptions.cs new file mode 100644 index 0000000..5bb2b2d --- /dev/null +++ b/src/TypedRest.CodeGeneration.Kotlin/KotlinGenerationOptions.cs @@ -0,0 +1,36 @@ +using TypedRest.CodeGeneration.Generation; +using TypedRest.CodeGeneration.Jvm; + +namespace TypedRest.CodeGeneration.Kotlin; + +/// +/// Options controlling the generation of a Kotlin TypedRest client. +/// +public class KotlinGenerationOptions : JvmGenerationOptions +{ + /// + /// Creates new generation options. + /// + /// The service name to use for the entry endpoint. + public KotlinGenerationOptions(string serviceName) + : base(serviceName) + {} + + /// + /// Creates new generation options, copying the common options from . + /// + public KotlinGenerationOptions(ClientGenerationOptions other) + : base(other) + {} + + /// + /// + /// kotlinx.serialization is the default of EntryEndpoint itself, so a client generated for it needs no + /// serializer passed at all. + /// + protected override string DefaultSerializerName => JvmSerializer.Kotlinx; + + /// + public override IReadOnlyCollection SupportedSerializers + => [JvmSerializer.Kotlinx, JvmSerializer.Jackson, JvmSerializer.Moshi]; +} diff --git a/src/TypedRest.CodeGeneration.Kotlin/KotlinWriter.cs b/src/TypedRest.CodeGeneration.Kotlin/KotlinWriter.cs new file mode 100644 index 0000000..e52418a --- /dev/null +++ b/src/TypedRest.CodeGeneration.Kotlin/KotlinWriter.cs @@ -0,0 +1,306 @@ +using TypedRest.CodeGeneration.Jvm; +using TypedRest.CodeGeneration.Jvm.Model; + +namespace TypedRest.CodeGeneration.Kotlin; + +/// +/// Renders the shared JVM type model as Kotlin source code. +/// +/// Supplies the annotations carrying wire names on generated DTOs. +/// Controls whether the entry endpoint gets a generated constructor. +public sealed class KotlinWriter(JvmSerializer serializer, bool entryConstructor = true) +{ + /// + /// The file extension of Kotlin source files. + /// + public const string FileExtension = ".kt"; + + /// + /// Writes a file declaring . + /// + public void WriteFile(TextWriter textWriter, IJvmType type) + { + var writer = new JvmWriter(textWriter); + + var package = type.Identifier.Package; + if (package is {Name.Length: > 0}) + { + writer.WriteLine($"package {package.Name}"); + writer.WriteLine(); + } + + var imports = Imports(type).ToList(); + if (imports.Count != 0) + { + foreach (string import in imports) + writer.WriteLine($"import {import}"); + writer.WriteLine(); + } + + Write(writer, type); + } + + /// + /// Returns the sorted, deduplicated imports a file declaring needs. + /// + private IEnumerable Imports(IJvmType type) + => AllImports(type) + .Where(x => x.Package is {} package + && package.Name.Length != 0 + && !Equals(package, type.Identifier.Package) + && !Equals(package, Packages.JavaLang)) + .Select(x => x.QualifiedName) + .Distinct(StringComparer.Ordinal) + .OrderBy(x => x, StringComparer.Ordinal); + + private IEnumerable AllImports(IJvmType type) + { + foreach (var import in type.GetImports()) yield return import; + + // Annotations are attached by this writer rather than by the model, so they contribute imports of their own + foreach (var annotation in AnnotationsFor(type)) + { + foreach (var import in annotation.GetImports()) yield return import; + } + + if (type is JvmEndpointClass {BaseType: var baseType} && Equals(baseType, Packages.EntryEndpoint) && entryConstructor) + { + yield return JvmIdentifier.Uri; + if (serializer.RuntimeSerializer is {} runtimeSerializer) yield return runtimeSerializer; + } + } + + /// + /// Returns every annotation on a type, including the ones its members carry. + /// + private IEnumerable AnnotationsFor(IJvmType type) + => type switch + { + JvmDto dto => serializer.TypeAnnotations() + .Concat(dto.Properties.Select(x => serializer.PropertyName(x.WireName)).OfType()), + JvmEnum @enum => serializer.EnumAnnotations() + .Concat(@enum.Values.Select(x => serializer.EnumMemberName(x.WireName)).OfType()), + _ => [] + }; + + private void Write(JvmWriter writer, IJvmType type) + { + switch (type) + { + case JvmEndpointClass endpoint: + WriteEndpoint(writer, endpoint); + break; + case JvmDto dto: + WriteDto(writer, dto); + break; + case JvmEnum @enum: + WriteEnum(writer, @enum); + break; + default: + throw new ArgumentException($"Cannot write a {type.GetType().Name} as Kotlin.", nameof(type)); + } + } + + private void WriteEndpoint(JvmWriter writer, JvmEndpointClass type) + { + writer.WriteDocComment(type.Summary, type.Deprecated); + if (type.Deprecated) writer.WriteLine("@Deprecated(\"\")"); + + // Generated endpoints are open so that consumers can derive from them to add their own members + string declaration = $"open class {type.Identifier.Name}"; + var (parameters, baseCall) = Header(type); + + // The base call carries the entity class and, for a collection, a factory lambda, which together run well past a readable line length. + if (declaration.Length + parameters.Length + baseCall.Length > MaxLineLength) + { + writer.WriteLine(declaration + parameters); + using (writer.Indent()) + writer.WriteLine(baseCall.TrimStart() + " {"); + } + else + writer.WriteLine(declaration + parameters + baseCall + " {"); + + using (writer.Indent()) + { + bool first = true; + foreach (var child in type.Children) + { + if (!first) writer.WriteLine(); + WriteChild(writer, child); + first = false; + } + } + + writer.WriteLine("}"); + } + + /// + /// The line length past which a class declaration is wrapped before its supertype call. + /// + private const int MaxLineLength = 120; + + /// + /// Writes the constructor parameters and supertype call of an endpoint, e.g. + /// (referrer: Endpoint) and : FooEndpointImpl(referrer, "foo"). + /// + private (string parameters, string baseCall) Header(JvmEndpointClass type) + { + if (Equals(type.BaseType, Packages.EntryEndpoint)) + { + // Without a generated constructor the class only names its base type, leaving the constructors to the + // consumer. Kotlin needs the base type spelled without parentheses for that to compile. + if (!entryConstructor) return ("", $" : {TypeExpression(type.BaseType)}"); + + string arguments = serializer.RuntimeSerializer is {} runtimeSerializer + ? $"uri, serializer = {runtimeSerializer.Name}()" + : "uri"; + return ("(uri: URI)", $" : {TypeExpression(type.BaseType)}({arguments})"); + } + + var constructor = type.Constructor; + if (constructor is null or {Parameters.Count: 0}) return ("", $" : {TypeExpression(type.BaseType)}()"); + + string parameters = string.Join(", ", constructor.Parameters.Select(x => $"{x.Name}: {TypeExpression(x.Type)}")); + string baseArguments = string.Join(", ", constructor.BaseArguments.Select(Expression)); + + return ($"({parameters})", $" : {TypeExpression(type.BaseType)}({baseArguments})"); + } + + private void WriteChild(JvmWriter writer, JvmChildEndpoint child) + { + writer.WriteDocComment(child.Summary, child.Deprecated); + if (child.Deprecated) writer.WriteLine("@Deprecated(\"\")"); + + writer.WriteLine($"val {child.Name}: {TypeExpression(child.Type)} ="); + using (writer.Indent()) + writer.WriteLine(Expression(child.Value)); + } + + /// + /// Writes a DTO as a data class, which gives it equality, toString and copy for free. + /// + private void WriteDto(JvmWriter writer, JvmDto type) + { + writer.WriteDocComment(type.Summary, type.Deprecated); + if (type.Deprecated) writer.WriteLine("@Deprecated(\"\")"); + + foreach (var annotation in serializer.TypeAnnotations()) + writer.WriteLine(annotation.Write()); + + // A data class needs at least one property; anything else has to be a plain class + if (type.Properties.Count == 0) + { + writer.WriteLine($"class {type.Identifier.Name}"); + return; + } + + writer.WriteLine($"data class {type.Identifier.Name}("); + + using (writer.Indent()) + { + for (int i = 0; i < type.Properties.Count; i++) + { + var property = type.Properties[i]; + string separator = i == type.Properties.Count - 1 ? "" : ","; + + writer.WriteDocComment(property.Summary, property.Deprecated); + if (property.Deprecated) writer.WriteLine("@Deprecated(\"\")"); + if (serializer.PropertyName(property.WireName) is {} annotation) + writer.WriteLine(annotation.Write()); + + // An optional property defaults to null so that a DTO can be built without naming every field. + // A required one deliberately gets no default, making a missing value a compile error. + string @default = property.Required ? "" : " = null"; + writer.WriteLine($"val {property.Name}: {TypeExpression(property.Type)}{@default}{separator}"); + } + } + + writer.WriteLine(")"); + } + + private void WriteEnum(JvmWriter writer, JvmEnum type) + { + writer.WriteDocComment(type.Summary, type.Deprecated); + if (type.Deprecated) writer.WriteLine("@Deprecated(\"\")"); + + foreach (var annotation in serializer.EnumAnnotations()) + writer.WriteLine(annotation.Write()); + + writer.WriteLine($"enum class {type.Identifier.Name} {{"); + + using (writer.Indent()) + { + for (int i = 0; i < type.Values.Count; i++) + { + var value = type.Values[i]; + string separator = i == type.Values.Count - 1 ? "" : ","; + + writer.WriteDocComment(value.Summary); + if (serializer.EnumMemberName(value.WireName) is {} annotation) + writer.WriteLine(annotation.Write()); + writer.WriteLine(value.Name + separator); + } + } + + writer.WriteLine("}"); + } + + /// + /// Writes a type reference, e.g. List<Contact>?. + /// + public string TypeExpression(JvmIdentifier identifier) + { + string name = identifier.Kind switch + { + // Kotlin has its own names for these, mapped back to the JVM types by the compiler + JvmTypeKind.Object => "Any", + _ => identifier.Name + }; + + string core = identifier.TypeArguments.Count == 0 + ? name + : $"{name}<{string.Join(", ", identifier.TypeArguments.Select(TypeExpression))}>"; + + return identifier.Nullable ? core + "?" : core; + } + + /// + /// Writes an expression. + /// + public string Expression(JvmExpression expression) + => expression switch + { + JvmThis => "this", + JvmName name => name.Name, + JvmLiteral literal => JvmSyntax.Quote(literal.Value, escapeDollar: true), + JvmUriLiteral uri => $"URI({JvmSyntax.Quote(uri.Value, escapeDollar: true)})", + + // Kotlin's class literal is a KClass, which the endpoints need as a java.lang.Class + JvmClassLiteral classLiteral => $"{TypeExpression(classLiteral.Type.ToNonNullable())}::class.java", + + // Kotlin infers the lambda's parameter types from the function type the constructor declares + JvmElementFactory factory => + $"{{ {JvmElementFactory.ReferrerParameter}, {JvmElementFactory.RelativeUriParameter} -> {Expression(factory.Body)} }}", + + JvmNew creation => WriteCreation(creation), + + _ => throw new ArgumentException($"Cannot write a {expression.GetType().Name} as Kotlin.", nameof(expression)) + }; + + /// + /// Writes an object creation, moving a trailing lambda outside the parentheses as Kotlin style prefers. + /// + private string WriteCreation(JvmNew creation) + { + string type = creation.Type.Name; + + var arguments = creation.Arguments; + if (arguments.Count != 0 && arguments[arguments.Count - 1] is JvmElementFactory trailing) + { + string leading = string.Join(", ", arguments.Take(arguments.Count - 1).Select(Expression)); + return $"{type}({leading}) {Expression(trailing)}"; + } + + return $"{type}({string.Join(", ", arguments.Select(Expression))})"; + } +} diff --git a/src/TypedRest.CodeGeneration.Kotlin/OpenApiDocumentExtensions.cs b/src/TypedRest.CodeGeneration.Kotlin/OpenApiDocumentExtensions.cs new file mode 100644 index 0000000..6a8c887 --- /dev/null +++ b/src/TypedRest.CodeGeneration.Kotlin/OpenApiDocumentExtensions.cs @@ -0,0 +1,56 @@ +using TypedRest.CodeGeneration.Generation; +using TypedRest.CodeGeneration.Jvm; +using TypedRest.CodeGeneration.Jvm.Dtos; +using TypedRest.CodeGeneration.Jvm.Endpoints; +using TypedRest.CodeGeneration.Jvm.Model; +using TypedRest.CodeGeneration.Patterns; + +namespace TypedRest.CodeGeneration.Kotlin; + +/// +/// Generates Kotlin TypedRest clients for OpenAPI/Swagger documents. +/// +public static class OpenApiDocumentExtensions +{ + /// + /// Generates the source files of a Kotlin TypedRest client for . + /// + /// The document describing the service. + /// Options controlling the generation. + /// Collects messages about aspects of the document that Kotlin cannot express. + /// Controls what is inferred when the document has no x-typedrest extension. + /// Controls what code is emitted for each kind of endpoint. + public static IEnumerable GenerateTypedRestKotlin(this OpenApiDocument doc, KotlinGenerationOptions options, IGenerationLog? log = null, PatternRegistry? patterns = null, BuilderRegistry? builders = null) + { + var naming = options.NamingStrategy(); + + // Endpoints and DTOs may share a package, so they have to agree on the names they hand out + var typeNames = new TypeNameRegistry(); + + var types = doc.GenerateTypedRestKotlinEndpoints(naming, log, patterns, builders, typeNames).ToList(); + if (options.GenerateDtos) + types.AddRange(doc.GenerateKotlinDtos(naming, typeNames)); + + var writer = new KotlinWriter(options.ResolveSerializer(), options.GenerateEntryConstructor); + return types.Select(type => (IGeneratedFile)new KotlinGeneratedFile(type, writer)); + } + + /// + /// Generates the endpoint types of a Kotlin TypedRest client for , without the DTOs. + /// + public static IEnumerable GenerateTypedRestKotlinEndpoints(this OpenApiDocument doc, INamingStrategy naming, IGenerationLog? log = null, PatternRegistry? patterns = null, BuilderRegistry? builders = null, TypeNameRegistry? typeNames = null) + { + var generator = new EndpointGenerator(naming, builders ?? BuilderRegistry.Default, typeNames) + { + Log = log ?? NullGenerationLog.Instance + }; + var entryEndpoint = doc.GetTypedRest() ?? doc.MatchTypedRestPatterns(patterns); + return generator.Generate(entryEndpoint); + } + + /// + /// Generates Kotlin types for the schemas in . + /// + public static IEnumerable GenerateKotlinDtos(this OpenApiDocument doc, INamingStrategy naming, TypeNameRegistry? typeNames = null) + => new DtoGenerator(naming, typeNames).Generate(doc.Components?.Schemas ?? new Dictionary()); +} diff --git a/src/TypedRest.CodeGeneration.Kotlin/README.md b/src/TypedRest.CodeGeneration.Kotlin/README.md new file mode 100644 index 0000000..0e9b03b --- /dev/null +++ b/src/TypedRest.CodeGeneration.Kotlin/README.md @@ -0,0 +1,89 @@ +# ![TypedRest](https://raw.githubusercontent.com/TypedRest/TypedRest-DotNet/master/logo.svg) Code Generation for Kotlin + +Generates Kotlin source code for [TypedRest for the JVM](https://github.com/TypedRest/TypedRest-Java) clients from [OpenAPI/Swagger](https://swagger.io/resources/open-api/) documents. + + dotnet add package TypedRest.CodeGeneration.Kotlin + +Use this to build your own code generator. If you just want to generate a client for your API, use the [command-line tool](https://www.nuget.org/packages/typedrest-codegen/) instead; it is built on this library. + +## Usage + +```csharp +var reader = new OpenApiStreamReader(new OpenApiReaderSettings().AddTypedRest()); +var doc = reader.Read(File.OpenRead("myapi.yml"), out _); + +foreach (var file in doc.GenerateTypedRestKotlin(new KotlinGenerationOptions("MyService") +{ + Namespace = "com.mycompany.myservice", + GenerateDtos = true +})) + file.WriteToDirectory("src/main/kotlin/"); +``` + +`GenerateTypedRestKotlin()` uses the endpoints described by the document's `x-typedrest` extension, or infers them from the paths using [TypedRest.CodeGeneration](https://www.nuget.org/packages/TypedRest.CodeGeneration/) if there is no such extension. + +The generated code needs the TypedRest artifacts on the classpath: + +```kotlin +dependencies { + implementation("net.typedrest:typedrest:") + + // Only when generating DTOs for the default kotlinx serializer + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:") +} +``` + +Add `net.typedrest:typedrest-reactive` as well if the document describes any polling or streaming endpoints. + +The explicit `kotlinx-serialization-json` is easy to miss: TypedRest depends on it only as `implementation`, so it does not reach a consumer's compile classpath, and the `kotlin("plugin.serialization")` plugin adds the compiler plugin but no dependency. Without it the `@Serializable` and `@SerialName` annotations on the generated DTOs do not resolve. + +## Output + +One file per generated type, in a directory matching its package. `Namespace` is the package for the endpoints, defaulting to the service name; `DtoNamespace` is the package for the DTOs, defaulting to a `dtos` subpackage of the endpoints. + +Endpoints become `open class`es deriving from the TypedRest `Impl` classes and exposing their children as `val`s. They are `open` so that you can derive from them to add members of your own. + +DTOs become `data class`es, and schemas with an `enum` become `enum class`es. A property the document does not mark as required is nullable and defaults to `null`, so a DTO can be built without naming every optional field; a required one deliberately gets no default, making a missing value a compile error. + +A `$ref` inside `allOf` is flattened into the type rather than becoming a base class, because a Kotlin `data class` is final and cannot be derived from. Every property is still present; only the inheritance relationship is lost. + +## Serializers + +`Serializer` picks which annotations carry the wire names: + +| Value | Type annotation | Property annotation | Artifact | +| ------------------- | ------------------------------------ | ------------------- | --------------------------------------------- | +| `kotlinx` (default) | `@Serializable` | `@SerialName` | `net.typedrest:typedrest` | +| `jackson` | | `@JsonProperty` | `net.typedrest:typedrest-serializers-jackson` | +| `moshi` | `@JsonClass(generateAdapter = true)` | `@Json(name = ...)` | `net.typedrest:typedrest-serializers-moshi` | + +kotlinx.serialization is the default of `EntryEndpoint` itself, so a client generated for it passes no serializer at all. The others are passed explicitly in the generated entry endpoint constructor. + +Generating for `kotlinx` requires the `kotlin-serialization` Gradle plugin in the consuming project — the `@Serializable` annotation does nothing without the compiler plugin that acts on it: + +```kotlin +plugins { + kotlin("plugin.serialization") version "" +} +``` + +## Extension points + +`GenerateTypedRestKotlin()` takes an optional `PatternRegistry` controlling what is inferred, and an optional `BuilderRegistry` controlling what is emitted: + +```csharp +var files = doc.GenerateTypedRestKotlin(options, log, patterns, builders); +``` + +Both registries live in [TypedRest.CodeGeneration.Jvm](https://www.nuget.org/packages/TypedRest.CodeGeneration.Jvm/) and are shared with the Java generator, because both languages target the same runtime types. Implement `IBuilder` to change the code emitted for an endpoint kind, or derive from `NamingStrategy` to change how types and properties are named. + +## Related packages + +- [TypedRest.CodeGeneration.Jvm](https://www.nuget.org/packages/TypedRest.CodeGeneration.Jvm/) is the basis of this library and holds everything shared with the Java generator. +- [TypedRest.CodeGeneration.Java](https://www.nuget.org/packages/TypedRest.CodeGeneration.Java/) does the same for Java. +- [typedrest-codegen](https://www.nuget.org/packages/typedrest-codegen/) is a command-line tool that builds on this library and writes the generated code to disk. + +## Links + +- [Code generation documentation](https://typedrest.net/code-generation/) +- [API documentation](https://code-generation.typedrest.net/) diff --git a/src/TypedRest.CodeGeneration.Kotlin/TypedRest.CodeGeneration.Kotlin.csproj b/src/TypedRest.CodeGeneration.Kotlin/TypedRest.CodeGeneration.Kotlin.csproj new file mode 100644 index 0000000..d91b62b --- /dev/null +++ b/src/TypedRest.CodeGeneration.Kotlin/TypedRest.CodeGeneration.Kotlin.csproj @@ -0,0 +1,17 @@ + + + + + netstandard2.0;net8.0;net10.0 + Kotlin code generator for TypedRest clients from OpenAPI/Swagger + Generates Kotlin source code for TypedRest clients from OpenAPI/Swagger documents. + Typed REST OpenAPI Swagger CodeGen Kotlin JVM + ..\..\artifacts\$(Configuration)\ + + + + + + + + diff --git a/src/TypedRest.CodeGeneration.slnx b/src/TypedRest.CodeGeneration.slnx index 63abdb9..5aae932 100644 --- a/src/TypedRest.CodeGeneration.slnx +++ b/src/TypedRest.CodeGeneration.slnx @@ -6,6 +6,9 @@ + + + diff --git a/src/UnitTests/sample-nested.yml b/src/UnitTests/sample-nested.yml index c08792c..1229e16 100644 --- a/src/UnitTests/sample-nested.yml +++ b/src/UnitTests/sample-nested.yml @@ -68,3 +68,11 @@ x-typedrest: kind: action uri: ./search description: Searches all projects. + admin: + uri: ./admin + description: Administrative operations. + children: + reindex: + kind: action + uri: ./reindex + description: Rebuilds the index. diff --git a/src/test.ps1 b/src/test.ps1 index dd42fd4..bb037c2 100644 --- a/src/test.ps1 +++ b/src/test.ps1 @@ -8,6 +8,15 @@ function Run-DotNet { if ($LASTEXITCODE -ne 0) {throw "Exit Code: $LASTEXITCODE"} } +function Run-Gradle { + if (Get-Command gradle -ErrorAction SilentlyContinue) { + gradle @args + } else { + & $zeroInstall run --batch https://apps.0install.net/java/gradle.xml @args + } + if ($LASTEXITCODE -ne 0) {throw "Exit Code: $LASTEXITCODE"} +} + function Run-Npm { if (Get-Command npm -ErrorAction SilentlyContinue) { npm @args @@ -22,6 +31,16 @@ Run-DotNet test --no-build --logger trx --configuration Release UnitTests\UnitTe $cli = "..\artifacts\Release\net10.0\TypedRest.CodeGeneration.Cli.dll" +# JVM smoke test +if (Test-Path SmokeTest.Jvm\generated) {Remove-Item SmokeTest.Jvm\generated -Recurse -Force} +Run-DotNet $cli generate -l kotlin -f UnitTests\sample-v3.yml -o SmokeTest.Jvm\generated\kotlin -s Sample -n net.typedrest.smoketest.kotlin --generate-dtos +Run-DotNet $cli generate -l java -f UnitTests\sample-v3.yml -o SmokeTest.Jvm\generated\java -s Sample -n net.typedrest.smoketest.java --generate-dtos +Run-DotNet $cli generate -l kotlin -f UnitTests\sample-nested.yml -o SmokeTest.Jvm\generated\kotlin -s NestedSample -n net.typedrest.smoketest.nested.kotlin --generate-dtos +Run-DotNet $cli generate -l java -f UnitTests\sample-nested.yml -o SmokeTest.Jvm\generated\java -s NestedSample -n net.typedrest.smoketest.nested.java --generate-dtos +pushd SmokeTest.Jvm +Run-Gradle --quiet --no-daemon compileKotlin compileJava +popd + # TypeScript smoke test if (Test-Path SmokeTest.TypeScript\generated) {Remove-Item SmokeTest.TypeScript\generated -Recurse -Force} Run-DotNet $cli generate -l typescript -f UnitTests\sample-v3.yml -o SmokeTest.TypeScript\generated\sample -s Sample --generate-dtos diff --git a/src/test.sh b/src/test.sh index 4251f7b..ad892f8 100755 --- a/src/test.sh +++ b/src/test.sh @@ -11,6 +11,13 @@ else dotnet="$zeroinstall run --version 10.0..!10.1 https://apps.0install.net/dotnet/sdk.xml" fi +# Find gradle +if command -v gradle > /dev/null 2> /dev/null; then + gradle="gradle" +else + gradle="$zeroinstall run https://apps.0install.net/java/gradle.xml" +fi + # Find npm if command -v npm > /dev/null 2> /dev/null; then npm="npm" @@ -23,6 +30,17 @@ $dotnet test --no-build --logger trx --configuration Release UnitTests/UnitTests cli="../artifacts/Release/net10.0/TypedRest.CodeGeneration.Cli.dll" +# JVM smoke test +rm -rf SmokeTest.Jvm/generated +$dotnet "$cli" generate -l kotlin -f UnitTests/sample-v3.yml -o SmokeTest.Jvm/generated/kotlin -s Sample -n net.typedrest.smoketest.kotlin --generate-dtos +$dotnet "$cli" generate -l java -f UnitTests/sample-v3.yml -o SmokeTest.Jvm/generated/java -s Sample -n net.typedrest.smoketest.java --generate-dtos +$dotnet "$cli" generate -l kotlin -f UnitTests/sample-nested.yml -o SmokeTest.Jvm/generated/kotlin -s NestedSample -n net.typedrest.smoketest.nested.kotlin --generate-dtos +$dotnet "$cli" generate -l java -f UnitTests/sample-nested.yml -o SmokeTest.Jvm/generated/java -s NestedSample -n net.typedrest.smoketest.nested.java --generate-dtos +( + cd SmokeTest.Jvm + $gradle --quiet --no-daemon compileKotlin compileJava +) + # TypeScript smoke test rm -rf SmokeTest.TypeScript/generated $dotnet "$cli" generate -l typescript -f UnitTests/sample-v3.yml -o SmokeTest.TypeScript/generated/sample -s Sample --generate-dtos From f302d545bb35ecabedc8fd91465cb3a182f9e2d9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:47:32 +0000 Subject: [PATCH 2/2] Update dependency org.jetbrains.kotlinx:kotlinx-serialization-json to v1.11.0 --- src/SmokeTest.Jvm/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SmokeTest.Jvm/build.gradle.kts b/src/SmokeTest.Jvm/build.gradle.kts index 648674e..b7adf8a 100644 --- a/src/SmokeTest.Jvm/build.gradle.kts +++ b/src/SmokeTest.Jvm/build.gradle.kts @@ -25,7 +25,7 @@ dependencies { // The @Serializable and @SerialName annotations the Kotlin generator emits. TypedRest depends on // kotlinx-serialization only as `implementation`, so it does not reach a consumer's compile classpath and has // to be declared here; the kotlin("plugin.serialization") plugin adds the compiler plugin but no dependency. - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0") // Carries the @Nullable annotations the Java generator emits, so Kotlin sees real nullability compileOnly("org.jspecify:jspecify:1.0.1")