diff --git a/build.gradle.kts b/build.gradle.kts index 5b1181ad..bc1fb52c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -17,7 +17,7 @@ allprojects { apply(plugin = "maven-publish") group = "io.javalin.community.openapi" - version = "7.2.2" + version = "7.3.0-RC.1" repositories { mavenCentral() diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index 2ee07078..f1685e20 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -56,7 +56,8 @@ export default defineConfig({ text: 'Advanced', collapsed: false, items: [ - { text: 'Compile-time Configuration', link: '/advanced/configuration' }, + { text: 'Static Configuration', link: '/advanced/configuration' }, + { text: 'Scripting Configuration', link: '/advanced/scripting' }, { text: 'Runtime Builder DSL', link: '/advanced/runtime-builder' }, ], }, diff --git a/docs/advanced/configuration.md b/docs/advanced/configuration.md index 4895cf52..eb11ba44 100644 --- a/docs/advanced/configuration.md +++ b/docs/advanced/configuration.md @@ -1,75 +1,14 @@ -# Compile-time Configuration +# Static Configuration -Configure the annotation processor using a Groovy script. This allows custom type mappings, property filters, and advanced type processors. +Pass key-value options to the backend at build time. The `info.*` options apply to both the annotation processor (APT/Kapt) and KSP; `openapi.groovy.path` is APT/Kapt-only. -## Setup +## Options -Create a Groovy configuration script (e.g. `openapi.groovy`) anywhere in your project -and point the annotation processor to it using the `openapi.groovy.path` option: - -```groovy -import io.javalin.openapi.experimental.* - -@ExperimentalCompileOpenApiConfiguration -class OpenApiConfiguration - implements OpenApiAnnotationProcessorConfigurer { - - @Override - void configure( - OpenApiAnnotationProcessorConfiguration configuration - ) { - // Configuration goes here - } -} -``` - -::: code-group - -```kotlin [Gradle (Kotlin)] -kapt { - arguments { - arg( - "openapi.groovy.path", - "$projectDir/src/main/compile/openapi.groovy" - ) - } -} -``` - -```groovy [Gradle (Groovy)] -kapt { - arguments { - arg( - 'openapi.groovy.path', - "$projectDir/src/main/compile/openapi.groovy" - ) - } -} -``` - -```xml [Maven] - - org.apache.maven.plugins - maven-compiler-plugin - - - -Aopenapi.groovy.path=${project.basedir}/src/main/compile/openapi.groovy - - - -``` - -::: - -## Annotation Processor Options - -The following options can be passed to the annotation processor: - -| Option | Description | -|-------------------------|----------------------------------------------------------| -| `openapi.info.title` | Set the `info.title` field in the generated specification | -| `openapi.info.version` | Set the `info.version` field in the generated specification | -| `openapi.groovy.path` | Path to the Groovy configuration script | +| Option | Description | +|------------------------|------------------------------------------------------------------------------------------------------| +| `openapi.info.title` | Set the `info.title` field in the generated specification | +| `openapi.info.version` | Set the `info.version` field in the generated specification | +| `openapi.groovy.path` | Path to a Groovy script for advanced configuration (APT/Kapt only, see [Scripting Configuration](./scripting)) | ::: code-group @@ -82,6 +21,13 @@ kapt { } ``` +```kotlin [Gradle (KSP)] +ksp { + arg("openapi.info.title", "My API") + arg("openapi.info.version", "1.0.0") +} +``` + ```xml [Maven] org.apache.maven.plugins @@ -97,108 +43,4 @@ kapt { ::: -## Custom Type Mappings - -Map custom types to simple OpenAPI types: - -```groovy -void configure( - OpenApiAnnotationProcessorConfiguration configuration -) { - configuration.simpleTypeMappings[ - 'org.bson.types.ObjectId' - ] = new SimpleType("string") - - configuration.simpleTypeMappings[ - 'com.example.CustomId' - ] = new SimpleType(/* type */ "integer", /* format */ "int64") -} -``` - -## Property Filters - -Control which properties are included in schemas: - -```groovy -configuration.propertyInSchemeFilter = { - ctx, type, property -> - !property.simpleName - .toString() - .startsWith("internal") -} -``` - -## Custom Type Processors - -Insert custom logic for handling specific types (e.g., unwrapping `AtomicReference`): - -```groovy -configuration.insertEmbeddedTypeProcessor({ - EmbeddedTypeProcessorContext context -> - if (context.type.simpleName == 'AtomicReference' - && context.type.generics.size() == 1) { - context.parentContext.typeSchemaGenerator.addType( - context.scheme, - context.type.generics[0], - context.inlineRefs, - context.references, - false - ) - return true // handled - } - - return false // use default processing -}) -``` - -Custom type processors run before all built-in type processing, so they can override the default behavior for any type. - -## Debug Mode - -Enable debug output during annotation processing: - -```groovy -configuration.debug = true -``` - -## Parser Validation - -Validate the generated specification with Swagger Parser: - -```groovy -configuration.validateWithParser = true // default -``` - -## Full Example - -```groovy -import io.javalin.openapi.experimental.* - -@ExperimentalCompileOpenApiConfiguration -class OpenApiConfiguration - implements OpenApiAnnotationProcessorConfigurer { - - @Override - void configure( - OpenApiAnnotationProcessorConfiguration configuration - ) { - configuration.simpleTypeMappings[ - 'org.bson.types.ObjectId' - ] = new SimpleType("string") - - configuration.simpleTypeMappings[ - 'com.example.Money' - ] = new SimpleType("string") - - configuration.propertyInSchemeFilter = { - ctx, type, property -> - !property.simpleName - .toString() - .startsWith("_") - } - - configuration.debug = false - configuration.validateWithParser = true - } -} -``` +For custom type mappings, property filters, and custom type processors, see [Scripting Configuration](./scripting). diff --git a/docs/advanced/runtime-builder.md b/docs/advanced/runtime-builder.md index 361ee006..8acfdeb6 100644 --- a/docs/advanced/runtime-builder.md +++ b/docs/advanced/runtime-builder.md @@ -4,9 +4,9 @@ The `OpenApiSchemaBuilder` provides a Kotlin DSL for building and modifying Open ## When to Use -- **Extend compile-time schemas** — add servers, security, or extra endpoints at startup -- **Dynamic endpoints** — describe routes registered at runtime that the annotation processor can't see -- **Testing** — build expected schemas in tests without JSON strings +- **Extend compile-time schemas** - add servers, security, or extra endpoints at startup +- **Dynamic endpoints** - describe routes registered at runtime that the annotation processor can't see +- **Testing** - build expected schemas in tests without JSON strings ## Basic Usage @@ -41,21 +41,21 @@ The `SchemaBuilder` DSL provides a clean way to define inline schemas without wo ```kotlin schema { type("string") } -// → { "type": "string" } +// -> { "type": "string" } ``` ### Type with Format ```kotlin schema { type("integer"); format("int32") } -// → { "type": "integer", "format": "int32" } +// -> { "type": "integer", "format": "int32" } ``` ### Reference ```kotlin schema { ref("#/components/schemas/User") } -// → { "$ref": "#/components/schemas/User" } +// -> { "$ref": "#/components/schemas/User" } ``` The schema DSL is available on media types, parameters, headers, and object schema properties. @@ -217,7 +217,67 @@ schema.path("/users").operation("get") { val json = schema.toJson() ``` -Reopening an existing operation preserves all fields — only the fields you set are changed. This makes it safe to layer runtime additions on top of compile-time output. +Reopening an existing operation preserves all fields - only the fields you set are changed. This makes it safe to layer runtime additions on top of compile-time output. + +## Auto-generating Docs for Registered Routes + +For a springdoc-style experience - documenting routes that are registered programmatically (and that the compile-time processor never sees) - use the **dynamic hook** module. It adds undocumented Javalin routes to the served document. + +```kotlin [Gradle (Kotlin)] +dependencies { + val openapi = "7.3.0-RC.1" + implementation("io.javalin.community.openapi:javalin-openapi-dynamic-hook:$openapi") +} +``` + +Register `RegisteredRoutesHook` on the `OpenApiPlugin`: + +```kotlin +Javalin.start { config -> + config.registerPlugin(OpenApiPlugin { it.withHook(RegisteredRoutesHook()) }) + + config.routes.get("/users") { /* ... */ } + config.routes.get("/users/{id}") { /* ... */ } +} +``` + +Every newly discovered route is documented with its path, method, path parameters (typed as `string`), and a default `200` response. Existing operations from compile-time documentation are left unchanged unless the route carries runtime metadata. + +Routes registered by `OpenApiPlugin`, `SwaggerPlugin`, and `ReDocPlugin` are excluded by default, including custom UI and WebJar paths. Add prefix exclusions for your own non-API routes: + +```kotlin +RegisteredRoutesHook { routes -> + routes.withIgnoredPathPrefixes("/assets", "/internal") +} +``` + +Each prefix matches the path itself and its descendants, so `/assets` excludes `/assets/logo.svg` but not `/assets-admin`. A trailing `/*` is accepted as an equivalent spelling. To include the default excluded plugin routes, call `clearDefaultIgnoredRoutes()`. + +### Enriching a Route + +Javalin handlers are opaque lambdas, so without extra information the hook can only emit those stubs - it cannot infer request/response bodies. Attach an `OpenApiMetadata` to a route to describe it using the same operation DSL shown above; `schema(Class)` resolves the type through the reflection schema engine (no annotation processing required): + +```kotlin +config.routes.addEndpoint( + Endpoint.create(HandlerType.GET, "/users/{id}") + .addMetadata(OpenApiMetadata { + summary("Get a user") + responses { + response("200") { + description("The user") + content { mediaType("application/json") { schema(User::class.java) } } + } + } + }) + .handler { /* ... */ } +) +``` + +For newly discovered operations, the path-parameter skeleton is auto-added before your metadata is applied. Existing operations retain their parameters, and referenced types (e.g. `User`) are emitted into `components/schemas`. + +::: warning +The document is built on its first request, so routes registered after that request are not included. Runtime reflection cannot see CLASS-retention annotations or automatically discover discriminator subtypes. Runtime reflection is opt-in: it only happens when you add this module and register the hook, so the compile-time backends stay reflection-free. +::: ## Merge Behavior diff --git a/docs/advanced/scripting.md b/docs/advanced/scripting.md new file mode 100644 index 00000000..bb09cd83 --- /dev/null +++ b/docs/advanced/scripting.md @@ -0,0 +1,203 @@ +# Scripting Configuration + +For advanced configuration - custom type mappings, property filters, and custom type processors - the annotation processor can run a Groovy script at compile time. + +::: warning Experimental, and intentionally temporary +Scripting (`@ExperimentalCompileOpenApiConfiguration`) is a stop-gap gateway that exists only because there are no built-in static options for these advanced cases yet. It is **experimental**: its API may change, and the goal is to fold its capabilities into proper [static configuration](./configuration) over time, so this scripting layer may shrink or fade away in future releases. Prefer static options where they exist; reach for scripting only when you must. + +It also applies to the **APT/Kapt backend only** - it is not loaded by the KSP backend. +::: + +## Setup + +Create a Groovy configuration script (e.g. `openapi.groovy`) anywhere in your project +and point the annotation processor at it with the `openapi.groovy.path` option: + +```groovy +import io.javalin.openapi.experimental.* + +@ExperimentalCompileOpenApiConfiguration +class OpenApiConfiguration + implements OpenApiAnnotationProcessorConfigurer { + + @Override + void configure( + OpenApiAnnotationProcessorConfiguration configuration + ) { + // Configuration goes here + } +} +``` + +::: code-group + +```kotlin [Gradle (Kotlin)] +kapt { + arguments { + arg( + "openapi.groovy.path", + "$projectDir/src/main/compile/openapi.groovy" + ) + } +} +``` + +```groovy [Gradle (Groovy)] +kapt { + arguments { + arg( + 'openapi.groovy.path', + "$projectDir/src/main/compile/openapi.groovy" + ) + } +} +``` + +```xml [Maven] + + org.apache.maven.plugins + maven-compiler-plugin + + + -Aopenapi.groovy.path=${project.basedir}/src/main/compile/openapi.groovy + + + +``` + +::: + +## Custom Type Mappings + +Map custom types to simple OpenAPI types: + +```groovy +void configure( + OpenApiAnnotationProcessorConfiguration configuration +) { + configuration.simpleTypeMappings[ + 'org.bson.types.ObjectId' + ] = new SimpleType("string") + + configuration.simpleTypeMappings[ + 'com.example.CustomId' + ] = new SimpleType(/* type */ "integer", /* format */ "int64") +} +``` + +## Property Filters + +Control which properties are included in schemas: + +```groovy +configuration.propertyInSchemeFilter = { + ctx, type, property -> + !property.simpleName + .toString() + .startsWith("internal") +} +``` + +## Custom Type Processors + +Insert custom logic for handling specific types (e.g., unwrapping `AtomicReference`): + +```groovy +configuration.insertEmbeddedTypeProcessor({ + EmbeddedTypeProcessorContext context -> + if (context.type.simpleName == 'AtomicReference' + && context.type.generics.size() == 1) { + context.parentContext.typeSchemaGenerator.addType( + context.scheme, + context.type.generics[0], + context.inlineRefs, + context.references, + false + ) + return true // handled + } + + return false // use default processing +}) +``` + +Custom type processors run before all built-in type processing, so they can override the default behavior for any type. + +## Debug Mode + +Enable debug output during annotation processing: + +```groovy +configuration.debug = true +``` + +## Parser Validation + +Validate the generated specification with Swagger Parser: + +```groovy +configuration.validateWithParser = true // default +``` + +## Full Example + +```groovy +import io.javalin.openapi.experimental.* + +@ExperimentalCompileOpenApiConfiguration +class OpenApiConfiguration + implements OpenApiAnnotationProcessorConfigurer { + + @Override + void configure( + OpenApiAnnotationProcessorConfiguration configuration + ) { + configuration.simpleTypeMappings[ + 'org.bson.types.ObjectId' + ] = new SimpleType("string") + + configuration.simpleTypeMappings[ + 'com.example.Money' + ] = new SimpleType("string") + + configuration.propertyInSchemeFilter = { + ctx, type, property -> + !property.simpleName + .toString() + .startsWith("_") + } + + configuration.debug = false + configuration.validateWithParser = true + } +} +``` + +## Migrating your `openapi.groovy` + +The scripting API renamed two types. If your script references the schema type or its mirror accessor - typically inside `propertyInSchemeFilter` - update these names: + +| Before | After | +|---------------------------|-----------------------| +| `ClassDefinition` | `OpenApiType` | +| `ClassDefinitionHandleKt` | `OpenApiTypeHandleKt` | + +```groovy +// before +import io.javalin.openapi.experimental.ClassDefinition +import io.javalin.openapi.experimental.ClassDefinitionHandleKt + +configuration.propertyInSchemeFilter = { ctx, ClassDefinition type, property -> + ctx.isAssignable(ClassDefinitionHandleKt.getMirror(type), ...) +} + +// after +import io.javalin.openapi.experimental.OpenApiType +import io.javalin.openapi.experimental.OpenApiTypeHandleKt + +configuration.propertyInSchemeFilter = { ctx, OpenApiType type, property -> + ctx.isAssignable(OpenApiTypeHandleKt.getMirror(type), ...) +} +``` + +Everything else is unchanged: `simpleTypeMappings`, `insertEmbeddedTypeProcessor`, `debug`, `validateWithParser`, and the `OpenApiAnnotationProcessorConfiguration`/`OpenApiAnnotationProcessorConfigurer`/`SimpleType`/`EmbeddedTypeProcessorContext` types all keep their names and members. Scripts that don't name `ClassDefinition`/`ClassDefinitionHandleKt` need no changes. diff --git a/docs/index.md b/docs/index.md index 1f05d80a..0a54d58e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ layout: home hero: name: Javalin OpenAPI text: Compile-time API Documentation - tagline: Generate OpenAPI specifications and JSON Schemas at compile time with zero runtime reflection. Annotation-driven, type-safe, and ready for Swagger UI and ReDoc. + tagline: Generate OpenAPI specifications and JSON Schemas at compile time - via Java annotation processing, kapt, or KSP. Annotation-driven, type-safe, and ready for Swagger UI and ReDoc. actions: - theme: brand text: Get Started @@ -15,9 +15,11 @@ hero: features: - title: Compile-time Generation - details: Schemas are generated during compilation using annotation processing. No runtime reflection, no classpath scanning, no startup overhead. + details: Schemas are generated during compilation via Java annotation processing, kapt, or KSP. No runtime reflection, no classpath scanning, no startup overhead. - title: Two Modes - details: Generate OpenAPI 3.1.0 endpoint documentation with @OpenApi, or standalone JSON Schema 2020-12 files with @JsonSchema — using the same annotation processor. + details: Generate OpenAPI 3.1.0 endpoint documentation with @OpenApi, or standalone JSON Schema 2020-12 files with @JsonSchema, from the same engine. + - title: Optional Runtime Backend + details: An opt-in reflection backend documents routes registered programmatically at runtime, for the dynamic cases the compile-time processor can't see. - title: Swagger UI & ReDoc details: Built-in plugins serve Swagger UI and ReDoc out of the box. Register the plugin and your interactive API documentation is live. - title: Schema Customization @@ -25,5 +27,5 @@ features: - title: Enum Support details: Rename values with @OpenApiName, apply naming strategies with @OpenApiNaming, create integer enums with @OpenApiPropertyType, and add descriptions. - title: Compile-time Configuration - details: Fine-tune the annotation processor with openapi.groovy — custom type mappings, property filters, and embedded type processors. + details: Set API info through build options. For advanced cases like custom type mappings, an experimental Groovy hook is available while first-class options grow. --- diff --git a/docs/introduction/json-schema-setup.md b/docs/introduction/json-schema-setup.md index a1bf71a5..96dde8f1 100644 --- a/docs/introduction/json-schema-setup.md +++ b/docs/introduction/json-schema-setup.md @@ -10,42 +10,48 @@ You can serve them with any HTTP server, use them for validation, feed them to c ## Installation -You only need the annotation processor and the specification module — no Javalin plugins required: +You only need the annotation processor and the specification module - no Javalin plugins required: ::: code-group ```kotlin [Gradle (Kotlin)] dependencies { - val openapi = "7.2.2" - - annotationProcessor( - "io.javalin.community.openapi:openapi-annotation-processor:$openapi" - ) - implementation( - "io.javalin.community.openapi:openapi-specification:$openapi" - ) + val openapi = "7.3.0-RC.1" + + annotationProcessor("io.javalin.community.openapi:openapi-annotation-processor:$openapi") + implementation("io.javalin.community.openapi:openapi-specification:$openapi") } ``` ```kotlin [Gradle (Kotlin) with Kapt] dependencies { - val openapi = "7.2.2" - - kapt( - "io.javalin.community.openapi:openapi-annotation-processor:$openapi" - ) - implementation( - "io.javalin.community.openapi:openapi-specification:$openapi" - ) + val openapi = "7.3.0-RC.1" + + kapt("io.javalin.community.openapi:openapi-annotation-processor:$openapi") + implementation("io.javalin.community.openapi:openapi-specification:$openapi") +} +``` + +```kotlin [Gradle (Kotlin) with KSP] +plugins { + // KSP is released against a specific Kotlin version - use the build that matches yours (github.com/google/ksp/releases) + id("com.google.devtools.ksp") version "2.3.9" +} + +dependencies { + val openapi = "7.3.0-RC.1" + + ksp("io.javalin.community.openapi:openapi-ksp:$openapi") + implementation("io.javalin.community.openapi:openapi-specification:$openapi") } ``` -```xml [Maven] +```xml [Maven (Java)] io.javalin.community.openapi openapi-specification - 7.2.2 + 7.3.0-RC.1 @@ -59,7 +65,7 @@ dependencies { io.javalin.community.openapi openapi-annotation-processor - 7.2.2 + 7.3.0-RC.1 @@ -68,6 +74,62 @@ dependencies { ``` +```xml [Maven (Kotlin)] + + 2.1.0 + + + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + io.javalin.community.openapi + openapi-specification + 7.3.0-RC.1 + + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + kapt + + kapt + + + + src/main/kotlin + + + + io.javalin.community.openapi + openapi-annotation-processor + 7.3.0-RC.1 + + + + + + compile + compile + + compile + + + + + + +``` + ::: ## Annotate Your Types @@ -106,14 +168,14 @@ At compile time, this generates a `/json-schemes/com.example.UserConfig` resourc ### Differences between the Two Modes -| Aspect | OpenAPI | JSON Schema | -|--------|---------|-------------| -| Output format | OpenAPI 3.1.0 | JSON Schema 2020-12 | -| Scope | Endpoint docs | Standalone type schemas | -| References | `$ref` | All types inlined | -| Trigger | `@OpenApi` | `@JsonSchema` | +| Aspect | OpenAPI | JSON Schema | +|---------------|---------------|-------------------------| +| Output format | OpenAPI 3.1.0 | JSON Schema 2020-12 | +| Scope | Endpoint docs | Standalone type schemas | +| References | `$ref` | All types inlined | +| Trigger | `@OpenApi` | `@JsonSchema` | -Both modes are framework-agnostic at the annotation processing level — Javalin is only needed if you want to serve the specs via the Javalin plugins. +Both modes are framework-agnostic at the annotation processing level - Javalin is only needed if you want to serve the specs via the Javalin plugins. ## Loading OpenAPI Specs at Runtime @@ -122,7 +184,7 @@ Use `OpenApiLoader` to load the generated OpenAPI specifications from your class ```kotlin val loader = OpenApiLoader() -// version → JSON +// version -> JSON val schemes = loader.loadOpenApiSchemes() for ((version, json) in schemes) { @@ -171,17 +233,17 @@ class InternalEntity Most property-level annotations work in both OpenAPI and JSON Schema modes: -- `@OpenApiName` — override property names -- `@OpenApiDescription` — add descriptions -- `@OpenApiIgnore` — exclude properties -- `@OpenApiRequired` — force required -- `@OpenApiPropertyType` — override types -- `@OpenApiNullable` — mark nullable -- `@OpenApiNaming` — apply naming strategies -- `@OpenApiByFields` — resolve from fields instead of getters -- Validation annotations — `@OpenApiNumberValidation`, `@OpenApiStringValidation`, `@OpenApiArrayValidation`, `@OpenApiObjectValidation` +- `@OpenApiName` - override property names +- `@OpenApiDescription` - add descriptions +- `@OpenApiIgnore` - exclude properties +- `@OpenApiRequired` - force required +- `@OpenApiPropertyType` - override types +- `@OpenApiNullable` - mark nullable +- `@OpenApiNaming` - apply naming strategies +- `@OpenApiByFields` - resolve from fields instead of getters +- Validation annotations - `@OpenApiNumberValidation`, `@OpenApiStringValidation`, `@OpenApiArrayValidation`, `@OpenApiObjectValidation` ## Next Steps -- [Type Composition](../json-schema/getting-started) — `@OneOf`, `@AnyOf`, `@AllOf` with discriminators -- [Custom Properties](../json-schema/custom-properties) — add custom schema properties with `@Custom` +- [Type Composition](../json-schema/getting-started) - `@OneOf`, `@AnyOf`, `@AllOf` with discriminators +- [Custom Properties](../json-schema/custom-properties) - add custom schema properties with `@Custom` diff --git a/docs/introduction/migration-from-6x.md b/docs/introduction/migration-from-6x.md index 597f8aa7..9176df66 100644 --- a/docs/introduction/migration-from-6x.md +++ b/docs/introduction/migration-from-6x.md @@ -48,8 +48,8 @@ New `@OpenApiNaming` annotation applies automatic name transformation to propert ```kotlin @OpenApiNaming(OpenApiNamingStrategy.SNAKE_CASE) class UserResponse( - val firstName: String, // → "first_name" - val lastName: String, // → "last_name" + val firstName: String, // -> "first_name" + val lastName: String, // -> "last_name" ) ``` @@ -95,7 +95,7 @@ New built-in type mappings: ### Simplified Plugin API -Security methods are available directly on `OpenApiSchemaBuilder` — no more `withSecurity { ... }` wrapper: +Security methods are available directly on `OpenApiSchemaBuilder` - no more `withSecurity { ... }` wrapper: ```kotlin openApiConfig.withDefinitionConfiguration { version, builder -> @@ -130,11 +130,11 @@ Update the version in your build file: ::: code-group ```kotlin [Gradle (Kotlin)] -val openapi = "7.2.2" // was 6.x +val openapi = "7.3.0-RC.1" // was 6.x ``` ```xml [Maven] -7.2.2 +7.3.0-RC.1 ``` ::: @@ -184,7 +184,7 @@ kapt { io.javalin.community.openapi openapi-annotation-processor - 7.2.2 + 7.3.0-RC.1 @@ -198,7 +198,7 @@ kapt { If you don't use a Groovy configuration script, no changes are needed. -### OpenAPI 3.0.3 → 3.1.0 {#openapi-303-310} +### OpenAPI 3.0.3 -> 3.1.0 {#openapi-303-310} Generated specs are now **OpenAPI 3.1.0** (was 3.0.3), based on JSON Schema 2020-12. This changes the generated output in several ways. @@ -232,7 +232,7 @@ Nullable `$ref` types are wrapped in `anyOf`: Nullable `oneOf`/`anyOf` types append a null entry: ```json -// 7.0 — oneOf with nullable +// 7.0 - oneOf with nullable { "oneOf": [ { "$ref": "#/components/schemas/Cat" }, @@ -242,7 +242,7 @@ Nullable `oneOf`/`anyOf` types append a null entry: } ``` -**No annotation changes needed** — this is handled automatically by the generator. If you have tooling that parses the generated spec (validators, code generators, etc.), it needs to understand 3.1.0 nullable semantics. +**No annotation changes needed** - this is handled automatically by the generator. If you have tooling that parses the generated spec (validators, code generators, etc.), it needs to understand 3.1.0 nullable semantics. #### `additionalProperties` default @@ -253,10 +253,10 @@ Nullable `oneOf`/`anyOf` types append a null entry: Changed from `Boolean` flags to numeric `String` values, matching JSON Schema 2020-12: ```kotlin -// 7.0 — numeric value (the exclusive bound itself) +// 7.0 - numeric value (the exclusive bound itself) @OpenApiNumberValidation(exclusiveMinimum = "0") -// 6.x — boolean flag (means: minimum is exclusive) +// 6.x - boolean flag (means: minimum is exclusive) @OpenApiNumberValidation(minimum = "0", exclusiveMinimum = true) ``` @@ -311,9 +311,9 @@ openApiConfig.withDefinitionConfiguration { version, definition -> ``` Key differences: -- `withInfo(...)` → `info(...)` -- `withServer(...)` → `server(...)` -- Security methods (`withBearerAuth`, `withBasicAuth`, `withOAuth2`, etc.) moved from `SecurityComponentConfiguration` to `OpenApiSchemaBuilder` directly — no more `withSecurity(security -> ...)` wrapper +- `withInfo(...)` -> `info(...)` +- `withServer(...)` -> `server(...)` +- Security methods (`withBearerAuth`, `withBasicAuth`, `withOAuth2`, etc.) moved from `SecurityComponentConfiguration` to `OpenApiSchemaBuilder` directly - no more `withSecurity(security -> ...)` wrapper - `withDefinitionProcessor` moved from `DefinitionConfiguration` to `OpenApiPluginConfiguration` #### Swagger & ReDoc configuration style diff --git a/docs/introduction/redoc.md b/docs/introduction/redoc.md index 7559abb5..315981bb 100644 --- a/docs/introduction/redoc.md +++ b/docs/introduction/redoc.md @@ -36,4 +36,4 @@ When running behind a reverse proxy that adds a base path, set `basePath` so the redoc.basePath = "/api" ``` -`ReDocPlugin` is repeatable — you can register multiple instances for different configurations. +`ReDocPlugin` is repeatable - you can register multiple instances for different configurations. diff --git a/docs/introduction/setup.md b/docs/introduction/setup.md index f59841db..8613ff87 100644 --- a/docs/introduction/setup.md +++ b/docs/introduction/setup.md @@ -19,22 +19,14 @@ repositories { } dependencies { - val openapi = "7.2.2" + val openapi = "7.3.0-RC.1" - annotationProcessor( - "io.javalin.community.openapi:openapi-annotation-processor:$openapi" - ) - implementation( - "io.javalin.community.openapi:javalin-openapi-plugin:$openapi" - ) + annotationProcessor("io.javalin.community.openapi:openapi-annotation-processor:$openapi") + implementation("io.javalin.community.openapi:javalin-openapi-plugin:$openapi") // Optional: Swagger UI - implementation( - "io.javalin.community.openapi:javalin-swagger-plugin:$openapi" - ) + implementation("io.javalin.community.openapi:javalin-swagger-plugin:$openapi") // Optional: ReDoc - implementation( - "io.javalin.community.openapi:javalin-redoc-plugin:$openapi" - ) + implementation("io.javalin.community.openapi:javalin-redoc-plugin:$openapi") } ``` @@ -44,41 +36,54 @@ plugins { } dependencies { - val openapi = "7.2.2" + val openapi = "7.3.0-RC.1" - kapt( - "io.javalin.community.openapi:openapi-annotation-processor:$openapi" - ) - implementation( - "io.javalin.community.openapi:javalin-openapi-plugin:$openapi" - ) - implementation( - "io.javalin.community.openapi:javalin-swagger-plugin:$openapi" - ) - implementation( - "io.javalin.community.openapi:javalin-redoc-plugin:$openapi" - ) + kapt("io.javalin.community.openapi:openapi-annotation-processor:$openapi") + implementation("io.javalin.community.openapi:javalin-openapi-plugin:$openapi") + implementation("io.javalin.community.openapi:javalin-swagger-plugin:$openapi") + implementation("io.javalin.community.openapi:javalin-redoc-plugin:$openapi") +} +``` + +```kotlin [Gradle (Kotlin) with KSP] +plugins { + // KSP is released against a specific Kotlin version - use the build that matches yours (github.com/google/ksp/releases) + id("com.google.devtools.ksp") version "2.3.9" +} + +dependencies { + val openapi = "7.3.0-RC.1" + + ksp("io.javalin.community.openapi:openapi-ksp:$openapi") + implementation("io.javalin.community.openapi:javalin-openapi-plugin:$openapi") + implementation("io.javalin.community.openapi:javalin-swagger-plugin:$openapi") + implementation("io.javalin.community.openapi:javalin-redoc-plugin:$openapi") +} + +ksp { + arg("openapi.info.title", "My API") + arg("openapi.info.version", "1.0.0") } ``` -```xml [Maven] +```xml [Maven (Java)] io.javalin.community.openapi javalin-openapi-plugin - 7.2.2 + 7.3.0-RC.1 io.javalin.community.openapi javalin-swagger-plugin - 7.2.2 + 7.3.0-RC.1 io.javalin.community.openapi javalin-redoc-plugin - 7.2.2 + 7.3.0-RC.1 @@ -92,7 +97,7 @@ dependencies { io.javalin.community.openapi openapi-annotation-processor - 7.2.2 + 7.3.0-RC.1 @@ -101,6 +106,90 @@ dependencies { ``` +```xml [Maven (Kotlin)] + + 2.1.0 + + + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + io.javalin.community.openapi + javalin-openapi-plugin + 7.3.0-RC.1 + + + + io.javalin.community.openapi + javalin-swagger-plugin + 7.3.0-RC.1 + + + + io.javalin.community.openapi + javalin-redoc-plugin + 7.3.0-RC.1 + + + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + + kapt + + kapt + + + + src/main/kotlin + + + + io.javalin.community.openapi + openapi-annotation-processor + 7.3.0-RC.1 + + + + + + compile + compile + + compile + + + + + + +``` + +::: + +## Choosing a Processor + +The same generation engine runs on three backends - pick the one that matches your build: + +| Backend | Dependency | Sources | Best for | +|-----------------------------|--------------------------------|---------------|--------------------------------------------| +| APT (`annotationProcessor`) | `openapi-annotation-processor` | Java | Java projects, or Kotlin via `kapt` | +| Kapt (`kapt`) | `openapi-annotation-processor` | Java + Kotlin | Mixed Java/Kotlin projects | +| KSP (`ksp`) | `openapi-ksp` *(experimental)* | Kotlin only | Kotlin-only projects wanting faster builds | + +All three emit the same `openapi-plugin/openapi-*.json` resource format that the `OpenApiPlugin` serves. APT/Kapt remain the reference backends; KSP is experimental and has the limitations listed below. + +::: warning KSP limitations +KSP processes **Kotlin sources only** - `@OpenApi`/`@JsonSchema` on Java types are not picked up (use APT/Kapt for those). The Groovy [scripting configuration](../advanced/scripting) (custom type mappings, property filters, custom type processors) and parser validation are currently APT/Kapt-only. `@OpenApiByFields(only = true)` is also APT/Kapt-only; the KSP backend fails the build with a clear error instead of generating an empty schema. ::: ## Register the Plugin @@ -206,11 +295,11 @@ openapi.withDefinitionConfiguration { version, builder -> } ``` -`OpenApiPlugin` is repeatable — you can register multiple instances for different configurations. +`OpenApiPlugin` is repeatable - you can register multiple instances for different configurations. ## Next Steps -- [Javalin Swagger UI](./swagger) — interactive API explorer -- [Javalin ReDoc](./redoc) — clean API reference -- [OpenAPI Getting Started](../openapi/getting-started) — annotate your first endpoint -- [Runtime Builder DSL](../advanced/runtime-builder) — build or extend specs programmatically at runtime +- [Javalin Swagger UI](./swagger) - interactive API explorer +- [Javalin ReDoc](./redoc) - clean API reference +- [OpenAPI Getting Started](../openapi/getting-started) - annotate your first endpoint +- [Runtime Builder DSL](../advanced/runtime-builder) - build or extend specs programmatically at runtime diff --git a/docs/introduction/swagger.md b/docs/introduction/swagger.md index 0b3911c3..d615d13c 100644 --- a/docs/introduction/swagger.md +++ b/docs/introduction/swagger.md @@ -57,4 +57,4 @@ When running behind a reverse proxy that adds a base path, set `basePath` so the swagger.basePath = "/api" ``` -`SwaggerPlugin` is repeatable — you can register multiple instances for different API versions or configurations. +`SwaggerPlugin` is repeatable - you can register multiple instances for different API versions or configurations. diff --git a/docs/json-schema/custom-properties.md b/docs/json-schema/custom-properties.md index 749172f4..939b864f 100644 --- a/docs/json-schema/custom-properties.md +++ b/docs/json-schema/custom-properties.md @@ -14,7 +14,7 @@ class InternalService { } ``` -`@Custom` is repeatable — you can add multiple custom properties to the same element. +`@Custom` is repeatable - you can add multiple custom properties to the same element. ## @CustomAnnotation diff --git a/docs/json-schema/getting-started.md b/docs/json-schema/getting-started.md index 9e9da6bb..2aa3128f 100644 --- a/docs/json-schema/getting-started.md +++ b/docs/json-schema/getting-started.md @@ -35,7 +35,7 @@ At compile time, this produces a `/json-schemes/com.example.ServerConfig` resour } ``` -All nested types are inlined directly — there are no `$ref` references. +All nested types are inlined directly - there are no `$ref` references. ## Loading at Runtime @@ -75,12 +75,12 @@ class InternalEntity All property-level `@OpenApi*` annotations also apply to JSON Schema output: -- `@OpenApiName` — override property names -- `@OpenApiDescription` — add descriptions -- `@OpenApiIgnore` — exclude properties -- `@OpenApiRequired` — force required -- `@OpenApiPropertyType` — override types -- `@OpenApiNullable` — mark nullable -- `@OpenApiNaming` — naming strategies (snake_case, kebab-case) -- `@OpenApiByFields` — resolve from fields instead of getters -- `@OpenApiNumberValidation`, `@OpenApiStringValidation`, `@OpenApiArrayValidation`, `@OpenApiObjectValidation` — validation constraints +- `@OpenApiName` - override property names +- `@OpenApiDescription` - add descriptions +- `@OpenApiIgnore` - exclude properties +- `@OpenApiRequired` - force required +- `@OpenApiPropertyType` - override types +- `@OpenApiNullable` - mark nullable +- `@OpenApiNaming` - naming strategies (snake_case, kebab-case) +- `@OpenApiByFields` - resolve from fields instead of getters +- `@OpenApiNumberValidation`, `@OpenApiStringValidation`, `@OpenApiArrayValidation`, `@OpenApiObjectValidation` - validation constraints diff --git a/docs/openapi/enums.md b/docs/openapi/enums.md index 4f019dc6..843fe2d3 100644 --- a/docs/openapi/enums.md +++ b/docs/openapi/enums.md @@ -44,9 +44,9 @@ Apply `@OpenApiNaming` to transform all value names automatically: ```kotlin @OpenApiNaming(OpenApiNamingStrategy.KEBAB_CASE) enum class ErrorCode { - NOT_FOUND, // → "not-found" - BAD_REQUEST, // → "bad-request" - SERVER_ERROR // → "server-error" + NOT_FOUND, // -> "not-found" + BAD_REQUEST, // -> "bad-request" + SERVER_ERROR // -> "server-error" } ``` diff --git a/docs/openapi/naming.md b/docs/openapi/naming.md index eaefee7e..7de58216 100644 --- a/docs/openapi/naming.md +++ b/docs/openapi/naming.md @@ -9,9 +9,9 @@ Apply `@OpenApiNaming` on a class to transform all property names: ```kotlin @OpenApiNaming(OpenApiNamingStrategy.SNAKE_CASE) class UserProfile { - val firstName: String = "" // → "first_name" - val lastName: String = "" // → "last_name" - val emailAddress: String = "" // → "email_address" + val firstName: String = "" // -> "first_name" + val lastName: String = "" // -> "last_name" + val emailAddress: String = "" // -> "email_address" } ``` @@ -30,10 +30,10 @@ class UserProfile { ```kotlin @OpenApiNaming(OpenApiNamingStrategy.SNAKE_CASE) class UserProfile { - val firstName: String = "" // → "first_name" (from strategy) + val firstName: String = "" // -> "first_name" (from strategy) @OpenApiName("ID") - val id: String = "" // → "ID" (explicit override) + val id: String = "" // -> "ID" (explicit override) } ``` @@ -44,9 +44,9 @@ class UserProfile { ```kotlin @OpenApiNaming(OpenApiNamingStrategy.KEBAB_CASE) enum class StatusCode { - NOT_FOUND, // → "not-found" - BAD_REQUEST, // → "bad-request" - INTERNAL_ERROR // → "internal-error" + NOT_FOUND, // -> "not-found" + BAD_REQUEST, // -> "bad-request" + INTERNAL_ERROR // -> "internal-error" } ``` @@ -55,12 +55,12 @@ Use `@OpenApiName` on individual values to override specific entries: ```kotlin @OpenApiNaming(OpenApiNamingStrategy.KEBAB_CASE) enum class StatusCode { - NOT_FOUND, // → "not-found" + NOT_FOUND, // -> "not-found" @OpenApiName("custom-value") - BAD_REQUEST, // → "custom-value" + BAD_REQUEST, // -> "custom-value" - INTERNAL_ERROR // → "internal-error" + INTERNAL_ERROR // -> "internal-error" } ``` diff --git a/docs/openapi/parameters.md b/docs/openapi/parameters.md index f3aa9813..100b4eec 100644 --- a/docs/openapi/parameters.md +++ b/docs/openapi/parameters.md @@ -83,9 +83,9 @@ Use `@OpenApiParam` to describe path, query, header, and cookie parameters. | Property | Type | Default | Description | |----------|------|---------|-------------| -| `name` | `String` | — | Parameter name (required) | +| `name` | `String` | - | Parameter name (required) | | `type` | `KClass<*>` | `String::class` | Parameter type | -| `description` | `String` | — | Description | +| `description` | `String` | - | Description | | `deprecated` | `Boolean` | `false` | Mark as deprecated | | `required` | `Boolean` | `false` | Mark as required | | `allowEmptyValue` | `Boolean` | `false` | Allow empty values | diff --git a/docs/openapi/request-body.md b/docs/openapi/request-body.md index 60c7f4be..1a88ab31 100644 --- a/docs/openapi/request-body.md +++ b/docs/openapi/request-body.md @@ -52,9 +52,9 @@ Use `@OpenApiRequestBody` and `@OpenApiContent` to describe request bodies. When `mimeType` is not specified, the content type is auto-detected from the `from` type: -- Object types → `application/json` -- `String` → `text/plain` -- `ByteArray`, `InputStream`, `File` → `application/octet-stream` +- Object types -> `application/json` +- `String` -> `text/plain` +- `ByteArray`, `InputStream`, `File` -> `application/octet-stream` ## Inline Properties @@ -109,11 +109,11 @@ Generates: | Property | Type | Default | Description | |----------|------|---------|-------------| -| `from` | `KClass<*>` | — | Schema type | +| `from` | `KClass<*>` | - | Schema type | | `mimeType` | `String` | Auto-detect | Content type | -| `type` | `String` | — | Override type | -| `format` | `String` | — | Override format | +| `type` | `String` | - | Override type | +| `format` | `String` | - | Override format | | `properties` | `OpenApiContentProperty[]` | `[]` | Inline properties | -| `additionalProperties` | `OpenApiAdditionalContent` | — | Map value type | -| `example` | `String` | — | Example value | +| `additionalProperties` | `OpenApiAdditionalContent` | - | Map value type | +| `example` | `String` | - | Example value | | `exampleObjects` | `OpenApiExampleProperty[]` | `[]` | Structured example | diff --git a/docs/openapi/responses.md b/docs/openapi/responses.md index e5cd56af..16738df6 100644 --- a/docs/openapi/responses.md +++ b/docs/openapi/responses.md @@ -74,7 +74,7 @@ OpenApiResponse( | Property | Type | Default | Description | |----------|------|---------|-------------| -| `status` | `String` | — | HTTP status code (required) | +| `status` | `String` | - | HTTP status code (required) | | `content` | `OpenApiContent[]` | `[]` | Response content | | `description` | `String` | `""` | Response description | | `headers` | `OpenApiParam[]` | `[]` | Response headers | diff --git a/docs/openapi/schemas.md b/docs/openapi/schemas.md index 1541084c..0d94e6fd 100644 --- a/docs/openapi/schemas.md +++ b/docs/openapi/schemas.md @@ -8,17 +8,17 @@ The annotation processor automatically generates OpenAPI component schemas from | Java Type | OpenAPI Type | Format | |-----------|-------------|--------| -| `boolean` / `Boolean` | `boolean` | — | +| `boolean` / `Boolean` | `boolean` | - | | `byte` / `Byte` | `integer` | `int32` | | `short` / `Short` | `integer` | `int32` | | `int` / `Integer` | `integer` | `int32` | | `long` / `Long` | `integer` | `int64` | | `float` / `Float` | `number` | `float` | | `double` / `Double` | `number` | `double` | -| `char` / `Character` | `string` | — | -| `String` | `string` | — | -| `BigDecimal` | `string` | — | -| `BigInteger` | `integer` | — | +| `char` / `Character` | `string` | - | +| `String` | `string` | - | +| `BigDecimal` | `string` | - | +| `BigInteger` | `integer` | - | | `UUID` | `string` | `uuid` | ### Date & Time Types @@ -65,8 +65,8 @@ The annotation processor automatically generates OpenAPI component schemas from By default, properties are resolved from getter methods following JavaBean conventions. The `get` / `is` prefix is stripped: -- `getName()` → `name` -- `isActive()` → `active` +- `getName()` -> `name` +- `isActive()` -> `active` ### Java Records @@ -86,6 +86,8 @@ class Config { The `value` parameter controls the minimum field visibility. Use `only = true` to ignore methods entirely. +`only = true` is supported by the APT/Kapt backend. KSP does not expose the Java-style field model needed for this mode yet, so the KSP backend fails the build with a clear error if it sees `@OpenApiByFields(only = true)`. + ## Property Annotations ### @OpenApiIgnore @@ -175,7 +177,7 @@ Generic type parameters are resolved when used in concrete contexts. `Page ## Custom Type Mappings -Register custom type mappings in the [compile-time configuration](../advanced/configuration): +Register custom type mappings in the [scripting configuration](../advanced/scripting): ```groovy configuration.simpleTypeMappings['org.bson.types.ObjectId'] = new SimpleType("string") diff --git a/examples/javalin-gradle-kotlin/src/main/compile/openapi.groovy b/examples/javalin-gradle-kotlin/src/main/compile/openapi.groovy index 548cfcae..2d3b04ed 100644 --- a/examples/javalin-gradle-kotlin/src/main/compile/openapi.groovy +++ b/examples/javalin-gradle-kotlin/src/main/compile/openapi.groovy @@ -7,7 +7,6 @@ class OpenApiConfiguration implements OpenApiAnnotationProcessorConfigurer { @Override void configure(OpenApiAnnotationProcessorConfiguration openApiAnnotationProcessorConfiguration) { - // openApiAnnotationProcessorConfiguration.debug = true } -} \ No newline at end of file +} diff --git a/examples/javalin-gradle-kotlin/src/main/java/io/javalin/openapi/plugin/test/JavalinTest.java b/examples/javalin-gradle-kotlin/src/main/java/io/javalin/openapi/plugin/test/JavalinTest.java index 519d4a81..8fca8b89 100644 --- a/examples/javalin-gradle-kotlin/src/main/java/io/javalin/openapi/plugin/test/JavalinTest.java +++ b/examples/javalin-gradle-kotlin/src/main/java/io/javalin/openapi/plugin/test/JavalinTest.java @@ -45,7 +45,7 @@ enum Rules implements RouteRole { * @param args args */ public static void main(String[] args) { - var app = Javalin.create(config -> { + Javalin.start(config -> { // config.routing.contextPath = "/custom"; String deprecatedDocsPath = "/api/openapi.json"; // by default it's /openapi @@ -110,7 +110,6 @@ public static void main(String[] args) { System.out.println(generatedJsonSchema.getContentAsString()); } }); - app.start(); } @OpenApi( @@ -217,7 +216,6 @@ public static void main(String[] args) { @OpenApiContent(from = EntityDto[].class), // array @OpenApiContent(from = File.class), // file @OpenApiContent(type = "application/json"), // empty - @OpenApiContent(), // empty @OpenApiContent(mimeType = "image/png", type = "string", format = "base64"), // single file upload, @OpenApiContent(mimeType = "multipart/form-data", properties = { @OpenApiContentProperty(name = "form-element", type = "integer"), // random element in form-data diff --git a/examples/javalin-ksp-kotlin/build.gradle.kts b/examples/javalin-ksp-kotlin/build.gradle.kts new file mode 100644 index 00000000..dfff3311 --- /dev/null +++ b/examples/javalin-ksp-kotlin/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + alias(libs.plugins.ksp) +} + +dependencies { + ksp(project(":openapi-ksp")) + implementation(project(":javalin-plugins:javalin-openapi-plugin")) + implementation(project(":javalin-plugins:javalin-swagger-plugin")) + implementation(project(":javalin-plugins:javalin-redoc-plugin")) + + implementation(libs.javalin) + implementation(libs.jackson.databind) + implementation(libs.logback.classic) +} + +ksp { + arg("openapi.info.title", "Awesome KSP App") + arg("openapi.info.version", "1.0.0") +} + +application { + mainClass.set("io.javalin.openapi.ksp.example.AppKt") +} + +repositories { + mavenCentral() +} diff --git a/examples/javalin-ksp-kotlin/src/main/kotlin/io/javalin/openapi/ksp/example/App.kt b/examples/javalin-ksp-kotlin/src/main/kotlin/io/javalin/openapi/ksp/example/App.kt new file mode 100644 index 00000000..b941298f --- /dev/null +++ b/examples/javalin-ksp-kotlin/src/main/kotlin/io/javalin/openapi/ksp/example/App.kt @@ -0,0 +1,51 @@ +package io.javalin.openapi.ksp.example + +import io.javalin.Javalin +import io.javalin.http.Context +import io.javalin.http.Handler +import io.javalin.openapi.HttpMethod +import io.javalin.openapi.OpenApi +import io.javalin.openapi.OpenApiContent +import io.javalin.openapi.OpenApiResponse +import io.javalin.openapi.OpenApiStatus +import io.javalin.openapi.plugin.OpenApiPlugin +import io.javalin.openapi.plugin.redoc.ReDocPlugin +import io.javalin.openapi.plugin.swagger.SwaggerPlugin + +data class Account( + val id: String, + val age: Int, + val roles: List, +) + +class AccountHandler : Handler { + @OpenApi( + path = "/account", + methods = [HttpMethod.GET], + summary = "Get the current account", + operationId = "getAccount", + responses = [ + OpenApiResponse( + status = OpenApiStatus.OK, + content = [OpenApiContent(from = Account::class)], + ) + ], + ) + override fun handle(ctx: Context) { + ctx.json(Account(id = "u-1", age = 30, roles = listOf("admin"))) + } +} + +fun main() { + Javalin.start { config -> + config.registerPlugin(OpenApiPlugin {}) + config.registerPlugin(SwaggerPlugin {}) + config.registerPlugin(ReDocPlugin {}) + + config.routes.get("/account", AccountHandler()) + } + + println("OpenAPI document: http://localhost:8080/openapi") + println("Swagger UI: http://localhost:8080/swagger") + println("ReDoc: http://localhost:8080/redoc") +} diff --git a/examples/javalin-ksp-kotlin/src/main/resources/logback.xml b/examples/javalin-ksp-kotlin/src/main/resources/logback.xml new file mode 100644 index 00000000..776c8b44 --- /dev/null +++ b/examples/javalin-ksp-kotlin/src/main/resources/logback.xml @@ -0,0 +1,11 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n + + + + + + + diff --git a/examples/javalin-maven-java/pom.xml b/examples/javalin-maven-java/pom.xml index 9d9cf41e..c2863a96 100644 --- a/examples/javalin-maven-java/pom.xml +++ b/examples/javalin-maven-java/pom.xml @@ -12,7 +12,7 @@ 11 11 7.2.2 - 7.2.2 + 7.3.0-RC.1 2.1.0 diff --git a/examples/javalin-maven-java/src/main/java/io/javalin/openapi/plugin/test/JavalinTest.java b/examples/javalin-maven-java/src/main/java/io/javalin/openapi/plugin/test/JavalinTest.java index 1eb364f7..23f2a52c 100644 --- a/examples/javalin-maven-java/src/main/java/io/javalin/openapi/plugin/test/JavalinTest.java +++ b/examples/javalin-maven-java/src/main/java/io/javalin/openapi/plugin/test/JavalinTest.java @@ -41,7 +41,7 @@ public final class JavalinTest implements Handler { * @param args args */ public static void main(String[] args) { - Javalin.create(config -> { + Javalin.start(config -> { String deprecatedDocsPath = "/swagger-docs"; config.registerPlugin(new OpenApiPlugin(openApiConfig -> @@ -101,8 +101,7 @@ public static void main(String[] args) { config.registerPlugin(new ReDocPlugin(reDocConfiguration -> reDocConfiguration.documentationPath = deprecatedDocsPath )); - }) - .start(8080); + }); } private static final String ROUTE = "/main/{name}"; diff --git a/examples/javalin-maven-kotlin/pom.xml b/examples/javalin-maven-kotlin/pom.xml index 0ff2be0d..040cf91d 100644 --- a/examples/javalin-maven-kotlin/pom.xml +++ b/examples/javalin-maven-kotlin/pom.xml @@ -10,7 +10,7 @@ 7.2.2 - 7.2.2 + 7.3.0-RC.1 2.1.0 diff --git a/examples/javalin-maven-kotlin/src/main/kotlin/io/javalin/openapi/plugin/test/KotlinTest.kt b/examples/javalin-maven-kotlin/src/main/kotlin/io/javalin/openapi/plugin/test/KotlinTest.kt index 87244489..ac6be15c 100644 --- a/examples/javalin-maven-kotlin/src/main/kotlin/io/javalin/openapi/plugin/test/KotlinTest.kt +++ b/examples/javalin-maven-kotlin/src/main/kotlin/io/javalin/openapi/plugin/test/KotlinTest.kt @@ -14,7 +14,7 @@ import io.javalin.openapi.plugin.swagger.SwaggerPlugin path = "/" ) fun main() { - Javalin.createAndStart { config -> + Javalin.start { config -> config.registerPlugin( OpenApiPlugin { it.documentationPath = "/openapi" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 47c3ccac..b487c2bd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,8 @@ assertj = "3.27.6" lombok = "1.18.42" json-unit = "4.1.1" unirest = "3.14.5" +ksp = "2.3.9" +kctfork = "0.12.1" [libraries] # Javalin @@ -40,6 +42,11 @@ jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module- jackson-dataformat-yaml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml", version.ref = "jackson" } jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "jackson" } +# KSP (introspection-ksp backend) +ksp-symbol-processing-api = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" } +kctfork-core = { module = "dev.zacsweers.kctfork:core", version.ref = "kctfork" } +kctfork-ksp = { module = "dev.zacsweers.kctfork:ksp", version.ref = "kctfork" } + # Other groovy = { module = "org.apache.groovy:groovy", version.ref = "groovy" } lombok = { module = "org.projectlombok:lombok", version.ref = "lombok" } @@ -60,4 +67,5 @@ unirest = { module = "com.konghq:unirest-java", version.ref = "unirest" } [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } nexus-publish = { id = "io.github.gradle-nexus.publish-plugin", version.ref = "nexus-publish" } \ No newline at end of file diff --git a/introspection/introspection-api/build.gradle.kts b/introspection/introspection-api/build.gradle.kts new file mode 100644 index 00000000..845f6a3c --- /dev/null +++ b/introspection/introspection-api/build.gradle.kts @@ -0,0 +1,8 @@ +description = "Introspection API | Backend-agnostic type & symbol introspection model" + +dependencies { + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.assertj.core) +} diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/AnnotationSet.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/AnnotationSet.kt new file mode 100644 index 00000000..33b66ec7 --- /dev/null +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/AnnotationSet.kt @@ -0,0 +1,60 @@ +package io.javalin.introspection + +interface AnnotationSet { + + fun all(): List + + fun find(type: Class): AnnotationProjection? + + fun findAll(type: Class): List + + fun contains(simpleName: String): Boolean + + fun contains(type: Class): Boolean = find(type) != null + +} + +interface AnnotationProjection { + + val simpleName: String + + val metadata: AnnotationSet + + val values: Map + + operator fun get(member: String): AnnotationValue = AnnotationValue(values[member]) + +} + +data class AnnotationValue(private val value: Any?) { + + fun raw(): Any? = value + + fun asString(): String? = value as? String + + fun asBoolean(): Boolean? = value as? Boolean + + fun asClassDefinition(): ClassDefinition? = value as? ClassDefinition + + fun asClassDefinitions(): List = + asList().filterIsInstance() + + fun asList(): List<*> = value as? List<*> ?: emptyList() + + fun asMap(): Map<*, *>? = value as? Map<*, *> + +} + +class RepeatableAnnotationProjection( + override val simpleName: String, + override val values: Map, +) : AnnotationProjection { + override val metadata: AnnotationSet = EmptyAnnotationSet +} + +private object EmptyAnnotationSet : AnnotationSet { + override fun all(): List = emptyList() + override fun find(type: Class): AnnotationProjection? = null + override fun findAll(type: Class): List = emptyList() + override fun contains(simpleName: String): Boolean = false +} diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt new file mode 100644 index 00000000..69a52214 --- /dev/null +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/ClassDefinition.kt @@ -0,0 +1,37 @@ +package io.javalin.introspection + +abstract class ClassDefinition( + val simpleName: String, + val fullName: String, + val generics: List = emptyList(), + val structureType: StructureType = StructureType.DEFAULT, +) { + + @InternalIntrospectionApi + abstract val source: Any + + abstract fun isEnum(): Boolean + + abstract fun getEnumConstants(): List + + abstract fun getProperties(): List + + abstract fun getAnnotations(): AnnotationSet + + override fun toString(): String = + when { + generics.isEmpty() -> fullName + else -> "$fullName<${generics.joinToString(", ")}>" + } +} + +enum class StructureType { + DEFAULT, + ARRAY, + DICTIONARY, +} + +data class EnumConstant( + val name: String, + val annotations: AnnotationSet, +) diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/CompileTimeIntrospector.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/CompileTimeIntrospector.kt new file mode 100644 index 00000000..94bf4dc0 --- /dev/null +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/CompileTimeIntrospector.kt @@ -0,0 +1,10 @@ +package io.javalin.introspection + +interface CompileTimeIntrospector : TypeIntrospector { + + fun typesAnnotatedWith( + annotationType: Class, + assignableTo: ClassDefinition? = null, + ): List + +} diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/InternalIntrospectionApi.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/InternalIntrospectionApi.kt new file mode 100644 index 00000000..10b442fb --- /dev/null +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/InternalIntrospectionApi.kt @@ -0,0 +1,9 @@ +package io.javalin.introspection + +@RequiresOptIn( + level = RequiresOptIn.Level.ERROR, + message = "Target agnostic code must not touch that part of the API.", +) +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.PROPERTY) +annotation class InternalIntrospectionApi diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyProjection.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyProjection.kt new file mode 100644 index 00000000..6b28e6d4 --- /dev/null +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/PropertyProjection.kt @@ -0,0 +1,26 @@ +package io.javalin.introspection + +class PropertyProjection( + val name: String, + val type: ClassDefinition, + val accessor: Accessor, + val nullable: Boolean, + val visibility: MemberVisibility, + val transient: Boolean, + val annotations: AnnotationSet, + @property:InternalIntrospectionApi val source: Any, +) + +enum class Accessor { FIELD, GETTER, RECORD_COMPONENT } + +enum class MemberVisibility { PUBLIC, PROTECTED, PACKAGE_PRIVATE, PRIVATE } + +fun isGetterName(name: String): Boolean = + (name.startsWith("get") && name.length > 3 && name[3].isUpperCase()) || + (name.startsWith("is") && name.length > 2 && name[2].isUpperCase()) + +fun propertyName(getterName: String): String = + when { + getterName.startsWith("get") -> getterName.removePrefix("get") + else -> getterName.removePrefix("is") + }.replaceFirstChar { it.lowercase() } diff --git a/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/TypeIntrospector.kt b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/TypeIntrospector.kt new file mode 100644 index 00000000..d501952d --- /dev/null +++ b/introspection/introspection-api/src/main/kotlin/io/javalin/introspection/TypeIntrospector.kt @@ -0,0 +1,5 @@ +package io.javalin.introspection + +fun interface TypeIntrospector { + fun introspect(source: Any): ClassDefinition +} diff --git a/introspection/introspection-jap/build.gradle.kts b/introspection/introspection-jap/build.gradle.kts new file mode 100644 index 00000000..2b5494c4 --- /dev/null +++ b/introspection/introspection-jap/build.gradle.kts @@ -0,0 +1,10 @@ +description = "Introspection JAP | javax.lang.model (Java annotation processing) TypeIntrospector backend" + +dependencies { + api(project(":introspection:introspection-api")) + + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.assertj.core) +} diff --git a/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt new file mode 100644 index 00000000..56c2a102 --- /dev/null +++ b/introspection/introspection-jap/src/main/kotlin/io/javalin/introspection/jap/JapTypeIntrospector.kt @@ -0,0 +1,455 @@ +package io.javalin.introspection.jap + +import io.javalin.introspection.Accessor +import io.javalin.introspection.AnnotationProjection +import io.javalin.introspection.AnnotationSet +import io.javalin.introspection.ClassDefinition +import io.javalin.introspection.CompileTimeIntrospector +import io.javalin.introspection.EnumConstant +import io.javalin.introspection.InternalIntrospectionApi +import io.javalin.introspection.MemberVisibility +import io.javalin.introspection.PropertyProjection +import io.javalin.introspection.RepeatableAnnotationProjection +import io.javalin.introspection.StructureType +import io.javalin.introspection.StructureType.ARRAY +import io.javalin.introspection.StructureType.DEFAULT +import io.javalin.introspection.StructureType.DICTIONARY +import io.javalin.introspection.isGetterName +import io.javalin.introspection.propertyName +import java.lang.annotation.Inherited +import java.lang.annotation.Repeatable as JavaRepeatable +import javax.annotation.processing.RoundEnvironment +import javax.lang.model.element.AnnotationMirror +import javax.lang.model.element.AnnotationValue +import javax.lang.model.element.Element +import javax.lang.model.element.ElementKind +import javax.lang.model.element.ExecutableElement +import javax.lang.model.element.Modifier +import javax.lang.model.element.TypeElement +import javax.lang.model.element.VariableElement +import javax.lang.model.type.ArrayType +import javax.lang.model.type.DeclaredType +import javax.lang.model.type.PrimitiveType +import javax.lang.model.type.TypeKind +import javax.lang.model.type.TypeMirror +import javax.lang.model.type.TypeVariable +import javax.lang.model.type.WildcardType +import javax.lang.model.util.Elements +import javax.lang.model.util.SimpleAnnotationValueVisitor8 +import javax.lang.model.util.Types + +class JapTypeIntrospector( + private val types: Types, + private val elements: Elements, + private val roundEnvProvider: () -> RoundEnvironment? = { null }, +) : CompileTimeIntrospector { + + override fun introspect(source: Any): ClassDefinition { + require(source is TypeMirror) { "JapTypeIntrospector expects a javax.lang.model.type.TypeMirror, got ${source::class.java.name}" } + return resolve(source) + } + + fun annotationsOf(element: Element): AnnotationSet = + AnnotationsView(listOf(element), includeInherited = element is TypeElement) + + @OptIn(InternalIntrospectionApi::class) + override fun typesAnnotatedWith(annotationType: Class, assignableTo: ClassDefinition?): List { + val roundEnv = roundEnvProvider() ?: return emptyList() + val target = assignableTo?.source as? TypeMirror + return roundEnv.getElementsAnnotatedWith(annotationType) + .filterIsInstance() + .filter { target == null || types.isAssignable(it.asType(), target) } + .map { resolve(it.asType()) } + } + + private fun resolve( + mirror: TypeMirror, + generics: List = emptyList(), + structureType: StructureType = DEFAULT, + visitingTypeVariables: Set = emptySet(), + ): ClassDefinition = + when (mirror) { + is TypeVariable -> { + val key = mirror.asElement()?.toString() ?: mirror.toString() + val bound = mirror.upperBound ?: mirror.lowerBound + when { + key in visitingTypeVariables -> objectDefinition(structureType) + bound != null -> + resolve( + mirror = bound, + generics = generics, + structureType = structureType, + visitingTypeVariables = visitingTypeVariables + key, + ) + else -> objectDefinition(structureType) + } + } + is WildcardType -> + resolve( + mirror = mirror.extendsBound ?: objectMirror(), + generics = generics, + structureType = structureType, + visitingTypeVariables = visitingTypeVariables, + ) + is ArrayType -> resolve( + mirror = mirror.componentType, + generics = generics, + structureType = ARRAY, + visitingTypeVariables = visitingTypeVariables, + ) + is PrimitiveType -> definition( + mirror = types.boxedClass(mirror).asType(), + generics = generics, + structureType = structureType, + ) + is DeclaredType -> declared( + mirror = mirror, + generics = generics, + structureType = structureType, + visitingTypeVariables = visitingTypeVariables, + ) + else -> + types + .asElement(mirror) + ?.asType() + ?.takeIf { it != mirror } + ?.let { + resolve( + mirror = it, + generics = generics, + structureType = structureType, + visitingTypeVariables = visitingTypeVariables, + ) + } + ?: objectDefinition(structureType) + } + + private fun declared( + mirror: DeclaredType, + generics: List, + structureType: StructureType, + visitingTypeVariables: Set, + ): ClassDefinition { + val erasure = types.erasure(mirror) + return when { + types.isAssignable(erasure, erasureOf("java.util.Map")) -> { + val keyType = resolve( + mirror.typeArguments.getOrElse(0) { objectMirror() }, + visitingTypeVariables = visitingTypeVariables, + ) + val valueType = resolve( + mirror.typeArguments.getOrElse(1) { objectMirror() }, + visitingTypeVariables = visitingTypeVariables, + ) + definition( + mirror = mirror, + generics = listOf(keyType, valueType), + structureType = DICTIONARY, + ) + } + types.isAssignable(erasure, erasureOf("java.util.Collection")) -> + resolve( + mirror = mirror.typeArguments.getOrElse(0) { objectMirror() }, + generics = generics, + structureType = ARRAY, + visitingTypeVariables = visitingTypeVariables, + ) + else -> + definition( + mirror = mirror, + generics = mirror.typeArguments.map { resolve(it, visitingTypeVariables = visitingTypeVariables) }, + structureType = structureType, + ) + } + } + + private fun definition( + mirror: TypeMirror, + generics: List, + structureType: StructureType, + sourceMirror: TypeMirror = mirror, + ): ClassDefinition { + val element = types.asElement(mirror) as? TypeElement + val fullName = element?.qualifiedName?.toString() ?: mirror.toString().substringBefore("<") + + return Definition( + simpleName = element?.simpleName?.toString() ?: fullName.substringAfterLast('.'), + fullName = fullName, + generics = generics, + structureType = structureType, + mirror = mirror, + sourceMirror = sourceMirror, + ) + } + + private fun primitiveDefinition(mirror: PrimitiveType): ClassDefinition = + definition( + mirror = types.boxedClass(mirror).asType(), + generics = emptyList(), + structureType = DEFAULT, + sourceMirror = mirror, + ) + + private fun objectDefinition(structureType: StructureType = DEFAULT): ClassDefinition = + definition( + mirror = objectMirror(), + generics = emptyList(), + structureType = structureType, + ) + + private fun objectMirror(): TypeMirror = + elements.getTypeElement(Any::class.java.name).asType() + + private fun erasureOf(name: String): TypeMirror = + types.erasure(elements.getTypeElement(name).asType()) + + private val annotationMirrorValueVisitor = object : SimpleAnnotationValueVisitor8() { + override fun defaultAction(value: Any?, p: Nothing?): Any? = value + + override fun visitType(type: TypeMirror, p: Nothing?): Any = + when { + type is PrimitiveType -> primitiveDefinition(type) + else -> resolve(type) + } + + override fun visitEnumConstant(constant: VariableElement, p: Nothing?): Any = + constant.simpleName.toString() + + override fun visitArray(values: MutableList, p: Nothing?): Any = + values.map { it.accept(this, null) } + + override fun visitAnnotation(annotation: AnnotationMirror, p: Nothing?): Any = + mirrorValues(annotation) + } + + private fun mirrorValues(mirror: AnnotationMirror): Map = + elements + .getElementValuesWithDefaults(mirror) + .entries + .associate { (member, value) -> member.simpleName.toString() to value.accept(annotationMirrorValueVisitor, null) } + + private inner class Definition( + simpleName: String, + fullName: String, + generics: List, + structureType: StructureType, + private val mirror: TypeMirror, + private val sourceMirror: TypeMirror = mirror, + ) : ClassDefinition( + simpleName = simpleName, + fullName = fullName, + generics = generics, + structureType = structureType, + ) { + + @InternalIntrospectionApi + override val source: Any + get() = sourceMirror + + override fun isEnum(): Boolean = + typeElement()?.kind == ElementKind.ENUM + + override fun getEnumConstants(): List = + typeElement() + ?.takeIf { it.kind == ElementKind.ENUM } + ?.enclosedElements + ?.filter { it.kind == ElementKind.ENUM_CONSTANT } + ?.map { + EnumConstant( + name = it.simpleName.toString(), + annotations = AnnotationsView(sources = listOf(it)), + ) + } + .orEmpty() + + override fun getAnnotations(): AnnotationSet = + AnnotationsView( + sources = listOfNotNull(typeElement()), + includeInherited = true, + ) + + override fun getProperties(): List { + val element = typeElement() ?: return emptyList() + + if (element.kind == ElementKind.RECORD) { + val recordProperties = element.recordComponents.map { component -> + PropertyProjection( + name = component.simpleName.toString(), + type = resolve(component.asType()), + accessor = Accessor.RECORD_COMPONENT, + nullable = component.asType().nullable(), + visibility = MemberVisibility.PUBLIC, + transient = false, + source = component, + annotations = AnnotationsView(listOf(component)), + ) + } + val recordPropertyNames = recordProperties.mapTo(mutableSetOf()) { it.name } + val extraGetters = elements.getAllMembers(element).mapNotNull { member -> + if (!member.isGetter()) { + return@mapNotNull null + } + + val getter = member as ExecutableElement + val name = propertyName(getter.simpleName.toString()) + if (name in recordPropertyNames) { + return@mapNotNull null + } + + PropertyProjection( + name = name, + type = resolve(getter.returnType), + accessor = Accessor.GETTER, + nullable = getter.returnType.nullable(), + visibility = getter.visibility(), + transient = false, + source = getter, + annotations = AnnotationsView(listOf(getter)), + ) + } + + return recordProperties + extraGetters + } + + return elements.getAllMembers(element).mapNotNull { member -> + when { + member.isGetter() -> (member as ExecutableElement).let { getter -> + PropertyProjection( + name = propertyName(getter.simpleName.toString()), + type = resolve(getter.returnType), + accessor = Accessor.GETTER, + nullable = getter.returnType.nullable(), + visibility = getter.visibility(), + transient = false, + source = getter, + annotations = AnnotationsView(listOf(getter)), + ) + } + member.isInstanceField() -> (member as VariableElement).let { field -> + PropertyProjection( + name = field.simpleName.toString(), + type = resolve(field.asType()), + accessor = Accessor.FIELD, + nullable = field.asType().nullable(), + visibility = field.visibility(), + transient = Modifier.TRANSIENT in field.modifiers, + source = field, + annotations = AnnotationsView(listOf(field)), + ) + } + else -> null + } + } + } + + private fun typeElement(): TypeElement? = + types.asElement(mirror) as? TypeElement + + private fun TypeMirror.nullable(): Boolean = + !kind.isPrimitive + + private fun Element.isGetter(): Boolean = + when { + kind != ElementKind.METHOD || this !is ExecutableElement -> false + Modifier.STATIC in modifiers -> false + parameters.isNotEmpty() || enclosingElement.toString() == Any::class.java.name -> false + returnType.kind == TypeKind.VOID -> false + else -> + isGetterName(simpleName.toString()) || + annotationMirrors.any { it.annotationType.asElement().simpleName.contentEquals("OpenApiName") } + } + + private fun Element.isInstanceField(): Boolean = + kind == ElementKind.FIELD && this is VariableElement && Modifier.STATIC !in modifiers + + private fun Element.visibility(): MemberVisibility = + when { + Modifier.PUBLIC in modifiers -> MemberVisibility.PUBLIC + Modifier.PROTECTED in modifiers -> MemberVisibility.PROTECTED + Modifier.PRIVATE in modifiers -> MemberVisibility.PRIVATE + else -> MemberVisibility.PACKAGE_PRIVATE + } + } + + private inner class AnnotationsView( + private val sources: List, + private val includeInherited: Boolean = false, + ) : AnnotationSet { + + private fun mirrors(source: Element): List { + if (!includeInherited) { + return source.annotationMirrors + } + + val superclass = (source as? TypeElement)?.superclass + val inherited = + generateSequence(superclass) { mirror -> (types.asElement(mirror) as? TypeElement)?.superclass } + .mapNotNull { types.asElement(it) as? TypeElement } + .takeWhile { it.qualifiedName.toString() != Any::class.java.name } + .flatMap { supertype -> + supertype + .annotationMirrors + .filter { it.annotationType.asElement().hasAnnotation(Inherited::class.java) } + } + .toList() + + return source.annotationMirrors + inherited + } + + override fun contains(simpleName: String): Boolean = + sources.any { source -> + mirrors(source).any { + it.annotationType.asElement().simpleName.contentEquals(simpleName) + } + } + + override fun find(type: Class): AnnotationProjection? = + sources + .firstNotNullOfOrNull { source -> mirrors(source).firstOrNull { it.named(type) } } + ?.let { JapAnnotationProjection(it) } + + override fun findAll(type: Class): List { + val annotationMirrors = sources.flatMap { mirrors(it) } + val direct = annotationMirrors.filter { it.named(type) }.map { JapAnnotationProjection(it) } + // javac wraps repeated annotations in their @Repeatable container; unwrap its `value` array + val containerName = type.getAnnotation(JavaRepeatable::class.java)?.value?.java?.canonicalName + val container = containerName?.let { name -> annotationMirrors.firstOrNull { it.named(name) } } + val repeated = (container?.let { mirrorValues(it)["value"] } as? List<*>) + ?.filterIsInstance>() + ?.map { RepeatableAnnotationProjection(type.simpleName, it) } + .orEmpty() + return direct + repeated + } + + override fun all(): List = + sources + .flatMap { mirrors(it) } + .distinctBy { it.annotationType.asElement() } + .map { JapAnnotationProjection(it) } + + private fun AnnotationMirror.named(qualifiedName: String): Boolean = + (annotationType.asElement() as? TypeElement) + ?.qualifiedName + ?.contentEquals(qualifiedName) == true + + private fun AnnotationMirror.named(type: Class): Boolean = + named(type.name) || type.canonicalName?.let { named(it) } == true + + private fun Element.hasAnnotation(type: Class): Boolean = + annotationMirrors.any { it.named(type) } + + } + + private inner class JapAnnotationProjection(private val mirror: AnnotationMirror) : AnnotationProjection { + + override val simpleName: String + get() = mirror.annotationType.asElement().simpleName.toString() + + override val metadata: AnnotationSet + get() = AnnotationsView(listOf(mirror.annotationType.asElement())) + + override val values: Map + get() = mirrorValues(mirror) + + } +} diff --git a/introspection/introspection-ksp/build.gradle.kts b/introspection/introspection-ksp/build.gradle.kts new file mode 100644 index 00000000..569de30d --- /dev/null +++ b/introspection/introspection-ksp/build.gradle.kts @@ -0,0 +1,14 @@ +description = "Introspection KSP | Kotlin Symbol Processing TypeIntrospector backend" + +dependencies { + api(project(":introspection:introspection-api")) + implementation(libs.ksp.symbol.processing.api) + + testImplementation(project(":introspection:introspection-runtime")) + testImplementation(libs.kctfork.core) + testImplementation(libs.kctfork.ksp) + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.assertj.core) +} diff --git a/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt new file mode 100644 index 00000000..09222990 --- /dev/null +++ b/introspection/introspection-ksp/src/main/kotlin/io/javalin/introspection/ksp/KspTypeIntrospector.kt @@ -0,0 +1,328 @@ +package io.javalin.introspection.ksp + +import com.google.devtools.ksp.KspExperimental +import com.google.devtools.ksp.getVisibility +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.symbol.ClassKind +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSAnnotation +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSDeclaration +import com.google.devtools.ksp.symbol.KSType +import com.google.devtools.ksp.symbol.KSTypeParameter +import com.google.devtools.ksp.symbol.Modifier +import com.google.devtools.ksp.symbol.Visibility as KspVisibility +import io.javalin.introspection.Accessor +import io.javalin.introspection.AnnotationProjection +import io.javalin.introspection.AnnotationSet +import io.javalin.introspection.ClassDefinition +import io.javalin.introspection.CompileTimeIntrospector +import io.javalin.introspection.EnumConstant +import io.javalin.introspection.InternalIntrospectionApi +import io.javalin.introspection.PropertyProjection +import io.javalin.introspection.RepeatableAnnotationProjection +import io.javalin.introspection.StructureType +import io.javalin.introspection.StructureType.ARRAY +import io.javalin.introspection.StructureType.DEFAULT +import io.javalin.introspection.StructureType.DICTIONARY +import io.javalin.introspection.MemberVisibility +import java.lang.annotation.Inherited +import java.lang.annotation.Repeatable as JavaRepeatable + +class KspTypeIntrospector(private val resolver: Resolver) : CompileTimeIntrospector { + + private val mapType = builtin("kotlin.collections.Map") + private val collectionType = builtin("kotlin.collections.Collection") + private val primitiveArrayElementTypes = mapOf( + "kotlin.BooleanArray" to "kotlin.Boolean", + "kotlin.ByteArray" to "kotlin.Byte", + "kotlin.ShortArray" to "kotlin.Short", + "kotlin.IntArray" to "kotlin.Int", + "kotlin.LongArray" to "kotlin.Long", + "kotlin.FloatArray" to "kotlin.Float", + "kotlin.DoubleArray" to "kotlin.Double", + "kotlin.CharArray" to "kotlin.Char", + ) + + override fun introspect(source: Any): ClassDefinition { + require(source is KSType) { "KspTypeIntrospector expects a com.google.devtools.ksp.symbol.KSType, got ${source::class.java.name}" } + return resolve(source) + } + + fun introspect(qualifiedName: String): ClassDefinition { + val declaration = resolver.getClassDeclarationByName(resolver.getKSNameFromString(qualifiedName)) + ?: error("KSP cannot resolve $qualifiedName") + return resolve(declaration.asStarProjectedType()) + } + + fun annotationsOf(annotated: KSAnnotated): AnnotationSet = + KspAnnotations(annotated) + + @OptIn(InternalIntrospectionApi::class) + override fun typesAnnotatedWith(annotationType: Class, assignableTo: ClassDefinition?): List { + val target = assignableTo?.source as? KSType + return resolver.getSymbolsWithAnnotation(annotationType.name) + .filterIsInstance() + .map { it.asStarProjectedType() } + .filter { target == null || target.isAssignableFrom(it) } + .map { resolve(it) } + .toList() + } + + private fun resolve( + type: KSType, + structureType: StructureType = DEFAULT, + visitingTypeParameters: Set = emptySet(), + ): ClassDefinition { + val declaration = type.declaration + if (declaration is KSTypeParameter) { + val key = declaration.qualifiedName?.asString() ?: declaration.simpleName.asString() + if (key in visitingTypeParameters) { + return objectDefinition(structureType) + } + val bound = declaration.bounds.firstOrNull()?.resolve() + return when { + bound != null -> + resolve( + type = bound, + structureType = structureType, + visitingTypeParameters = visitingTypeParameters + key, + ) + else -> objectDefinition(structureType) + } + } + val qualifiedName = declaration.qualifiedName?.asString() ?: return objectDefinition(structureType) + return when { + mapType != null && mapType.isAssignableFrom(type.starProjection()) -> { + val keyType = resolve(argument(type, 0), visitingTypeParameters = visitingTypeParameters) + val valueType = resolve(argument(type, 1), visitingTypeParameters = visitingTypeParameters) + definition( + type = type, + structureType = DICTIONARY, + generics = listOf(keyType, valueType), + ) + } + collectionType != null && collectionType.isAssignableFrom(type.starProjection()) -> + resolve( + type = argument(type, 0), + structureType = ARRAY, + visitingTypeParameters = visitingTypeParameters, + ) + qualifiedName == "kotlin.Array" -> + resolve( + type = argument(type, 0), + structureType = ARRAY, + visitingTypeParameters = visitingTypeParameters, + ) + primitiveArrayElementTypes[qualifiedName] != null -> + resolve( + type = builtin(primitiveArrayElementTypes.getValue(qualifiedName))!!, + structureType = ARRAY, + visitingTypeParameters = visitingTypeParameters, + ) + else -> { + val generics = + type + .arguments + .mapNotNull { it.type?.resolve() } + .map { resolve(it, visitingTypeParameters = visitingTypeParameters) } + definition(type = type, structureType = structureType, generics = generics) + } + } + } + + private fun definition(type: KSType, structureType: StructureType, generics: List): ClassDefinition { + val fullName = canonicalName(type.declaration) + return Definition( + simpleName = fullName.substringAfterLast('.'), + fullName = fullName, + generics = generics, + structureType = structureType, + type = type, + ) + } + + @OptIn(KspExperimental::class) + private fun canonicalName(declaration: KSDeclaration): String { + val kotlinName = declaration.qualifiedName ?: return declaration.simpleName.asString() + return (resolver.mapKotlinNameToJava(kotlinName) ?: kotlinName).asString() + } + + private fun objectDefinition(structureType: StructureType = DEFAULT): ClassDefinition = + definition( + type = builtin("kotlin.Any")!!, + structureType = structureType, + generics = emptyList(), + ) + + private fun argument(type: KSType, index: Int): KSType = + type.arguments.getOrNull(index)?.type?.resolve() ?: builtin("kotlin.Any")!! + + private fun builtin(qualifiedName: String): KSType? = + resolver.getClassDeclarationByName(resolver.getKSNameFromString(qualifiedName))?.asStarProjectedType() + + private inner class Definition( + simpleName: String, + fullName: String, + generics: List, + structureType: StructureType, + private val type: KSType, + ) : ClassDefinition( + simpleName = simpleName, + fullName = fullName, + generics = generics, + structureType = structureType, + ) { + + private val declaration: KSClassDeclaration? + get() = type.declaration as? KSClassDeclaration + + @InternalIntrospectionApi + override val source: Any + get() = type + + override fun isEnum(): Boolean = + declaration?.classKind == ClassKind.ENUM_CLASS + + override fun getEnumConstants(): List = + declaration + ?.takeIf { it.classKind == ClassKind.ENUM_CLASS } + ?.declarations + ?.filterIsInstance() + ?.filter { it.classKind == ClassKind.ENUM_ENTRY } + ?.map { + EnumConstant( + name = it.simpleName.asString(), + annotations = KspAnnotations(elements = listOf(it)), + ) + } + ?.toList() + .orEmpty() + + override fun getAnnotations(): AnnotationSet = KspAnnotations(declaration) + + override fun getProperties(): List { + val declaration = declaration ?: return emptyList() + return declaration + .getAllProperties() + .filter { property -> property.getVisibility() !in setOf(KspVisibility.PRIVATE, KspVisibility.LOCAL) } + .map { property -> + val propertyType = property.type.resolve() + PropertyProjection( + name = property.simpleName.asString(), + type = resolve(propertyType), + accessor = Accessor.GETTER, + nullable = propertyType.isMarkedNullable, + visibility = visibilityOf(property.getVisibility()), + transient = Modifier.JAVA_TRANSIENT in property.modifiers, + source = property, + annotations = KspAnnotations(elements = listOfNotNull(property, property.getter)), + ) + } + .toList() + } + } + + private inner class KspAnnotations(private val elements: List) : AnnotationSet { + + constructor(element: KSAnnotated?) : this(listOfNotNull(element)) + + private fun annotations(): List = + elements.flatMap { element -> + element.annotations.toList() + inheritedAnnotations(element) + } + + private fun inheritedAnnotations(element: KSAnnotated): List { + val declaration = element as? KSClassDeclaration ?: return emptyList() + return generateSequence(declaration) { current -> current.superclass() } + .drop(1) + .flatMap { superclass -> superclass.annotations.filter { it.isInherited() }.toList() } + .toList() + } + + private fun KSClassDeclaration.superclass(): KSClassDeclaration? = + superTypes + .mapNotNull { it.resolve().declaration as? KSClassDeclaration } + .firstOrNull { it.classKind == ClassKind.CLASS } + + private fun KSAnnotation.isInherited(): Boolean = + annotationType + .resolve() + .declaration + .annotations + .any { it.qualifiedName() == Inherited::class.java.name } + + override fun contains(simpleName: String): Boolean = + annotations().any { it.shortName.asString() == simpleName } + + override fun find(type: Class): AnnotationProjection? = + annotations() + .firstOrNull { it.named(type) } + ?.let { KspAnnotationProjection(it) } + + override fun findAll(type: Class): List { + val annotations = annotations() + val direct = + annotations + .filter { it.named(type) } + .map { KspAnnotationProjection(it) } + + val containerName = type.getAnnotation(JavaRepeatable::class.java)?.value?.java?.canonicalName + val repeated = containerName + ?.let { name -> annotations.firstOrNull { it.qualifiedName() == name } } + ?.let { argumentValues(it)["value"] as? List<*> } + ?.filterIsInstance>() + ?.map { RepeatableAnnotationProjection(type.simpleName, it) } + .orEmpty() + return direct + repeated + } + + override fun all(): List = + annotations() + .distinctBy { it.annotationType.resolve().declaration } + .map { KspAnnotationProjection(it) } + + private fun KSAnnotation.named(type: Class): Boolean { + val qualifiedName = qualifiedName() + return qualifiedName == type.name || qualifiedName == type.canonicalName + } + + private fun KSAnnotation.qualifiedName(): String? = + annotationType.resolve().declaration.qualifiedName?.asString() + } + + private inner class KspAnnotationProjection(private val annotation: KSAnnotation) : AnnotationProjection { + + override val simpleName: String + get() = annotation.shortName.asString() + + override val metadata: AnnotationSet + get() = KspAnnotations(annotation.annotationType.resolve().declaration) + + override val values: Map + get() = argumentValues(annotation) + + } + + private fun argumentValues(annotation: KSAnnotation): Map = + annotation.arguments.mapNotNull { argument -> + argument.name?.asString()?.let { it to normalize(argument.value) } + }.toMap() + + private fun normalize(value: Any?): Any? = + when (value) { + is KSType -> resolve(value) + is KSAnnotation -> argumentValues(value) + is List<*> -> value.map { normalize(it) } + is KSDeclaration -> value.simpleName.asString() + else -> value + } + + private fun visibilityOf(visibility: KspVisibility): MemberVisibility = + when (visibility) { + KspVisibility.PUBLIC, KspVisibility.INTERNAL -> MemberVisibility.PUBLIC + KspVisibility.PROTECTED -> MemberVisibility.PROTECTED + KspVisibility.PRIVATE, KspVisibility.LOCAL -> MemberVisibility.PRIVATE + else -> MemberVisibility.PACKAGE_PRIVATE + } +} diff --git a/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt b/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt new file mode 100644 index 00000000..7c67ba4c --- /dev/null +++ b/introspection/introspection-ksp/src/test/kotlin/io/javalin/introspection/ksp/KspTypeIntrospectorTest.kt @@ -0,0 +1,133 @@ +package io.javalin.introspection.ksp + +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider +import com.google.devtools.ksp.symbol.KSAnnotated +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.SourceFile +import com.tschuchort.compiletesting.symbolProcessorProviders +import com.tschuchort.compiletesting.useKsp2 +import org.assertj.core.api.Assertions.assertThat +import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi +import org.junit.jupiter.api.Test +import kotlin.reflect.KClass + +@OptIn(ExperimentalCompilerApi::class) +class KspTypeIntrospectorTest { + + private fun withKsp(block: (KspTypeIntrospector) -> R): R { + var result: Result? = null + val provider = object : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor = + object : SymbolProcessor { + override fun process(resolver: Resolver): List { + if (result == null) result = runCatching { block(KspTypeIntrospector(resolver)) } + return emptyList() + } + } + } + val compilation = KotlinCompilation().apply { + useKsp2() + sources = listOf(SourceFile.kotlin("Trigger.kt", "package trigger\nclass Trigger")) + symbolProcessorProviders = mutableListOf(provider) + inheritClassPath = true + messageOutputStream = System.out + } + val compiled = compilation.compile() + check(compiled.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${compiled.messages}" } + return (result ?: error("KSP processor did not run")).getOrThrow() + } + + @Test + fun `KSP backend resolves property types`() { + val properties = + withKsp { ksp -> + ksp + .introspect("$REF_PKG.Account") + .getProperties() + .associate { property -> property.name to "${property.type.fullName}:${property.type.structureType}" } + } + + assertThat(properties).containsAllEntriesOf( + mapOf( + "id" to "java.lang.String:DEFAULT", + "age" to "java.lang.Integer:DEFAULT", + "color" to "$REF_PKG.Color:DEFAULT", + "address" to "$REF_PKG.Address:DEFAULT", + "tags" to "java.lang.String:ARRAY", + "meta" to "java.util.Map:DICTIONARY", + ) + ) + } + + @Test + fun `KSP backend resolves enum constants`() { + val (isEnum, constants) = + withKsp { ksp -> + val color = ksp.introspect("$REF_PKG.Color") + color.isEnum() to color.getEnumConstants().map { it.name } + } + + assertThat(isEnum).isTrue() + assertThat(constants).containsExactly("RED", "GREEN") + } + + @Test + fun `KSP backend reads annotations by name`() { + val (hasRef, refValue) = + withKsp { ksp -> + val annotations = + ksp + .introspect("$REF_PKG.Account") + .getAnnotations() + annotations.contains("Ref") to annotations.find(Ref::class.java)?.get("value")?.asClassDefinition()?.fullName + } + + assertThat(hasRef).isTrue() + assertThat(refValue).isEqualTo("$REF_PKG.Address") + } + + @Test + fun `KSP backend finds annotations by meta-annotation`() { + val label = + withKsp { ksp -> + ksp + .introspect("$REF_PKG.Account") + .getAnnotations() + .all() + .first { it.metadata.contains("MetaMarker") } + .get("label") + .asString() + } + + assertThat(label).isEqualTo("x") + } + + private companion object { + const val REF_PKG = "io.javalin.introspection.ksp" + } +} + +enum class Color { RED, GREEN } + +annotation class Ref(val value: KClass<*>) + +class Address(val city: String, val zip: String) + +annotation class MetaMarker + +@MetaMarker +annotation class Tagged(val label: String) + +@Ref(Address::class) +@Tagged("x") +class Account( + val id: String, + val age: Int, + val color: Color, + val address: Address?, + val tags: List, + val meta: Map, +) diff --git a/introspection/introspection-runtime/build.gradle.kts b/introspection/introspection-runtime/build.gradle.kts new file mode 100644 index 00000000..26db5667 --- /dev/null +++ b/introspection/introspection-runtime/build.gradle.kts @@ -0,0 +1,10 @@ +description = "Introspection Runtime | Reflection-based TypeIntrospector backend" + +dependencies { + api(project(":introspection:introspection-api")) + + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.assertj.core) +} diff --git a/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt new file mode 100644 index 00000000..849b714b --- /dev/null +++ b/introspection/introspection-runtime/src/main/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospector.kt @@ -0,0 +1,390 @@ +package io.javalin.introspection.runtime + +import io.javalin.introspection.Accessor +import io.javalin.introspection.AnnotationProjection +import io.javalin.introspection.AnnotationSet +import io.javalin.introspection.ClassDefinition +import io.javalin.introspection.EnumConstant +import io.javalin.introspection.InternalIntrospectionApi +import io.javalin.introspection.PropertyProjection +import io.javalin.introspection.StructureType +import io.javalin.introspection.StructureType.ARRAY +import io.javalin.introspection.StructureType.DEFAULT +import io.javalin.introspection.StructureType.DICTIONARY +import io.javalin.introspection.TypeIntrospector +import io.javalin.introspection.MemberVisibility +import io.javalin.introspection.isGetterName +import io.javalin.introspection.propertyName +import java.lang.reflect.AnnotatedElement +import java.lang.reflect.Array as JavaArray +import java.lang.reflect.Field +import java.lang.reflect.GenericArrayType +import java.lang.reflect.Method +import java.lang.reflect.Modifier +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type +import java.lang.reflect.TypeVariable +import java.lang.reflect.WildcardType + +class ReflectionTypeIntrospector : TypeIntrospector { + + override fun introspect(source: Any): ClassDefinition { + require(source is Type) { "ReflectionTypeIntrospector expects a java.lang.reflect.Type, got ${source::class.java.name}" } + return reflect(source) + } +} + +private fun reflect( + type: Type, + generics: List = emptyList(), + structureType: StructureType = DEFAULT, + visitingTypeVariables: Set> = emptySet(), +): ClassDefinition = + when (type) { + is GenericArrayType -> + reflect( + type = type.genericComponentType, + generics = generics, + structureType = ARRAY, + visitingTypeVariables = visitingTypeVariables, + ) + is WildcardType -> + reflect( + type = type.upperBounds.firstOrNull() ?: Any::class.java, + generics = generics, + structureType = structureType, + visitingTypeVariables = visitingTypeVariables, + ) + is TypeVariable<*> -> + when { + type in visitingTypeVariables -> objectDefinition(structureType) + else -> + reflect( + type = type.bounds.firstOrNull() ?: Any::class.java, + generics = generics, + structureType = structureType, + visitingTypeVariables = visitingTypeVariables + type, + ) + } + is ParameterizedType -> parameterized( + type = type, + generics = generics, + structureType = structureType, + visitingTypeVariables = visitingTypeVariables, + ) + is Class<*> -> raw( + clazz = type, + generics = generics, + structureType = structureType, + visitingTypeVariables = visitingTypeVariables, + ) + else -> definition(erasure = Any::class.java, generics = emptyList(), structureType = structureType) + } + +private fun raw( + clazz: Class<*>, + generics: List, + structureType: StructureType, + visitingTypeVariables: Set>, +): ClassDefinition = + when { + clazz.isArray -> reflect( + type = clazz.componentType, + generics = generics, + structureType = ARRAY, + visitingTypeVariables = visitingTypeVariables, + ) + clazz.isPrimitive -> + definition( + erasure = clazz.kotlin.javaObjectType, + generics = generics, + structureType = structureType, + source = clazz, + ) + Map::class.java.isAssignableFrom(clazz) -> + definition( + erasure = clazz, + generics = listOf(objectDefinition(), objectDefinition()), + structureType = DICTIONARY, + ) + Collection::class.java.isAssignableFrom(clazz) -> objectDefinition(ARRAY) + else -> definition(erasure = clazz, generics = generics, structureType = structureType) + } + +private fun parameterized( + type: ParameterizedType, + generics: List, + structureType: StructureType, + visitingTypeVariables: Set>, +): ClassDefinition { + val erasure = type.rawType as Class<*> + val arguments = type.actualTypeArguments + return when { + Map::class.java.isAssignableFrom(erasure) -> { + val keyType = reflect(arguments.getOrElse(0) { Any::class.java }, visitingTypeVariables = visitingTypeVariables) + val valueType = reflect(arguments.getOrElse(1) { Any::class.java }, visitingTypeVariables = visitingTypeVariables) + definition( + erasure = erasure, + generics = listOf(keyType, valueType), + structureType = DICTIONARY, + ) + } + Collection::class.java.isAssignableFrom(erasure) -> + reflect( + type = arguments.getOrElse(0) { Any::class.java }, + generics = generics, + structureType = ARRAY, + visitingTypeVariables = visitingTypeVariables, + ) + else -> { + val resolvedGenerics = arguments.map { + reflect(it, visitingTypeVariables = visitingTypeVariables) + } + definition(erasure = erasure, generics = resolvedGenerics, structureType = structureType) + } + } +} + +private fun definition( + erasure: Class<*>, + generics: List, + structureType: StructureType, + source: Class<*> = erasure, +): ClassDefinition = + ReflectionClassDefinition( + simpleName = erasure.simpleName.ifEmpty { erasure.name.substringAfterLast('.') }, + fullName = erasure.canonicalName ?: erasure.name, + generics = generics, + structureType = structureType, + erasure = erasure, + sourceType = source, + ) + +private fun objectDefinition(structureType: StructureType = DEFAULT): ClassDefinition = + definition(erasure = Any::class.java, generics = emptyList(), structureType = structureType) + +private class ReflectionClassDefinition( + simpleName: String, + fullName: String, + generics: List, + structureType: StructureType, + private val erasure: Class<*>, + private val sourceType: Class<*>, +) : ClassDefinition( + simpleName = simpleName, + fullName = fullName, + generics = generics, + structureType = structureType, +) { + + @InternalIntrospectionApi + override val source: Any = sourceType + + override fun isEnum(): Boolean = + erasure.isEnum + + override fun getEnumConstants(): List { + if (!erasure.isEnum) { + return emptyList() + } + + return erasure.enumConstants.map { constant -> + val name = (constant as Enum<*>).name + val field = runCatching { erasure.getDeclaredField(name) }.getOrNull() + EnumConstant( + name = name, + annotations = ReflectionAnnotations(listOfNotNull(field)), + ) + } + } + + override fun getProperties(): List = + collectMembers(erasure).map { member -> + PropertyProjection( + name = when { + member.accessor == Accessor.GETTER -> propertyName(member.name) + else -> member.name + }, + type = reflect(member.genericType), + accessor = member.accessor, + nullable = (member.genericType as? Class<*>)?.isPrimitive != true, + visibility = member.visibility, + transient = member.transient, + source = member.source, + annotations = ReflectionAnnotations(member.sources), + ) + } + + override fun getAnnotations(): AnnotationSet = ReflectionAnnotations(listOf(erasure)) +} + +private fun collectMembers(clazz: Class<*>): List { + if (clazz.isRecord) { + return clazz.recordComponents.map { component -> + val backingField = runCatching { clazz.getDeclaredField(component.name) }.getOrNull() + Member( + name = component.name, + genericType = component.genericType, + accessor = Accessor.RECORD_COMPONENT, + visibility = MemberVisibility.PUBLIC, + transient = false, + source = component.accessor, + sources = listOfNotNull(component.accessor, backingField, component), + ) + } + } + + val members = mutableListOf() + val getterNames = mutableSetOf() + + for (method in clazz.methods) { + if (Modifier.isStatic(method.modifiers) || method.isBridge || method.isSynthetic) continue + if (method.parameterCount != 0 || method.declaringClass == Any::class.java) continue + if (method.returnType == Void.TYPE || !method.isPropertyGetter()) continue + if (getterNames.add(method.name)) { + members += method.toMember() + } + } + + for (method in nonPublicGettersHierarchy(clazz)) { + if (getterNames.add(method.name)) { + members += method.toMember() + } + } + + for (field in declaredFieldsHierarchy(clazz)) { + if (Modifier.isStatic(field.modifiers) || field.isSynthetic) continue + members += Member( + name = field.name, + genericType = field.genericType, + accessor = Accessor.FIELD, + visibility = visibilityOf(field.modifiers), + transient = Modifier.isTransient(field.modifiers), + source = field, + sources = listOf(field), + ) + } + + return members +} + +private class Member( + val name: String, + val genericType: Type, + val accessor: Accessor, + val visibility: MemberVisibility, + val transient: Boolean, + val source: AnnotatedElement, + val sources: List, +) + +private fun Method.toMember(): Member = + Member( + name = name, + genericType = genericReturnType, + accessor = Accessor.GETTER, + visibility = visibilityOf(modifiers), + transient = false, + source = this, + sources = listOf(this), + ) + +private fun Method.isPropertyGetter(): Boolean = + isGetterName(name) || annotations.any { it.annotationClass.simpleName == "OpenApiName" } + +private fun visibilityOf(modifiers: Int): MemberVisibility = + when { + Modifier.isPublic(modifiers) -> MemberVisibility.PUBLIC + Modifier.isProtected(modifiers) -> MemberVisibility.PROTECTED + Modifier.isPrivate(modifiers) -> MemberVisibility.PRIVATE + else -> MemberVisibility.PACKAGE_PRIVATE + } + +private fun declaredFieldsHierarchy(clazz: Class<*>): List { + val fields = mutableListOf() + var current: Class<*>? = clazz + while (current != null && current != Any::class.java) { + current.declaredFields.filterTo(fields) { field -> + memberIsInheritedBy( + type = clazz, + declaringClass = field.declaringClass, + modifiers = field.modifiers, + ) + } + current = current.superclass + } + return fields +} + +private fun nonPublicGettersHierarchy(clazz: Class<*>): List { + val getters = mutableListOf() + var current: Class<*>? = clazz + while (current != null && current != Any::class.java) { + for (method in current.declaredMethods) { + val modifiers = method.modifiers + if (Modifier.isStatic(modifiers) || Modifier.isPublic(modifiers) || method.isBridge || method.isSynthetic) continue + if (method.parameterCount != 0 || method.returnType == Void.TYPE || !method.isPropertyGetter()) continue + if (memberIsInheritedBy(clazz, method.declaringClass, modifiers)) { + getters += method + } + } + current = current.superclass + } + return getters +} + +private fun memberIsInheritedBy( + type: Class<*>, + declaringClass: Class<*>, + modifiers: Int, +): Boolean = + when { + declaringClass == type -> true + Modifier.isPrivate(modifiers) -> false + Modifier.isPublic(modifiers) || Modifier.isProtected(modifiers) -> true + else -> declaringClass.packageName == type.packageName + } + +private class ReflectionAnnotations(private val sources: List) : AnnotationSet { + + override fun contains(simpleName: String): Boolean = + sources.any { source -> source.annotations.any { it.annotationClass.simpleName == simpleName } } + + override fun find(type: Class): AnnotationProjection? = + sources.firstNotNullOfOrNull { it.getAnnotation(type) }?.let { ReflectionAnnotationProjection(it) } + + override fun findAll(type: Class): List = + sources.flatMap { it.getAnnotationsByType(type).toList() }.map { ReflectionAnnotationProjection(it) } + + override fun all(): List = + sources.flatMap { it.annotations.toList() }.distinctBy { it.annotationClass }.map { ReflectionAnnotationProjection(it) } +} + +private class ReflectionAnnotationProjection(private val annotation: Annotation) : AnnotationProjection { + + override val simpleName: String + get() = annotation.annotationClass.java.simpleName + + override val metadata: AnnotationSet + get() = ReflectionAnnotations(listOf(annotation.annotationClass.java)) + + override val values: Map + get() = annotationToMap(annotation) +} + +private fun annotationToMap(annotation: Annotation): Map = + annotation.annotationClass.java.declaredMethods.associate { + it.trySetAccessible() + it.name to normalize(it.invoke(annotation)) + } + +private fun normalize(value: Any?): Any? = + when { + value is Class<*> -> reflect(value) + value is Enum<*> -> value.name + value is Annotation -> annotationToMap(value) + value is Array<*> -> value.map { normalize(it) } + value != null && value::class.java.isArray -> + (0 until JavaArray.getLength(value)).map { normalize(JavaArray.get(value, it)) } + else -> value + } diff --git a/introspection/introspection-runtime/src/test/java/io/javalin/introspection/runtime/PackagePrivateAnnotationTestModels.java b/introspection/introspection-runtime/src/test/java/io/javalin/introspection/runtime/PackagePrivateAnnotationTestModels.java new file mode 100644 index 00000000..90a253c9 --- /dev/null +++ b/introspection/introspection-runtime/src/test/java/io/javalin/introspection/runtime/PackagePrivateAnnotationTestModels.java @@ -0,0 +1,13 @@ +package io.javalin.introspection.runtime; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.RUNTIME) +@interface PackagePrivateAnnotation { + String value(); +} + +@PackagePrivateAnnotation("package-private") +class PackagePrivateAnnotated { +} diff --git a/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt b/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt new file mode 100644 index 00000000..98cc1e28 --- /dev/null +++ b/introspection/introspection-runtime/src/test/kotlin/io/javalin/introspection/runtime/ReflectionTypeIntrospectorTest.kt @@ -0,0 +1,172 @@ +package io.javalin.introspection.runtime + +import io.javalin.introspection.Accessor +import io.javalin.introspection.PropertyProjection +import io.javalin.introspection.StructureType +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import kotlin.reflect.KClass + +class ReflectionTypeIntrospectorTest { + + private val introspector = ReflectionTypeIntrospector() + + private fun props(type: Class<*>): Map = + introspector.introspect(type).getProperties().associateBy { it.name } + + private fun accountProperties(): Map = + props(Account::class.java) + + @Test + fun `resolves a class into the shared model`() { + val account = introspector.introspect(Account::class.java) + assertThat(account.simpleName).isEqualTo("Account") + assertThat(account.fullName).isEqualTo("io.javalin.introspection.runtime.Account") + assertThat(account.structureType).isEqualTo(StructureType.DEFAULT) + } + + @Test + fun `exposes getter members under logical names`() { + val props = accountProperties() + + assertThat(props.keys).contains("id", "age", "name", "color", "address", "tags", "meta") + assertThat(props.getValue("id").accessor).isEqualTo(Accessor.GETTER) + } + + @Test + fun `boxes primitive getter types`() { + val props = accountProperties() + + assertThat(props.getValue("age").type.fullName).isEqualTo("java.lang.Integer") + } + + @Test + fun `reads getter nullability`() { + val props = accountProperties() + + assertThat(props.getValue("age").nullable).isFalse() + assertThat(props.getValue("name").nullable).isTrue() + } + + @Test + fun `resolves collection types`() { + val props = accountProperties() + + assertThat(props.getValue("tags").type.structureType).isEqualTo(StructureType.ARRAY) + assertThat(props.getValue("tags").type.fullName).isEqualTo("java.lang.String") + } + + @Test + fun `resolves map types`() { + val props = accountProperties() + + val meta = props.getValue("meta").type + assertThat(meta.structureType).isEqualTo(StructureType.DICTIONARY) + assertThat(meta.generics.map { it.fullName }).containsExactly("java.lang.String", "java.lang.Integer") + } + + @Test + fun `resolves nested types`() { + val props = accountProperties() + + assertThat(props.getValue("address").type.fullName).isEqualTo(Address::class.java.name) + } + + @Test + fun `exposes annotations without applying policy`() { + val id = props(Account::class.java).getValue("id") + assertThat(id.annotations.contains(Nn::class.java)).isTrue() + assertThat(id.annotations.contains("Nn")).isTrue() + assertThat(props(Account::class.java).getValue("name").annotations.contains(Nn::class.java)).isFalse() + } + + @Test + fun `reads enum constants raw`() { + val color = introspector.introspect(Color::class.java) + assertThat(color.isEnum()).isTrue() + assertThat(color.getEnumConstants().map { it.name }).containsExactly("RED", "GREEN") + assertThat(introspector.introspect(Account::class.java).getEnumConstants()).isEmpty() + } + + @Test + fun `tags each property with its backing accessors`() { + class FieldBag { + @JvmField val tag: String = "" + fun getName(): String = "" + } + + val props = props(FieldBag::class.java) + assertThat(props.getValue("name").accessor).isEqualTo(Accessor.GETTER) + assertThat(props.getValue("tag").accessor).isEqualTo(Accessor.FIELD) + } + + @Test + fun `resolves Class-valued annotation members into ClassDefinitions`() { + val annotations = introspector.introspect(Holder::class.java).getAnnotations() + + assertThat(annotations.find(Ref::class.java)?.get("value")?.asClassDefinition()?.fullName).isEqualTo(Address::class.java.name) + assertThat(annotations.find(Refs::class.java)?.get("value")?.asClassDefinitions().orEmpty().map { it.fullName }) + .containsExactly(Address::class.java.name, Color::class.java.name) + } + + @Test + fun `reads all annotation members into a neutral value map`() { + val annotations = introspector.introspect(Holder::class.java).getAnnotations() + + val values = annotations.find(Mixed::class.java)?.values!! + assertThat(values["name"]).isEqualTo("x") + assertThat(values["count"]).isEqualTo(3) + assertThat((values["type"] as io.javalin.introspection.ClassDefinition).fullName).isEqualTo(Address::class.java.name) + assertThat(values["shade"]).isEqualTo("RED") + assertThat(annotations.find(java.lang.Deprecated::class.java)?.values).isNull() + } + + @Test + fun `skips synthetic fields from inner classes`() { + val properties = introspector.introspect(RuntimeOuter.Inner::class.java).getProperties().map { it.name } + + assertThat(properties).contains("visible") + assertThat(properties).doesNotContain("this\$0") + } + + @Test + fun `reads values from package-private runtime annotations`() { + val annotation = introspector.introspect(PackagePrivateAnnotated::class.java) + .getAnnotations() + .find(PackagePrivateAnnotation::class.java) + + assertThat(annotation?.get("value")?.asString()).isEqualTo("package-private") + } +} + +private class Address + +private enum class Color { RED, GREEN } + +private annotation class Nn + +private class Account { + @Nn + fun getId(): String = "" + fun getAge(): Int = 0 + fun getName(): String = "" + fun getColor(): Color = Color.RED + fun getAddress(): Address? = null + fun getTags(): List = emptyList() + fun getMeta(): Map = emptyMap() +} + +private annotation class Ref(val value: KClass<*>) +private annotation class Refs(vararg val value: KClass<*>) +private annotation class Mixed(val name: String, val count: Int, val type: KClass<*>, val shade: Color) + +@Ref(Address::class) +@Refs(Address::class, Color::class) +@Mixed(name = "x", count = 3, type = Address::class, shade = Color.RED) +private class Holder + +private class RuntimeOuter { + inner class Inner { + val visible: String = "" + } +} diff --git a/introspection/introspection-test/build.gradle.kts b/introspection/introspection-test/build.gradle.kts new file mode 100644 index 00000000..18ed34a5 --- /dev/null +++ b/introspection/introspection-test/build.gradle.kts @@ -0,0 +1,14 @@ +dependencies { + testImplementation(project(":introspection:introspection-api")) + testImplementation(project(":introspection:introspection-runtime")) + testImplementation(project(":introspection:introspection-jap")) + testImplementation(project(":introspection:introspection-ksp")) + testImplementation(libs.ksp.symbol.processing.api) + testImplementation(libs.kctfork.core) + testImplementation(libs.kctfork.ksp) + + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.assertj.core) +} diff --git a/introspection/introspection-test/src/test/java/io/javalin/introspection/test/Note.java b/introspection/introspection-test/src/test/java/io/javalin/introspection/test/Note.java new file mode 100644 index 00000000..3a104df6 --- /dev/null +++ b/introspection/introspection-test/src/test/java/io/javalin/introspection/test/Note.java @@ -0,0 +1,20 @@ +package io.javalin.introspection.test; + +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Repeatable(Notes.class) +@Retention(RetentionPolicy.RUNTIME) +public @interface Note { + String value(); +} + +@Retention(RetentionPolicy.RUNTIME) +@interface Notes { + Note[] value(); +} + +@Note("a") +@Note("b") +class Noted {} diff --git a/introspection/introspection-test/src/test/java/io/javalin/introspection/test/sub/PackagePrivateBase.java b/introspection/introspection-test/src/test/java/io/javalin/introspection/test/sub/PackagePrivateBase.java new file mode 100644 index 00000000..92794b92 --- /dev/null +++ b/introspection/introspection-test/src/test/java/io/javalin/introspection/test/sub/PackagePrivateBase.java @@ -0,0 +1,6 @@ +package io.javalin.introspection.test.sub; + +public class PackagePrivateBase { + String packagePrivateField = ""; + public String publicField = ""; +} diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/AnnotationProcessing.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/AnnotationProcessing.kt new file mode 100644 index 00000000..20e91003 --- /dev/null +++ b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/AnnotationProcessing.kt @@ -0,0 +1,43 @@ +package io.javalin.introspection.test + +import io.javalin.introspection.ClassDefinition +import io.javalin.introspection.jap.JapTypeIntrospector +import java.net.URI +import javax.annotation.processing.AbstractProcessor +import javax.annotation.processing.RoundEnvironment +import javax.lang.model.SourceVersion +import javax.lang.model.element.TypeElement +import javax.tools.JavaFileObject +import javax.tools.SimpleJavaFileObject +import javax.tools.ToolProvider +import kotlin.reflect.KClass + +object AnnotationProcessing { + + fun introspect(type: KClass<*>, block: (ClassDefinition) -> R): R { + val compiler = requireNotNull(ToolProvider.getSystemJavaCompiler()) { "A JDK is required (no system Java compiler)" } + val trigger = object : SimpleJavaFileObject(URI.create("string:///Trigger.java"), JavaFileObject.Kind.SOURCE) { + override fun getCharContent(ignoreEncodingErrors: Boolean): CharSequence = "class Trigger {}" + } + + var result: Result? = null + val processor = object : AbstractProcessor() { + override fun getSupportedAnnotationTypes(): Set = setOf("*") + override fun getSupportedSourceVersion(): SourceVersion = SourceVersion.latestSupported() + override fun process(annotations: Set, round: RoundEnvironment): Boolean { + if (result == null) { + val backend = JapTypeIntrospector(processingEnv.typeUtils, processingEnv.elementUtils) + val mirror = processingEnv.elementUtils.getTypeElement(type.qualifiedName).asType() + result = runCatching { block(backend.introspect(mirror)) } + } + return false + } + } + + val options = listOf("-proc:only", "-classpath", System.getProperty("java.class.path")) + val task = compiler.getTask(null, null, null, options, null, listOf(trigger)) + task.setProcessors(listOf(processor)) + check(task.call()) { "annotation processing run failed" } + return (result ?: error("processor did not run")).getOrThrow() + } +} diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt new file mode 100644 index 00000000..2bd801de --- /dev/null +++ b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/IntrospectionParityTest.kt @@ -0,0 +1,313 @@ +package io.javalin.introspection.test + +import io.javalin.introspection.Accessor +import io.javalin.introspection.AnnotationSet +import io.javalin.introspection.ClassDefinition +import io.javalin.introspection.MemberVisibility +import io.javalin.introspection.StructureType +import io.javalin.introspection.runtime.ReflectionTypeIntrospector +import io.javalin.introspection.test.sub.PackagePrivateBase +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import kotlin.reflect.KClass + +class IntrospectionParityTest { + + private val runtime = ReflectionTypeIntrospector() + + private fun assertParity(type: KClass<*>) { + val runtimeShape = runtime.introspect(type.java).toShape() + val processedShape = AnnotationProcessing.introspect(type) { it.toShape() } + assertThat(processedShape).isEqualTo(runtimeShape) + } + + @Test + fun `scalar, collection, map and nested members match across backends`() = assertParity(Account::class) + + @Test + fun `plain object members match across backends`() = assertParity(Address::class) + + @Test + fun `enum constants match across backends`() = assertParity(Color::class) + + @Test + fun `inherited getters and private superclass fields match across backends`() = assertParity(Derived::class) + + @Test + fun `record components match across backends`() = assertParity(Point::class) + + @Test + fun `bounded type variables resolve to their bound across backends`() = assertParity(Bounded::class) + + @Test + fun `cross-package inherited fields match across backends`() = assertParity(CrossPackageChild::class) + + @Test + fun `transient fields are flagged on both backends`() { + assertParity(WithTransient::class) + val transientByName = runtime.introspect(WithTransient::class.java).getProperties() + .filter { it.accessor == Accessor.FIELD } + .associate { it.name to it.transient } + assertThat(transientByName.getValue("skipped")).isTrue() + assertThat(transientByName.getValue("kept")).isFalse() + } + + @Test + fun `getter detection excludes get-or-is lookalikes on both backends`() { + assertParity(Tricky::class) + val names = runtime.introspect(Tricky::class.java).getProperties().map { it.name } + assertThat(names).contains("name").doesNotContain("issue", "getaway", "result") + } + + @Test + fun `property-level annotations resolve identically across backends`() { + val runtimeMarked = runtime.introspect(Annotated::class.java).getProperties() + .filter { it.accessor == Accessor.GETTER } + .associate { it.name to it.annotations.contains("Marker") } + val processedMarked = AnnotationProcessing.introspect(Annotated::class) { + it.getProperties().filter { p -> p.accessor == Accessor.GETTER } + .associate { p -> p.name to p.annotations.contains("Marker") } + } + assertThat(processedMarked).isEqualTo(runtimeMarked) + assertThat(runtimeMarked.getValue("tagged")).isTrue() + assertThat(runtimeMarked.getValue("plain")).isFalse() + } + + @Test + fun `annotation enumeration with meta-annotations matches across backends`() { + fun scan(annotations: AnnotationSet): Pair { + val tagged = annotations.all().first { it.metadata.contains("MetaMarker") } + return tagged.simpleName to tagged["label"].asString() + } + val runtimeScan = scan(runtime.introspect(Scanned::class.java).getAnnotations()) + val processedScan = AnnotationProcessing.introspect(Scanned::class) { scan(it.getAnnotations()) } + assertThat(processedScan).isEqualTo(runtimeScan).isEqualTo("Tagged" to "x") + } + + @Test + fun `repeatable annotations enumerate identically across backends`() { + fun notes(annotations: AnnotationSet) = annotations.findAll(Note::class.java).map { it.get("value").asString() } + val runtimeNotes = notes(runtime.introspect(Noted::class.java).getAnnotations()) + val processedNotes = AnnotationProcessing.introspect(Noted::class) { notes(it.getAnnotations()) } + assertThat(processedNotes).isEqualTo(runtimeNotes) + assertThat(runtimeNotes).containsExactlyInAnyOrder("a", "b") + } + + @Test + fun `nested annotation members normalize to maps identically across backends`() { + val runtimeMeta = runtime.introspect(Wrapped::class.java).getAnnotations().find(Outer::class.java)!!.get("meta").asMap() + val processedMeta = AnnotationProcessing.introspect(Wrapped::class) { it.getAnnotations().find(Outer::class.java)!!.get("meta").asMap() } + assertThat(processedMeta).isEqualTo(runtimeMeta).isEqualTo(mapOf("note" to "x")) + } + + @Test + fun `Class-valued annotation members resolve identically across backends`() { + val runtimeAnnotations = runtime.introspect(Holder::class.java).getAnnotations() + val runtimeRef = runtimeAnnotations.find(Ref::class.java)?.get("value")?.asClassDefinition()?.fullName + val runtimeRefs = runtimeAnnotations.find(Refs::class.java)?.get("value")?.asClassDefinitions().orEmpty().map { it.fullName } + + val (processedRef, processedRefs) = AnnotationProcessing.introspect(Holder::class) { + val annotations = it.getAnnotations() + annotations.find(Ref::class.java)?.get("value")?.asClassDefinition()?.fullName to annotations.find(Refs::class.java)?.get("value")?.asClassDefinitions().orEmpty().map { it.fullName } + } + + assertThat(processedRef).isEqualTo(runtimeRef).isEqualTo(Address::class.java.name) + assertThat(processedRefs).isEqualTo(runtimeRefs).containsExactly(Address::class.java.name, Color::class.java.name) + } + + @Test + fun `annotation value maps match across backends`() { + val runtimeValue = runtime.introspect(Holder::class.java).getAnnotations().find(Ref::class.java)!!.get("value").asClassDefinition() + val processedValue = AnnotationProcessing.introspect(Holder::class) { it.getAnnotations().find(Ref::class.java)!!.get("value").asClassDefinition() } + assertThat(processedValue?.fullName) + .isEqualTo(runtimeValue?.fullName) + .isEqualTo(Address::class.java.name) + } + + @Test + fun `primitive array annotation members normalize identically across backends`() { + val runtimeInts = runtime.introspect(Flagged::class.java).getAnnotations().find(Flags::class.java)!!.get("ints").asList() + val processedInts = AnnotationProcessing.introspect(Flagged::class) { it.getAnnotations().find(Flags::class.java)!!.get("ints").asList() } + assertThat(processedInts).isEqualTo(runtimeInts).isEqualTo(listOf(1, 2, 3)) + } + + @Test + fun `KSP reports the same property names and types as reflection`() { + fun namesAndTypes(definition: ClassDefinition) = + definition.getProperties().map { it.name to it.type.fullName }.toSet() + val runtimeProperties = namesAndTypes(runtime.introspect(Address::class.java)) + val kspProperties = SymbolProcessing.introspect(Address::class) { namesAndTypes(it) } + assertThat(kspProperties).isEqualTo(runtimeProperties) + .isEqualTo(setOf("city" to String::class.java.name, "zip" to String::class.java.name)) + } + + @Test + fun `KSP collapses each property to a single getter where the JVM backends split field and getter`() { + val kspAccessors = SymbolProcessing.introspect(Address::class) { definition -> + definition.getProperties().map { it.accessor } + } + assertThat(kspAccessors).containsExactly(Accessor.GETTER, Accessor.GETTER) + + val runtimeAccessors = runtime.introspect(Address::class.java).getProperties().map { it.accessor }.toSet() + assertThat(runtimeAccessors).containsExactlyInAnyOrder(Accessor.GETTER, Accessor.FIELD) + } + + @Test + fun `KSP enum constants match reflection`() { + val runtimeConstants = runtime.introspect(Color::class.java).getEnumConstants().map { it.name }.sorted() + val kspConstants = SymbolProcessing.introspect(Color::class) { it.getEnumConstants().map { constant -> constant.name }.sorted() } + assertThat(kspConstants).isEqualTo(runtimeConstants).isEqualTo(listOf("GREEN", "RED")) + } + + @Test + fun `self-bounded type variables do not recurse forever across backends`() { + fun childType(definition: ClassDefinition): Pair> { + val child = definition.getProperties().first { it.name == "child" }.type + return child.fullName to child.generics.map { it.fullName } + } + + val runtimeChild = childType(runtime.introspect(SelfBounded::class.java)) + val processedChild = AnnotationProcessing.introspect(SelfBounded::class) { childType(it) } + val kspChild = SymbolProcessing.introspect(SelfBounded::class) { childType(it) } + + assertThat(processedChild).isEqualTo(runtimeChild) + assertThat(kspChild.first).isEqualTo(SelfBounded::class.java.name) + assertThat(kspChild.second).containsExactly(Any::class.java.name) + } + + @Test + fun `nested annotation classes can be found by class across backends`() { + fun value(definition: ClassDefinition): String? = + definition.getAnnotations().find(AnnotationContainer.Nested::class.java)?.get("value")?.asString() + + val runtimeValue = value(runtime.introspect(NestedAnnotated::class.java)) + val processedValue = AnnotationProcessing.introspect(NestedAnnotated::class) { value(it) } + val kspValue = SymbolProcessing.introspect(NestedAnnotated::class) { value(it) } + + assertThat(processedValue).isEqualTo(runtimeValue).isEqualTo("nested") + assertThat(kspValue).isEqualTo(runtimeValue) + } +} + +private data class TypeShape( + val fullName: String, + val simpleName: String, + val structure: StructureType, + val isEnum: Boolean, + val enumConstants: List, + val properties: List, +) + +private data class PropertyShape( + val name: String, + val typeFullName: String, + val typeStructure: StructureType, + val typeGenerics: List, + val accessor: Accessor, + val nullable: Boolean, + val visibility: MemberVisibility, + val transient: Boolean, +) + +private fun ClassDefinition.toShape(): TypeShape = + TypeShape( + fullName = fullName, + simpleName = simpleName, + structure = structureType, + isEnum = isEnum(), + enumConstants = getEnumConstants().map { it.name }.sorted(), + properties = if (isEnum()) emptyList() else getProperties() + .map { property -> + PropertyShape( + name = property.name, + typeFullName = property.type.fullName, + typeStructure = property.type.structureType, + typeGenerics = property.type.generics.map { it.fullName }, + accessor = property.accessor, + nullable = property.nullable, + visibility = property.visibility, + transient = property.transient, + ) + } + .sortedBy { "${it.accessor}:${it.name}" }, + ) + +class Address(val city: String, val zip: String) + +enum class Color { RED, GREEN } + +class Box(val value: T) + +class Bounded(val value: T, val many: List) + +@JvmRecord +data class Point(val x: Int, val label: String, val tags: List) + +class Account( + val id: String, + val age: Int, + val color: Color, + val address: Address?, + val tags: List, + val meta: Map, + val bounded: Box, +) + +open class Base(val baseField: String) { + val computed: String get() = "" + protected val secret: String get() = "" +} + +class Derived(val own: String) : Base("") + +class CrossPackageChild : PackagePrivateBase() + +class WithTransient(@Transient val skipped: String, val kept: String) + +class Tricky { + fun getName(): String = "" + fun issue(): String = "" + fun getaway(): String = "" + fun getResult() {} +} + +annotation class Marker + +class Annotated(@get:Marker val tagged: String, val plain: String) + +annotation class Meta(val note: String) + +annotation class Outer(val meta: Meta) + +@Outer(Meta("x")) +class Wrapped + +annotation class MetaMarker + +@MetaMarker +annotation class Tagged(val label: String) + +@Tagged("x") +class Scanned + +annotation class Flags(val ints: IntArray) + +@Flags(ints = [1, 2, 3]) +class Flagged + +annotation class Ref(val value: KClass<*>) + +annotation class Refs(vararg val value: KClass<*>) + +@Ref(Address::class) +@Refs(Address::class, Color::class) +class Holder + +class SelfBounded>(val child: T?) + +class AnnotationContainer { + annotation class Nested(val value: String) +} + +@AnnotationContainer.Nested("nested") +class NestedAnnotated diff --git a/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/SymbolProcessing.kt b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/SymbolProcessing.kt new file mode 100644 index 00000000..b826fa59 --- /dev/null +++ b/introspection/introspection-test/src/test/kotlin/io/javalin/introspection/test/SymbolProcessing.kt @@ -0,0 +1,46 @@ +package io.javalin.introspection.test + +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider +import com.google.devtools.ksp.symbol.KSAnnotated +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.SourceFile +import com.tschuchort.compiletesting.symbolProcessorProviders +import com.tschuchort.compiletesting.useKsp2 +import io.javalin.introspection.ClassDefinition +import io.javalin.introspection.ksp.KspTypeIntrospector +import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi +import kotlin.reflect.KClass + +@OptIn(ExperimentalCompilerApi::class) +object SymbolProcessing { + + fun introspect(type: KClass<*>, block: (ClassDefinition) -> R): R { + var result: Result? = null + val provider = object : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor = + object : SymbolProcessor { + override fun process(resolver: Resolver): List { + if (result == null) { + result = runCatching { block(KspTypeIntrospector(resolver).introspect(type.qualifiedName!!)) } + } + return emptyList() + } + } + } + + val compilation = KotlinCompilation().apply { + useKsp2() + sources = listOf(SourceFile.kotlin("Trigger.kt", "package trigger\nclass Trigger")) + symbolProcessorProviders = mutableListOf(provider) + inheritClassPath = true + messageOutputStream = System.out + } + + val compiled = compilation.compile() + check(compiled.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${compiled.messages}" } + return (result ?: error("KSP processor did not run")).getOrThrow() + } +} diff --git a/javalin-plugins/javalin-openapi-dynamic-hook/build.gradle.kts b/javalin-plugins/javalin-openapi-dynamic-hook/build.gradle.kts new file mode 100644 index 00000000..1a54b670 --- /dev/null +++ b/javalin-plugins/javalin-openapi-dynamic-hook/build.gradle.kts @@ -0,0 +1,17 @@ +description = "Javalin OpenAPI Dynamic Hook | Runtime OpenAPI generation hook for the OpenApiPlugin (route-based)" + +dependencies { + api(project(":openapi-dynamic")) + api(project(":javalin-plugins:javalin-openapi-plugin")) + compileOnly(libs.javalin) + + testImplementation(project(":javalin-plugins:javalin-redoc-plugin")) + testImplementation(project(":javalin-plugins:javalin-swagger-plugin")) + testImplementation(libs.javalin) + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.assertj.core) + testImplementation(libs.unirest) + testImplementation(libs.logback.classic) +} diff --git a/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/OpenApiMetadata.kt b/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/OpenApiMetadata.kt new file mode 100644 index 00000000..56ed3408 --- /dev/null +++ b/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/OpenApiMetadata.kt @@ -0,0 +1,19 @@ +package io.javalin.openapi.dynamic.hook + +import io.javalin.openapi.dynamic.ReflectionSchemaContext +import io.javalin.openapi.schema.MediaTypeBuilder +import io.javalin.openapi.schema.OperationBuilder +import io.javalin.router.EndpointMetadata +import java.util.function.Consumer + +class OpenApiMetadata(val configure: OperationBuilder.() -> Unit) : EndpointMetadata { + + companion object { + @JvmStatic + fun of(configure: Consumer): OpenApiMetadata = + OpenApiMetadata { configure.accept(this) } + } +} + +fun MediaTypeBuilder.schema(type: Class<*>) = + schema(ReflectionSchemaContext().inlineSchema(type)) diff --git a/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt b/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt new file mode 100644 index 00000000..495512ec --- /dev/null +++ b/javalin-plugins/javalin-openapi-dynamic-hook/src/main/kotlin/io/javalin/openapi/dynamic/hook/RegisteredRoutesHook.kt @@ -0,0 +1,93 @@ +package io.javalin.openapi.dynamic.hook + +import io.javalin.openapi.OpenApiPluginRouteHandler +import io.javalin.openapi.dynamic.ReflectionSchemaContext +import io.javalin.openapi.plugin.OpenApiHook +import io.javalin.openapi.plugin.OpenApiHookContext +import io.javalin.router.Endpoint +import java.util.function.Consumer + +class RegisteredRoutesHookConfiguration { + private var ignoreDefaultRoutes = true + private val ignoredPathPrefixes = linkedSetOf() + + fun clearDefaultIgnoredRoutes(): RegisteredRoutesHookConfiguration = apply { + this.ignoreDefaultRoutes = false + } + + fun withIgnoredPathPrefix(prefix: String): RegisteredRoutesHookConfiguration = + withIgnoredPathPrefixes(prefix) + + fun withIgnoredPathPrefixes(vararg prefixes: String): RegisteredRoutesHookConfiguration = apply { + prefixes.forEach { prefix -> + ignoredPathPrefixes.add(normalizePathPrefix(prefix)) + } + } + + internal fun ignores(endpoint: Endpoint): Boolean = + (ignoreDefaultRoutes && endpoint.handler is OpenApiPluginRouteHandler) || + ignoredPathPrefixes.any { endpoint.path.matchesPathPrefix(it) } + + private fun normalizePathPrefix(prefix: String): String { + val normalized = prefix.removeSuffix("/*").trimEnd('/').ifEmpty { "/" } + require(normalized.startsWith('/')) { "Ignored path prefixes must start with '/': $prefix" } + require('*' !in normalized) { "Ignored path prefixes only support a trailing /*: $prefix" } + return normalized + } + + private fun String.matchesPathPrefix(prefix: String): Boolean = + prefix == "/" || this == prefix || startsWith("$prefix/") +} + +class RegisteredRoutesHook @JvmOverloads constructor( + userConfig: Consumer = Consumer {}, +) : OpenApiHook { + private val config = RegisteredRoutesHookConfiguration().also(userConfig::accept) + + override fun apply(context: OpenApiHookContext) { + val schemaContext = ReflectionSchemaContext() + context.builder.openApiVersion("3.1.0").ensureInfo() + + for (handler in context.state.internalRouter.allHttpHandlers()) { + val endpoint = handler.endpoint + if (!endpoint.method.isHttpMethod || config.ignores(endpoint)) { + continue + } + + val path = toOpenApiPath(endpoint.path) + val method = endpoint.method.name().lowercase() + val pathParams = PATH_PARAM.findAll(endpoint.path).map { it.groupValues[1] }.toList() + val metadata = endpoint.metadata(OpenApiMetadata::class.java) + val operationAlreadyDocumented = context.builder.hasOperation(path, method) + + if (metadata == null && operationAlreadyDocumented) { + continue + } + + context.builder.path(path).operation(method) { + if (!operationAlreadyDocumented && pathParams.isNotEmpty()) { + parameters { + pathParams.forEach { name -> + parameter(name = name, location = "path", required = true) { type("string") } + } + } + } + + when (metadata) { + null -> responses { response("200") { description("OK") } } + else -> metadata.configure(this) + } + } + } + + context.builder.resolveComponentReferences { type -> schemaContext.componentSchema(type) } + } + + private fun toOpenApiPath(path: String): String = + path.replace(ANGLE_PARAM) { "{${it.groupValues[1]}}" } + + private companion object { + private val PATH_PARAM = Regex("[{<]([^}>]+)[}>]") + private val ANGLE_PARAM = Regex("<([^>]+)>") + } +} diff --git a/javalin-plugins/javalin-openapi-dynamic-hook/src/test/java/io/javalin/openapi/dynamic/hook/User.java b/javalin-plugins/javalin-openapi-dynamic-hook/src/test/java/io/javalin/openapi/dynamic/hook/User.java new file mode 100644 index 00000000..a15869d2 --- /dev/null +++ b/javalin-plugins/javalin-openapi-dynamic-hook/src/test/java/io/javalin/openapi/dynamic/hook/User.java @@ -0,0 +1,12 @@ +package io.javalin.openapi.dynamic.hook; + +public class User { + + public int getId() { + return 0; + } + + public String getName() { + return ""; + } +} diff --git a/javalin-plugins/javalin-openapi-dynamic-hook/src/test/kotlin/io/javalin/openapi/dynamic/hook/DynamicOpenApiHookTest.kt b/javalin-plugins/javalin-openapi-dynamic-hook/src/test/kotlin/io/javalin/openapi/dynamic/hook/DynamicOpenApiHookTest.kt new file mode 100644 index 00000000..e1e3535c --- /dev/null +++ b/javalin-plugins/javalin-openapi-dynamic-hook/src/test/kotlin/io/javalin/openapi/dynamic/hook/DynamicOpenApiHookTest.kt @@ -0,0 +1,197 @@ +package io.javalin.openapi.dynamic.hook + +import io.javalin.Javalin +import io.javalin.http.HandlerType +import io.javalin.openapi.experimental.processor.shared.jsonMapper +import io.javalin.openapi.plugin.OpenApiPlugin +import io.javalin.openapi.plugin.redoc.ReDocPlugin +import io.javalin.openapi.plugin.swagger.SwaggerPlugin +import io.javalin.router.Endpoint +import kong.unirest.Unirest +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class DynamicOpenApiHookTest { + + @Test + fun `autogenerates docs for registered user routes`() { + val app = Javalin.start { config -> + config.jetty.port = 0 + config.registerPlugin(OpenApiPlugin { it.withHook(RegisteredRoutesHook()) }) + config.routes.get("/users") { } + config.routes.post("/users") { } + config.routes.get("/users/{id}") { } + } + + try { + val body = Unirest.get("http://localhost:${app.port()}/openapi").asString().body + val document = jsonMapper.readTree(body) + val paths = document.path("paths") + + assertThat(document.path("info").has("title")).isTrue() + assertThat(document.path("info").has("version")).isTrue() + assertThat(paths.has("/openapi")).isFalse() + assertThat(paths.path("/users").has("get")).isTrue() + assertThat(paths.path("/users").has("post")).isTrue() + + val byId = paths.path("/users/{id}").path("get") + assertThat(byId.path("responses").path("200").path("description").asText()).isEqualTo("OK") + + val idParam = byId.path("parameters")[0] + assertThat(idParam.path("name").asText()).isEqualTo("id") + assertThat(idParam.path("in").asText()).isEqualTo("path") + assertThat(idParam.path("required").asBoolean()).isTrue() + assertThat(idParam.path("schema").path("type").asText()).isEqualTo("string") + } finally { + app.stop() + } + } + + @Test + fun `ignores routes registered by OpenAPI plugins by default`() { + val app = Javalin.start { config -> + config.jetty.port = 0 + config.registerPlugin(OpenApiPlugin { it.withHook(RegisteredRoutesHook()) }) + config.registerPlugin(SwaggerPlugin { swagger -> + swagger + .withUiPath("/documentation/swagger") + .withWebJarPath("/documentation/assets/swagger") + }) + config.registerPlugin(ReDocPlugin { redoc -> + redoc + .withUiPath("/documentation/redoc") + .withWebJarPath("/documentation/assets/redoc") + }) + config.routes.get("/users") { } + } + + try { + val document = jsonMapper.readTree(Unirest.get("http://localhost:${app.port()}/openapi").asString().body) + val paths = document.path("paths").fieldNames().asSequence().toList() + + assertThat(paths).containsExactly("/users") + } finally { + app.stop() + } + } + + @Test + fun `includes default ignored routes when configured`() { + val app = Javalin.start { config -> + config.jetty.port = 0 + config.registerPlugin( + OpenApiPlugin { + it.withHook(RegisteredRoutesHook { routes -> routes.clearDefaultIgnoredRoutes() }) + } + ) + config.routes.get("/users") { } + } + + try { + val document = jsonMapper.readTree(Unirest.get("http://localhost:${app.port()}/openapi").asString().body) + val paths = document.path("paths") + + assertThat(paths.has("/openapi")).isTrue() + assertThat(paths.has("/users")).isTrue() + } finally { + app.stop() + } + } + + @Test + fun `ignores configured path prefixes without matching adjacent paths`() { + val app = Javalin.start { config -> + config.jetty.port = 0 + config.registerPlugin( + OpenApiPlugin { + it.withHook(RegisteredRoutesHook { routes -> routes.withIgnoredPathPrefix("/assets/*") }) + } + ) + config.routes.get("/assets/logo.svg") { } + config.routes.get("/assets-admin") { } + } + + try { + val document = jsonMapper.readTree(Unirest.get("http://localhost:${app.port()}/openapi").asString().body) + val paths = document.path("paths") + + assertThat(paths.has("/assets/logo.svg")).isFalse() + assertThat(paths.has("/assets-admin")).isTrue() + } finally { + app.stop() + } + } + + @Test + fun `enriches a route from OpenApiMetadata`() { + val app = Javalin.start { config -> + config.jetty.port = 0 + config.registerPlugin(OpenApiPlugin { it.withHook(RegisteredRoutesHook()) }) + config.routes.addEndpoint( + Endpoint.create(HandlerType.GET, "/users/{id}") + .addMetadata(OpenApiMetadata { + summary("Get a user") + responses { + response("200") { + description("The user") + content { mediaType("application/json") { schema(User::class.java) } } + } + } + }) + .handler { } + ) + } + + try { + val document = jsonMapper.readTree(Unirest.get("http://localhost:${app.port()}/openapi").asString().body) + val get = document.path("paths").path("/users/{id}").path("get") + + assertThat(get.path("summary").asText()).isEqualTo("Get a user") + assertThat(get.path("parameters")[0].path("name").asText()).isEqualTo("id") + + val schema = get.path("responses").path("200").path("content").path("application/json").path("schema") + assertThat(schema.path($$"$ref").asText()).isEqualTo("#/components/schemas/User") + + val user = document.path("components").path("schemas").path("User") + assertThat(user.path("type").asText()).isEqualTo("object") + assertThat(user.path("properties").fieldNames().asSequence().toList()).containsExactlyInAnyOrder("id", "name") + } finally { + app.stop() + } + } + + @Test + fun `leaves an existing operation untouched when no runtime metadata is present`() { + val app = Javalin.start { config -> + config.jetty.port = 0 + config.registerPlugin( + OpenApiPlugin { plugin -> + plugin.withHook { context -> + context.builder.path("/users/{id}").operation("get") { + summary("Find a user") + parameters { + parameter("id", "path", required = true) { type("integer") } + } + responses { + response("200") { description("A user") } + } + } + } + plugin.withHook(RegisteredRoutesHook()) + } + ) + config.routes.get("/users/{id}") { } + } + + try { + val document = jsonMapper.readTree(Unirest.get("http://localhost:${app.port()}/openapi").asString().body) + val operation = document.path("paths").path("/users/{id}").path("get") + + assertThat(operation.path("summary").asText()).isEqualTo("Find a user") + assertThat(operation.path("parameters")[0].path("schema").path("type").asText()).isEqualTo("integer") + assertThat(operation.path("responses").path("200").path("description").asText()).isEqualTo("A user") + } finally { + app.stop() + } + } +} diff --git a/javalin-plugins/javalin-openapi-plugin/build.gradle.kts b/javalin-plugins/javalin-openapi-plugin/build.gradle.kts index 049c70b4..4adfd9c1 100644 --- a/javalin-plugins/javalin-openapi-plugin/build.gradle.kts +++ b/javalin-plugins/javalin-openapi-plugin/build.gradle.kts @@ -10,6 +10,7 @@ dependencies { kaptTest(project(":openapi-annotation-processor")) + testImplementation(project(":openapi-dynamic")) testImplementation(libs.javalin) testImplementation(libs.junit.jupiter.params) testImplementation(libs.junit.jupiter.api) diff --git a/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiConfiguration.kt b/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiConfiguration.kt index a67e1cb6..ff05f969 100644 --- a/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiConfiguration.kt +++ b/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiConfiguration.kt @@ -3,6 +3,7 @@ package io.javalin.openapi.plugin import com.fasterxml.jackson.databind.node.ObjectNode +import io.javalin.config.JavalinState import io.javalin.openapi.schema.OpenApiSchemaBuilder import io.javalin.security.RouteRole import java.util.function.BiConsumer @@ -12,6 +13,18 @@ fun interface DefinitionProcessor { fun process(content: ObjectNode): String } +/** Context for an [OpenApiHook]: the document [builder] for a [version], plus the live Javalin [state]. */ +class OpenApiHookContext( + val version: String, + val builder: OpenApiSchemaBuilder, + val state: JavalinState, +) + +/** Hook that extends the OpenApi document at runtime, per version. */ +fun interface OpenApiHook { + fun apply(context: OpenApiHookContext) +} + /** Configure OpenApi plugin */ class OpenApiPluginConfiguration @JvmOverloads constructor( @JvmField var documentationPath: String = "/openapi", @@ -19,38 +32,45 @@ class OpenApiPluginConfiguration @JvmOverloads constructor( @JvmField var prettyOutputEnabled: Boolean = true, @JvmField var definitionConfiguration: BiConsumer? = null, @JvmField var definitionProcessor: DefinitionProcessor? = null, - @JvmField var resourceClassLoader: ClassLoader? = null + @JvmField var resourceClassLoader: ClassLoader? = null, + @JvmField var hooks: MutableList = mutableListOf(), ) { + /** Register a hook that extends the document at runtime (e.g. dynamically generated routes) */ + fun withHook(hook: OpenApiHook): OpenApiPluginConfiguration = apply { + this.hooks.add(hook) + } + /** Path to host documentation as JSON */ - fun withDocumentationPath(path: String): OpenApiPluginConfiguration = also { + fun withDocumentationPath(path: String): OpenApiPluginConfiguration = apply { this.documentationPath = path } /** List of roles eligible to access OpenApi routes */ - fun withRoles(vararg roles: RouteRole): OpenApiPluginConfiguration = also { + fun withRoles(vararg roles: RouteRole): OpenApiPluginConfiguration = apply { this.roles = arrayOf(*roles) } /** Pretty print JSON output */ @JvmOverloads - fun withPrettyOutput(enabled: Boolean = true): OpenApiPluginConfiguration = also { + fun withPrettyOutput(enabled: Boolean = true): OpenApiPluginConfiguration = apply { this.prettyOutputEnabled = enabled } /** Dynamically apply custom changes to generated OpenApi specifications */ - fun withDefinitionConfiguration(definitionConfigurationConfigurer: BiConsumer): OpenApiPluginConfiguration = also { + fun withDefinitionConfiguration( + definitionConfigurationConfigurer: BiConsumer, + ): OpenApiPluginConfiguration = apply { this.definitionConfiguration = definitionConfigurationConfigurer } /** Global definition processor applied to all versions without their own processor */ - fun withDefinitionProcessor(definitionProcessor: DefinitionProcessor): OpenApiPluginConfiguration = also { + fun withDefinitionProcessor(definitionProcessor: DefinitionProcessor): OpenApiPluginConfiguration = apply { this.definitionProcessor = definitionProcessor } /** Set custom class loader for loading generated OpenAPI resources from classpath */ - fun withResourceClassLoader(classLoader: ClassLoader): OpenApiPluginConfiguration = also { + fun withResourceClassLoader(classLoader: ClassLoader): OpenApiPluginConfiguration = apply { this.resourceClassLoader = classLoader } - } diff --git a/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiHandler.kt b/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiHandler.kt index 0aa7190c..7f5c28ac 100644 --- a/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiHandler.kt +++ b/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiHandler.kt @@ -4,15 +4,18 @@ import io.javalin.http.ContentType import io.javalin.http.Context import io.javalin.http.Handler import io.javalin.http.Header +import io.javalin.openapi.OpenApiPluginRouteHandler -internal class OpenApiHandler(private val documentation: Lazy>) : Handler { +internal class OpenApiHandler(private val documentation: Lazy>) : Handler, OpenApiPluginRouteHandler { override fun handle(context: Context) { + val version = context.queryParamMap()["v"]?.firstOrNull() ?: "default" + context .header(Header.ACCESS_CONTROL_ALLOW_ORIGIN, "*") .header(Header.ACCESS_CONTROL_ALLOW_METHODS, "GET") .contentType(ContentType.JSON) - .result(documentation.value[context.queryParamMap()["v"]?.firstOrNull() ?: "default"] ?: "{}") + .result(documentation.value[version] ?: "{}") } -} \ No newline at end of file +} diff --git a/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiPlugin.kt b/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiPlugin.kt index 27a53385..21d92325 100644 --- a/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiPlugin.kt +++ b/javalin-plugins/javalin-openapi-plugin/src/main/kotlin/io/javalin/openapi/plugin/OpenApiPlugin.kt @@ -8,26 +8,35 @@ import io.javalin.openapi.schema.OpenApiSchemaBuilder import io.javalin.plugin.Plugin import java.util.function.Consumer -open class OpenApiPlugin(userConfig: Consumer) : Plugin(userConfig, OpenApiPluginConfiguration()) { +open class OpenApiPlugin( + userConfig: Consumer, +) : Plugin(userConfig, OpenApiPluginConfiguration()) { override fun repeatable(): Boolean = true override fun onStart(state: JavalinState) { state.routes.get( pluginConfig.documentationPath, - OpenApiHandler(createDocumentation()), + OpenApiHandler(createDocumentation(state)), *pluginConfig.roles ) } - private fun createDocumentation(): Lazy> = + private fun createDocumentation(state: JavalinState): Lazy> = lazy { OpenApiLoader(pluginConfig.resourceClassLoader ?: OpenApiLoader::class.java.classLoader) .loadOpenApiSchemes() .mapValues { (version, rawDocs) -> val builder = OpenApiSchemaBuilder.fromJson(rawDocs) + val context = OpenApiHookContext(version, builder, state) + pluginConfig.hooks.forEach { it.apply(context) } pluginConfig.definitionConfiguration?.accept(version, builder) - val json = if (pluginConfig.prettyOutputEnabled) builder.toJson() else builder.toCompactJson() + + val json = when { + pluginConfig.prettyOutputEnabled -> builder.toJson() + else -> builder.toCompactJson() + } + when (val processor = pluginConfig.definitionProcessor) { null -> json else -> processor.process(jsonMapper.readTree(json) as ObjectNode) diff --git a/javalin-plugins/javalin-openapi-plugin/src/test/kotlin/OpenApiPluginTest.kt b/javalin-plugins/javalin-openapi-plugin/src/test/kotlin/OpenApiPluginTest.kt index 1eb549c4..66467532 100644 --- a/javalin-plugins/javalin-openapi-plugin/src/test/kotlin/OpenApiPluginTest.kt +++ b/javalin-plugins/javalin-openapi-plugin/src/test/kotlin/OpenApiPluginTest.kt @@ -1,5 +1,7 @@ import io.javalin.Javalin import io.javalin.openapi.OpenApi +import io.javalin.openapi.dynamic.ReflectionSchemaContext +import io.javalin.openapi.experimental.processor.shared.jsonMapper import io.javalin.openapi.plugin.OpenApiPlugin import kong.unirest.Unirest import org.assertj.core.api.Assertions.assertThat @@ -12,6 +14,8 @@ class OpenApiPluginTest { ) private object OpenApiTest + private data class DefinitionConfigurationUser(val id: String) + @Test fun `should support schema modifications in definition configuration`() { val app = @@ -47,9 +51,7 @@ class OpenApiPluginTest { config.registerPlugin( OpenApiPlugin { - it.withDefinitionConfiguration { _, _ -> - /* do nothing */ - } + it.withDefinitionConfiguration { _, _ -> } } ) } @@ -65,4 +67,62 @@ class OpenApiPluginTest { } } + @Test + fun `should support explicit schema reference resolution in definition configuration`() { + val schemaContext = ReflectionSchemaContext() + + val app = Javalin.start { config -> + config.jetty.port = 0 + + config.registerPlugin( + OpenApiPlugin { + it.withDefinitionConfiguration { _, builder -> + builder.path("/runtime-user").operation("get") { + responses { + response("200") { + description("OK") + content { + mediaType("application/json") { + schema(schemaContext.inlineSchema(DefinitionConfigurationUser::class.java)) + } + } + } + } + } + builder.resolveComponentReferences { type -> schemaContext.componentSchema(type) } + } + } + ) + } + + try { + val response = Unirest.get("http://localhost:${app.port()}/openapi") + .asString() + .body + + val document = jsonMapper.readTree(response) + val schema = document + .path("paths") + .path("/runtime-user") + .path("get") + .path("responses") + .path("200") + .path("content") + .path("application/json") + .path("schema") + + assertThat(schema.path("\$ref").asText()).isEqualTo("#/components/schemas/DefinitionConfigurationUser") + assertThat(document.path("components").path("schemas").has("DefinitionConfigurationUser")).isTrue() + assertThat( + document.path("components") + .path("schemas") + .path("DefinitionConfigurationUser") + .path("properties") + .has("id") + ).isTrue() + } finally { + app.stop() + } + } + } diff --git a/javalin-plugins/javalin-redoc-plugin/src/main/kotlin/io/javalin/openapi/plugin/redoc/ReDocHandler.kt b/javalin-plugins/javalin-redoc-plugin/src/main/kotlin/io/javalin/openapi/plugin/redoc/ReDocHandler.kt index 854c29af..6906a9cd 100644 --- a/javalin-plugins/javalin-redoc-plugin/src/main/kotlin/io/javalin/openapi/plugin/redoc/ReDocHandler.kt +++ b/javalin-plugins/javalin-redoc-plugin/src/main/kotlin/io/javalin/openapi/plugin/redoc/ReDocHandler.kt @@ -2,6 +2,7 @@ package io.javalin.openapi.plugin.redoc import io.javalin.http.Context import io.javalin.http.Handler +import io.javalin.openapi.OpenApiPluginRouteHandler /** * Based on https://github.com/tipsy/javalin/blob/master/javalin-openapi/src/main/java/io/javalin/plugin/openapi/ui/ReDocRenderer.kt by @chsfleury @@ -11,8 +12,8 @@ class ReDocHandler( private val documentationPath: String, private val version: String, private val routingPath: String, - private val basePath: String? -) : Handler { + private val basePath: String?, +) : Handler, OpenApiPluginRouteHandler { override fun handle(context: Context) { context @@ -55,4 +56,4 @@ class ReDocHandler( private fun String.removedDoubledPathOperators(): String = replace(multiplePathOperatorsRegex, "/") -} \ No newline at end of file +} diff --git a/javalin-plugins/javalin-redoc-plugin/src/main/kotlin/io/javalin/openapi/plugin/redoc/ReDocWebJarHandler.kt b/javalin-plugins/javalin-redoc-plugin/src/main/kotlin/io/javalin/openapi/plugin/redoc/ReDocWebJarHandler.kt index 8bbac392..1e107266 100644 --- a/javalin-plugins/javalin-redoc-plugin/src/main/kotlin/io/javalin/openapi/plugin/redoc/ReDocWebJarHandler.kt +++ b/javalin-plugins/javalin-redoc-plugin/src/main/kotlin/io/javalin/openapi/plugin/redoc/ReDocWebJarHandler.kt @@ -2,17 +2,21 @@ package io.javalin.openapi.plugin.redoc import io.javalin.http.Context import io.javalin.http.Handler +import io.javalin.openapi.OpenApiPluginRouteHandler import org.eclipse.jetty.http.HttpStatus import org.eclipse.jetty.http.MimeTypes internal class ReDocWebJarHandler( private val redocWebJarPath: String, private val classLoader: ClassLoader = ReDocWebJarHandler::class.java.classLoader, -) : Handler { +) : Handler, OpenApiPluginRouteHandler { override fun handle(context: Context) { - val resourcePath = "META-INF/resources" + redocWebJarPath + context.path().replaceFirst(context.contextPath(), "").replaceFirst(redocWebJarPath, "") - val resource = classLoader.getResourceAsStream(resourcePath) + val resourceRootPath = "META-INF/resources$redocWebJarPath" + val requestedResource = context.path() + .replaceFirst(context.contextPath(), "") + .replaceFirst(redocWebJarPath, "") + val resource = classLoader.getResourceAsStream(resourceRootPath + requestedResource) if (resource == null) { context.status(HttpStatus.NOT_FOUND_404) @@ -27,4 +31,4 @@ internal class ReDocWebJarHandler( } } -} \ No newline at end of file +} diff --git a/javalin-plugins/javalin-redoc-plugin/src/test/kotlin/io/javalin/openapi/plugin/redoc/RedocPluginTest.kt b/javalin-plugins/javalin-redoc-plugin/src/test/kotlin/io/javalin/openapi/plugin/redoc/RedocPluginTest.kt index 43f4c035..1c6ab9ef 100644 --- a/javalin-plugins/javalin-redoc-plugin/src/test/kotlin/io/javalin/openapi/plugin/redoc/RedocPluginTest.kt +++ b/javalin-plugins/javalin-redoc-plugin/src/test/kotlin/io/javalin/openapi/plugin/redoc/RedocPluginTest.kt @@ -10,7 +10,10 @@ internal class RedocPluginTest { @Test fun `should properly host redoc ui`() { - val app = Javalin.create { it.registerPlugin(ReDocPlugin()) }.start(0) + val app = Javalin.start { + it.jetty.port = 0 + it.registerPlugin(ReDocPlugin()) + } try { val response = Unirest.get("http://localhost:${app.port()}/redoc") diff --git a/javalin-plugins/javalin-swagger-plugin/src/main/kotlin/io/javalin/openapi/plugin/swagger/SwaggerHandler.kt b/javalin-plugins/javalin-swagger-plugin/src/main/kotlin/io/javalin/openapi/plugin/swagger/SwaggerHandler.kt index 3a3a8e0e..2b49210d 100644 --- a/javalin-plugins/javalin-swagger-plugin/src/main/kotlin/io/javalin/openapi/plugin/swagger/SwaggerHandler.kt +++ b/javalin-plugins/javalin-swagger-plugin/src/main/kotlin/io/javalin/openapi/plugin/swagger/SwaggerHandler.kt @@ -3,6 +3,7 @@ package io.javalin.openapi.plugin.swagger import io.javalin.http.Context import io.javalin.http.Handler import io.javalin.http.HandlerType +import io.javalin.openapi.OpenApiPluginRouteHandler import io.javalin.router.Endpoint import io.javalin.security.Roles import io.javalin.security.RouteRole @@ -12,7 +13,7 @@ class SwaggerEndpoint( method: HandlerType, path: String, roles: Set, - handler: Handler + handler: Handler, ) : Endpoint( method = method, path = path, @@ -39,8 +40,8 @@ class SwaggerHandler( private val tagsSorter: String, private val operationsSorter: String, private val customStylesheetFiles: List>, - private val customJavaScriptFiles: List> -) : Handler { + private val customJavaScriptFiles: List>, +) : Handler, OpenApiPluginRouteHandler { private val swaggerUiHtml = createSwaggerUiHtml() @@ -56,17 +57,24 @@ class SwaggerHandler( val publicSwaggerAssetsPath = "$rootPath/webjars/swagger-ui/$swaggerVersion".removedDoubledPathOperators() val publicDocumentationPath = (rootPath + documentationPath).removedDoubledPathOperators() - val allDocumentations = versions - .joinToString(separator = ",\n") { - when (it) { - is SwaggerVersionMapping.OpenApiLoader -> "{ name: '${it.name}', url: '$publicDocumentationPath?v=${it.name}' }" - is SwaggerVersionMapping.Custom -> "{ name: '${it.name}', url: '${it.url}' }" + val allDocumentations = + versions + .joinToString(separator = ",\n") { + when (it) { + is SwaggerVersionMapping.OpenApiLoader -> "{ name: '${it.name}', url: '$publicDocumentationPath?v=${it.name}' }" + is SwaggerVersionMapping.Custom -> "{ name: '${it.name}', url: '${it.url}' }" + } } - } - val allCustomStylesheets = customStylesheetFiles - .joinToString(separator = "\n") { "" } - val allCustomJavaScripts = customJavaScriptFiles - .joinToString(separator = "\n") { "" } + val resolvedValidatorUrl = when { + validatorUrl != null -> "\"$validatorUrl\"" + else -> "null" + } @Suppress("JSUnresolvedReference") @Language("html") @@ -117,7 +125,7 @@ class SwaggerHandler( layout: "StandaloneLayout", tagsSorter: $tagsSorter, operationsSorter: $operationsSorter, - validatorUrl: ${if (validatorUrl != null) "\"$validatorUrl\"" else "null"} + validatorUrl: $resolvedValidatorUrl }) } @@ -134,4 +142,4 @@ class SwaggerHandler( return replace(multiplePathOperatorsRegex, "/") } -} \ No newline at end of file +} diff --git a/javalin-plugins/javalin-swagger-plugin/src/main/kotlin/io/javalin/openapi/plugin/swagger/SwaggerWebJarHandler.kt b/javalin-plugins/javalin-swagger-plugin/src/main/kotlin/io/javalin/openapi/plugin/swagger/SwaggerWebJarHandler.kt index 912c7f6a..98a729ef 100644 --- a/javalin-plugins/javalin-swagger-plugin/src/main/kotlin/io/javalin/openapi/plugin/swagger/SwaggerWebJarHandler.kt +++ b/javalin-plugins/javalin-swagger-plugin/src/main/kotlin/io/javalin/openapi/plugin/swagger/SwaggerWebJarHandler.kt @@ -2,14 +2,14 @@ package io.javalin.openapi.plugin.swagger import io.javalin.http.Context import io.javalin.http.Handler +import io.javalin.openapi.OpenApiPluginRouteHandler import org.eclipse.jetty.http.HttpStatus import org.eclipse.jetty.http.MimeTypes -import java.io.InputStream internal class SwaggerWebJarHandler( private val swaggerWebJarPath: String, private val classLoader: ClassLoader = SwaggerWebJarHandler::class.java.classLoader, -) : Handler { +) : Handler, OpenApiPluginRouteHandler { override fun handle(context: Context) { val resourceRootPath = "META-INF/resources$swaggerWebJarPath" @@ -18,7 +18,7 @@ internal class SwaggerWebJarHandler( .replaceFirst(context.contextPath(), "") .replaceFirst(swaggerWebJarPath, "") - val resource: InputStream? = classLoader.getResourceAsStream(resourceRootPath + requestedResource) + val resource = classLoader.getResourceAsStream(resourceRootPath + requestedResource) if (resource == null) { context.status(HttpStatus.NOT_FOUND_404) @@ -28,9 +28,9 @@ internal class SwaggerWebJarHandler( context.result(resource) context.res().characterEncoding = "UTF-8" - MimeTypes.DEFAULTS.getMimeByExtension(context.path())?.let { // Swagger returns various non-standard assets like .js.map that are not recognized + MimeTypes.DEFAULTS.getMimeByExtension(context.path())?.let { context.contentType(it) } } -} \ No newline at end of file +} diff --git a/javalin-plugins/javalin-swagger-plugin/src/test/kotlin/io/javalin/openapi/plugin/swagger/SwaggerPluginTest.kt b/javalin-plugins/javalin-swagger-plugin/src/test/kotlin/io/javalin/openapi/plugin/swagger/SwaggerPluginTest.kt index 744952e0..d8e70831 100644 --- a/javalin-plugins/javalin-swagger-plugin/src/test/kotlin/io/javalin/openapi/plugin/swagger/SwaggerPluginTest.kt +++ b/javalin-plugins/javalin-swagger-plugin/src/test/kotlin/io/javalin/openapi/plugin/swagger/SwaggerPluginTest.kt @@ -7,6 +7,22 @@ import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test internal class SwaggerPluginTest { + + private fun swaggerPage(configure: SwaggerConfiguration.() -> Unit): String { + val app = Javalin.start { + it.jetty.port = 0 + it.registerPlugin(SwaggerPlugin { swagger -> swagger.configure() }) + } + + return try { + Unirest.get("http://localhost:${app.port()}/swagger") + .asString() + .body + } finally { + app.stop() + } + } + @Test fun `should properly host swagger ui`() { val app = Javalin.start { @@ -46,30 +62,32 @@ internal class SwaggerPluginTest { } @Test - fun `should have custom version, css and js injected`() { - val app = Javalin.start { - it.jetty.port = 0 - it.registerPlugin(SwaggerPlugin { swagger -> - swagger - .injectStylesheet("/swagger.css") - .injectStylesheet("/swagger-the-print.css", "print") - .injectJavaScript("/script.js") - .injectCustomVersion("custom", "/openapi.yaml") - }) + fun `injects custom stylesheets`() { + val response = swaggerPage { + injectStylesheet("/swagger.css") + injectStylesheet("/swagger-the-print.css", "print") } - try { - val response = Unirest.get("http://localhost:${app.port()}/swagger") - .asString() - .body + assertThat(response).contains("""link href='/swagger.css' rel='stylesheet' media='screen' type='text/css'""") + assertThat(response).contains("""link href='/swagger-the-print.css' rel='stylesheet' media='print' type='text/css'""") + } - assertThat(response).contains("""link href='/swagger.css' rel='stylesheet' media='screen' type='text/css'""") - assertThat(response).contains("""link href='/swagger-the-print.css' rel='stylesheet' media='print' type='text/css'""") - assertThat(response).contains("""script src='/script.js' type='text/javascript'""") - assertThat(response).contains("{ name: 'custom', url: '/openapi.yaml' }") - } finally { - app.stop() + @Test + fun `injects custom JavaScript`() { + val response = swaggerPage { + injectJavaScript("/script.js") } + + assertThat(response).contains("""""") + } + + @Test + fun `injects custom documentation versions`() { + val response = swaggerPage { + injectCustomVersion("custom", "/openapi.yaml") + } + + assertThat(response).contains("{ name: 'custom', url: '/openapi.yaml' }") } @Test @@ -113,7 +131,7 @@ internal class SwaggerPluginTest { } @Test - fun `should not fail if second swagger plugin is registered with routes`(){ + fun `should not fail if second swagger plugin is registered with routes`() { val app = Javalin.start { it.jetty.port = 0 it.registerPlugin(SwaggerPlugin()) diff --git a/openapi-annotation-processor/build.gradle.kts b/openapi-annotation-processor/build.gradle.kts index b6198c96..25b7d442 100644 --- a/openapi-annotation-processor/build.gradle.kts +++ b/openapi-annotation-processor/build.gradle.kts @@ -8,16 +8,13 @@ plugins { dependencies { api(project(":openapi-generator")) + implementation(project(":introspection:introspection-jap")) kaptTest(project(":openapi-annotation-processor")) testImplementation(project(":openapi-annotation-processor")) implementation(kotlin("reflect")) implementation(libs.groovy) - implementation(libs.javalin) { - exclude(group = "org.slf4j") - } - implementation(libs.swagger.parser) { exclude(group = "com.fasterxml.jackson") exclude(group = "com.fasterxml.jackson.core") diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt index 95e6a975..c485a56d 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/AnnotationProcessorContext.kt @@ -1,34 +1,119 @@ package io.javalin.openapi.experimental import com.sun.source.util.Trees +import io.javalin.introspection.AnnotationSet +import io.javalin.introspection.EnumConstant +import io.javalin.introspection.InternalIntrospectionApi +import io.javalin.introspection.PropertyProjection +import io.javalin.introspection.ClassDefinition as RawType +import io.javalin.introspection.StructureType as RawStructureType +import io.javalin.introspection.jap.JapTypeIntrospector +import io.javalin.openapi.DiscriminatorMappingName import io.javalin.openapi.OpenApiName -import io.javalin.openapi.experimental.StructureType.DEFAULT import io.javalin.openapi.experimental.processor.generators.TypeSchemaGenerator -import io.javalin.openapi.experimental.processor.shared.getTypeMirror -import io.javalin.openapi.experimental.processor.shared.getTypeMirrors import javax.annotation.processing.Messager import javax.annotation.processing.ProcessingEnvironment import javax.annotation.processing.RoundEnvironment import javax.lang.model.element.Element +import javax.lang.model.element.ElementKind import javax.lang.model.element.ExecutableElement import javax.lang.model.element.TypeElement +import javax.lang.model.type.PrimitiveType import javax.lang.model.type.TypeMirror import javax.lang.model.util.Types -import kotlin.reflect.KClass +import javax.tools.Diagnostic.Kind.NOTE +import javax.tools.Diagnostic.Kind.WARNING class AnnotationProcessorContext( val parameters: OpenApiAnnotationProcessorParameters, val configuration: OpenApiAnnotationProcessorConfiguration, val env: ProcessingEnvironment, val trees: Trees?, -) { +) : SchemaGenerationContext { val types: Types = env.typeUtils - val typeSchemaGenerator: TypeSchemaGenerator = TypeSchemaGenerator(this) + override val typeSchemaGenerator: TypeSchemaGenerator = TypeSchemaGenerator(this) var roundEnv: RoundEnvironment? = null - fun inContext(body: AnnotationProcessorContext.() -> R): R = - body() + override val simpleTypeMappings: Map get() = configuration.simpleTypeMappings + override val embeddedTypeProcessors: List get() = configuration.embeddedTypeProcessors + + private val japIntrospector: JapTypeIntrospector by lazy { + JapTypeIntrospector( + types = types, + elements = env.elementUtils, + ) { roundEnv } + } + + override fun isEnum(type: OpenApiType): Boolean = + type.source.kind == ElementKind.ENUM + + override fun annotationsOf(type: OpenApiType): AnnotationSet = + japIntrospector.annotationsOf(type.source) + + fun annotationsOf(element: Element): AnnotationSet = + japIntrospector.annotationsOf(element) + + override fun propertiesOf(type: OpenApiType): List = + japIntrospector.introspect(type.mirror).getProperties() + + override fun enumConstantsOf(type: OpenApiType): List = + japIntrospector.introspect(type.mirror).getEnumConstants() + + @OptIn(InternalIntrospectionApi::class) + override fun toOpenApiType(raw: RawType): OpenApiType { + val rawMirror = raw.source as TypeMirror + val mirror = when { + rawMirror.kind.isPrimitive -> types.boxedClass(rawMirror as PrimitiveType).asType() + else -> rawMirror + } + val source = when { + raw.structureType == RawStructureType.DICTIONARY -> mapType() + else -> types.asElement(mirror) ?: objectType() + } + + return OpenApiType( + simpleName = mirror.getSimpleName(), + fullName = mirror.getFullName(), + generics = raw.generics.map { toOpenApiType(it) }, + structureType = StructureType.valueOf(raw.structureType.name), + handle = OpenApiTypeHandle( + mirror = mirror, + source = source, + ), + ) + } + + override fun reportWarning(message: String) { + env.messager.printMessage(WARNING, message) + } + + override fun reportDebug(message: String) { + inDebug { it.printMessage(NOTE, message) } + } + + fun TypeMirror.toOpenApiType(): OpenApiType = + toOpenApiType(japIntrospector.introspect(this)) + + @OptIn(InternalIntrospectionApi::class) + override fun acceptsProperty(type: OpenApiType, property: PropertyProjection): Boolean = + configuration.propertyInSchemeFilter?.filter(this, type, property.source as Element) != false + + override fun discriminatorSubtypes(type: OpenApiType): List> { + val source = japIntrospector.introspect(type.mirror) + val subtypes = japIntrospector.typesAnnotatedWith( + annotationType = DiscriminatorMappingName::class.java, + assignableTo = source, + ) + return subtypes.mapNotNull { subtype -> + subtype + .getAnnotations() + .find(DiscriminatorMappingName::class.java) + ?.get("value") + ?.asString() + ?.let { name -> name to toOpenApiType(subtype) } + } + } fun inDebug(body: (Messager) -> Unit) { if (configuration.debug) { @@ -36,17 +121,14 @@ class AnnotationProcessorContext( } } - fun getClassDefinition(mirror: TypeMirror, generics: List = emptyList(), type: StructureType = DEFAULT): ClassDefinition = - classDefinitionFrom(this, mirror, generics, type) - - fun getClassDefinitions(mirrors: Set): Set = - mirrors.map { getClassDefinition(it) }.toSet() - fun forTypeElement(name: String): TypeElement? = env.elementUtils.getTypeElement(name) - fun forTypeElement(mirror: TypeMirror): TypeElement = - env.typeUtils.asElement(mirror) as TypeElement + private fun objectType(): TypeElement = + forTypeElement(Any::class.java.name)!! + + private fun mapType(): TypeElement = + forTypeElement(Map::class.java.name)!! fun isAssignable(implementation: TypeMirror, superclass: TypeMirror): Boolean = env.typeUtils.isAssignable(implementation, superclass) @@ -54,25 +136,25 @@ class AnnotationProcessorContext( fun hasElement(type: TypeElement, element: Element): Boolean = when (element) { is ExecutableElement -> env.elementUtils.getAllMembers(type).let { members -> - members.contains(element) || members.filterIsInstance().any { env.elementUtils.overrides(element, it, type) } + members.contains(element) || + members + .filterIsInstance() + .any { env.elementUtils.overrides(element, it, type) } } else -> false } - fun getFullName(mirror: TypeMirror): String = - env.typeUtils.asElement(mirror) - ?.getAnnotation(OpenApiName::class.java) - ?.value - ?.let { mirror.toString().substringBeforeLast(".") + "." + it } - ?: env.typeUtils.asElement(mirror)?.toString()?.substringBefore("<") - ?: mirror.toString().substringBefore("<") - - /* Extension methods, should be replaced by context receivers in the future */ - - fun TypeMirror.toClassDefinition( - generics: List = emptyList(), - type: StructureType = DEFAULT - ): ClassDefinition = getClassDefinition(this, generics, type) + fun getFullName(mirror: TypeMirror): String { + val element = env.typeUtils.asElement(mirror) + val fullName = element?.toString()?.substringBefore("<") ?: mirror.toString().substringBefore("<") + val customName = element?.getAnnotation(OpenApiName::class.java)?.value + val packageName = fullName.substringBeforeLast('.', "") + return when { + customName == null -> fullName + packageName.isEmpty() -> customName + else -> "$packageName.$customName" + } + } fun TypeMirror.getSimpleName(): String = getFullName().substringAfterLast(".") @@ -81,12 +163,4 @@ class AnnotationProcessorContext( fun TypeMirror.getFullName(): String = getFullName(this) - fun A.getClassDefinitions(supplier: A.() -> Array>): Set = - getTypeMirrors(supplier) - .map { it.toClassDefinition() } - .toSet() - - fun A.getClassDefinition(supplier: A.() -> KClass<*>): ClassDefinition = - getTypeMirror(supplier).toClassDefinition() - } diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/ClassDefinitionFactory.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/ClassDefinitionFactory.kt deleted file mode 100644 index a07e01af..00000000 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/ClassDefinitionFactory.kt +++ /dev/null @@ -1,65 +0,0 @@ -package io.javalin.openapi.experimental - -import io.javalin.openapi.experimental.StructureType.ARRAY -import io.javalin.openapi.experimental.StructureType.DEFAULT -import io.javalin.openapi.experimental.StructureType.DICTIONARY -import io.javalin.openapi.experimental.processor.shared.collectionType -import io.javalin.openapi.experimental.processor.shared.mapType -import io.javalin.openapi.experimental.processor.shared.objectType -import javax.lang.model.type.ArrayType -import javax.lang.model.type.DeclaredType -import javax.lang.model.type.PrimitiveType -import javax.lang.model.type.TypeMirror -import javax.lang.model.type.TypeVariable - -fun classDefinitionFrom( - context: AnnotationProcessorContext, - mirror: TypeMirror, - generics: List = emptyList(), - type: StructureType = DEFAULT -): ClassDefinition = - with(context) { - when (mirror) { - is TypeVariable -> - mirror.upperBound?.toClassDefinition(generics, type) ?: mirror.lowerBound?.toClassDefinition(generics, type) - is ArrayType -> - mirror.componentType.toClassDefinition(generics, type = ARRAY) - is PrimitiveType -> { - val boxedMirror = types.boxedClass(mirror).asType() - val boxedElement = types.boxedClass(mirror) - ClassDefinition( - simpleName = context.inContext { boxedMirror.getSimpleName() }, - fullName = context.inContext { boxedMirror.getFullName() }, - generics = generics, - structureType = type, - handle = ClassDefinitionHandle(boxedMirror, boxedElement) - ) - } - is DeclaredType -> - when { - types.isAssignable(types.erasure(mirror), mapType().asType()) -> - ClassDefinition( - simpleName = context.inContext { mirror.getSimpleName() }, - fullName = context.inContext { mirror.getFullName() }, - generics = listOfNotNull( - mirror.typeArguments.getOrElse(0) { objectType().asType() }.toClassDefinition(), - mirror.typeArguments.getOrElse(1) { objectType().asType() }.toClassDefinition() - ), - structureType = DICTIONARY, - handle = ClassDefinitionHandle(mirror, mapType()) - ) - types.isAssignable(types.erasure(mirror), collectionType().asType()) -> - mirror.typeArguments.getOrElse(0) { objectType().asType() }.toClassDefinition(generics, ARRAY) - else -> - ClassDefinition( - simpleName = context.inContext { mirror.getSimpleName() }, - fullName = context.inContext { mirror.getFullName() }, - generics = mirror.typeArguments.mapNotNull { it.toClassDefinition() }, - structureType = type, - handle = ClassDefinitionHandle(mirror, mirror.asElement()) - ) - } - else -> - types.asElement(mirror)?.asType()?.toClassDefinition(generics, type) - } ?: objectType().asType().toClassDefinition(type = type) - } diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/ClassDefinitionHandle.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/ClassDefinitionHandle.kt deleted file mode 100644 index 25fe3674..00000000 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/ClassDefinitionHandle.kt +++ /dev/null @@ -1,15 +0,0 @@ -package io.javalin.openapi.experimental - -import javax.lang.model.element.Element -import javax.lang.model.type.TypeMirror - -data class ClassDefinitionHandle( - val mirror: TypeMirror, - val source: Element -) - -val ClassDefinition.mirror: TypeMirror - get() = (handle as ClassDefinitionHandle).mirror - -val ClassDefinition.source: Element - get() = (handle as ClassDefinitionHandle).source diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/OpenApiAnnotationProcessorConfiguration.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/OpenApiAnnotationProcessorConfiguration.kt index 8c81b81a..63e54b10 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/OpenApiAnnotationProcessorConfiguration.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/OpenApiAnnotationProcessorConfiguration.kt @@ -1,11 +1,7 @@ package io.javalin.openapi.experimental -import com.fasterxml.jackson.databind.node.ObjectNode -import io.javalin.openapi.experimental.defaults.ArrayEmbeddedTypeProcessor -import io.javalin.openapi.experimental.defaults.CompositionEmbeddedTypeProcessor -import io.javalin.openapi.experimental.defaults.DictionaryEmbeddedTypeProcessor +import io.javalin.openapi.experimental.defaults.createDefaultEmbeddedTypeProcessors import io.javalin.openapi.experimental.defaults.createDefaultSimpleTypeMappings -import io.javalin.openapi.experimental.processor.generators.PropertyComposition import javax.lang.model.element.Element @ExperimentalCompileOpenApiConfiguration @@ -18,33 +14,13 @@ class OpenApiAnnotationProcessorConfiguration { var validateWithParser: Boolean = true var propertyInSchemeFilter: PropertyInSchemeFilter? = null val simpleTypeMappings: MutableMap = createDefaultSimpleTypeMappings() - val embeddedTypeProcessors: MutableList = mutableListOf( - CompositionEmbeddedTypeProcessor(), - ArrayEmbeddedTypeProcessor(), - DictionaryEmbeddedTypeProcessor() - ) + val embeddedTypeProcessors: MutableList = createDefaultEmbeddedTypeProcessors() fun insertEmbeddedTypeProcessor(embeddedTypeProcessor: EmbeddedTypeProcessor) { embeddedTypeProcessors.add(0, embeddedTypeProcessor) } - } fun interface PropertyInSchemeFilter { - fun filter(context: AnnotationProcessorContext, type: ClassDefinition, property: Element): Boolean -} - -data class EmbeddedTypeProcessorContext( - val parentContext: AnnotationProcessorContext, - val scheme: ObjectNode, - val references: MutableSet, - val type: ClassDefinition, - val inlineRefs: Boolean = false, - val requiresNonNulls: Boolean = true, - val composition: PropertyComposition? = null, - val extra: Map = emptyMap() -) - -fun interface EmbeddedTypeProcessor { - fun process(context: EmbeddedTypeProcessorContext): Boolean + fun filter(context: AnnotationProcessorContext, type: OpenApiType, property: Element): Boolean } diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/OpenApiTypeHandle.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/OpenApiTypeHandle.kt new file mode 100644 index 00000000..4f8c47e2 --- /dev/null +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/OpenApiTypeHandle.kt @@ -0,0 +1,17 @@ +package io.javalin.openapi.experimental + +import javax.lang.model.element.Element +import javax.lang.model.type.TypeMirror + +data class OpenApiTypeHandle( + val mirror: TypeMirror, + val source: Element, +) + +@OptIn(InternalOpenApiTypeApi::class) +val OpenApiType.mirror: TypeMirror + get() = (handle as OpenApiTypeHandle).mirror + +@OptIn(InternalOpenApiTypeApi::class) +val OpenApiType.source: Element + get() = (handle as OpenApiTypeHandle).source diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/defaults/ArrayEmbeddedTypeProcessor.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/defaults/ArrayEmbeddedTypeProcessor.kt deleted file mode 100644 index b3b018e2..00000000 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/defaults/ArrayEmbeddedTypeProcessor.kt +++ /dev/null @@ -1,30 +0,0 @@ -package io.javalin.openapi.experimental.defaults - -import com.fasterxml.jackson.databind.JsonNode -import io.javalin.openapi.experimental.EmbeddedTypeProcessor -import io.javalin.openapi.experimental.EmbeddedTypeProcessorContext -import io.javalin.openapi.experimental.StructureType.ARRAY -import io.javalin.openapi.experimental.processor.shared.createObjectNode - -class ArrayEmbeddedTypeProcessor : EmbeddedTypeProcessor { - - override fun process(context: EmbeddedTypeProcessorContext): Boolean = with(context) { - if (type.structureType == ARRAY) { - if (type.simpleName == "Byte") { - scheme.put("type", "string") - scheme.put("format", "binary") - } - else { - context.scheme.put("type", "array") - val items = createObjectNode() - context.parentContext.typeSchemaGenerator.addType(items, type, inlineRefs, references, requiresNonNulls) - context.scheme.set("items", items) - } - - return true - } - - return false - } - -} diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/defaults/CompositionEmbeddedTypeProcessor.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/defaults/CompositionEmbeddedTypeProcessor.kt deleted file mode 100644 index 2051bb77..00000000 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/defaults/CompositionEmbeddedTypeProcessor.kt +++ /dev/null @@ -1,23 +0,0 @@ -package io.javalin.openapi.experimental.defaults - -import io.javalin.openapi.experimental.EmbeddedTypeProcessor -import io.javalin.openapi.experimental.EmbeddedTypeProcessorContext -import io.javalin.openapi.experimental.processor.generators.createComposition - -class CompositionEmbeddedTypeProcessor : EmbeddedTypeProcessor { - - override fun process(context: EmbeddedTypeProcessorContext): Boolean = - context.composition - ?.let { - context.scheme.createComposition( - context = context.parentContext, - classDefinition = context.type, - propertyComposition = it, - references = context.references, - inlineRefs = context.inlineRefs, - requiresNonNulls = context.requiresNonNulls - ) - true - } ?: false - -} diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/defaults/DictionaryEmbeddedTypeProcessor.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/defaults/DictionaryEmbeddedTypeProcessor.kt deleted file mode 100644 index b627e4de..00000000 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/defaults/DictionaryEmbeddedTypeProcessor.kt +++ /dev/null @@ -1,41 +0,0 @@ -package io.javalin.openapi.experimental.defaults - -import com.fasterxml.jackson.databind.JsonNode -import io.javalin.openapi.experimental.EmbeddedTypeProcessor -import io.javalin.openapi.experimental.EmbeddedTypeProcessorContext -import io.javalin.openapi.experimental.StructureType.DICTIONARY -import io.javalin.openapi.experimental.processor.shared.createObjectNode - -class DictionaryEmbeddedTypeProcessor : EmbeddedTypeProcessor { - - override fun process(context: EmbeddedTypeProcessorContext): Boolean = with (context) { - if (type.structureType == DICTIONARY) { - scheme.put("type", "object") - val additionalProperties = createObjectNode() - val additionalType = context.type.generics[1] - - context.parentContext.configuration.embeddedTypeProcessors - .firstOrNull { - it.process( - context.copy( - scheme = additionalProperties, - type = additionalType - ) - ) - } - ?: parentContext.typeSchemaGenerator.addType( - scheme = additionalProperties, - type = additionalType, - inlineRefs = inlineRefs, - references = references, - requiresNonNulls = requiresNonNulls - ) - - scheme.set("additionalProperties", additionalProperties) - return true - } - - return false - } - -} diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt deleted file mode 100644 index 4760b597..00000000 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt +++ /dev/null @@ -1,99 +0,0 @@ -package io.javalin.openapi.experimental.processor.generators - -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.node.ObjectNode -import io.javalin.openapi.AllOf -import io.javalin.openapi.AnyOf -import io.javalin.openapi.Composition.ALL_OF -import io.javalin.openapi.Composition.ANY_OF -import io.javalin.openapi.Composition.ONE_OF -import io.javalin.openapi.DiscriminatorMappingName -import io.javalin.openapi.NULL_STRING -import io.javalin.openapi.OneOf -import io.javalin.openapi.experimental.AnnotationProcessorContext -import io.javalin.openapi.experimental.ClassDefinition -import io.javalin.openapi.experimental.CustomProperty -import io.javalin.openapi.experimental.mirror -import io.javalin.openapi.experimental.processor.shared.createJsonObjectOf -import io.javalin.openapi.experimental.processor.shared.createObjectNode -import io.javalin.openapi.experimental.processor.shared.toJsonArray -import io.javalin.openapi.experimental.processor.shared.toJsonObject -import javax.lang.model.element.Element -import javax.lang.model.element.TypeElement - -fun findCompositionInElement(context: AnnotationProcessorContext, element: Element): PropertyComposition? = - with (context) { - element.getAnnotation(OneOf::class.java)?.let { PropertyComposition(ONE_OF, it.getClassDefinitions { value }, it.discriminator) } - ?: element.getAnnotation(AnyOf::class.java)?.let { PropertyComposition(ANY_OF, it.getClassDefinitions { value }, it.discriminator) } - ?: element.getAnnotation(AllOf::class.java)?.let { PropertyComposition(ALL_OF, it.getClassDefinitions { value }, it.discriminator) } - } - -fun ObjectNode.createComposition( - context: AnnotationProcessorContext, - classDefinition: ClassDefinition, - propertyComposition: PropertyComposition, - references: MutableSet, - inlineRefs: Boolean = false, - requiresNonNulls: Boolean = true, -) { - with (context) { - val subtypes by lazy { - context.roundEnv!!.getElementsAnnotatedWith(DiscriminatorMappingName::class.java) - .asSequence() - .filterIsInstance() - .map { it.getAnnotation(DiscriminatorMappingName::class.java).value to context.getClassDefinition(it.asType()) } - .filter { (_, type) -> context.isAssignable(type.mirror, classDefinition.mirror) } - .toList() - } - - val refs = propertyComposition.references.ifEmpty { subtypes.map { it.second } } - - when (inlineRefs) { - true -> - refs - .map { context.typeSchemaGenerator.createTypeSchema(type = it, inlineRefs = true, requireNonNullsByDefault = requiresNonNulls) } - .onEach { (_, refs) -> references.addAll(refs) } - .map { (scheme, _) -> scheme } - .toJsonArray { add(it) } - .let { set(propertyComposition.type.propertyName, it) } - - false -> - refs - .onEach { references.add(it) } - .map { createJsonObjectOf($$"$ref", "#/components/schemas/${it.simpleName}") } - .toJsonArray { add(it) } - .let { set(propertyComposition.type.propertyName, it) } - } - - propertyComposition.discriminator - .takeIf { it.property.name != NULL_STRING } - ?.also { discriminator -> - val discriminatorObject = createObjectNode() - set("discriminator", discriminatorObject) - - val discriminatorProperty = discriminator.property - discriminatorObject.put("propertyName", discriminatorProperty.name) - - val mapping = discriminator.mapping - .map { it.name to it.getClassDefinition { value } } - .ifEmpty { subtypes } - - if (discriminatorProperty.injectInMappings) { - val customProperty = CustomProperty( - name = discriminatorProperty.name, - type = discriminatorProperty.getClassDefinition { type } - ) - - mapping.forEach { (_, mappedClass) -> - mappedClass.extra.add(customProperty) - } - } - - mapping - .onEach { (_, mappedClass) -> references.add(mappedClass) } - .associate { (name, mappedClass) -> name to "#/components/schemas/${mappedClass.simpleName}" } - .takeIf { it.isNotEmpty() } - ?.also { discriminatorObject.set("mapping", it.toJsonObject()) } - } - } -} diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt deleted file mode 100644 index 88c4ac9a..00000000 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt +++ /dev/null @@ -1,478 +0,0 @@ -package io.javalin.openapi.experimental.processor.generators - -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.node.ArrayNode -import com.fasterxml.jackson.databind.node.ObjectNode -import io.javalin.openapi.* -import io.javalin.openapi.experimental.* -import io.javalin.openapi.experimental.processor.shared.* -import javax.lang.model.element.* -import javax.lang.model.element.ElementKind.* -import javax.lang.model.type.TypeMirror - -class TypeSchemaGenerator(val context: AnnotationProcessorContext) { - - // The cache helps to avoid processing the same property multiple times & prevent infinite recursion - // ~ https://github.com/javalin/javalin-openapi/issues/230 - private val processedProperties = mutableMapOf() - - fun createTypeSchema( - type: ClassDefinition, - inlineRefs: Boolean = false, - requireNonNullsByDefault: Boolean = true - ): ResultScheme = with (context) { - val source = type.source - val definedBy = source.getAnnotation(OpenApiPropertyType::class.java)?.getClassDefinition { definedBy } - - if (definedBy != null && source.kind != ENUM) { - return createTypeSchema(definedBy, inlineRefs, requireNonNullsByDefault) - } - - val schema = createObjectNode() - val references = mutableSetOf() - val composition = findCompositionInElement(context, source) - - when { - composition != null -> { - schema.createComposition(context, type, composition, references, inlineRefs, requireNonNullsByDefault) - } - source.kind == ENUM -> { - val enumType = definedBy - ?.let { context.configuration.simpleTypeMappings[it.fullName] } - - val namingStrategy = source.getAnnotation(OpenApiNaming::class.java)?.value - val values = createArrayNode() - val descriptions = createArrayNode() - - source.enclosedElements - .filterIsInstance() - .filter { it.modifiers.contains(Modifier.STATIC) } - .filter { context.isAssignable(it.asType(), type.mirror) } - .map { element -> - val customName = element.getAnnotation(OpenApiName::class.java) - val description = element.getAnnotation(OpenApiDescription::class.java) - val name = when { - customName != null -> customName.value - namingStrategy != null -> translatePropertyName(namingStrategy, element.toSimpleName()) - else -> element.toSimpleName() - } - - Pair(name, description?.value ?: "") - } - .forEach { (name, description) -> - if (enumType != null && enumType.type != "string") { - values.add(jsonMapper.readTree(name)) - } else { - values.add(name) - } - - descriptions.add(description) - } - - schema.put("type", enumType?.type ?: "string") - enumType?.format?.also { schema.put("format", it) } - schema.set("enum", values) - - if (descriptions.find({ description -> description.isTextual && description.asText().isNotEmpty()}) != null) { - schema.set("x-enum-descriptions", descriptions) - } - - val extra = source.findExtra(context) - schema.addExtra(extra) - } - else -> { - schema.put("type", "object") - - val extra = source.findExtra(context) - schema.addExtra(extra) - - val propertiesObject = createObjectNode() - schema.set("properties", propertiesObject) - - val requireNonNulls = source.getAnnotation(JsonSchema::class.java) - ?.requireNonNulls - ?: requireNonNullsByDefault - - val properties = context.findAllProperties(type, requireNonNulls) - - properties.forEach { property -> - val result = - when { - processedProperties.contains(property) -> - processedProperties[property]!! - else -> - createEmbeddedTypeDescription( - type = property.type, - inlineRefs = inlineRefs, - requiresNonNulls = requireNonNulls, - composition = property.composition, - extra = property.extra, - nullable = property.nullable, - ).also { - processedProperties[property] = it - } - } - propertiesObject.set(property.name, result.json) - references.addAll(result.references) - } - - if (properties.any { it.required }) { - val required = createArrayNode() - properties.filter { it.required }.forEach { required.add(it.name) } - schema.set("required", required) - } - } - } - - return ResultScheme(schema, references) - } - - fun createEmbeddedTypeDescription( - type: ClassDefinition, - inlineRefs: Boolean = false, - requiresNonNulls: Boolean = true, - composition: PropertyComposition? = null, - extra: Map = emptyMap(), - nullable: Boolean = false, - ): ResultScheme = context.inContext { - val definedBy = type.source.getAnnotation(OpenApiPropertyType::class.java)?.getClassDefinition { definedBy } - - if (definedBy != null && type.source.kind != ENUM) { - return@inContext createEmbeddedTypeDescription(definedBy, inlineRefs, requiresNonNulls, composition, extra, nullable) - } - - val scheme = createObjectNode() - val references = mutableSetOf() - - val handledByCustomProcessor = - context.configuration.embeddedTypeProcessors.firstOrNull { - it.process( - EmbeddedTypeProcessorContext( - parentContext = context, - scheme = scheme, - references = references, - type = type, - inlineRefs = inlineRefs, - requiresNonNulls = requiresNonNulls, - composition = composition, - extra = extra - ) - ) - } - - if (handledByCustomProcessor == null) { - // Unwrap Optional as nullable T - if (type.fullName == "java.util.Optional" && type.generics.size == 1) { - return@inContext createEmbeddedTypeDescription(type.generics.first(), inlineRefs, requiresNonNulls, composition, extra, nullable = true) - } - - addType(scheme, type, inlineRefs, references, requiresNonNulls) - } - - scheme.addExtra(extra) - - if (nullable) { - val currentType = scheme.get("type")?.takeIf { it.isTextual }?.asText() - val currentRef = scheme.get($$"$ref")?.asText() - val compositionKey = listOf("oneOf", "anyOf", "allOf").firstOrNull { scheme.has(it) } - when { - currentType != null -> { - scheme.remove("type") - scheme.set("type", createArrayNode().add(currentType).add("null")) - } - currentRef != null -> { - scheme.remove($$"$ref") - val anyOf = createArrayNode() - anyOf.add(createObjectNode().put($$"$ref", currentRef)) - anyOf.add(createObjectNode().put("type", "null")) - scheme.set("anyOf", anyOf) - } - compositionKey == "allOf" -> { - val allOfArray = scheme.remove("allOf") - val discriminator = scheme.remove("discriminator") - val inner = createObjectNode() - inner.set("allOf", allOfArray) - if (discriminator != null) inner.set("discriminator", discriminator) - val anyOf = createArrayNode() - anyOf.add(inner) - anyOf.add(createObjectNode().put("type", "null")) - scheme.set("anyOf", anyOf) - } - compositionKey != null -> { - (scheme.get(compositionKey) as? ArrayNode)?.add(createObjectNode().put("type", "null")) - } - } - } - - ResultScheme(scheme, references) - } - - fun addType( - scheme: ObjectNode, - type: ClassDefinition, - inlineRefs: Boolean, - references: MutableSet, - requiresNonNulls: Boolean - ) { - when (val nonRefType = context.configuration.simpleTypeMappings[type.fullName]) { - null -> { - if (inlineRefs) { - val (subScheme, subReferences) = createTypeSchema(type, true, requiresNonNulls) - subScheme.properties().forEach { (key, value) -> scheme.set(key, value) } - references.addAll(subReferences) - } else { - references.add(type) - scheme.put($$"$ref", "#/components/schemas/${type.simpleName}") - } - } - else -> { - scheme.put("type", nonRefType.type) - nonRefType.format?.also { scheme.put("format", it) } - } - } - } - -} - -internal fun AnnotationProcessorContext.findAllProperties(type: ClassDefinition, requireNonNulls: Boolean): Collection = inContext { - val source = type.source - val openApiByFields: OpenApiByFields? = source.getAnnotation(OpenApiByFields::class.java) - - val isRecord = when (recordType()) { - null -> false - else -> isAssignable(type.mirror, recordType()!!.asType()) - } - - inDebug { it.info("TypeSchemaGenerator#findAllProperties | Enclosed elements of ${type.mirror}: ${source.enclosedElements}") } - val properties = mutableListOf() - - for (property in env.elementUtils.getAllMembers(forTypeElement(type.mirror))) { - if (property is Element) { - if (configuration.propertyInSchemeFilter?.filter(this@findAllProperties, type, property) == false) { - continue - } - - if (property.modifiers.contains(Modifier.STATIC)) { - continue - } - - when { - property.kind != METHOD && openApiByFields == null -> continue - property.kind == METHOD && openApiByFields?.only == true -> continue - } - - val acceptFields = openApiByFields?.value - - if (acceptFields != null) { - val modifiers = property.modifiers - - val fieldVisibility = when { - modifiers.contains(Modifier.PRIVATE) -> Visibility.PRIVATE - modifiers.contains(Modifier.PROTECTED) -> Visibility.PROTECTED - modifiers.contains(Modifier.DEFAULT) -> Visibility.DEFAULT - modifiers.contains(Modifier.PUBLIC) -> Visibility.PUBLIC - else -> Visibility.DEFAULT - } - - if (acceptFields.priority > fieldVisibility.priority) { - continue - } - } - - if (property.getAnnotation(OpenApiIgnore::class.java) != null || property.modifiers.contains(Modifier.TRANSIENT)) { - continue - } - - if (objectType().enclosedElements.any { it.toSimpleName() == property.toSimpleName() }) { - continue - } - - val simpleName = property.toSimpleName() - val customName = property.getAnnotation(OpenApiName::class.java) - val namingStrategy = source.getAnnotation(OpenApiNaming::class.java)?.value - - val name = when { - customName != null -> customName.value - isRecord || property.kind == FIELD -> simpleName - simpleName.startsWith("get") -> simpleName.replaceFirst("get", "").replaceFirstChar { it.lowercase() } - simpleName.startsWith("is") -> simpleName.replaceFirst("is", "").replaceFirstChar { it.lowercase() } - else -> continue - } - - val finalName = if (customName == null && namingStrategy != null) { - translatePropertyName(namingStrategy, name) - } else { - name - } - - val customType = property.getAnnotation(OpenApiPropertyType::class.java) - - val propertyType = customType?.getTypeMirror { definedBy } - ?: (property as? ExecutableElement)?.returnType - ?: (property as? VariableElement)?.asType() - ?: continue - - val isNotNull = when { - customType?.nullability == Nullability.NOT_NULL -> true - customType?.nullability == Nullability.NULLABLE -> false - property.hasAnnotation("NotNull") -> true - propertyType.isPrimitive() -> true - property.hasAnnotation("Nullable") -> false - else -> false - } - - val required = when { - property.getAnnotation(OpenApiRequired::class.java) != null -> true - else -> requireNonNulls && isNotNull - } - - val openApiNullable = property.getAnnotation(OpenApiNullable::class.java) - - val isExplicitlyNullable = when { - openApiNullable != null -> openApiNullable.nullable - customType?.nullability == Nullability.NULLABLE -> true - property.hasAnnotation("Nullable") -> true - else -> false - } - - properties.add( - Property( - name = finalName, - type = propertyType.toClassDefinition(), - composition = findCompositionInElement(this@findAllProperties, property), - required = required, - nullable = isExplicitlyNullable, - extra = property.findExtra(this@findAllProperties) - ) - ) - } - } - - type.extra - .filterIsInstance() - .forEach { extraProperty -> - properties.add( - Property( - name = extraProperty.name, - type = extraProperty.type, - required = requireNonNulls - ) - ) - } - - properties -} - -private fun Element.findExtra(context: AnnotationProcessorContext): Map = context.inContext { - val extra = mutableMapOf( - "description" to getAnnotation(OpenApiDescription::class.java)?.value - ) - - getAnnotationsByType(OpenApiExample::class.java).forEach { example -> - when { - example.value != NULL_STRING -> { - extra["example"] = example.value - } - example.raw != NULL_STRING -> { - extra["example"] = jsonMapper.readTree(example.raw) - } - example.objects.isNotEmpty() -> { - val result = ExampleGenerator.generateFromExamples(example.objects.map { it.toExampleProperty() }) - extra["example"] = result.jsonElement ?: result.simpleValue - } - } - } - - getAnnotationsByType(OpenApiNumberValidation::class.java).forEach { validation -> - extra["minimum"] = validation.minimum.takeIf { it != NULL_STRING }?.toBigDecimal() - extra["maximum"] = validation.maximum.takeIf { it != NULL_STRING }?.toBigDecimal() - extra["exclusiveMinimum"] = validation.exclusiveMinimum.takeIf { it != NULL_STRING }?.toBigDecimal() - extra["exclusiveMaximum"] = validation.exclusiveMaximum.takeIf { it != NULL_STRING }?.toBigDecimal() - extra["multipleOf"] = validation.multipleOf.takeIf { it != NULL_STRING }?.toBigDecimal() - } - - getAnnotationsByType(OpenApiStringValidation::class.java).forEach { validation -> - extra["minLength"] = validation.minLength.takeIf { it != NULL_STRING }?.toInt() - extra["maxLength"] = validation.maxLength.takeIf { it != NULL_STRING }?.toInt() - extra["format"] = validation.format.takeIf { it != NULL_STRING } - extra["pattern"] = validation.pattern.takeIf { it != NULL_STRING } - } - - getAnnotationsByType(OpenApiArrayValidation::class.java).forEach { validation -> - extra["minItems"] = validation.minItems.takeIf { it != NULL_STRING }?.toInt() - extra["maxItems"] = validation.maxItems.takeIf { it != NULL_STRING }?.toInt() - extra["uniqueItems"] = validation.uniqueItems.takeIf { it } - } - - getAnnotationsByType(OpenApiObjectValidation::class.java).forEach { validation -> - extra["minProperties"] = validation.minProperties.takeIf { it != NULL_STRING }?.toInt() - extra["maxProperties"] = validation.maxProperties.takeIf { it != NULL_STRING }?.toInt() - } - - getAnnotationsByType(Custom::class.java).forEach { custom -> - extra[custom.name] = custom.value - } - - context.env.elementUtils.getAllAnnotationMirrors(this@findExtra) - .filterNot { it.annotationType.getFullName() == Metadata::class.qualifiedName } - .onEach { annotation -> inDebug { it.info("TypeSchemaGenerator#findExtra | Annotation: ${annotation.annotationType}") } } - .filter { annotation -> - val isCustom = annotation.annotationType.asElement()?.getAnnotation(CustomAnnotation::class.java) != null - - if (!isCustom) { - inDebug { - it.info("TypeSchemaGenerator#findExtra | Usage: $annotation") - it.info("TypeSchemaGenerator#findExtra | Implementation:") - context.env.elementUtils.printElements( - MessagerWriter(context), - annotation.annotationType.asElement() - ) - } - } - - isCustom - } - .flatMap { customAnnotation -> - inDebug { it.info("TypeSchemaGenerator#findExtra | Custom annotation: $customAnnotation") } - val elements = context.env.elementUtils.getElementValuesWithDefaults(customAnnotation) - inDebug { it.info("TypeSchemaGenerator#findExtra | Element values with defaults: $elements") } - elements.asSequence() - } - .forEach { (element, value) -> - extra[element.toSimpleName()] = value.accept(object : AnnotationValueVisitor { - override fun visit(av: AnnotationValue, p: Nothing?) = av.value.toString() - override fun visitBoolean(boolean: Boolean, p: Nothing?) = boolean - override fun visitByte(byte: Byte, p: Nothing?) = byte - override fun visitChar(char: Char, p: Nothing?) = char - override fun visitDouble(double: Double, p: Nothing?) = double - override fun visitFloat(float: Float, p: Nothing?) = float - override fun visitInt(int: Int, p: Nothing?) = int - override fun visitLong(long: Long, p: Nothing?) = long - override fun visitShort(short: Short, p: Nothing?) = short - override fun visitString(string: String, p: Nothing?) = string.trimIndent() - override fun visitType(type: TypeMirror, p: Nothing?) = type.getFullName() - override fun visitEnumConstant(variable: VariableElement, p: Nothing?) = variable.toSimpleName() - override fun visitArray(values: MutableList, p: Nothing?): ArrayNode = createArrayNode().also { array -> - values.forEach { - when (val result = it.accept(this, null)) { - is Boolean -> array.add(result) - is Int -> array.add(result) - is Long -> array.add(result) - is Double -> array.add(result) - is Float -> array.add(result) - is Short -> array.add(result.toInt()) - is Byte -> array.add(result.toInt()) - is String -> array.add(result) - is JsonNode -> array.add(result) - else -> throw UnsupportedOperationException("[CustomAnnotation] Unsupported array value: $it") - } - } - } - override fun visitAnnotation(annotationMirror: AnnotationMirror?, p: Nothing?) = throw UnsupportedOperationException("[CustomAnnotation] Unsupported nested annotations") - override fun visitUnknown(av: AnnotationValue?, p: Nothing?) = throw UnsupportedOperationException("[CustomAnnotation] Unknown value $av") - }, null) - inDebug { it.info("TypeSchemaGenerator#findExtra | Visited entry ($element, $value) mapped to ${extra[element.toSimpleName()]}") } - } - - extra -} diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/AnnotationProcessorExtensions.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/AnnotationProcessorExtensions.kt index 7b922891..4d491afc 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/AnnotationProcessorExtensions.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/AnnotationProcessorExtensions.kt @@ -1,7 +1,6 @@ package io.javalin.openapi.experimental.processor.shared import io.javalin.openapi.experimental.AnnotationProcessorContext -import java.io.Writer import javax.annotation.processing.Filer import javax.annotation.processing.FilerException import javax.annotation.processing.Messager @@ -19,7 +18,6 @@ fun Filer.saveResource(context: AnnotationProcessorContext, name: String, conten } resource } catch (_: FilerException) { - // file has been created during previous compilation phase null } catch (throwable: Throwable) { context.env.messager.printException(throwable) @@ -42,27 +40,8 @@ fun Messager.printException(kind: Kind, throwable: Throwable) { printMessage(kind, error.toString()) - if (throwable.cause != null) { + throwable.cause?.let { cause -> printMessage(kind, "---") - printException(throwable.cause!!) + printException(cause) } } - -class MessagerWriter(val context: AnnotationProcessorContext) : Writer() { - - private val builder = StringBuilder() - - override fun flush() { - context.env.messager.info(builder.toString()) - builder.clear() - } - - override fun write(cbuf: CharArray, off: Int, len: Int) { - builder.append(cbuf, off, len) - } - - override fun close() { - flush() - } - -} diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/ModelExtensions.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/ModelExtensions.kt deleted file mode 100644 index 9a131c85..00000000 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/experimental/processor/shared/ModelExtensions.kt +++ /dev/null @@ -1,44 +0,0 @@ -package io.javalin.openapi.experimental.processor.shared - -import io.javalin.openapi.experimental.AnnotationProcessorContext -import javax.lang.model.element.Element -import javax.lang.model.element.TypeElement -import javax.lang.model.element.VariableElement -import javax.lang.model.type.MirroredTypeException -import javax.lang.model.type.MirroredTypesException -import javax.lang.model.type.TypeMirror -import kotlin.reflect.KClass - -fun AnnotationProcessorContext.objectType(): TypeElement = forTypeElement(Object::class.java.name)!! -fun AnnotationProcessorContext.collectionType(): TypeElement = forTypeElement(Collection::class.java.name)!! -fun AnnotationProcessorContext.mapType(): TypeElement = forTypeElement(Map::class.java.name)!! -fun AnnotationProcessorContext.recordType(): TypeElement? = forTypeElement("java.lang.Record") - -fun TypeMirror.isPrimitive(): Boolean = - kind.isPrimitive - -fun Element.hasAnnotation(simpleName: String): Boolean = - annotationMirrors.any { it.annotationType.asElement().simpleName.contentEquals(simpleName) } - -fun Element.getFullName(): String = - toString() - -fun Element.toSimpleName(): String = - simpleName.toString() - -fun VariableElement.toSimpleName(): String = - simpleName.toString() - -fun A.getTypeMirrors(supplier: A.() -> Array>): Set = - try { - throw Error(supplier().toString()) // always throws MirroredTypesException, because we cannot get Class instance from annotation at compile-time - } catch (mirroredTypeException: MirroredTypesException) { - mirroredTypeException.typeMirrors.toSet() - } - -fun > A.getTypeMirror(supplier: A.() -> K): TypeMirror = - try { - throw Error(supplier().toString()) // always throws MirroredTypeException, because we cannot get Class instance from annotation at compile-time - } catch (mirroredTypeException: MirroredTypeException) { - mirroredTypeException.typeMirror - } diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/OpenApiAnnotationProcessor.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/OpenApiAnnotationProcessor.kt index d13de4b2..9711559e 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/OpenApiAnnotationProcessor.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/OpenApiAnnotationProcessor.kt @@ -3,6 +3,7 @@ package io.javalin.openapi.processor import io.javalin.openapi.experimental.ExperimentalCompileOpenApiConfiguration import io.javalin.openapi.JsonSchema import io.javalin.openapi.OpenApi +import io.javalin.openapi.OpenApis import io.javalin.openapi.experimental.AnnotationProcessorContext import io.javalin.openapi.experimental.OPENAPI_GROOVY_SCRIPT_PATH import io.javalin.openapi.experimental.OPENAPI_INFO_TITLE @@ -74,10 +75,11 @@ open class OpenApiAnnotationProcessor : AbstractProcessor() { override fun getSupportedAnnotationTypes(): Set = setOf( OpenApi::class.qualifiedName!!, + OpenApis::class.qualifiedName!!, JsonSchema::class.qualifiedName!!, ) override fun getSupportedSourceVersion(): SourceVersion = SourceVersion.latestSupported() -} \ No newline at end of file +} diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/JsonSchemaGenerator.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/JsonSchemaGenerator.kt index 57a9a870..e305ba6a 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/JsonSchemaGenerator.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/JsonSchemaGenerator.kt @@ -8,17 +8,24 @@ import javax.lang.model.element.Element class JsonSchemaGenerator { - fun generate(roundEnvironment: RoundEnvironment) = - roundEnvironment.getElementsAnnotatedWith(JsonSchema::class.java) - .filter { it.getAnnotation(JsonSchema::class.java)!!.generateResource } - .onEach { context.env.filer.saveResource(context, "json-schemes/${it}", generate(it)) } - .run { context.env.filer.saveResource(context, "json-schemes/index", joinToString(separator = "\n")) } + fun generate(roundEnvironment: RoundEnvironment) { + val elements = + roundEnvironment + .getElementsAnnotatedWith(JsonSchema::class.java) + .filter { it.getAnnotation(JsonSchema::class.java)!!.generateResource } + + for (element in elements) { + context.env.filer.saveResource(context, "json-schemes/$element", generate(element)) + } + + context.env.filer.saveResource(context, "json-schemes/index", elements.joinToString(separator = "\n")) + } private fun generate(element: Element): String = - context.inContext { - context.typeSchemaGenerator.createTypeSchema( - type = element.asType().toClassDefinition(), - inlineRefs = true + with(context) { + typeSchemaGenerator.createTypeSchema( + type = element.asType().toOpenApiType(), + inlineRefs = true, ).toJsonSchemaString() } diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/OpenApiGenerator.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/OpenApiGenerator.kt index 281f1a4c..0fdd476b 100644 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/OpenApiGenerator.kt +++ b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/OpenApiGenerator.kt @@ -1,6 +1,5 @@ package io.javalin.openapi.processor.generators -import io.javalin.http.HttpStatus import io.javalin.openapi.OpenApi import io.javalin.openapi.OpenApis import io.javalin.openapi.experimental.processor.shared.saveResource @@ -16,58 +15,54 @@ internal class OpenApiGenerator { private val schemaGenerator = OpenApiSchemaGenerator( context = context, - defaultStatusDescription = { status -> - status.toIntOrNull()?.let { HttpStatus.forStatus(it) }?.message - } + title = context.parameters.info.title, + version = context.parameters.info.version, ) fun generate(roundEnvironment: RoundEnvironment) { - val aggregatedOpenApiAnnotations = roundEnvironment.getElementsAnnotatedWith(OpenApis::class.java) - .flatMap { element -> - element - .getAnnotation(OpenApis::class.java)!! - .value - .asSequence() - .map { element to it } - } - - val standaloneOpenApiAnnotations = - roundEnvironment - .getElementsAnnotatedWith(OpenApi::class.java) - .map { it to it.getAnnotation(OpenApi::class.java)!! } - - val openApiAnnotationsByVersion = (aggregatedOpenApiAnnotations + standaloneOpenApiAnnotations) - .flatMap { it.second.versions.map { version -> version to it } } - .groupBy { (version, _) -> version } - .mapValues { (_, annotations) -> annotations.map { it.second } } + val routes = + listOf( + roundEnvironment.getElementsAnnotatedWith(OpenApis::class.java), + roundEnvironment.getElementsAnnotatedWith(OpenApi::class.java), + ) + .flatten() + .flatMap { element -> + context + .annotationsOf(element) + .findAll(OpenApi::class.java) + .map { it.values } + } - openApiAnnotationsByVersion - .map { (version, openApiAnnotations) -> - val preparedOpenApiAnnotations = openApiAnnotations.toSet() - val generatedOpenApiSchema = schemaGenerator.generateSchema(preparedOpenApiAnnotations) + val resourceNames = + schemaGenerator + .generateVersionedSchemas(routes) + .map { (version, generatedOpenApiSchema) -> + val resourceName = "openapi-${version.replace(" ", "-")}.json" + val resource = + context + .env + .filer + .saveResource(context, "openapi-plugin/$resourceName", generatedOpenApiSchema) + ?.toUri() + ?.toString() + ?: return - val resourceName = "openapi-${version.replace(" ", "-")}.json" - val resource = context.env.filer.saveResource(context, "openapi-plugin/$resourceName", generatedOpenApiSchema) - ?.toUri() - ?.toString() - ?: return + if (context.configuration.validateWithParser) { + val parsedSchema = OpenAPIV3Parser().readLocation(resource, emptyList(), ParseOptions()) - if (context.configuration.validateWithParser) { - val parsedSchema = OpenAPIV3Parser().readLocation(resource, emptyList(), ParseOptions()) + if (parsedSchema.messages.isNotEmpty()) { + context.env.messager.printMessage(Diagnostic.Kind.NOTE, "OpenApi Validation Warnings :: ${parsedSchema.messages.size}") + } - if (parsedSchema.messages.isNotEmpty()) { - context.env.messager.printMessage(Diagnostic.Kind.NOTE, "OpenApi Validation Warnings :: ${parsedSchema.messages.size}") + parsedSchema.messages.forEach { message -> + context.env.messager.printMessage(WARNING, message) + } } - parsedSchema.messages.forEach { - context.env.messager.printMessage(WARNING, it) - } + resourceName } - resourceName - } - .joinToString(separator = "\n") - .let { context.env.filer.saveResource(context, "openapi-plugin/.index", it) } + context.env.filer.saveResource(context, "openapi-plugin/.index", resourceNames.joinToString(separator = "\n")) } } diff --git a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt b/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt deleted file mode 100644 index bc0de1f7..00000000 --- a/openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt +++ /dev/null @@ -1,369 +0,0 @@ -package io.javalin.openapi.schema - -import io.javalin.openapi.* -import io.javalin.openapi.OpenApiOperation.AUTO_GENERATE -import io.javalin.openapi.experimental.AnnotationProcessorContext -import io.javalin.openapi.experimental.StructureType.ARRAY -import io.javalin.openapi.experimental.mirror -import io.javalin.openapi.experimental.processor.generators.ResultScheme -import io.javalin.openapi.experimental.processor.shared.getTypeMirror -import java.util.Locale -import java.util.TreeMap -import javax.lang.model.element.Element -import javax.lang.model.type.TypeMirror -import javax.tools.Diagnostic.Kind.WARNING - -class OpenApiSchemaGenerator( - private val context: AnnotationProcessorContext, - private val defaultStatusDescription: (String) -> String? = { null }, -) { - - /** - * Based on https://swagger.io/specification/ - * - * @param openApiAnnotations annotation instances to map - * @return OpenApi JSON response - */ - fun generateSchema(openApiAnnotations: Collection>): String { - val schema = - OpenApiSchemaBuilder() - .openApiVersion("3.1.0") - .info { it.title(context.parameters.info.title).version(context.parameters.info.version) } - - for ((openApiElement, routeAnnotation) in openApiAnnotations.sortedBy { it.second.getFormattedPath() }) { - if (routeAnnotation.ignore) { - continue - } - - // https://swagger.io/specification/#paths-object - val pathBuilder = schema.path(routeAnnotation.getFormattedPath()) - - for (method in routeAnnotation.methods.sortedBy { it.name }) { - pathBuilder.operation(method.name.lowercase()) { - // General - tags(routeAnnotation.tags.toList()) - summary(routeAnnotation.summary.takeIf { it != NULL_STRING }) - description(routeAnnotation.description.takeIf { it != NULL_STRING }) - - // ExternalDocs - // ~ https://swagger.io/specification/#external-documentation-object - // UNSUPPORTED - - // OperationId - operationId(generateOperationId(method, routeAnnotation).takeIf { it != NULL_STRING }) - - // Parameters - // ~ https://swagger.io/specification/#parameter-object - buildParameters(routeAnnotation) - - // RequestBody - // ~ https://swagger.io/specification/#request-body-object - buildRequestBody(openApiElement, routeAnnotation.requestBody) - - // Responses - // ~ https://swagger.io/specification/#responses-object - buildResponses(openApiElement, routeAnnotation.responses) - - // Callbacks - // ~ https://swagger.io/specification/#callback-object - buildCallbacks(openApiElement, routeAnnotation.callbacks) - - // Deprecated - if (routeAnnotation.deprecated) { - deprecated(true) - } - - // Security - // ~ https://swagger.io/specification/#security-requirement-object - if (routeAnnotation.security.isNotEmpty()) { - security { - for (securityAnnotation in routeAnnotation.security.sortedBy { it.name }) { - securityRequirement(securityAnnotation.name, *securityAnnotation.scopes) - } - } - } - } - } - } - - schema.resolveComponentReferences { type -> context.typeSchemaGenerator.createTypeSchema(type, false) } - return schema.toJson() - } - - private fun OperationBuilder.buildParameters(routeAnnotation: OpenApi) { - parameters { - val parameterAnnotations = linkedMapOf( - In.COOKIE to routeAnnotation.cookies, - In.HEADER to routeAnnotation.headers, - In.PATH to routeAnnotation.pathParams, - In.QUERY to routeAnnotation.queryParams - ) - - parameterAnnotations.forEach { (parameterType, annotations) -> - annotations.forEach { parameterAnnotation -> - val paramSchema = createTypeDescriptionWithReferences(parameterAnnotation.getTypeMirror { type }) - parameter( - name = parameterAnnotation.name, - location = parameterType.identifier, - schema = paramSchema, - description = parameterAnnotation.description.takeIf { it != NULL_STRING }, - required = parameterAnnotation.required || parameterType == In.PATH, - deprecated = parameterAnnotation.deprecated, - allowEmptyValue = parameterAnnotation.allowEmptyValue, - example = parameterAnnotation.example.takeIf { it.isNotEmpty() }, - ) - } - } - } - } - - private fun OperationBuilder.buildRequestBody(element: Element, annotation: OpenApiRequestBody) { - requestBody { - description(annotation.description.takeIf { it != NULL_STRING }) - content { addResolvedContent(element, annotation.content) } - if (annotation.required) { required(true) } - } - } - - private fun OperationBuilder.buildResponses(element: Element, responseAnnotations: Array) { - responses { - for (responseAnnotation in responseAnnotations.sortedBy { it.status }) { - response(responseAnnotation.status) { - val description = responseAnnotation.description - .takeIf { it != NULL_STRING } - ?: defaultStatusDescription(responseAnnotation.status) - ?: "" - - description(description) - content { addResolvedContent(element, responseAnnotation.content) } - headers { - responseAnnotation.headers.forEach { headerParam -> - val headerSchema = createTypeDescriptionWithReferences(headerParam.getTypeMirror { type }) - header( - name = headerParam.name, - schema = headerSchema, - description = headerParam.description.takeIf { it != NULL_STRING }, - required = headerParam.required, - deprecated = headerParam.deprecated, - allowEmptyValue = headerParam.allowEmptyValue, - example = headerParam.example.takeIf { it.isNotEmpty() }, - ) - } - } - } - } - } - } - - private fun OperationBuilder.buildCallbacks(element: Element, callbackAnnotations: Array) { - if (callbackAnnotations.isEmpty()) { - return - } - - callbacks { - callbackAnnotations.forEach { callbackAnnotation -> - callback( - name = callbackAnnotation.name, - url = callbackAnnotation.url, - method = callbackAnnotation.method.name.lowercase() - ) { - summary(callbackAnnotation.summary.takeIf { it != NULL_STRING }) - description(callbackAnnotation.description.takeIf { it != NULL_STRING }) - requestBody { - description(callbackAnnotation.requestBody.description.takeIf { it != NULL_STRING }) - content { addResolvedContent(element, callbackAnnotation.requestBody.content) } - if (callbackAnnotation.requestBody.required) { required(true) } - } - responses { - for (responseAnnotation in callbackAnnotation.responses.sortedBy { it.status }) { - response(responseAnnotation.status) { - val description = responseAnnotation.description - .takeIf { it != NULL_STRING } - ?: defaultStatusDescription(responseAnnotation.status) - ?: "" - - description(description) - content { addResolvedContent(element, responseAnnotation.content) } - } - } - } - } - } - } - } - - private fun ContentBuilder.addResolvedContent(element: Element, contentAnnotations: Array) { - val resolvedEntries = TreeMap Unit>() - - for (contentAnnotation in contentAnnotations) { - val resolved = resolveMediaType(element, contentAnnotation) ?: continue - resolvedEntries[resolved.first] = resolved.second - } - - resolvedEntries.forEach { (mimeType, configure) -> mediaType(mimeType, configure) } - } - - enum class In(val identifier: String) { - QUERY("query"), - HEADER("header"), - PATH("path"), - COOKIE("cookie"), - } - - private fun generateOperationId( - httpMethod: HttpMethod, - openApi: OpenApi, - pathParamPrefix: String = "By" - ): String = - when (openApi.operationId) { - AUTO_GENERATE -> { - httpMethod.name.lowercase() + openApi.path.split('/') - .map { pathPart -> - if (pathPart.startsWith('{') || pathPart.startsWith('<')) { - val pathParam = pathPart - .drop(1) - .dropLast(1) - .split('-') - .joinToString(separator = "") { it.capitalise() } - pathParamPrefix + pathParam - } else { - pathPart.capitalise() - } - } - .toList() - .joinToString(separator = "") { - it.split('-').joinToString(separator = "") { it.capitalise() } - } - } - else -> openApi.operationId - } - - private fun String.capitalise(): String = this.replaceFirstChar { - it.titlecase(Locale.getDefault()) - } - - private fun resolveMediaType(element: Element, source: OpenApiContent): Pair Unit>? = - context.inContext { - var contentData = source.toData() - val from = source.getTypeMirror { contentData.from() } - - if (contentData.mimeType == null) { - contentData = - when (NULL_CLASS::class.qualifiedName) { - from.getFullName() -> contentData.copy(mimeType = contentData.type, type = null) - else -> contentData.copy(mimeType = detectContentType(from)) - } - } - - if (contentData.mimeType == null) { - val trees = context.trees - - if (trees != null) { - val compilationUnit = trees.getPath(element).compilationUnit - val tree = trees.getTree(element) - val startPosition = trees.sourcePositions.getStartPosition(compilationUnit, tree) - - context.env.messager.printMessage( - WARNING, - """ - OpenApi generator cannot find matching mime type defined. - Source: - Annotation in ${compilationUnit.lineMap.getLineNumber(startPosition)} at ${compilationUnit.sourceFile.name} line - Annotation: - $source - """.trimIndent() - ) - } - - return@inContext null - } - - val resolvedContentData = contentData - val fromMirror = source.getTypeMirror { resolvedContentData.from() } - - val configure: MediaTypeBuilder.() -> Unit = { - when (resolvedContentData.properties) { - null if resolvedContentData.additionalProperties == null && fromMirror.getFullName() != NULL_CLASS::class.java.name -> - schema(createTypeDescriptionWithReferences(fromMirror)) - - null if resolvedContentData.additionalProperties == null -> - schema { - resolvedContentData.type?.let { type(it) } - resolvedContentData.format?.let { format(it) } - } - - else -> objectSchema { - resolvedContentData.properties?.let { buildProperties(it) } - resolvedContentData.additionalProperties?.let { buildAdditionalProperties(it) } - } - } - - applyExample(resolvedContentData) - } - - return@inContext resolvedContentData.mimeType!! to configure - } - - private fun ExampleHolder.applyExample(contentData: OpenApiContentData) { - contentData.example?.let { example(it) } - contentData.exampleObjects?.let { applyExamples(it) } - } - - private fun ObjectSchemaBuilder.buildProperties(properties: List) { - context.inContext { - for (contentProperty in properties) { - val propertyFormat = contentProperty.format.takeIf { it != NULL_STRING } - val contentPropertyFrom = contentProperty.getTypeMirror { contentProperty.from } - val isResolved = contentPropertyFrom.getFullName() != NULL_CLASS::class.java.name - - if (contentProperty.isArray) { - if (isResolved) { - arrayProperty(contentProperty.name, createTypeDescriptionWithReferences(contentPropertyFrom)) - } else { - arrayProperty(contentProperty.name, contentProperty.type, propertyFormat) - } - } else { - if (isResolved) { - property(contentProperty.name, createTypeDescriptionWithReferences(contentPropertyFrom)) - } else { - property(contentProperty.name, contentProperty.type, propertyFormat) - } - } - } - } - } - - private fun ObjectSchemaBuilder.buildAdditionalProperties(annotation: OpenApiAdditionalContent) { - context.inContext { - val additionalData = annotation.toData() - val from = annotation.getTypeMirror { additionalData.from() } - - if (from.getFullName() != NULL_CLASS::class.java.name) { - additionalProperties(createTypeDescriptionWithReferences(from)) - } else { - additionalProperties(additionalData.type, additionalData.format) - } - - applyExample(additionalData) - } - } - - private fun detectContentType(typeMirror: TypeMirror): String = - context.inContext { - val model = typeMirror.toClassDefinition() - - when { - (model.structureType == ARRAY && model.simpleName == "Byte") || model.simpleName == "[B" || model.simpleName == "File" -> "application/octet-stream" - model.structureType == ARRAY -> "application/json" - model.simpleName == "String" -> "text/plain" - else -> "application/json" - } - } - - private fun createTypeDescriptionWithReferences(type: TypeMirror): ResultScheme = - context.inContext { - val model = type.toClassDefinition() - context.typeSchemaGenerator.createEmbeddedTypeDescription(model) - } - -} diff --git a/openapi-annotation-processor/src/test/compile/openapi.groovy b/openapi-annotation-processor/src/test/compile/openapi.groovy index c5e8e1fb..6156eb91 100644 --- a/openapi-annotation-processor/src/test/compile/openapi.groovy +++ b/openapi-annotation-processor/src/test/compile/openapi.groovy @@ -1,6 +1,6 @@ import io.javalin.openapi.experimental.AnnotationProcessorContext -import io.javalin.openapi.experimental.ClassDefinition -import io.javalin.openapi.experimental.ClassDefinitionHandleKt +import io.javalin.openapi.experimental.OpenApiType +import io.javalin.openapi.experimental.OpenApiTypeHandleKt import io.javalin.openapi.experimental.EmbeddedTypeProcessorContext import io.javalin.openapi.experimental.ExperimentalCompileOpenApiConfiguration import io.javalin.openapi.experimental.OpenApiAnnotationProcessorConfiguration @@ -16,28 +16,18 @@ class OpenApiConfiguration implements OpenApiAnnotationProcessorConfigurer { @Override void configure(OpenApiAnnotationProcessorConfiguration configuration) { configuration.validateWithParser = false - // configuration.debug = false - // Used by TypeMappersTest configuration.simpleTypeMappings['io.javalin.openapi.processor.TypeMappersTest.CustomType'] = new SimpleType("string") - // Used by UserCasesTest - configuration.propertyInSchemeFilter = { AnnotationProcessorContext ctx, ClassDefinition type, Element property -> - TypeElement specificRecord = ctx.forTypeElement('io.javalin.openapi.processor.UserCasesTest.SpecificRecord') - TypeElement specificRecordBase = ctx.forTypeElement('io.javalin.openapi.processor.UserCasesTest.SpecificRecordBase') + configuration.propertyInSchemeFilter = { AnnotationProcessorContext ctx, OpenApiType type, Element property -> + TypeElement filteredRecord = ctx.forTypeElement('io.javalin.openapi.processor.PropertySelectionTest.FilteredRecord') + TypeElement filteredRecordBase = ctx.forTypeElement('io.javalin.openapi.processor.PropertySelectionTest.FilteredRecordBase') - if (ctx.isAssignable(ClassDefinitionHandleKt.getMirror(type), specificRecord.asType()) && ctx.hasElement(specificRecord, property)) { - return false // exclude + return [filteredRecord, filteredRecordBase].every { filteredType -> + !ctx.isAssignable(OpenApiTypeHandleKt.getMirror(type), filteredType.asType()) || !ctx.hasElement(filteredType, property) } - - if (ctx.isAssignable(ClassDefinitionHandleKt.getMirror(type), specificRecordBase.asType()) && ctx.hasElement(specificRecordBase, property)) { - return false // exclude - } - - return true // include } - // Used by CustomTypeMappingsTest - unwrap AtomicReference to T configuration.insertEmbeddedTypeProcessor({ EmbeddedTypeProcessorContext context -> if (context.type.simpleName == 'AtomicReference' && context.type.generics.size() == 1) { context.parentContext.typeSchemaGenerator.addType(context.scheme, context.type.generics[0], context.inlineRefs, context.references, false) diff --git a/openapi-annotation-processor/src/test/java/io/javalin/openapi/processor/CustomAnnotationsTestModels.java b/openapi-annotation-processor/src/test/java/io/javalin/openapi/processor/CustomAnnotationsTestModels.java new file mode 100644 index 00000000..4049e4fe --- /dev/null +++ b/openapi-annotation-processor/src/test/java/io/javalin/openapi/processor/CustomAnnotationsTestModels.java @@ -0,0 +1,45 @@ +package io.javalin.openapi.processor; + +import io.javalin.openapi.CustomAnnotation; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Inherited +@CustomAnnotation +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@interface InheritedExtra { + String inherited(); +} + +@InheritedExtra(inherited = "yes") +class InheritedExtraBase { +} + +class InheritedExtraChild extends InheritedExtraBase { + public String getName() { + return ""; + } +} + +@CustomAnnotation +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@interface NestedExtra { + NestedValue nested(); +} + +@Retention(RetentionPolicy.RUNTIME) +@interface NestedValue { + String note(); +} + +@NestedExtra(nested = @NestedValue(note = "x")) +class NestedExtraDto { + public String getName() { + return ""; + } +} diff --git a/openapi-annotation-processor/src/test/java/io/javalin/openapi/processor/CustomTypeMappingsTestModels.java b/openapi-annotation-processor/src/test/java/io/javalin/openapi/processor/CustomTypeMappingsTestModels.java new file mode 100644 index 00000000..da5fb79d --- /dev/null +++ b/openapi-annotation-processor/src/test/java/io/javalin/openapi/processor/CustomTypeMappingsTestModels.java @@ -0,0 +1,11 @@ +package io.javalin.openapi.processor; + +import io.javalin.openapi.OpenApiPropertyType; +import java.time.Instant; + +class PrimitiveRedirectDto { + @OpenApiPropertyType(definedBy = long.class) + public Instant getCreatedAt() { + return Instant.EPOCH; + } +} diff --git a/openapi-annotation-processor/src/test/java/io/javalin/openapi/processor/PropertySelectionTestModels.java b/openapi-annotation-processor/src/test/java/io/javalin/openapi/processor/PropertySelectionTestModels.java new file mode 100644 index 00000000..9ff03c3b --- /dev/null +++ b/openapi-annotation-processor/src/test/java/io/javalin/openapi/processor/PropertySelectionTestModels.java @@ -0,0 +1,16 @@ +package io.javalin.openapi.processor; + +import io.javalin.openapi.OpenApiName; + +class FluentOpenApiNameDto { + @OpenApiName("age") + public int age() { + return 1; + } +} + +record RecordWithExtraGetter(String id) { + public String getDisplayName() { + return ""; + } +} diff --git a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/ComponentAnnotationsTest.kt b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/ComponentAnnotationsTest.kt index 72416b3a..c1499c88 100644 --- a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/ComponentAnnotationsTest.kt +++ b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/ComponentAnnotationsTest.kt @@ -30,8 +30,6 @@ internal class ComponentAnnotationsTest : OpenApiAnnotationProcessorSpecificatio ) @Test fun should_include_openapi_description() = withOpenApi("should_include_openapi_description") { - println(it) - assertThatJson(it) .inPath("$.components.schemas.ClassWithOpenApiDescription") .isObject @@ -55,8 +53,6 @@ internal class ComponentAnnotationsTest : OpenApiAnnotationProcessorSpecificatio ) @Test fun should_change_property_type() = withOpenApi("should_change_property_type") { - println(it) - assertThatJson(it) .inPath("$.components.schemas.ClassWithOpenApiType") .isObject @@ -87,8 +83,6 @@ internal class ComponentAnnotationsTest : OpenApiAnnotationProcessorSpecificatio ) @Test fun should_add_nullable_property() = withOpenApi("should_control_nullability") { - println(it) - assertThatJson(it) .inPath("$.components.schemas.ClassWithNullableProperties.properties.testProperty.type") .isEqualTo(json("""["string", "null"]""")) diff --git a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/SchemeTest.kt b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/ComponentSchemaTest.kt similarity index 95% rename from openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/SchemeTest.kt rename to openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/ComponentSchemaTest.kt index eb6e531e..3d5fd797 100644 --- a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/SchemeTest.kt +++ b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/ComponentSchemaTest.kt @@ -10,7 +10,7 @@ import net.javacrumbs.jsonunit.assertj.assertThatJson import org.junit.jupiter.api.Test import java.io.Serializable -internal class SchemeTest : OpenApiAnnotationProcessorSpecification() { +internal class ComponentSchemaTest : OpenApiAnnotationProcessorSpecification() { private open class BaseType { val baseProperty: String = "Test" @@ -37,8 +37,6 @@ internal class SchemeTest : OpenApiAnnotationProcessorSpecification() { ) @Test fun should_generate_reference_with_inherited_properties() = withOpenApi("should_generate_reference_with_inherited_properties") { - println(it) - assertThatJson(it) .inPath("$.components.schemas.FinalClass.properties") .isObject diff --git a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/CompositionTest.kt b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/CompositionTest.kt index e5c387e4..72bb0183 100644 --- a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/CompositionTest.kt +++ b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/CompositionTest.kt @@ -79,8 +79,6 @@ internal class CompositionTest : OpenApiAnnotationProcessorSpecification() { """)) } - // Nullable composition types - @JsonSchema class NullableOneOfConfig( @get:OpenApiNullable @@ -226,8 +224,6 @@ internal class CompositionTest : OpenApiAnnotationProcessorSpecification() { """)) } - // Discriminator tests - @OneOf( discriminator = Discriminator( property = DiscriminatorProperty( @@ -246,6 +242,11 @@ internal class CompositionTest : OpenApiAnnotationProcessorSpecification() { @OpenApiName("B") data class C(val b: String) : Union + data class UnionEnvelope( + val direct: A, + val union: Union, + ) + @OpenApi( path = "discriminator", versions = ["should_resolve_subtypes_as_mapping"], @@ -253,8 +254,6 @@ internal class CompositionTest : OpenApiAnnotationProcessorSpecification() { ) @Test fun should_resolve_subtypes_as_mapping() = withOpenApi("should_resolve_subtypes_as_mapping") { - println(it) - assertThatJson(it) .inPath("$.components.schemas.Union") .isObject @@ -322,4 +321,18 @@ internal class CompositionTest : OpenApiAnnotationProcessorSpecification() { """)) } -} \ No newline at end of file + @OpenApi( + path = "discriminator-direct-subtype", + versions = ["should_inject_discriminator_into_previously_referenced_subtype"], + responses = [OpenApiResponse(status = "200", content = [OpenApiContent(from = UnionEnvelope::class)])] + ) + @Test + fun should_inject_discriminator_into_previously_referenced_subtype() = + withOpenApi("should_inject_discriminator_into_previously_referenced_subtype") { + assertThatJson(it) + .inPath("$.components.schemas.A.properties.type") + .isObject + .isEqualTo(json("""{ "type": "string" }""")) + } + +} diff --git a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/CustomAnnotationsTest.kt b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/CustomAnnotationsTest.kt index 6586584c..8b9873a3 100644 --- a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/CustomAnnotationsTest.kt +++ b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/CustomAnnotationsTest.kt @@ -26,6 +26,16 @@ internal class CustomAnnotationsTest : OpenApiAnnotationProcessorSpecification() @Target(PROPERTY_GETTER) private annotation class CustomAnnotationOnGetter(val onGetter: BooleanArray) + @CustomAnnotation + @Target(PROPERTY_GETTER) + private annotation class Schema( + val allowableValues: Array = [], + val description: String = "", + val example: String = "", + val format: String = "", + val pattern: String = "", + ) + @Custom(name = "description", value = "Custom description") @CustomAnnotationOnClass(onClass = [true]) private class CustomEntity( @@ -40,8 +50,6 @@ internal class CustomAnnotationsTest : OpenApiAnnotationProcessorSpecification() ) @Test fun should_include_custom_annotation_in_type_scheme() = withOpenApi("should_include_custom_annotation_in_type_scheme") { - println(it) - assertThatJson(it) .inPath("$.paths['/custom'].get.responses.200.content['application/json'].schema") .isObject @@ -59,6 +67,33 @@ internal class CustomAnnotationsTest : OpenApiAnnotationProcessorSpecification() .containsEntry("onGetter", json("[true]")) } + private data class KeypairCreateResponse( + @get:Schema( + allowableValues = ["valid", "expired", "revoked"], + format = "fingerprint", + pattern = "^(valid|expired|revoked)$", + description = "status of the key like valid|expired|revoked", + ) + val fingerprint: String, + ) + + @OpenApi( + path = "/custom-annotation-array", + versions = ["should_include_array_values_from_custom_annotations"], + responses = [OpenApiResponse(status = "200", content = [OpenApiContent(from = KeypairCreateResponse::class)])], + ) + @Test + fun should_include_array_values_from_custom_annotations() = + withOpenApi("should_include_array_values_from_custom_annotations") { + assertThatJson(it) + .inPath("$.components.schemas.KeypairCreateResponse.properties.fingerprint") + .isObject + .containsEntry("allowableValues", json("[\"valid\", \"expired\", \"revoked\"]")) + .containsEntry("description", "status of the key like valid|expired|revoked") + .containsEntry("format", "fingerprint") + .containsEntry("pattern", "^(valid|expired|revoked)$") + } + @OpenApiName("PandaEntity") private class OpenApiNameEntity @@ -80,4 +115,29 @@ internal class CustomAnnotationsTest : OpenApiAnnotationProcessorSpecification() .isObject } -} \ No newline at end of file + @OpenApi( + path = "/inherited-custom-annotation", + versions = ["should_include_inherited_custom_annotation_extras"], + responses = [OpenApiResponse(status = "200", content = [OpenApiContent(from = InheritedExtraChild::class)])] + ) + @Test + fun should_include_inherited_custom_annotation_extras() = withOpenApi("should_include_inherited_custom_annotation_extras") { + assertThatJson(it) + .inPath("$.components.schemas.InheritedExtraChild") + .isObject + .containsEntry("inherited", "yes") + } + + @OpenApi( + path = "/nested-custom-annotation", + versions = ["should_emit_nested_custom_annotation_values_as_json"], + responses = [OpenApiResponse(status = "200", content = [OpenApiContent(from = NestedExtraDto::class)])] + ) + @Test + fun should_emit_nested_custom_annotation_values_as_json() = withOpenApi("should_emit_nested_custom_annotation_values_as_json") { + assertThatJson(it) + .inPath("$.components.schemas.NestedExtraDto.nested") + .isEqualTo(json("""{ "note": "x" }""")) + } + +} diff --git a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/CustomTypeMappingsTest.kt b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/CustomTypeMappingsTest.kt index 195d1618..745e263c 100644 --- a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/CustomTypeMappingsTest.kt +++ b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/CustomTypeMappingsTest.kt @@ -26,8 +26,6 @@ internal class CustomTypeMappingsTest : OpenApiAnnotationProcessorSpecification( ) @Test fun should_unwrap_atomic_reference_via_custom_processor() = withOpenApi("should_unwrap_atomic_reference_via_custom_processor") { - println(it) - assertThatJson(it) .inPath("$.components.schemas.EntityWithAtomicReference") .isObject @@ -63,8 +61,6 @@ internal class CustomTypeMappingsTest : OpenApiAnnotationProcessorSpecification( ) @Test fun should_unwrap_optional_as_nullable() = withOpenApi("should_unwrap_optional_as_nullable") { - println(it) - assertThatJson(it) .inPath("$.components.schemas.EntityWithOptional") .isObject @@ -88,4 +84,21 @@ internal class CustomTypeMappingsTest : OpenApiAnnotationProcessorSpecification( """)) } -} \ No newline at end of file + @OpenApi( + path = "/primitive-redirect", + versions = ["should_keep_primitive_redirect_required"], + responses = [OpenApiResponse(status = "200", content = [OpenApiContent(from = PrimitiveRedirectDto::class)])] + ) + @Test + fun should_keep_primitive_redirect_required() = withOpenApi("should_keep_primitive_redirect_required") { + assertThatJson(it) + .inPath("$.components.schemas.PrimitiveRedirectDto.properties.createdAt") + .isObject + .isEqualTo(json("""{ "type": "integer", "format": "int64" }""")) + + assertThatJson(it) + .inPath("$.components.schemas.PrimitiveRedirectDto.required") + .isEqualTo(json("""["createdAt"]""")) + } + +} diff --git a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/JsonSchemaTest.kt b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/JsonSchemaTest.kt new file mode 100644 index 00000000..1d6082e5 --- /dev/null +++ b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/JsonSchemaTest.kt @@ -0,0 +1,125 @@ +@file:Suppress("unused") + +package io.javalin.openapi.processor + +import io.javalin.openapi.JsonSchema +import io.javalin.openapi.OpenApi +import io.javalin.openapi.OpenApiContent +import io.javalin.openapi.OpenApiResponse +import io.javalin.openapi.experimental.processor.shared.jsonMapper +import io.javalin.openapi.processor.specification.OpenApiAnnotationProcessorSpecification +import net.javacrumbs.jsonunit.assertj.JsonAssertions.json +import net.javacrumbs.jsonunit.assertj.assertThatJson +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +internal class JsonSchemaTest : OpenApiAnnotationProcessorSpecification() { + + @JsonSchema(requireNonNulls = false) + private class JsonSchemaWithoutRequired(val name: String, val age: Int) + + @Test + fun should_honor_json_schema_require_non_nulls_false() = withJsonScheme("JsonSchemaWithoutRequired") { + val document = jsonMapper.readTree(it) + + assertThat(document.path("\$schema").asText()).isEqualTo("https://json-schema.org/draft/2020-12/schema") + assertThat(document.has("required")).isFalse() + assertThat(document.path("properties").path("name").path("type").asText()).isEqualTo("string") + assertThat(document.path("properties").path("age").path("type").asText()).isEqualTo("integer") + } + + @JsonSchema(generateResource = false) + private class DisabledJsonSchema(val ignored: String) + + @Test + fun should_honor_json_schema_generate_resource_false() { + val generatedNames = io.javalin.openapi.JsonSchemaLoader().loadGeneratedSchemes().map { it.name } + + assertThat(generatedNames).noneMatch { it.contains("DisabledJsonSchema") } + } + + @JsonSchema + private class NestedJsonSchema(val child: NestedJsonSchemaChild) + + private class NestedJsonSchemaChild(val value: String) + + @Test + fun should_inline_nested_types_in_standalone_json_schema() = withJsonScheme("NestedJsonSchema") { + assertThatJson(it) + .inPath("$.properties.child") + .isObject + .isEqualTo(json(""" + { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": ["value"] + } + """)) + } + + @JsonSchema + private class RecursiveJsonSchema(val entities: List) + + private class RecursiveJsonSchemaEntity(val schema: RecursiveJsonSchema) + + @Test + fun should_use_local_references_for_recursive_standalone_json_schemas() = withJsonScheme("RecursiveJsonSchema") { + val document = jsonMapper.readTree(it) + val anchor = document.path($$"$anchor").asText() + val recursiveReference = document + .path("properties") + .path("entities") + .path("items") + .path("properties") + .path("schema") + .path($$"$ref") + .asText() + + assertThat(anchor).isNotBlank() + assertThat(recursiveReference).isEqualTo("#$anchor") + } + + @JsonSchema + private class RecursiveGenericJsonSchema(val child: RecursiveGenericJsonSchema>) + + @Test + fun should_handle_recursively_expanding_generic_json_schemas() = withJsonScheme("RecursiveGenericJsonSchema") { + val document = jsonMapper.readTree(it) + val anchor = document.path($$"$anchor").asText() + val recursiveReference = document + .path("properties") + .path("child") + .path($$"$ref") + .asText() + + assertThat(anchor).isNotBlank() + assertThat(recursiveReference).isEqualTo("#$anchor") + } + + private class SharedNestedType(val value: String) + + private class OpenApiDocumentWithSharedNestedType(val nested: SharedNestedType) + + @JsonSchema + private class JsonSchemaDocumentWithSharedNestedType(val nested: SharedNestedType) + + @OpenApi( + path = "/shared-nested-type", + versions = ["should_not_leak_openapi_references_into_json_schema"], + responses = [OpenApiResponse(status = "200", content = [OpenApiContent(from = OpenApiDocumentWithSharedNestedType::class)])], + ) + @Test + fun should_not_leak_openapi_references_into_json_schema() = withJsonScheme("JsonSchemaDocumentWithSharedNestedType") { + assertThatJson(it) + .inPath("$.properties.nested") + .isObject + .doesNotContainKey("${'$'}ref") + .containsEntry("type", "object") + .containsEntry("properties", json("""{ "value": { "type": "string" } }""")) + } + +} diff --git a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/NamingStrategyTest.kt b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/NamingStrategyTest.kt index 570694c8..a80cc707 100644 --- a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/NamingStrategyTest.kt +++ b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/NamingStrategyTest.kt @@ -86,8 +86,6 @@ internal class NamingStrategyTest : OpenApiAnnotationProcessorSpecification() { .doesNotContainKey("lastName") } - // Enum naming tests - @OpenApiNaming(OpenApiNamingStrategy.SNAKE_CASE) private enum class SnakeCaseEnum { MyValue, @@ -148,8 +146,6 @@ internal class NamingStrategyTest : OpenApiAnnotationProcessorSpecification() { .isEqualTo(json("""["my-value", "customName"]""")) } - // Property naming override tests - @OpenApiNaming(OpenApiNamingStrategy.SNAKE_CASE) private class NamingWithOverrideEntity( val firstName: String, diff --git a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/OpenApiAnnotationTest.kt b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/OpenApiAnnotationTest.kt index 82b19e0d..f44f6e03 100644 --- a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/OpenApiAnnotationTest.kt +++ b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/OpenApiAnnotationTest.kt @@ -6,12 +6,19 @@ import io.javalin.openapi.HttpMethod import io.javalin.openapi.OpenApi import io.javalin.openapi.OpenApiCallback import io.javalin.openapi.OpenApiContent +import io.javalin.openapi.OpenApiOperation import io.javalin.openapi.OpenApiRequestBody import io.javalin.openapi.OpenApiResponse import io.javalin.openapi.processor.specification.OpenApiAnnotationProcessorSpecification import net.javacrumbs.jsonunit.assertj.JsonAssertions.json import net.javacrumbs.jsonunit.assertj.assertThatJson +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test +import java.net.URI +import java.nio.file.Files +import javax.tools.JavaFileObject +import javax.tools.SimpleJavaFileObject +import javax.tools.ToolProvider internal class OpenApiAnnotationTest : OpenApiAnnotationProcessorSpecification() { @@ -56,6 +63,77 @@ internal class OpenApiAnnotationTest : OpenApiAnnotationProcessorSpecification() .doesNotContainKey("security") } + @OpenApi( + path = "/api/panda/list", + operationId = OpenApiOperation.AUTO_GENERATE, + versions = ["should_generate_operation_id_from_path"], + ) + @Test + fun should_generate_operation_id_from_path() = + withOpenApi("should_generate_operation_id_from_path") { + assertThatJson(it) + .inPath("$.paths['/api/panda/list'].get.operationId") + .isString + .isEqualTo("getApiPandaList") + } + + @OpenApi( + path = "/api/panda/{pandaId}/name/", + operationId = OpenApiOperation.AUTO_GENERATE, + versions = ["should_generate_operation_id_from_path_with_parameters"], + ) + @Test + fun should_generate_operation_id_from_path_with_parameters() = + withOpenApi("should_generate_operation_id_from_path_with_parameters") { + assertThatJson(it) + .inPath("$.paths['/api/panda/{pandaId}/name/'].get.operationId") + .isString + .isEqualTo("getApiPandaByPandaIdNameByStartsWith") + } + + @OpenApi( + path = "/api/cat/{cat-id}", + operationId = OpenApiOperation.AUTO_GENERATE, + versions = ["should_generate_operation_id_from_path_with_hyphenated_parameters"], + ) + @Test + fun should_generate_operation_id_from_path_with_hyphenated_parameters() = + withOpenApi("should_generate_operation_id_from_path_with_hyphenated_parameters") { + assertThatJson(it) + .inPath("$.paths['/api/cat/{cat-id}'].get.operationId") + .isString + .isEqualTo("getApiCatByCatId") + } + + @OpenApi( + path = "/api/panda", + methods = [HttpMethod.PUT], + operationId = OpenApiOperation.AUTO_GENERATE, + versions = ["should_generate_operation_id_from_http_method"], + ) + @Test + fun should_generate_operation_id_from_http_method() = + withOpenApi("should_generate_operation_id_from_http_method") { + assertThatJson(it) + .inPath("$.paths['/api/panda'].put.operationId") + .isString + .isEqualTo("putApiPanda") + } + + @OpenApi( + path = "/vip-accounts/{vip-account-id}", + operationId = OpenApiOperation.AUTO_GENERATE, + versions = ["should_generate_operation_id_from_hyphenated_path"], + ) + @Test + fun should_generate_operation_id_from_hyphenated_path() = + withOpenApi("should_generate_operation_id_from_hyphenated_path") { + assertThatJson(it) + .inPath("$.paths['/vip-accounts/{vip-account-id}'].get.operationId") + .isString + .isEqualTo("getVipAccountsByVipAccountId") + } + @OpenApi( path = "/callback", versions = ["should_generate_callback"], @@ -77,8 +155,6 @@ internal class OpenApiAnnotationTest : OpenApiAnnotationProcessorSpecification() ) @Test fun should_generate_callback() = withOpenApi("should_generate_callback") { - println(it) - assertThatJson(it) .inPath("$.paths['/callback'].get.callbacks") .isObject @@ -115,4 +191,42 @@ internal class OpenApiAnnotationTest : OpenApiAnnotationProcessorSpecification() }""")) } -} \ No newline at end of file + @Test + fun should_process_java_repeatable_open_api_annotations() { + val compiler = requireNotNull(ToolProvider.getSystemJavaCompiler()) { "A JDK is required (no system Java compiler)" } + val output = Files.createTempDirectory("openapi-repeatable") + val source = object : SimpleJavaFileObject(URI.create("string:///app/RepeatableRoutes.java"), JavaFileObject.Kind.SOURCE) { + override fun getCharContent(ignoreEncodingErrors: Boolean): CharSequence = + """ + package app; + + import io.javalin.openapi.HttpMethod; + import io.javalin.openapi.OpenApi; + + class RepeatableRoutes { + @OpenApi(path = "/first", methods = HttpMethod.GET) + @OpenApi(path = "/second", methods = HttpMethod.POST) + public void routes() { + } + } + """.trimIndent() + } + val options = listOf( + "-classpath", System.getProperty("java.class.path"), + "-d", output.toString(), + "-s", output.resolve("generated").toString(), + ) + val task = compiler.getTask(null, null, null, options, null, listOf(source)) + task.setProcessors(listOf(OpenApiAnnotationProcessor())) + + assertThat(task.call()).isTrue() + + val document = Files.readString(output.resolve("openapi-plugin/openapi-default.json")) + assertThatJson(document).inPath("$.paths").isObject + .containsKey("/first") + .containsKey("/second") + assertThatJson(document).inPath("$.paths['/first']").isObject.containsKey("get") + assertThatJson(document).inPath("$.paths['/second']").isObject.containsKey("post") + } + +} diff --git a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/OperationAnnotationsTest.kt b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/OperationAnnotationsTest.kt new file mode 100644 index 00000000..ed789985 --- /dev/null +++ b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/OperationAnnotationsTest.kt @@ -0,0 +1,98 @@ +@file:Suppress("unused") + +package io.javalin.openapi.processor + +import io.javalin.openapi.HttpMethod.POST +import io.javalin.openapi.OpenApi +import io.javalin.openapi.OpenApiContent +import io.javalin.openapi.OpenApiParam +import io.javalin.openapi.OpenApiRequestBody +import io.javalin.openapi.OpenApiResponse +import io.javalin.openapi.OpenApiSecurity +import io.javalin.openapi.processor.specification.OpenApiAnnotationProcessorSpecification +import net.javacrumbs.jsonunit.assertj.JsonAssertions.json +import net.javacrumbs.jsonunit.assertj.assertThatJson +import org.junit.jupiter.api.Test + +internal class OperationAnnotationsTest : OpenApiAnnotationProcessorSpecification() { + + @OpenApi( + path = "/parameters", + versions = ["should_describe_parameters"], + pathParams = [OpenApiParam(name = "id", type = Int::class, description = "Identifier", required = true)], + queryParams = [OpenApiParam(name = "page", type = Int::class)], + headers = [OpenApiParam(name = "X-Trace-Id")], + cookies = [OpenApiParam(name = "session")], + ) + @Test + fun should_describe_parameters() = withOpenApi("should_describe_parameters") { + assertThatJson(it) + .inPath("$.paths['/parameters'].get.parameters") + .isEqualTo(json(""" + [ + { "name": "session", "in": "cookie", "schema": { "type": "string" } }, + { "name": "X-Trace-Id", "in": "header", "schema": { "type": "string" } }, + { "name": "id", "in": "path", "description": "Identifier", "required": true, "schema": { "type": "integer", "format": "int32" } }, + { "name": "page", "in": "query", "schema": { "type": "integer", "format": "int32" } } + ] + """)) + } + + private class RequestDto(val field: String) + + @OpenApi( + path = "/request-body", + methods = [POST], + versions = ["should_describe_request_body"], + requestBody = OpenApiRequestBody(content = [OpenApiContent(from = RequestDto::class)], required = true, description = "Payload"), + ) + @Test + fun should_describe_request_body() = withOpenApi("should_describe_request_body") { + assertThatJson(it) + .inPath("$.paths['/request-body'].post.requestBody") + .isEqualTo(json(""" + { + "description": "Payload", + "content": { "application/json": { "schema": { "${'$'}ref": "#/components/schemas/RequestDto" } } }, + "required": true + } + """)) + + assertThatJson(it) + .inPath("$.components.schemas.RequestDto") + .isEqualTo(json("""{ "type": "object", "properties": { "field": { "type": "string" } }, "required": ["field"] }""")) + } + + @OpenApi( + path = "/secured", + versions = ["should_attach_security"], + security = [OpenApiSecurity(name = "BearerAuth", scopes = ["read", "write"])], + ) + @Test + fun should_attach_security() = withOpenApi("should_attach_security") { + assertThatJson(it) + .inPath("$.paths['/secured'].get.security") + .isEqualTo(json("""[ { "BearerAuth": ["read", "write"] } ]""")) + } + + @OpenApi( + path = "/responses", + versions = ["should_describe_multiple_responses"], + responses = [ + OpenApiResponse(status = "200", content = [OpenApiContent(from = String::class)]), + OpenApiResponse(status = "404", description = "Not found"), + ], + ) + @Test + fun should_describe_multiple_responses() = withOpenApi("should_describe_multiple_responses") { + assertThatJson(it) + .inPath("$.paths['/responses'].get.responses") + .isEqualTo(json(""" + { + "200": { "description": "OK", "content": { "text/plain": { "schema": { "type": "string" } } } }, + "404": { "description": "Not found" } + } + """)) + } + +} diff --git a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/ProcessorDiagnosticsTest.kt b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/ProcessorDiagnosticsTest.kt new file mode 100644 index 00000000..74d3f707 --- /dev/null +++ b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/ProcessorDiagnosticsTest.kt @@ -0,0 +1,155 @@ +package io.javalin.openapi.processor + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.net.URI +import java.nio.file.Files +import javax.annotation.processing.ProcessingEnvironment +import javax.annotation.processing.Processor +import javax.tools.Diagnostic +import javax.tools.DiagnosticCollector +import javax.tools.JavaFileObject +import javax.tools.SimpleJavaFileObject +import javax.tools.ToolProvider + +internal class ProcessorDiagnosticsTest { + + @Test + fun should_warn_when_content_mime_type_cannot_be_resolved() { + val diagnostics = compile( + """ + package app; + + import io.javalin.openapi.OpenApi; + import io.javalin.openapi.OpenApiContent; + import io.javalin.openapi.OpenApiResponse; + + class UnresolvedContentRoute { + @OpenApi( + path = "/unresolved", + responses = { + @OpenApiResponse(status = "200", content = { @OpenApiContent() }) + } + ) + public void route() { + } + } + """.trimIndent() + ) + + assertThat(diagnostics) + .filteredOn { it.kind == Diagnostic.Kind.WARNING } + .anySatisfy { + assertThat(it.getMessage(null)).contains("OpenApi generator cannot find matching mime type defined") + } + } + + @Test + fun should_emit_compact_debug_trace() { + val diagnostics = compile( + sourceCode = + """ + package app; + + import io.javalin.openapi.OpenApi; + import io.javalin.openapi.OpenApiContent; + import io.javalin.openapi.OpenApiResponse; + + class DebugRoute { + @OpenApi( + path = "/debug", + responses = { + @OpenApiResponse(status = "200", content = { @OpenApiContent(from = DebugDto.class) }) + } + ) + public void route() { + } + } + + class DebugDto { + public String getName() { + return ""; + } + } + """.trimIndent(), + processor = + object : OpenApiAnnotationProcessor() { + override fun init(processingEnv: ProcessingEnvironment) { + super.init(processingEnv) + context.configuration.debug = true + } + }, + ) + val notes = diagnostics + .filter { it.kind == Diagnostic.Kind.NOTE } + .map { it.getMessage(null) } + + assertThat(notes) + .contains( + "OpenApi | Debug mode enabled", + "OpenApi | Generating schema for app.DebugDto", + "OpenApi | Resolved 1 properties for app.DebugDto: name", + ) + } + + @Test + fun should_apply_custom_type_names_without_a_package() { + val diagnostics = compile( + sourceCode = + """ + import io.javalin.openapi.OpenApi; + import io.javalin.openapi.OpenApiContent; + import io.javalin.openapi.OpenApiName; + import io.javalin.openapi.OpenApiResponse; + + @OpenApiName("Renamed") + class Original { + } + + class DefaultPackageRoute { + @OpenApi( + path = "/renamed", + responses = { + @OpenApiResponse(status = "200", content = { @OpenApiContent(from = Original.class) }) + } + ) + public void route() { + } + } + """.trimIndent(), + processor = + object : OpenApiAnnotationProcessor() { + override fun init(processingEnv: ProcessingEnvironment) { + super.init(processingEnv) + context.configuration.debug = true + } + }, + ) + + assertThat(diagnostics.map { it.getMessage(null) }) + .contains("OpenApi | Generating schema for Renamed") + } + + private fun compile( + sourceCode: String, + processor: Processor = OpenApiAnnotationProcessor(), + ): List> { + val compiler = requireNotNull(ToolProvider.getSystemJavaCompiler()) { "A JDK is required (no system Java compiler)" } + val diagnostics = DiagnosticCollector() + val output = Files.createTempDirectory("openapi-processor") + val source = object : SimpleJavaFileObject(URI.create("string:///app/TestRoute.java"), JavaFileObject.Kind.SOURCE) { + override fun getCharContent(ignoreEncodingErrors: Boolean): CharSequence = sourceCode + } + val options = listOf( + "-classpath", System.getProperty("java.class.path"), + "-d", output.toString(), + "-s", output.resolve("generated").toString(), + ) + val task = compiler.getTask(null, null, diagnostics, options, null, listOf(source)) + task.setProcessors(listOf(processor)) + + assertThat(task.call()).isTrue() + return diagnostics.diagnostics + } + +} diff --git a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/PropertySelectionTest.kt b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/PropertySelectionTest.kt new file mode 100644 index 00000000..91104132 --- /dev/null +++ b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/PropertySelectionTest.kt @@ -0,0 +1,108 @@ +@file:Suppress("unused") + +package io.javalin.openapi.processor + +import io.javalin.openapi.OpenApi +import io.javalin.openapi.OpenApiByFields +import io.javalin.openapi.OpenApiContent +import io.javalin.openapi.OpenApiIgnore +import io.javalin.openapi.OpenApiResponse +import io.javalin.openapi.processor.specification.OpenApiAnnotationProcessorSpecification +import net.javacrumbs.jsonunit.assertj.assertThatJson +import org.junit.jupiter.api.Test + +internal class PropertySelectionTest : OpenApiAnnotationProcessorSpecification() { + + private class IgnoreEntity( + val visible: String, + @get:OpenApiIgnore val hidden: String, + ) + + @OpenApi( + path = "/ignore", + versions = ["should_exclude_ignored_property"], + responses = [OpenApiResponse(status = "200", content = [OpenApiContent(from = IgnoreEntity::class)])] + ) + @Test + fun should_exclude_ignored_property() = withOpenApi("should_exclude_ignored_property") { + assertThatJson(it) + .inPath("$.components.schemas.IgnoreEntity.properties") + .isObject + .containsKey("visible") + .doesNotContainKey("hidden") + } + + @OpenApiByFields + private class ByFieldsEntity { + @JvmField val fieldProperty: String = "" + val getterProperty: String = "" + } + + @OpenApi( + path = "/by-fields", + versions = ["should_include_public_fields_with_by_fields"], + responses = [OpenApiResponse(status = "200", content = [OpenApiContent(from = ByFieldsEntity::class)])] + ) + @Test + fun should_include_public_fields_with_by_fields() = withOpenApi("should_include_public_fields_with_by_fields") { + assertThatJson(it) + .inPath("$.components.schemas.ByFieldsEntity.properties") + .isObject + .containsKey("fieldProperty") + .containsKey("getterProperty") + } + + private interface FilteredRecord { + fun getRecord(): String + } + + private open class FilteredRecordBase { + fun getRecordBase(): String = "RecordBase" + } + + private class FilteredEmailRequest(val email: String) : FilteredRecordBase(), FilteredRecord { + override fun getRecord(): String = "Record" + } + + @OpenApi( + path = "/filtered-properties", + versions = ["should_exclude_properties_filtered_by_processor_configuration"], + responses = [OpenApiResponse(status = "200", content = [OpenApiContent(from = FilteredEmailRequest::class)])], + ) + @Test + fun should_exclude_properties_filtered_by_processor_configuration() = + withOpenApi("should_exclude_properties_filtered_by_processor_configuration") { + assertThatJson(it) + .inPath("$.components.schemas.FilteredEmailRequest.properties") + .isObject + .containsOnlyKeys("email") + } + + @OpenApi( + path = "/fluent-openapi-name", + versions = ["should_include_fluent_accessor_with_openapi_name"], + responses = [OpenApiResponse(status = "200", content = [OpenApiContent(from = FluentOpenApiNameDto::class)])] + ) + @Test + fun should_include_fluent_accessor_with_openapi_name() = withOpenApi("should_include_fluent_accessor_with_openapi_name") { + assertThatJson(it) + .inPath("$.components.schemas.FluentOpenApiNameDto.properties") + .isObject + .containsKey("age") + } + + @OpenApi( + path = "/record-extra-getter", + versions = ["should_include_record_components_and_extra_getters"], + responses = [OpenApiResponse(status = "200", content = [OpenApiContent(from = RecordWithExtraGetter::class)])] + ) + @Test + fun should_include_record_components_and_extra_getters() = withOpenApi("should_include_record_components_and_extra_getters") { + assertThatJson(it) + .inPath("$.components.schemas.RecordWithExtraGetter.properties") + .isObject + .containsKey("id") + .containsKey("displayName") + } + +} diff --git a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/TypeMappersTest.kt b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/TypeMappersTest.kt index de2528a8..fde5021a 100644 --- a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/TypeMappersTest.kt +++ b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/TypeMappersTest.kt @@ -25,7 +25,7 @@ import java.util.UUID internal class TypeMappersTest : OpenApiAnnotationProcessorSpecification() { - class CustomType // mapped by openapi.groovy + class CustomType enum class StandardEnum { VALUE_1, @@ -73,7 +73,7 @@ internal class TypeMappersTest : OpenApiAnnotationProcessorSpecification() { val localTime: LocalTime, val duration: Duration, val uri: URI, - val obj: Object, + val obj: Any, val map: Map<*, *>, val mapWithList: Map<*, List<*>>, val standardEnum: StandardEnum, @@ -94,8 +94,6 @@ internal class TypeMappersTest : OpenApiAnnotationProcessorSpecification() { ) @Test fun should_map_all_simple_types() = withOpenApi("should_map_all_simple_types") { - println(it) - assertThatJson(it) .inPath("$.components.schemas.SimpleTypesList.properties") .isObject @@ -275,8 +273,6 @@ internal class TypeMappersTest : OpenApiAnnotationProcessorSpecification() { ) @Test fun should_output_dictionary_structure() = withOpenApi("should_output_dictionary_structure") { - println(it) - assertThatJson(it) .inPath("$.paths['/dictionary-structure'].get.responses.200.content['application/map-string-string'].schema") .isObject @@ -329,8 +325,6 @@ internal class TypeMappersTest : OpenApiAnnotationProcessorSpecification() { ) @Test fun should_support_nested_lists_in_example_objects() = withOpenApi("should_support_nested_lists_in_example_objects") { - println(it) - assertThatJson(it) .inPath("$.paths['/nested-list-example'].get.responses.200.content['text/plain'].example") .isObject @@ -367,8 +361,6 @@ internal class TypeMappersTest : OpenApiAnnotationProcessorSpecification() { ) @Test fun should_support_raw_examples() = withOpenApi("should_support_raw_examples") { - println(it) - assertThatJson(it) .inPath("$.components.schemas.RawExampleEntity.properties.intField.example") .isEqualTo(1234) @@ -400,8 +392,6 @@ internal class TypeMappersTest : OpenApiAnnotationProcessorSpecification() { ) @Test fun should_support_integer_enum() = withOpenApi("should_support_integer_enum") { - println(it) - assertThatJson(it) .inPath("$.components.schemas.IntegerEnum") .isObject @@ -434,8 +424,6 @@ internal class TypeMappersTest : OpenApiAnnotationProcessorSpecification() { ) @Test fun should_support_x_enum_descriptions_enum() = withOpenApi("should_support_x_enum_descriptions_enum") { - println(it) - assertThatJson(it) .inPath("$.components.schemas.XEnumDescriptionsEnum") .isObject diff --git a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/UserCasesTest.kt b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/UserCasesTest.kt deleted file mode 100644 index 3dd0da3d..00000000 --- a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/UserCasesTest.kt +++ /dev/null @@ -1,159 +0,0 @@ -@file:Suppress("unused") - -package io.javalin.openapi.processor - -import io.javalin.openapi.* -import io.javalin.openapi.processor.specification.OpenApiAnnotationProcessorSpecification -import net.javacrumbs.jsonunit.assertj.JsonAssertions.json -import net.javacrumbs.jsonunit.assertj.assertThatJson -import org.junit.jupiter.api.Test - -internal class UserCasesTest : OpenApiAnnotationProcessorSpecification() { - - /* - * GH-125 Array fields in the custom annotations don't appear in the output - * ~ https://github.com/javalin/javalin-openapi/issues/125 - */ - - @Target(AnnotationTarget.PROPERTY_GETTER) - @CustomAnnotation - annotation class Schema( - val allowableValues: Array = [], - val description: String = "", - val example: String = "", - val format: String = "", - val pattern: String = "" - ) - - data class KeypairCreateResponse( - @get:Schema( - allowableValues = ["valid", "expired", "revoked"], - format = "fingerprint", - pattern = "^(valid|expired|revoked)$", - description = "status of the key like valid|expired|revoked", - ) - val fingerprint: String - ) - - @OpenApi( - path = "gh-125", - versions = ["gh-125"], - responses = [OpenApiResponse(status = "200", content = [OpenApiContent(from = KeypairCreateResponse::class)])] - ) - @Test - fun gh125() = withOpenApi("gh-125") { - println(it) - } - - /* - * GH-108 Ignore inherited properties - * ~ https://github.com/javalin/javalin-openapi/issues/108 - */ - - interface SpecificRecord { - fun getRecord(): String // it has to be implemented - } - - open class SpecificRecordBase { - fun getRecordBase(): String = "RecordBase" // it'll be excluded - } - - class EmailRequest(val email: String) : SpecificRecordBase(), SpecificRecord { - override fun getRecord(): String = "Record" // it will be excluded by `compile/openapi.groovy` script - } - - @OpenApi( - path = "gh-108", - versions = ["gh-108"], - responses = [OpenApiResponse(status = "200", content = [OpenApiContent(from = EmailRequest::class)])] - ) - @Test - fun gh108() = withOpenApi("gh-108") { - assertThatJson(it) - .inPath("$.components.schemas.EmailRequest") - .isObject - .containsEntry("required", json("['email']")) - } - - /* - * GH-151 Support auto-generated operationId like in old OpenApi plugin - * ~ https://github.com/javalin/javalin-openapi/issues/151 - */ - - @OpenApi( - path = "/api/panda/list", - operationId = OpenApiOperation.AUTO_GENERATE, - versions = ["should_generate_operation_id_from_path"] - ) - @Test - fun should_generate_operation_id_from_path() = withOpenApi("should_generate_operation_id_from_path") { - println(it) - - assertThatJson(it) - .inPath("$.paths['/api/panda/list'].get.operationId") - .isString - .isEqualTo("getApiPandaList") - } - - @OpenApi( - path = "/api/panda/{pandaId}/name/", - operationId = OpenApiOperation.AUTO_GENERATE, - versions = ["should_generate_operation_id_from_path_with_parameters"] - ) - @Test - fun should_generate_operation_id_from_path_with_parameters() = withOpenApi("should_generate_operation_id_from_path_with_parameters"){ - println(it) - - assertThatJson(it) - .inPath("$.paths['/api/panda/{pandaId}/name/'].get.operationId") - .isString - .isEqualTo("getApiPandaByPandaIdNameByStartsWith") - } - - @OpenApi( - path = "/api/cat/{cat-id}", - operationId = OpenApiOperation.AUTO_GENERATE, - versions = ["should_generate_operation_id_from_path_with_parameters_hyphenated"] - ) - @Test - fun should_generate_operation_id_from_path_with_parameters_hyphenated() = withOpenApi("should_generate_operation_id_from_path_with_parameters_hyphenated"){ - println(it) - // TODO not sure what to expect here - assertThatJson(it) - .inPath("$.paths['/api/cat/{cat-id}'].get.operationId") - .isString - .isEqualTo("getApiCatByCatId") - } - - @OpenApi( - path = "/api/panda", - methods= [HttpMethod.PUT], - operationId = OpenApiOperation.AUTO_GENERATE, - versions = ["should_generate_operation_id_from_path_method_put"] - ) - @Test - fun should_generate_operation_id_from_path_method_put() = withOpenApi("should_generate_operation_id_from_path_method_put") { - println(it) - - assertThatJson(it) - .inPath("$.paths['/api/panda'].put.operationId") - .isString - .isEqualTo("putApiPanda") - } - - @OpenApi( - path = "/vip-accounts/{vip-account-id}", - operationId = OpenApiOperation.AUTO_GENERATE, - versions = ["should_generate_operation_id_from_hyphenated_path_with_parameters_hyphenated"] - ) - @Test - fun should_generate_operation_id_from_hyphenated_path_with_parameters_hyphenated() = withOpenApi("should_generate_operation_id_from_hyphenated_path_with_parameters_hyphenated"){ - println(it) - - assertThatJson(it) - .inPath("$.paths['/vip-accounts/{vip-account-id}'].get.operationId") - .isString - .isEqualTo("getVipAccountsByVipAccountId") - } - -} diff --git a/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/ValidationAnnotationsTest.kt b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/ValidationAnnotationsTest.kt new file mode 100644 index 00000000..073093a5 --- /dev/null +++ b/openapi-annotation-processor/src/test/kotlin/io/javalin/openapi/processor/ValidationAnnotationsTest.kt @@ -0,0 +1,56 @@ +@file:Suppress("unused") + +package io.javalin.openapi.processor + +import io.javalin.openapi.OpenApi +import io.javalin.openapi.OpenApiArrayValidation +import io.javalin.openapi.OpenApiContent +import io.javalin.openapi.OpenApiNumberValidation +import io.javalin.openapi.OpenApiObjectValidation +import io.javalin.openapi.OpenApiResponse +import io.javalin.openapi.OpenApiStringValidation +import io.javalin.openapi.processor.specification.OpenApiAnnotationProcessorSpecification +import net.javacrumbs.jsonunit.assertj.JsonAssertions.json +import net.javacrumbs.jsonunit.assertj.assertThatJson +import org.junit.jupiter.api.Test + +internal class ValidationAnnotationsTest : OpenApiAnnotationProcessorSpecification() { + + private class ValidatedEntity( + @get:OpenApiNumberValidation(minimum = "1", maximum = "10", exclusiveMinimum = "0", exclusiveMaximum = "11", multipleOf = "2") + val score: Int, + @get:OpenApiStringValidation(minLength = "2", maxLength = "8", format = "email", pattern = "^[a-z]+$") + val name: String, + @get:OpenApiArrayValidation(minItems = "1", maxItems = "5", uniqueItems = true) + val tags: List, + @get:OpenApiObjectValidation(minProperties = "1", maxProperties = "3") + val meta: Map, + ) + + @OpenApi( + path = "/validations", + versions = ["should_emit_validation_keywords"], + responses = [OpenApiResponse(status = "200", content = [OpenApiContent(from = ValidatedEntity::class)])] + ) + @Test + fun should_emit_validation_keywords() = withOpenApi("should_emit_validation_keywords") { + val properties = "$.components.schemas.ValidatedEntity.properties" + + assertThatJson(it).inPath("$properties.score").isObject.isEqualTo(json(""" + { "type": "integer", "format": "int32", "minimum": 1, "maximum": 10, "exclusiveMinimum": 0, "exclusiveMaximum": 11, "multipleOf": 2 } + """)) + + assertThatJson(it).inPath("$properties.name").isObject.isEqualTo(json(""" + { "type": "string", "minLength": 2, "maxLength": 8, "format": "email", "pattern": "^[a-z]+$" } + """)) + + assertThatJson(it).inPath("$properties.tags").isObject.isEqualTo(json(""" + { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": 5, "uniqueItems": true } + """)) + + assertThatJson(it).inPath("$properties.meta").isObject.isEqualTo(json(""" + { "type": "object", "additionalProperties": { "type": "string" }, "minProperties": 1, "maxProperties": 3 } + """)) + } + +} diff --git a/openapi-dynamic/build.gradle.kts b/openapi-dynamic/build.gradle.kts new file mode 100644 index 00000000..7b60cce5 --- /dev/null +++ b/openapi-dynamic/build.gradle.kts @@ -0,0 +1,12 @@ +description = "Javalin OpenAPI Dynamic | Runtime reflection-based OpenAPI introspection (experimental)" + +dependencies { + api(project(":openapi-generator")) + api(project(":introspection:introspection-runtime")) + + testImplementation(libs.junit.jupiter.params) + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.assertj.core) +} diff --git a/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectionSchemaContext.kt b/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectionSchemaContext.kt new file mode 100644 index 00000000..47da4866 --- /dev/null +++ b/openapi-dynamic/src/main/kotlin/io/javalin/openapi/dynamic/ReflectionSchemaContext.kt @@ -0,0 +1,14 @@ +package io.javalin.openapi.dynamic + +import io.javalin.introspection.TypeIntrospector +import io.javalin.introspection.runtime.ReflectionTypeIntrospector +import io.javalin.openapi.experimental.IntrospectorSchemaContext +import io.javalin.openapi.experimental.SimpleType +import io.javalin.openapi.experimental.defaults.createDefaultSimpleTypeMappings + +class ReflectionSchemaContext( + simpleTypeMappings: Map = createDefaultSimpleTypeMappings(), +) : IntrospectorSchemaContext(simpleTypeMappings) { + + override val introspector: TypeIntrospector = ReflectionTypeIntrospector() +} diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Account.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Account.java new file mode 100644 index 00000000..21bc2ce7 --- /dev/null +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Account.java @@ -0,0 +1,55 @@ +package io.javalin.openapi.dynamic; + +import io.javalin.openapi.OpenApiDescription; +import io.javalin.openapi.OpenApiIgnore; +import io.javalin.openapi.OpenApiName; + +import java.util.List; +import java.util.Map; + +public class Account { + + @NotNull + public String getId() { + return ""; + } + + public int getAge() { + return 0; + } + + public String getName() { + return ""; + } + + public Role getRole() { + return Role.ADMIN; + } + + public Address getAddress() { + return null; + } + + public List getTags() { + return List.of(); + } + + public Map getMeta() { + return Map.of(); + } + + @OpenApiName("e_mail") + public String getEmail() { + return ""; + } + + @OpenApiIgnore + public String getSecret() { + return ""; + } + + @OpenApiDescription("Human readable label") + public String getLabel() { + return ""; + } +} diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Address.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Address.java new file mode 100644 index 00000000..4b6c0066 --- /dev/null +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Address.java @@ -0,0 +1,20 @@ +package io.javalin.openapi.dynamic; + +public class Address { + + private final String city; + private final String zip; + + public Address(String city, String zip) { + this.city = city; + this.zip = zip; + } + + public String getCity() { + return city; + } + + public String getZip() { + return zip; + } +} diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Cat.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Cat.java new file mode 100644 index 00000000..88b1dfd0 --- /dev/null +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Cat.java @@ -0,0 +1,7 @@ +package io.javalin.openapi.dynamic; + +public class Cat { + public String getMeow() { + return ""; + } +} diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Dog.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Dog.java new file mode 100644 index 00000000..06320fbe --- /dev/null +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Dog.java @@ -0,0 +1,7 @@ +package io.javalin.openapi.dynamic; + +public class Dog { + public String getBark() { + return ""; + } +} diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/FieldsDto.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/FieldsDto.java new file mode 100644 index 00000000..9b54deb9 --- /dev/null +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/FieldsDto.java @@ -0,0 +1,11 @@ +package io.javalin.openapi.dynamic; + +import io.javalin.openapi.OpenApiByFields; + +@OpenApiByFields +public class FieldsDto { + + public String publicField = ""; + private String privateField = ""; + public static String STATIC_FIELD = ""; +} diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/FluentOpenApiNameDto.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/FluentOpenApiNameDto.java new file mode 100644 index 00000000..c3921d6c --- /dev/null +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/FluentOpenApiNameDto.java @@ -0,0 +1,11 @@ +package io.javalin.openapi.dynamic; + +import io.javalin.openapi.OpenApiName; + +public class FluentOpenApiNameDto { + + @OpenApiName("age") + public int age() { + return 1; + } +} diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/NotNull.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/NotNull.java new file mode 100644 index 00000000..ccddbc24 --- /dev/null +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/NotNull.java @@ -0,0 +1,11 @@ +package io.javalin.openapi.dynamic; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD, ElementType.FIELD}) +public @interface NotNull { +} diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Role.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Role.java new file mode 100644 index 00000000..09393bf9 --- /dev/null +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Role.java @@ -0,0 +1,8 @@ +package io.javalin.openapi.dynamic; + +import io.javalin.openapi.OpenApiName; + +public enum Role { + ADMIN, + @OpenApiName("regular_user") USER +} diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Shape.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Shape.java new file mode 100644 index 00000000..871e32e0 --- /dev/null +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Shape.java @@ -0,0 +1,16 @@ +package io.javalin.openapi.dynamic; + +import io.javalin.openapi.OneOf; + +public class Shape { + + @OneOf({ Dog.class, Cat.class }) + public Object getAnimal() { + return null; + } + + @OneOf({}) + public Object getEmpty() { + return null; + } +} diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/SnakeCaseDto.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/SnakeCaseDto.java new file mode 100644 index 00000000..d0c59c93 --- /dev/null +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/SnakeCaseDto.java @@ -0,0 +1,12 @@ +package io.javalin.openapi.dynamic; + +import io.javalin.openapi.OpenApiNaming; +import io.javalin.openapi.OpenApiNamingStrategy; + +@OpenApiNaming(OpenApiNamingStrategy.SNAKE_CASE) +public class SnakeCaseDto { + + public String getFirstName() { + return ""; + } +} diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/TransientDto.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/TransientDto.java new file mode 100644 index 00000000..fed3f817 --- /dev/null +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/TransientDto.java @@ -0,0 +1,9 @@ +package io.javalin.openapi.dynamic; + +import io.javalin.openapi.OpenApiByFields; + +@OpenApiByFields +public class TransientDto { + public String kept = ""; + public transient String skipped = ""; +} diff --git a/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Validated.java b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Validated.java new file mode 100644 index 00000000..08df77ac --- /dev/null +++ b/openapi-dynamic/src/test/java/io/javalin/openapi/dynamic/Validated.java @@ -0,0 +1,24 @@ +package io.javalin.openapi.dynamic; + +import io.javalin.openapi.OpenApiNumberValidation; +import io.javalin.openapi.OpenApiPropertyType; + +import java.time.Instant; + +public class Validated { + + @OpenApiNumberValidation(minimum = "1", maximum = "10") + public int getScore() { + return 0; + } + + @OpenApiPropertyType(definedBy = String.class) + public int getRedirected() { + return 0; + } + + @OpenApiPropertyType(definedBy = long.class) + public Instant getCreatedAt() { + return Instant.EPOCH; + } +} diff --git a/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicCompositionTest.kt b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicCompositionTest.kt new file mode 100644 index 00000000..f837007a --- /dev/null +++ b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicCompositionTest.kt @@ -0,0 +1,53 @@ +package io.javalin.openapi.dynamic + +import com.fasterxml.jackson.databind.JsonNode +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class DynamicCompositionTest { + + private val schemaContext = ReflectionSchemaContext() + + private fun propertiesOf(type: Class<*>): JsonNode = + schemaContext.componentSchema(schemaContext.introspect(type)).json.path("properties") + + private fun schemaOf(type: Class<*>): JsonNode = + schemaContext.componentSchema(schemaContext.introspect(type)).json + + @Test + fun `emits oneOf with refs for composition annotations`() { + val refs = propertiesOf(Shape::class.java) + .path("animal") + .path("oneOf") + .map { it.path($$"$ref").asText() } + assertThat(refs).containsExactlyInAnyOrder( + "#/components/schemas/Dog", + "#/components/schemas/Cat", + ) + } + + @Test + fun `omits an empty composition instead of emitting an invalid oneOf`() { + assertThat(propertiesOf(Shape::class.java).path("empty").has("oneOf")).isFalse() + } + + @Test + fun `applies number validations`() { + val score = propertiesOf(Validated::class.java).path("score") + assertThat(score.path("minimum").asInt()).isEqualTo(1) + assertThat(score.path("maximum").asInt()).isEqualTo(10) + } + + @Test + fun `applies the OpenApiPropertyType redirect`() { + assertThat(propertiesOf(Validated::class.java).path("redirected").path("type").asText()).isEqualTo("string") + } + + @Test + fun `keeps primitive redirects required`() { + val schema = schemaOf(Validated::class.java) + + assertThat(schema.path("properties").path("createdAt").path("type").asText()).isEqualTo("integer") + assertThat(schema.path("required").map { it.asText() }).contains("createdAt") + } +} diff --git a/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicSchemaGeneratorTest.kt b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicSchemaGeneratorTest.kt new file mode 100644 index 00000000..59517c4d --- /dev/null +++ b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/DynamicSchemaGeneratorTest.kt @@ -0,0 +1,144 @@ +package io.javalin.openapi.dynamic + +import com.fasterxml.jackson.databind.JsonNode +import io.javalin.openapi.experimental.processor.shared.jsonMapper +import io.javalin.openapi.schema.OpenApiSchemaBuilder +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class DynamicSchemaGeneratorTest { + + private val schemaContext = ReflectionSchemaContext() + + private fun JsonNode.ref(): String = path($$"$ref").asText() + private fun JsonNode.stringArray(): List = map { it.asText() } + + private fun accountDocument(): JsonNode { + val builder = OpenApiSchemaBuilder().openApiVersion("3.1.0") + builder.path("/account").operation("get") { + responses { + response("200") { + description("OK") + content { + mediaType("application/json") { + schema(schemaContext.inlineSchema(Account::class.java)) + } + } + } + } + } + builder.resolveComponentReferences { type -> schemaContext.componentSchema(type) } + return jsonMapper.readTree(builder.toJson()) + } + + private fun accountSchema(): JsonNode = + accountDocument() + .path("components") + .path("schemas") + .path("Account") + + private fun accountProperties(): JsonNode = + accountSchema().path("properties") + + @Test + fun `discovers component schemas reachable from an account response`() { + val schemas = accountDocument().path("components").path("schemas") + + assertThat(schemas.fieldNames().asSequence().toList()) + .containsExactlyInAnyOrder("Account", "Address", "Role") + } + + @Test + fun `references the account component from a response`() { + val document = accountDocument() + + assertThat( + document.path("paths").path("/account").path("get") + .path("responses").path("200").path("content") + .path("application/json").path("schema").ref() + ).isEqualTo("#/components/schemas/Account") + } + + @Test + fun `renders the account component with required properties`() { + val account = accountSchema() + val properties = account.path("properties") + + assertThat(account.path("type").asText()).isEqualTo("object") + assertThat(account.path("required").stringArray()).containsExactlyInAnyOrder("id", "age") + assertThat(properties.path("id").path("type").asText()).isEqualTo("string") + assertThat(properties.path("age").path("type").asText()).isEqualTo("integer") + assertThat(properties.path("age").path("format").asText()).isEqualTo("int32") + } + + @Test + fun `honors renamed account properties`() { + val properties = accountProperties() + + assertThat(properties.path("e_mail").path("type").asText()).isEqualTo("string") + assertThat(properties.has("email")).isFalse() + } + + @Test + fun `omits ignored account properties`() { + val properties = accountProperties() + + assertThat(properties.has("secret")).isFalse() + } + + @Test + fun `renders account property descriptions`() { + val properties = accountProperties() + + assertThat(properties.path("label").path("description").asText()).isEqualTo("Human readable label") + } + + @Test + fun `references object component properties`() { + val properties = accountProperties() + + assertThat(properties.path("address").ref()).isEqualTo("#/components/schemas/Address") + } + + @Test + fun `references enum component properties`() { + val properties = accountProperties() + + assertThat(properties.path("role").ref()).isEqualTo("#/components/schemas/Role") + } + + @Test + fun `renders collection properties`() { + val properties = accountProperties() + + assertThat(properties.path("tags").path("type").asText()).isEqualTo("array") + assertThat(properties.path("tags").path("items").path("type").asText()).isEqualTo("string") + } + + @Test + fun `renders map properties`() { + val properties = accountProperties() + + assertThat(properties.path("meta").path("type").asText()).isEqualTo("object") + assertThat(properties.path("meta").path("additionalProperties").path("type").asText()).isEqualTo("integer") + } + + @Test + fun `renders object components`() { + val schemas = accountDocument().path("components").path("schemas") + + val address = schemas.path("Address") + assertThat(address.path("type").asText()).isEqualTo("object") + assertThat(address.path("properties").fieldNames().asSequence().toList()) + .containsExactlyInAnyOrder("city", "zip") + } + + @Test + fun `renders enum components`() { + val schemas = accountDocument().path("components").path("schemas") + + val role = schemas.path("Role") + assertThat(role.path("type").asText()).isEqualTo("string") + assertThat(role.path("enum").stringArray()).containsExactly("ADMIN", "regular_user") + } +} diff --git a/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/ReflectionSchemaContextTest.kt b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/ReflectionSchemaContextTest.kt new file mode 100644 index 00000000..89b851e5 --- /dev/null +++ b/openapi-dynamic/src/test/kotlin/io/javalin/openapi/dynamic/ReflectionSchemaContextTest.kt @@ -0,0 +1,126 @@ +package io.javalin.openapi.dynamic + +import com.fasterxml.jackson.databind.JsonNode +import io.javalin.openapi.experimental.StructureType +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class ReflectionSchemaContextTest { + + private val schemaContext = ReflectionSchemaContext() + + private fun schemaOf(type: Class<*>): JsonNode = + schemaContext.componentSchema(schemaContext.introspect(type)).json + + private fun propertyNames(type: Class<*>): List = + schemaOf(type).path("properties").fieldNames().asSequence().toList() + + private fun accountSchema(): JsonNode = + schemaOf(Account::class.java) + + private fun accountProperties(): JsonNode = + accountSchema().path("properties") + + @Test + fun `resolves a class into the shared OpenApiType model`() { + val account = schemaContext.introspect(Account::class.java) + + assertThat(account.simpleName).isEqualTo("Account") + assertThat(account.fullName).isEqualTo("io.javalin.openapi.dynamic.Account") + assertThat(account.structureType).isEqualTo(StructureType.DEFAULT) + } + + @Test + fun `honors renamed account properties`() { + val properties = accountProperties() + + assertThat(properties.has("e_mail")).isTrue() + assertThat(properties.has("email")).isFalse() + } + + @Test + fun `omits ignored account properties`() { + val properties = accountProperties() + + assertThat(properties.has("secret")).isFalse() + } + + @Test + fun `omits synthetic account properties`() { + val properties = accountProperties() + + assertThat(properties.has("class")).isFalse() + } + + @Test + fun `marks required account properties`() { + val account = accountSchema() + + assertThat(account.path("required").map { it.asText() }).containsExactlyInAnyOrder("id", "age") + } + + @Test + fun `renders account property descriptions`() { + val properties = accountProperties() + + assertThat(properties.path("label").path("description").asText()).isEqualTo("Human readable label") + } + + @Test + fun `renders collection properties`() { + val properties = accountProperties() + + assertThat(properties.path("tags").path("type").asText()).isEqualTo("array") + assertThat(properties.path("tags").path("items").path("type").asText()).isEqualTo("string") + } + + @Test + fun `renders map properties`() { + val properties = accountProperties() + + assertThat(properties.path("meta").path("type").asText()).isEqualTo("object") + assertThat(properties.path("meta").path("additionalProperties").path("type").asText()).isEqualTo("integer") + } + + @Test + fun `references nested object properties`() { + val properties = accountProperties() + + assertThat(properties.path("address").path($$"$ref").asText()).isEqualTo("#/components/schemas/Address") + } + + @Test + fun `renders nested object properties`() { + + assertThat(propertyNames(Address::class.java)).containsExactlyInAnyOrder("city", "zip") + } + + @Test + fun `reads enum constants with renames`() { + assertThat(schemaContext.isEnum(schemaContext.introspect(Role::class.java))).isTrue() + + val role = schemaOf(Role::class.java) + assertThat(role.path("type").asText()).isEqualTo("string") + assertThat(role.path("enum").map { it.asText() }).containsExactly("ADMIN", "regular_user") + } + + @Test + fun `applies the configured naming strategy`() { + assertThat(propertyNames(SnakeCaseDto::class.java)).contains("first_name") + } + + @Test + fun `includes fluent accessors annotated with OpenApiName`() { + assertThat(propertyNames(FluentOpenApiNameDto::class.java)).containsExactly("age") + } + + @Test + fun `reads fields and honors visibility when OpenApiByFields is present`() { + assertThat(propertyNames(FieldsDto::class.java)).containsExactly("publicField") + } + + @Test + fun `skips transient fields under OpenApiByFields`() { + assertThat(propertyNames(TransientDto::class.java)).containsExactly("kept") + } +} diff --git a/openapi-generator/build.gradle.kts b/openapi-generator/build.gradle.kts index 167ea7c3..74062633 100644 --- a/openapi-generator/build.gradle.kts +++ b/openapi-generator/build.gradle.kts @@ -2,6 +2,7 @@ description = "Javalin OpenAPI Generator | JSON schema generation for OpenAPI do dependencies { api(project(":openapi-specification")) + api(project(":introspection:introspection-api")) api(libs.jackson.databind) api(libs.jackson.module.kotlin) diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/ClassDefinitionApi.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/ClassDefinitionApi.kt deleted file mode 100644 index cef9429d..00000000 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/ClassDefinitionApi.kt +++ /dev/null @@ -1,50 +0,0 @@ -package io.javalin.openapi.experimental - -import io.javalin.openapi.experimental.StructureType.DEFAULT - -class ClassDefinition( - val simpleName: String, - val fullName: String, - val generics: List = emptyList(), - val structureType: StructureType = DEFAULT, - val extra: MutableList = mutableListOf(), - @JvmField val handle: Any? = null -) { - - override fun equals(other: Any?): Boolean = - when { - this === other -> true - other is ClassDefinition -> - this.fullName == other.fullName - && this.generics == other.generics - && this.structureType == other.structureType - else -> false - } - - override fun hashCode(): Int { - var result = fullName.hashCode() - result = 31 * result + generics.hashCode() - result = 31 * result + structureType.hashCode() - return result - } - - override fun toString(): String = - when { - generics.isEmpty() -> fullName - else -> "$fullName<${generics.joinToString(", ")}>" - } - -} - -enum class StructureType { - DEFAULT, - ARRAY, - DICTIONARY -} - -interface Extra - -class CustomProperty( - val name: String, - val type: ClassDefinition -) : Extra diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/EmbeddedTypeProcessor.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/EmbeddedTypeProcessor.kt new file mode 100644 index 00000000..b5480200 --- /dev/null +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/EmbeddedTypeProcessor.kt @@ -0,0 +1,19 @@ +package io.javalin.openapi.experimental + +import com.fasterxml.jackson.databind.node.ObjectNode +import io.javalin.openapi.experimental.processor.generators.PropertyComposition + +data class EmbeddedTypeProcessorContext( + val parentContext: SchemaGenerationContext, + val scheme: ObjectNode, + val references: MutableSet, + val type: OpenApiType, + val inlineRefs: Boolean = false, + val requiresNonNulls: Boolean = true, + val composition: PropertyComposition? = null, + val extra: Map = emptyMap() +) + +fun interface EmbeddedTypeProcessor { + fun process(context: EmbeddedTypeProcessorContext): Boolean +} diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/OpenApiAnnotationProcessorConfiguration.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/ExperimentalCompileOpenApiConfiguration.kt similarity index 74% rename from openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/OpenApiAnnotationProcessorConfiguration.kt rename to openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/ExperimentalCompileOpenApiConfiguration.kt index 63b6c5be..575ae684 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/OpenApiAnnotationProcessorConfiguration.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/ExperimentalCompileOpenApiConfiguration.kt @@ -8,8 +8,3 @@ import kotlin.annotation.AnnotationTarget.FUNCTION @Retention(BINARY) @Target(CLASS, FUNCTION) annotation class ExperimentalCompileOpenApiConfiguration - -data class SimpleType @JvmOverloads constructor( - val type: String, - val format: String? = null -) diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt new file mode 100644 index 00000000..7895f396 --- /dev/null +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/IntrospectorSchemaContext.kt @@ -0,0 +1,77 @@ +package io.javalin.openapi.experimental + +import io.javalin.introspection.AnnotationSet +import io.javalin.introspection.CompileTimeIntrospector +import io.javalin.introspection.EnumConstant +import io.javalin.introspection.PropertyProjection +import io.javalin.introspection.TypeIntrospector +import io.javalin.openapi.DiscriminatorMappingName +import io.javalin.openapi.OpenApiName +import io.javalin.openapi.experimental.defaults.createDefaultEmbeddedTypeProcessors +import io.javalin.openapi.experimental.defaults.createDefaultSimpleTypeMappings +import io.javalin.openapi.experimental.processor.generators.ResultScheme +import io.javalin.openapi.experimental.processor.generators.TypeSchemaGenerator +import io.javalin.introspection.ClassDefinition as RawType + +abstract class IntrospectorSchemaContext( + override val simpleTypeMappings: Map = createDefaultSimpleTypeMappings(), +) : SchemaGenerationContext { + + protected abstract val introspector: TypeIntrospector + + override val typeSchemaGenerator: TypeSchemaGenerator = TypeSchemaGenerator(this) + override val embeddedTypeProcessors: List = createDefaultEmbeddedTypeProcessors() + + fun introspect(nativeType: Any): OpenApiType = + toOpenApiType(introspector.introspect(nativeType)) + + fun componentSchema(type: OpenApiType): ResultScheme = + typeSchemaGenerator.createTypeSchema(type) + + fun inlineSchema(nativeType: Any): ResultScheme = + typeSchemaGenerator.createEmbeddedTypeDescription(introspect(nativeType)) + + override fun toOpenApiType(raw: RawType): OpenApiType { + val customName = raw.getAnnotations().find(OpenApiName::class.java)?.get("value")?.asString() + val packageName = raw.fullName.substringBeforeLast('.', "") + return OpenApiType( + simpleName = customName ?: raw.simpleName, + fullName = when { + customName == null -> raw.fullName + packageName.isEmpty() -> customName + else -> "$packageName.$customName" + }, + generics = raw.generics.map { toOpenApiType(it) }, + structureType = StructureType.valueOf(raw.structureType.name), + handle = raw, + ) + } + + override fun isEnum(type: OpenApiType): Boolean = + type.raw.isEnum() + + override fun annotationsOf(type: OpenApiType): AnnotationSet = + type.raw.getAnnotations() + + override fun propertiesOf(type: OpenApiType): List = + type.raw.getProperties() + + override fun enumConstantsOf(type: OpenApiType): List = + type.raw.getEnumConstants() + + override fun discriminatorSubtypes(type: OpenApiType): List> { + val scanner = introspector as? CompileTimeIntrospector ?: return emptyList() + val subtypes = scanner.typesAnnotatedWith(DiscriminatorMappingName::class.java, assignableTo = type.raw) + return subtypes.mapNotNull { subtype -> + subtype + .getAnnotations() + .find(DiscriminatorMappingName::class.java) + ?.get("value") + ?.asString() + ?.let { name -> name to toOpenApiType(subtype) } + } + } +} + +private val OpenApiType.raw: RawType + @OptIn(InternalOpenApiTypeApi::class) get() = handle as RawType diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/OpenApiType.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/OpenApiType.kt new file mode 100644 index 00000000..9ff682dc --- /dev/null +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/OpenApiType.kt @@ -0,0 +1,49 @@ +package io.javalin.openapi.experimental + +import io.javalin.openapi.experimental.StructureType.DEFAULT +import java.util.Objects + +@RequiresOptIn( + level = RequiresOptIn.Level.ERROR, + message = "handle is the backend-native token behind OpenApiType; only the producing backend may cast it.", +) +@Retention(AnnotationRetention.BINARY) +@Target(AnnotationTarget.PROPERTY) +annotation class InternalOpenApiTypeApi + +class OpenApiType( + val simpleName: String, + val fullName: String, + val generics: List = emptyList(), + val structureType: StructureType = DEFAULT, + val extra: MutableList = mutableListOf(), + @property:InternalOpenApiTypeApi val handle: Any? = null, +) { + override fun equals(other: Any?): Boolean = + other is OpenApiType + && fullName == other.fullName + && generics == other.generics + && structureType == other.structureType + + override fun hashCode(): Int = Objects.hash(fullName, generics, structureType) +} + +enum class StructureType { + DEFAULT, + ARRAY, + DICTIONARY, +} + +interface Extra + +data class CustomProperty( + val name: String, + val type: OpenApiType, +) : Extra + +internal fun OpenApiType.mergeExtraFrom(other: OpenApiType): Boolean { + val missingExtra = other.extra.filterNot(extra::contains) + if (missingExtra.isEmpty()) return false + extra.addAll(missingExtra) + return true +} diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt new file mode 100644 index 00000000..09e25819 --- /dev/null +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SchemaGenerationContext.kt @@ -0,0 +1,33 @@ +package io.javalin.openapi.experimental + +import io.javalin.introspection.AnnotationSet +import io.javalin.introspection.EnumConstant +import io.javalin.introspection.PropertyProjection +import io.javalin.openapi.experimental.processor.generators.TypeSchemaGenerator +import io.javalin.introspection.ClassDefinition as RawType + +interface SchemaGenerationContext { + + val typeSchemaGenerator: TypeSchemaGenerator + val simpleTypeMappings: Map + val embeddedTypeProcessors: List + + fun isEnum(type: OpenApiType): Boolean + + fun annotationsOf(type: OpenApiType): AnnotationSet + + fun propertiesOf(type: OpenApiType): List + + fun enumConstantsOf(type: OpenApiType): List + + fun toOpenApiType(raw: RawType): OpenApiType + + fun acceptsProperty(type: OpenApiType, property: PropertyProjection): Boolean = true + + fun discriminatorSubtypes(type: OpenApiType): List> = emptyList() + + fun reportWarning(message: String) {} + + fun reportDebug(message: String) {} + +} diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SimpleType.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SimpleType.kt new file mode 100644 index 00000000..d63961c9 --- /dev/null +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/SimpleType.kt @@ -0,0 +1,6 @@ +package io.javalin.openapi.experimental + +data class SimpleType @JvmOverloads constructor( + val type: String, + val format: String? = null +) diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/ArrayEmbeddedTypeProcessor.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/ArrayEmbeddedTypeProcessor.kt new file mode 100644 index 00000000..71d7344b --- /dev/null +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/ArrayEmbeddedTypeProcessor.kt @@ -0,0 +1,34 @@ +package io.javalin.openapi.experimental.defaults + +import com.fasterxml.jackson.databind.JsonNode +import io.javalin.openapi.experimental.EmbeddedTypeProcessor +import io.javalin.openapi.experimental.EmbeddedTypeProcessorContext +import io.javalin.openapi.experimental.StructureType.ARRAY +import io.javalin.openapi.experimental.processor.shared.createObjectNode + +class ArrayEmbeddedTypeProcessor : EmbeddedTypeProcessor { + + override fun process(context: EmbeddedTypeProcessorContext): Boolean { + if (context.type.structureType != ARRAY) { + return false + } + + if (context.type.simpleName == "Byte") { + context.scheme.put("type", "string") + context.scheme.put("format", "binary") + return true + } + + context.scheme.put("type", "array") + val items = createObjectNode() + context.parentContext.typeSchemaGenerator.addType( + scheme = items, + type = context.type, + inlineRefs = context.inlineRefs, + references = context.references, + requiresNonNulls = context.requiresNonNulls, + ) + context.scheme.set("items", items) + return true + } +} diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/CompositionEmbeddedTypeProcessor.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/CompositionEmbeddedTypeProcessor.kt new file mode 100644 index 00000000..9cd7fee3 --- /dev/null +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/CompositionEmbeddedTypeProcessor.kt @@ -0,0 +1,22 @@ +package io.javalin.openapi.experimental.defaults + +import io.javalin.openapi.experimental.EmbeddedTypeProcessor +import io.javalin.openapi.experimental.EmbeddedTypeProcessorContext +import io.javalin.openapi.experimental.processor.generators.createComposition + +class CompositionEmbeddedTypeProcessor : EmbeddedTypeProcessor { + + override fun process(context: EmbeddedTypeProcessorContext): Boolean { + val composition = context.composition ?: return false + + context.scheme.createComposition( + context = context.parentContext, + type = context.type, + propertyComposition = composition, + references = context.references, + inlineRefs = context.inlineRefs, + requiresNonNulls = context.requiresNonNulls, + ) + return true + } +} diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/DefaultEmbeddedTypeProcessors.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/DefaultEmbeddedTypeProcessors.kt new file mode 100644 index 00000000..8f51eedd --- /dev/null +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/DefaultEmbeddedTypeProcessors.kt @@ -0,0 +1,9 @@ +package io.javalin.openapi.experimental.defaults + +import io.javalin.openapi.experimental.EmbeddedTypeProcessor + +fun createDefaultEmbeddedTypeProcessors(): MutableList = mutableListOf( + CompositionEmbeddedTypeProcessor(), + ArrayEmbeddedTypeProcessor(), + DictionaryEmbeddedTypeProcessor(), +) diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/DictionaryEmbeddedTypeProcessor.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/DictionaryEmbeddedTypeProcessor.kt new file mode 100644 index 00000000..0ccf2bb3 --- /dev/null +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/defaults/DictionaryEmbeddedTypeProcessor.kt @@ -0,0 +1,40 @@ +package io.javalin.openapi.experimental.defaults + +import com.fasterxml.jackson.databind.JsonNode +import io.javalin.openapi.experimental.EmbeddedTypeProcessor +import io.javalin.openapi.experimental.EmbeddedTypeProcessorContext +import io.javalin.openapi.experimental.StructureType.DICTIONARY +import io.javalin.openapi.experimental.processor.shared.createObjectNode + +class DictionaryEmbeddedTypeProcessor : EmbeddedTypeProcessor { + + override fun process(context: EmbeddedTypeProcessorContext): Boolean { + if (context.type.structureType != DICTIONARY) { + return false + } + + context.scheme.put("type", "object") + val additionalProperties = createObjectNode() + val additionalType = context.type.generics[1] + val additionalContext = context.copy( + scheme = additionalProperties, + type = additionalType, + ) + val handled = context.parentContext.embeddedTypeProcessors.any { + it.process(additionalContext) + } + + if (!handled) { + context.parentContext.typeSchemaGenerator.addType( + scheme = additionalProperties, + type = additionalType, + inlineRefs = context.inlineRefs, + references = context.references, + requiresNonNulls = context.requiresNonNulls, + ) + } + + context.scheme.set("additionalProperties", additionalProperties) + return true + } +} diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt new file mode 100644 index 00000000..21c3a62b --- /dev/null +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/CompositionGenerator.kt @@ -0,0 +1,128 @@ +package io.javalin.openapi.experimental.processor.generators + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.ObjectNode +import io.javalin.introspection.AnnotationSet +import io.javalin.openapi.AllOf +import io.javalin.openapi.AnyOf +import io.javalin.openapi.Composition +import io.javalin.openapi.Composition.ALL_OF +import io.javalin.openapi.Composition.ANY_OF +import io.javalin.openapi.Composition.ONE_OF +import io.javalin.openapi.NULL_STRING +import io.javalin.openapi.OneOf +import io.javalin.openapi.experimental.CustomProperty +import io.javalin.openapi.experimental.OpenApiType +import io.javalin.openapi.experimental.SchemaGenerationContext +import io.javalin.openapi.experimental.processor.shared.createArrayNode +import io.javalin.openapi.experimental.processor.shared.createJsonObjectOf +import io.javalin.openapi.experimental.processor.shared.createObjectNode +import io.javalin.openapi.experimental.processor.shared.toJsonObject +import io.javalin.introspection.ClassDefinition as RawType + +fun findCompositionInElement(context: SchemaGenerationContext, annotations: AnnotationSet): PropertyComposition? = + compositionOf(context, annotations, OneOf::class.java, ONE_OF) + ?: compositionOf(context, annotations, AnyOf::class.java, ANY_OF) + ?: compositionOf(context, annotations, AllOf::class.java, ALL_OF) + +private fun compositionOf( + context: SchemaGenerationContext, + annotations: AnnotationSet, + annotationType: Class, + composition: Composition, +): PropertyComposition? { + val annotation = annotations.find(annotationType) ?: return null + val references = + annotation + .get("value") + .asClassDefinitions() + .map(context::toOpenApiType) + .toSet() + val discriminator = annotation.get("discriminator").asMap()?.let { discriminatorInfo(context, it) } + return PropertyComposition( + type = composition, + references = references, + discriminator = discriminator, + ) +} + +private fun discriminatorInfo(context: SchemaGenerationContext, discriminator: Map<*, *>): DiscriminatorInfo { + val property = discriminator["property"] as Map<*, *> + val mapping = + (discriminator["mapping"] as? List<*>) + .orEmpty() + .filterIsInstance>() + .map { entry -> + val name = entry["name"] as String + val type = entry["value"] as RawType + name to context.toOpenApiType(type) + } + return DiscriminatorInfo( + propertyName = property["name"] as String, + propertyType = context.toOpenApiType(property["type"] as RawType), + injectInMappings = property["injectInMappings"] as Boolean, + mapping = mapping, + ) +} + +fun ObjectNode.createComposition( + context: SchemaGenerationContext, + type: OpenApiType, + propertyComposition: PropertyComposition, + references: MutableSet, + inlineRefs: Boolean = false, + requiresNonNulls: Boolean = true, +) { + val subtypes by lazy { context.discriminatorSubtypes(type) } + + val refs = propertyComposition.references.ifEmpty { subtypes.map { it.second } } + + if (refs.isEmpty()) return + + val compositionValues = createArrayNode() + if (inlineRefs) { + for (ref in refs) { + val result = context.typeSchemaGenerator.createTypeSchema( + type = ref, + inlineRefs = true, + requireNonNullsByDefault = requiresNonNulls, + ) + result.references.forEach { references.addReference(it) } + compositionValues.add(result.json) + } + } else { + for (ref in refs) { + references.addReference(ref) + compositionValues.add(createJsonObjectOf($$"$ref", "#/components/schemas/${ref.simpleName}")) + } + } + set(propertyComposition.type.propertyName, compositionValues) + + val discriminator = propertyComposition.discriminator + ?.takeIf { it.propertyName != NULL_STRING } + ?: return + + val discriminatorObject = createObjectNode() + set("discriminator", discriminatorObject) + discriminatorObject.put("propertyName", discriminator.propertyName) + + val mapping = discriminator.mapping.ifEmpty { subtypes } + if (discriminator.injectInMappings) { + val customProperty = CustomProperty( + name = discriminator.propertyName, + type = discriminator.propertyType, + ) + + mapping.forEach { (_, mappedClass) -> + mappedClass.extra.add(customProperty) + } + } + + if (mapping.isNotEmpty()) { + mapping.forEach { (_, mappedClass) -> references.addReference(mappedClass) } + val mappings = mapping.associate { (name, mappedClass) -> + name to "#/components/schemas/${mappedClass.simpleName}" + } + discriminatorObject.set("mapping", mappings.toJsonObject()) + } +} diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ExampleGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ExampleGenerator.kt index 7b6b97a8..d1f3a628 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ExampleGenerator.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ExampleGenerator.kt @@ -3,7 +3,6 @@ package io.javalin.openapi.experimental.processor.generators import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.node.ObjectNode import io.javalin.openapi.NULL_STRING -import io.javalin.openapi.OpenApiExampleProperty import io.javalin.openapi.experimental.processor.shared.createArrayNode import io.javalin.openapi.experimental.processor.shared.createObjectNode import io.javalin.openapi.experimental.processor.shared.jsonMapper @@ -12,15 +11,18 @@ data class ExampleProperty( val name: String?, val value: String?, val raw: String?, - val objects: List? + val objects: List?, ) -fun OpenApiExampleProperty.toExampleProperty(): ExampleProperty = +fun Map.toExampleProperty(): ExampleProperty = ExampleProperty( - name = this.name.takeIf { it != NULL_STRING }, - value = this.value.takeIf { it != NULL_STRING }, - raw = this.raw.takeIf { it != NULL_STRING }, - objects = this.objects.map { it.toExampleProperty() }.takeIf { it.isNotEmpty() }, + name = (get("name") as? String)?.takeIf { it != NULL_STRING }, + value = (get("value") as? String)?.takeIf { it != NULL_STRING }, + raw = (get("raw") as? String)?.takeIf { it != NULL_STRING }, + objects = (get("objects") as? List<*>) + ?.filterIsInstance>() + ?.map { it.toExampleProperty() } + ?.takeIf { it.isNotEmpty() }, ) object ExampleGenerator { @@ -55,15 +57,15 @@ object ExampleGenerator { private fun ExampleProperty.toSimpleExampleValue(): GeneratorResult = when { - this.value != null -> GeneratorResult(this.value, null) - this.objects?.isNotEmpty() == true -> generateFromExamples(this.objects) - this.raw != null -> GeneratorResult(null, jsonMapper.readTree(this.raw)) + value != null -> GeneratorResult(value, null) + objects?.isNotEmpty() == true -> generateFromExamples(objects) + raw != null -> GeneratorResult(null, jsonMapper.readTree(raw)) else -> throw IllegalArgumentException("Example object must have value, raw value or objects ($this)") } private fun List.toJsonObject(): ObjectNode { val jsonObject = createObjectNode() - this.forEach { + forEach { val result = it.toSimpleExampleValue() if (it.name == null) { throw IllegalArgumentException("Example object must have a name ($it)") @@ -77,9 +79,17 @@ object ExampleGenerator { } private fun List.isObjectList(): Boolean = - this.isNotEmpty() && this.all { it.name == null && it.value == null && it.objects?.isNotEmpty() ?: false } + isNotEmpty() && all { example -> + example.name == null && + example.value == null && + example.objects?.isNotEmpty() == true + } private fun List.isRawList(): Boolean = - this.isNotEmpty() && this.all { it.name == null && it.value != null && it.objects?.isEmpty() ?: true } + isNotEmpty() && all { example -> + example.name == null && + example.value != null && + example.objects.isNullOrEmpty() + } } diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/PropertyComposition.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/PropertyComposition.kt index d82c9514..b83278fb 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/PropertyComposition.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/PropertyComposition.kt @@ -1,11 +1,17 @@ package io.javalin.openapi.experimental.processor.generators import io.javalin.openapi.Composition -import io.javalin.openapi.Discriminator -import io.javalin.openapi.experimental.ClassDefinition +import io.javalin.openapi.experimental.OpenApiType data class PropertyComposition( val type: Composition, - val references: Set, - val discriminator: Discriminator + val references: Set, + val discriminator: DiscriminatorInfo?, +) + +data class DiscriminatorInfo( + val propertyName: String, + val propertyType: OpenApiType, + val injectInMappings: Boolean, + val mapping: List>, ) diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ResultScheme.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ResultScheme.kt index b4a1c204..74526de8 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ResultScheme.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/ResultScheme.kt @@ -3,13 +3,14 @@ package io.javalin.openapi.experimental.processor.generators import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.node.ObjectNode import io.javalin.openapi.OpenApiNamingStrategy -import io.javalin.openapi.experimental.ClassDefinition +import io.javalin.openapi.experimental.OpenApiType +import io.javalin.openapi.experimental.mergeExtraFrom import io.javalin.openapi.experimental.processor.shared.createObjectNode import java.math.BigDecimal data class ResultScheme( val json: ObjectNode, - val references: Set + val references: Set, ) { fun toJsonSchemaString(): String { val scheme = createObjectNode() @@ -21,13 +22,20 @@ data class ResultScheme( data class Property( val name: String, - val type: ClassDefinition, + val type: OpenApiType, val composition: PropertyComposition? = null, val required: Boolean = true, val nullable: Boolean = false, val extra: Map = emptyMap() ) +internal fun MutableSet.addReference(reference: OpenApiType) { + when (val existing = firstOrNull { it == reference }) { + null -> add(reference) + else -> existing.mergeExtraFrom(reference) + } +} + fun splitCamelCase(name: String): List { val words = mutableListOf() val current = StringBuilder() @@ -54,7 +62,7 @@ fun translatePropertyName(strategy: OpenApiNamingStrategy, name: String): String OpenApiNamingStrategy.KEBAB_CASE -> splitCamelCase(name).joinToString("-") { it.lowercase() } } -fun ObjectNode.addExtra(extra: Map): ObjectNode = also { +fun ObjectNode.addExtra(extra: Map): ObjectNode { extra .filterValues { it != null } .forEach { (key, value) -> @@ -71,4 +79,5 @@ fun ObjectNode.addExtra(extra: Map): ObjectNode = also { else -> put(key, value.toString()) } } + return this } diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt new file mode 100644 index 00000000..094029d8 --- /dev/null +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/experimental/processor/generators/TypeSchemaGenerator.kt @@ -0,0 +1,540 @@ +package io.javalin.openapi.experimental.processor.generators + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.node.ArrayNode +import com.fasterxml.jackson.databind.node.ObjectNode +import io.javalin.introspection.Accessor +import io.javalin.introspection.AnnotationValue +import io.javalin.introspection.AnnotationSet +import io.javalin.introspection.ClassDefinition as RawType +import io.javalin.introspection.InternalIntrospectionApi +import io.javalin.introspection.MemberVisibility +import io.javalin.openapi.* +import io.javalin.openapi.experimental.* +import io.javalin.openapi.experimental.processor.shared.* + +class TypeSchemaGenerator(val context: SchemaGenerationContext) { + + private val processedProperties = mutableMapOf() + private val activeInlineTypes = mutableMapOf() + private val inlineTypeAnchors = mutableMapOf() + private var inlineSchemaDepth = 0 + + private data class InlineType( + val fullName: String, + val structureType: StructureType, + ) + + private data class ProcessedProperty( + val property: Property, + val requiresNonNulls: Boolean, + ) + + private fun OpenApiType.definedBy(): OpenApiType? = + context.annotationsOf(this) + .find(OpenApiPropertyType::class.java) + ?.get("definedBy") + ?.asClassDefinition() + ?.let(context::toOpenApiType) + + fun createTypeSchema( + type: OpenApiType, + inlineRefs: Boolean = false, + requireNonNullsByDefault: Boolean = true, + ): ResultScheme { + val isStandaloneJsonSchema = inlineRefs + val isRootJsonSchema = isStandaloneJsonSchema && inlineSchemaDepth == 0 + + if (isStandaloneJsonSchema) { + inlineSchemaDepth++ + } + + try { + if (isStandaloneJsonSchema) { + val inlineType = type.toInlineType() + activeInlineTypes[inlineType]?.let { activeSchema -> + activeSchema.put($$"$anchor", inlineAnchorFor(inlineType)) + return ResultScheme( + json = createObjectNode().put($$"$ref", "#${inlineAnchorFor(inlineType)}"), + references = emptySet(), + ) + } + } + + context.reportDebug("OpenApi | Generating schema for ${type.fullName}") + + val annotations = context.annotationsOf(type) + val isEnum = context.isEnum(type) + val definedBy = type.definedBy() + + if (definedBy != null && !isEnum) { + return createTypeSchema( + type = definedBy, + inlineRefs = inlineRefs, + requireNonNullsByDefault = requireNonNullsByDefault, + ) + } + + val schema = createObjectNode() + val references = mutableSetOf() + val composition = findCompositionInElement(context, annotations) + + if (isStandaloneJsonSchema) { + activeInlineTypes[type.toInlineType()] = schema + } + + try { + when { + composition != null -> { + schema.createComposition( + context = context, + type = type, + propertyComposition = composition, + references = references, + inlineRefs = inlineRefs, + requiresNonNulls = requireNonNullsByDefault, + ) + } + isEnum -> { + val enumType = definedBy + ?.let { context.simpleTypeMappings[it.fullName] } + + val namingStrategy = annotations.namingStrategy() + val values = createArrayNode() + val descriptions = createArrayNode() + + for (constant in context.enumConstantsOf(type)) { + val customName = constant.annotations.find(OpenApiName::class.java)?.get("value")?.asString() + val description = constant.annotations.find(OpenApiDescription::class.java)?.get("value")?.asString() + val name = when { + customName != null -> customName + namingStrategy != null -> translatePropertyName(namingStrategy, constant.name) + else -> constant.name + } + + when { + enumType != null && enumType.type != "string" -> values.add(jsonMapper.readTree(name)) + else -> values.add(name) + } + descriptions.add(description ?: "") + } + + schema.put("type", enumType?.type ?: "string") + enumType?.format?.also { schema.put("format", it) } + schema.set("enum", values) + + if (descriptions.any { it.isTextual && it.asText().isNotEmpty() }) { + schema.set("x-enum-descriptions", descriptions) + } + + schema.addExtra(annotations.findExtra()) + } + else -> { + schema.put("type", "object") + + schema.addExtra(annotations.findExtra()) + + val propertiesObject = createObjectNode() + schema.set("properties", propertiesObject) + + val requireNonNulls = (annotations.find(JsonSchema::class.java)?.get("requireNonNulls")?.asBoolean()) + ?: requireNonNullsByDefault + + val properties = context.findAllProperties(type, requireNonNulls) + + properties.forEach { property -> + val result = + when { + inlineRefs -> createEmbeddedTypeDescription( + type = property.type, + inlineRefs = true, + requiresNonNulls = requireNonNulls, + composition = property.composition, + extra = property.extra, + nullable = property.nullable, + ) + else -> processedProperties.getOrPut( + ProcessedProperty( + property = property, + requiresNonNulls = requireNonNulls, + ) + ) { + createEmbeddedTypeDescription( + type = property.type, + inlineRefs = false, + requiresNonNulls = requireNonNulls, + composition = property.composition, + extra = property.extra, + nullable = property.nullable, + ) + } + } + propertiesObject.set(property.name, result.json) + result.references.forEach { references.addReference(it) } + } + + if (properties.any { it.required }) { + val required = createArrayNode() + properties.filter { it.required }.forEach { required.add(it.name) } + schema.set("required", required) + } + } + } + } finally { + if (isStandaloneJsonSchema) { + activeInlineTypes.remove(type.toInlineType()) + } + } + + return ResultScheme(json = schema, references = references) + } finally { + if (isStandaloneJsonSchema) { + inlineSchemaDepth-- + } + if (isRootJsonSchema) { + inlineTypeAnchors.clear() + } + } + } + + private fun OpenApiType.toInlineType(): InlineType = + InlineType( + fullName = fullName, + structureType = structureType, + ) + + private fun inlineAnchorFor(type: InlineType): String = + inlineTypeAnchors.getOrPut(type) { "javalin-${inlineTypeAnchors.size}" } + + fun createEmbeddedTypeDescription( + type: OpenApiType, + inlineRefs: Boolean = false, + requiresNonNulls: Boolean = true, + composition: PropertyComposition? = null, + extra: Map = emptyMap(), + nullable: Boolean = false, + ): ResultScheme { + val definedBy = type.definedBy() + + if (definedBy != null && !context.isEnum(type)) { + return createEmbeddedTypeDescription( + type = definedBy, + inlineRefs = inlineRefs, + requiresNonNulls = requiresNonNulls, + composition = composition, + extra = extra, + nullable = nullable, + ) + } + + val scheme = createObjectNode() + val references = mutableSetOf() + + val processorContext = EmbeddedTypeProcessorContext( + parentContext = context, + scheme = scheme, + references = references, + type = type, + inlineRefs = inlineRefs, + requiresNonNulls = requiresNonNulls, + composition = composition, + extra = extra, + ) + val handled = context.embeddedTypeProcessors.any { processor -> + processor.process(processorContext) + } + + if (!handled) { + if (type.fullName == "java.util.Optional" && type.generics.size == 1) { + return createEmbeddedTypeDescription( + type = type.generics.first(), + inlineRefs = inlineRefs, + requiresNonNulls = requiresNonNulls, + composition = composition, + extra = extra, + nullable = true, + ) + } + + addType( + scheme = scheme, + type = type, + inlineRefs = inlineRefs, + references = references, + requiresNonNulls = requiresNonNulls, + ) + } + + scheme.addExtra(extra) + + if (nullable) { + val currentType = scheme.get("type")?.takeIf { it.isTextual }?.asText() + val currentRef = scheme.get($$"$ref")?.asText() + val compositionKey = listOf("oneOf", "anyOf", "allOf").firstOrNull { scheme.has(it) } + when { + currentType != null -> { + scheme.remove("type") + scheme.set("type", createArrayNode().add(currentType).add("null")) + } + currentRef != null -> { + scheme.remove($$"$ref") + val anyOf = createArrayNode() + anyOf.add(createObjectNode().put($$"$ref", currentRef)) + anyOf.add(createObjectNode().put("type", "null")) + scheme.set("anyOf", anyOf) + } + compositionKey == "allOf" -> { + val allOfArray = scheme.remove("allOf") + val discriminator = scheme.remove("discriminator") + val inner = createObjectNode() + inner.set("allOf", allOfArray) + if (discriminator != null) inner.set("discriminator", discriminator) + val anyOf = createArrayNode() + anyOf.add(inner) + anyOf.add(createObjectNode().put("type", "null")) + scheme.set("anyOf", anyOf) + } + compositionKey != null -> { + (scheme.get(compositionKey) as? ArrayNode)?.add(createObjectNode().put("type", "null")) + } + } + } + + return ResultScheme(json = scheme, references = references) + } + + fun addType( + scheme: ObjectNode, + type: OpenApiType, + inlineRefs: Boolean, + references: MutableSet, + requiresNonNulls: Boolean, + ) { + when (val nonRefType = context.simpleTypeMappings[type.fullName]) { + null -> { + when { + inlineRefs -> { + val (subScheme, subReferences) = createTypeSchema( + type = type, + inlineRefs = true, + requireNonNullsByDefault = requiresNonNulls, + ) + subScheme.properties().forEach { (key, value) -> scheme.set(key, value) } + subReferences.forEach { references.addReference(it) } + } + else -> { + references.addReference(type) + scheme.put($$"$ref", "#/components/schemas/${type.simpleName}") + } + } + } + else -> { + scheme.put("type", nonRefType.type) + nonRefType.format?.also { scheme.put("format", it) } + } + } + } + +} + +internal fun SchemaGenerationContext.findAllProperties(type: OpenApiType, requireNonNulls: Boolean): Collection { + val annotations = annotationsOf(type) + val byFields = annotations.find(OpenApiByFields::class.java) + val byFieldsOnly = byFields?.get("only")?.asBoolean() == true + val byFieldsVisibility = byFields?.get("value")?.asString()?.let { Visibility.valueOf(it) } + val namingStrategy = annotations.namingStrategy() + + val declaredProperties = mutableListOf() + + for (property in propertiesOf(type)) { + if (!acceptsProperty(type, property)) continue + + when (property.accessor) { + Accessor.FIELD -> if (byFields == null) continue + Accessor.GETTER -> if (byFieldsOnly) continue + Accessor.RECORD_COMPONENT -> {} + } + if (byFieldsVisibility != null && byFieldsVisibility.priority > property.visibility.toOpenApi().priority) continue + if (property.annotations.contains(OpenApiIgnore::class.java) || property.transient) continue + + val customName = property.annotations.find(OpenApiName::class.java)?.get("value")?.asString() + val name = customName ?: property.name + val finalName = when { + customName == null && namingStrategy != null -> translatePropertyName(namingStrategy, name) + else -> name + } + + val propertyType = property.annotations.find(OpenApiPropertyType::class.java) + val nullability = propertyType?.get("nullability")?.asString() + val redirect = propertyType?.get("definedBy")?.asClassDefinition() + val treatedAsNotNull = (redirect == null && !property.nullable) || redirect?.hasPrimitiveSource() == true + + val isNotNull = when { + nullability == Nullability.NOT_NULL.name -> true + nullability == Nullability.NULLABLE.name -> false + property.annotations.contains("NotNull") -> true + treatedAsNotNull -> true + property.annotations.contains("Nullable") -> false + else -> false + } + val required = property.annotations.contains(OpenApiRequired::class.java) || (requireNonNulls && isNotNull) + + val explicitNullable = property.annotations.find(OpenApiNullable::class.java)?.get("nullable")?.asBoolean() + val isExplicitlyNullable = when { + explicitNullable != null -> explicitNullable + nullability == Nullability.NULLABLE.name -> true + property.annotations.contains("Nullable") -> true + else -> false + } + + declaredProperties.add( + Property( + name = finalName, + type = toOpenApiType(redirect ?: property.type), + composition = findCompositionInElement(this, property.annotations), + required = required, + nullable = isExplicitlyNullable, + extra = property.annotations.findExtra(), + ) + ) + } + + val customProperties = + type + .extra + .filterIsInstance() + .map { extraProperty -> + Property( + name = extraProperty.name, + type = extraProperty.type, + required = requireNonNulls, + ) + } + val properties = declaredProperties + customProperties + + reportDebug("OpenApi | Resolved ${properties.size} properties for ${type.fullName}: ${properties.joinToString { it.name }}") + + return properties +} + +private fun AnnotationSet.namingStrategy(): OpenApiNamingStrategy? = + (find(OpenApiNaming::class.java)?.get("value")?.asString())?.let { OpenApiNamingStrategy.valueOf(it) } + +private fun AnnotationSet.findExtra(): Map { + val extra = mutableMapOf( + "description" to find(OpenApiDescription::class.java)?.get("value")?.asString() + ) + + find(OpenApiExample::class.java)?.also { example -> + val value = example["value"].notNullString() + val raw = example["raw"].notNullString() + val objects = example["objects"].asList().filterIsInstance>() + when { + value != null -> extra["example"] = value + raw != null -> extra["example"] = jsonMapper.readTree(raw) + objects.isNotEmpty() -> { + val result = ExampleGenerator.generateFromExamples(objects.map { it.toExampleProperty() }) + extra["example"] = result.jsonElement ?: result.simpleValue + } + } + } + + find(OpenApiNumberValidation::class.java)?.also { validation -> + extra["minimum"] = validation["minimum"].notNullString()?.toBigDecimal() + extra["maximum"] = validation["maximum"].notNullString()?.toBigDecimal() + extra["exclusiveMinimum"] = validation["exclusiveMinimum"].notNullString()?.toBigDecimal() + extra["exclusiveMaximum"] = validation["exclusiveMaximum"].notNullString()?.toBigDecimal() + extra["multipleOf"] = validation["multipleOf"].notNullString()?.toBigDecimal() + } + + find(OpenApiStringValidation::class.java)?.also { validation -> + extra["minLength"] = validation["minLength"].notNullString()?.toInt() + extra["maxLength"] = validation["maxLength"].notNullString()?.toInt() + extra["format"] = validation["format"].notNullString() + extra["pattern"] = validation["pattern"].notNullString() + } + + find(OpenApiArrayValidation::class.java)?.also { validation -> + extra["minItems"] = validation["minItems"].notNullString()?.toInt() + extra["maxItems"] = validation["maxItems"].notNullString()?.toInt() + extra["uniqueItems"] = validation["uniqueItems"].asBoolean()?.takeIf { unique -> unique } + } + + find(OpenApiObjectValidation::class.java)?.also { validation -> + extra["minProperties"] = validation["minProperties"].notNullString()?.toInt() + extra["maxProperties"] = validation["maxProperties"].notNullString()?.toInt() + } + + findAll(Custom::class.java).forEach { custom -> + extra[requireNotNull(custom.get("name").asString())] = custom.get("value").raw() + } + + all() + .filter { it.metadata.contains(CustomAnnotation::class.java) } + .flatMap { it.values.entries } + .forEach { (name, value) -> extra[name] = customAnnotationValue(value) } + + return extra +} + +private fun MemberVisibility.toOpenApi(): Visibility = + when (this) { + MemberVisibility.PUBLIC -> Visibility.PUBLIC + MemberVisibility.PROTECTED -> Visibility.PROTECTED + MemberVisibility.PRIVATE -> Visibility.PRIVATE + MemberVisibility.PACKAGE_PRIVATE -> Visibility.DEFAULT + } + +private fun AnnotationValue.notNullString(): String? = + asString()?.takeIf { it != NULL_STRING } + +private val primitiveSourceNames = setOf( + "boolean", "byte", "short", "int", "long", "float", "double", "char", + "Boolean", "Byte", "Short", "Int", "Long", "Float", "Double", "Char", + "kotlin.Boolean", "kotlin.Byte", "kotlin.Short", "kotlin.Int", "kotlin.Long", "kotlin.Float", "kotlin.Double", "kotlin.Char", +) + +@OptIn(InternalIntrospectionApi::class) +private fun RawType.hasPrimitiveSource(): Boolean = + source.toString() in primitiveSourceNames + +private fun customAnnotationValue(value: Any?): Any? = + when (value) { + is String -> value.trimIndent() + is RawType -> value.fullName + is Map<*, *> -> createObjectNode().also { node -> + value.forEach { (key, nestedValue) -> + val field = key as? String ?: return@forEach + when (val resolved = customAnnotationValue(nestedValue)) { + is Boolean -> node.put(field, resolved) + is Int -> node.put(field, resolved) + is Long -> node.put(field, resolved) + is Double -> node.put(field, resolved) + is Float -> node.put(field, resolved) + is Short -> node.put(field, resolved.toInt()) + is Byte -> node.put(field, resolved.toInt()) + is String -> node.put(field, resolved) + is JsonNode -> node.set(field, resolved) + null -> {} + else -> node.put(field, resolved.toString()) + } + } + } + is List<*> -> createArrayNode().also { array -> + value.forEach { + when (val element = customAnnotationValue(it)) { + is Boolean -> array.add(element) + is Int -> array.add(element) + is Long -> array.add(element) + is Double -> array.add(element) + is Float -> array.add(element) + is Short -> array.add(element.toInt()) + is Byte -> array.add(element.toInt()) + is String -> array.add(element) + is JsonNode -> array.add(element) + else -> throw UnsupportedOperationException("[CustomAnnotation] Unsupported array value: $it") + } + } + } + else -> value + } diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiRouteDefinition.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiRouteDefinition.kt new file mode 100644 index 00000000..b9f17ad2 --- /dev/null +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiRouteDefinition.kt @@ -0,0 +1,223 @@ +package io.javalin.openapi.schema + +import io.javalin.introspection.ClassDefinition +import io.javalin.openapi.ContentType +import io.javalin.openapi.NULL_CLASS +import io.javalin.openapi.NULL_STRING +import io.javalin.openapi.experimental.processor.generators.ExampleProperty +import io.javalin.openapi.experimental.processor.generators.toExampleProperty + +internal data class OpenApiRouteDefinition( + val path: String, + val methods: List, + val versions: List, + val ignore: Boolean, + val summary: String?, + val description: String?, + val operationId: String, + val deprecated: Boolean, + val tags: List, + val cookies: List, + val headers: List, + val pathParameters: List, + val queryParameters: List, + val requestBody: OpenApiRequestBodyDefinition?, + val callbacks: List, + val responses: List, + val security: List, +) { + val formattedPath: String + get() = when { + path.startsWith("/") -> path + else -> "/$path" + } + + companion object { + fun from(values: Map): OpenApiRouteDefinition = + OpenApiRouteDefinition( + path = values.requiredString("path"), + methods = values.strings("methods"), + versions = values.strings("versions"), + ignore = values.boolean("ignore"), + summary = values.text("summary"), + description = values.text("description"), + operationId = values.requiredString("operationId"), + deprecated = values.boolean("deprecated"), + tags = values.strings("tags"), + cookies = values.maps("cookies").map(OpenApiParameterDefinition::from), + headers = values.maps("headers").map(OpenApiParameterDefinition::from), + pathParameters = values.maps("pathParams").map(OpenApiParameterDefinition::from), + queryParameters = values.maps("queryParams").map(OpenApiParameterDefinition::from), + requestBody = values.child("requestBody")?.let(OpenApiRequestBodyDefinition::from), + callbacks = values.maps("callbacks").map(OpenApiCallbackDefinition::from), + responses = values.maps("responses").map(OpenApiResponseDefinition::from), + security = values.maps("security").map(OpenApiSecurityDefinition::from), + ) + } +} + +internal data class OpenApiParameterDefinition( + val name: String, + val type: ClassDefinition, + val description: String?, + val required: Boolean, + val deprecated: Boolean, + val allowEmptyValue: Boolean, + val example: String?, +) { + companion object { + fun from(values: Map): OpenApiParameterDefinition = + OpenApiParameterDefinition( + name = values.requiredString("name"), + type = values.requiredClassDefinition("type"), + description = values.text("description"), + required = values.boolean("required"), + deprecated = values.boolean("deprecated"), + allowEmptyValue = values.boolean("allowEmptyValue"), + example = values.nonEmptyString("example"), + ) + } +} + +internal data class OpenApiRequestBodyDefinition( + val content: List, + val required: Boolean, + val description: String?, +) { + companion object { + fun from(values: Map): OpenApiRequestBodyDefinition = + OpenApiRequestBodyDefinition( + content = values.maps("content").map(OpenApiContentDefinition::from), + required = values.boolean("required"), + description = values.text("description"), + ) + } +} + +internal data class OpenApiResponseDefinition( + val status: String, + val content: List, + val description: String?, + val headers: List, +) { + companion object { + fun from(values: Map): OpenApiResponseDefinition = + OpenApiResponseDefinition( + status = values.requiredString("status"), + content = values.maps("content").map(OpenApiContentDefinition::from), + description = values.text("description"), + headers = values.maps("headers").map(OpenApiParameterDefinition::from), + ) + } +} + +internal data class OpenApiCallbackDefinition( + val name: String, + val url: String, + val method: String, + val summary: String?, + val description: String?, + val requestBody: OpenApiRequestBodyDefinition?, + val responses: List, +) { + companion object { + fun from(values: Map): OpenApiCallbackDefinition = + OpenApiCallbackDefinition( + name = values.requiredString("name"), + url = values.requiredString("url"), + method = values.requiredString("method"), + summary = values.text("summary"), + description = values.text("description"), + requestBody = values.child("requestBody")?.let(OpenApiRequestBodyDefinition::from), + responses = values.maps("responses").map(OpenApiResponseDefinition::from), + ) + } +} + +internal data class OpenApiSecurityDefinition( + val name: String, + val scopes: List, +) { + companion object { + fun from(values: Map): OpenApiSecurityDefinition = + OpenApiSecurityDefinition( + name = values.requiredString("name"), + scopes = values.strings("scopes"), + ) + } +} + +internal data class OpenApiContentDefinition( + val from: ClassDefinition?, + val mimeType: String?, + val type: String?, + val format: String?, + val properties: List, + val additionalProperties: OpenApiContentDefinition?, + val example: String?, + val exampleObjects: List, +) { + val resolvedSource: ClassDefinition? + get() = from?.takeUnless { it.fullName == NULL_CLASS::class.java.name } + + companion object { + fun from(values: Map): OpenApiContentDefinition = + OpenApiContentDefinition( + from = values.classDefinition("from"), + mimeType = values.string("mimeType")?.takeIf { it != ContentType.AUTODETECT }, + type = values.text("type"), + format = values.text("format"), + properties = values.maps("properties").map(OpenApiContentPropertyDefinition::from), + additionalProperties = values.child("additionalProperties") + ?.takeIf { !it.boolean("_ignored") } + ?.let(::from), + example = values.text("example"), + exampleObjects = values.maps("exampleObjects").map { it.toExampleProperty() }, + ) + } +} + +internal data class OpenApiContentPropertyDefinition( + val from: ClassDefinition?, + val name: String, + val isArray: Boolean, + val type: String?, + val format: String?, +) { + val resolvedSource: ClassDefinition? + get() = from?.takeUnless { it.fullName == NULL_CLASS::class.java.name } + + companion object { + fun from(values: Map): OpenApiContentPropertyDefinition = + OpenApiContentPropertyDefinition( + from = values.classDefinition("from"), + name = values.requiredString("name"), + isArray = values.boolean("isArray"), + type = values.text("type"), + format = values.text("format"), + ) + } +} + +private fun Map.requiredString(key: String): String = get(key) as String + +private fun Map.requiredClassDefinition(key: String): ClassDefinition = get(key) as ClassDefinition + +private fun Map.string(key: String): String? = get(key) as? String + +private fun Map.text(key: String): String? = string(key)?.takeIf { it != NULL_STRING } + +private fun Map.nonEmptyString(key: String): String? = string(key)?.takeIf { it.isNotEmpty() } + +private fun Map.boolean(key: String): Boolean = get(key) as? Boolean ?: false + +private fun Map.classDefinition(key: String): ClassDefinition? = get(key) as? ClassDefinition + +private fun Map.strings(key: String): List = + (get(key) as? List<*>)?.filterIsInstance().orEmpty() + +@Suppress("UNCHECKED_CAST") +private fun Map.child(key: String): Map? = get(key) as? Map + +private fun Map.maps(key: String): List> = + (get(key) as? List<*>)?.filterIsInstance>().orEmpty() diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt index db8cc2d5..062046a1 100644 --- a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilder.kt @@ -8,16 +8,14 @@ import io.javalin.openapi.BasicAuth import io.javalin.openapi.BearerAuth import io.javalin.openapi.CookieAuth import io.javalin.openapi.OAuth2 -import io.javalin.openapi.OpenApiExampleProperty import io.javalin.openapi.OpenApiInfo import io.javalin.openapi.OpenApiServer import io.javalin.openapi.OpenID import io.javalin.openapi.Security import io.javalin.openapi.SecurityScheme -import io.javalin.openapi.experimental.ClassDefinition -import io.javalin.openapi.experimental.processor.generators.ExampleGenerator +import io.javalin.openapi.experimental.OpenApiType +import io.javalin.openapi.experimental.mergeExtraFrom import io.javalin.openapi.experimental.processor.generators.ResultScheme -import io.javalin.openapi.experimental.processor.generators.toExampleProperty import io.javalin.openapi.experimental.processor.shared.createArrayNode import io.javalin.openapi.experimental.processor.shared.createObjectNode import io.javalin.openapi.experimental.processor.shared.jsonMapper @@ -25,97 +23,138 @@ import java.util.TreeMap import java.util.function.Consumer fun interface ComponentSchemaResolver { - fun resolve(type: ClassDefinition): ResultScheme + fun resolve(type: OpenApiType): ResultScheme } class OpenApiSchemaBuilder { private val root = createObjectNode() private val paths = createObjectNode() private val componentSchemas = createObjectNode() - internal val componentReferences = mutableMapOf() + internal val componentReferences = mutableMapOf() - private val refCollector: (Set) -> Unit = { refs -> - componentReferences.putAll(refs.associateBy { it.fullName }) + private val refCollector: (Set) -> Unit = { references -> + references.forEach { reference -> mergeComponentReference(reference) } } - fun openApiVersion(version: String): OpenApiSchemaBuilder = apply { - root.put("openapi", version) - } + fun openApiVersion(version: String): OpenApiSchemaBuilder = + apply { + root.put("openapi", version) + } - fun info(configure: Consumer): OpenApiSchemaBuilder = apply { - val infoJson = jsonMapper.convertValue(OpenApiInfo().also { configure.accept(it) }, JsonNode::class.java) - val existingInfo = root.get("info") - val updatedInfo: JsonNode = - if (existingInfo != null) { - jsonMapper.readerForUpdating(existingInfo).readValue(infoJson) - } else { - infoJson + fun info(configure: Consumer): OpenApiSchemaBuilder = + apply { + val infoJson = jsonMapper.convertValue(OpenApiInfo().also { configure.accept(it) }, JsonNode::class.java) + val existingInfo = root.get("info") + val updatedInfo: JsonNode = when { + existingInfo != null -> jsonMapper.readerForUpdating(existingInfo).readValue(infoJson) + else -> infoJson } - root.set("info", updatedInfo) - } + root.set("info", updatedInfo) + } - fun server(configure: Consumer): OpenApiSchemaBuilder = apply { - val serversArray = root.get("servers") as? ArrayNode ?: createArrayNode() - serversArray.add(jsonMapper.convertValue(OpenApiServer().also { configure.accept(it) }, JsonNode::class.java)) - root.set("servers", serversArray) - } + fun ensureInfo(title: String = "", version: String = ""): OpenApiSchemaBuilder = + apply { + val info = root.get("info") as? ObjectNode + ?: createObjectNode().also { root.set("info", it) } + if (!info.has("title")) { + info.put("title", title) + } + if (!info.has("version")) { + info.put("version", version) + } + } + + fun server(configure: Consumer): OpenApiSchemaBuilder = + apply { + val serversArray = root.get("servers") as? ArrayNode ?: createArrayNode() + serversArray.add(jsonMapper.convertValue(OpenApiServer().also { configure.accept(it) }, JsonNode::class.java)) + root.set("servers", serversArray) + } /** Add a named security scheme */ - fun withSecurityScheme(name: String, scheme: SecurityScheme): OpenApiSchemaBuilder = apply { - val components = root.get("components") as? ObjectNode ?: createObjectNode().also { root.set("components", it) } - val schemes = components.get("securitySchemes") as? ObjectNode ?: createObjectNode().also { components.set("securitySchemes", it) } - schemes.set(name, jsonMapper.convertValue(scheme, JsonNode::class.java)) - } + fun withSecurityScheme(name: String, scheme: SecurityScheme): OpenApiSchemaBuilder = + apply { + val components = root.get("components") as? ObjectNode + ?: createObjectNode().also { root.set("components", it) } + val schemes = components.get("securitySchemes") as? ObjectNode + ?: createObjectNode().also { components.set("securitySchemes", it) } + schemes.set(name, jsonMapper.convertValue(scheme, JsonNode::class.java)) + } /** Add HTTP Basic authentication scheme */ @JvmOverloads - fun withBasicAuth(name: String = "BasicAuth", configure: Consumer = Consumer {}): OpenApiSchemaBuilder = + fun withBasicAuth( + name: String = "BasicAuth", + configure: Consumer = Consumer {}, + ): OpenApiSchemaBuilder = withSecurityScheme(name, BasicAuth().also { configure.accept(it) }) /** Add HTTP Bearer authentication scheme */ @JvmOverloads - fun withBearerAuth(name: String = "BearerAuth", configure: Consumer = Consumer {}): OpenApiSchemaBuilder = + fun withBearerAuth( + name: String = "BearerAuth", + configure: Consumer = Consumer {}, + ): OpenApiSchemaBuilder = withSecurityScheme(name, BearerAuth().also { configure.accept(it) }) /** Add API Key authentication scheme */ @JvmOverloads - fun withApiKeyAuth(name: String = "ApiKeyAuth", apiKeyName: String = "X-API-Key", configure: Consumer = Consumer {}): OpenApiSchemaBuilder = + fun withApiKeyAuth( + name: String = "ApiKeyAuth", + apiKeyName: String = "X-API-Key", + configure: Consumer = Consumer {}, + ): OpenApiSchemaBuilder = withSecurityScheme(name, ApiKeyAuth(name = apiKeyName).also { configure.accept(it) }) /** Add Cookie authentication scheme */ @JvmOverloads - fun withCookieAuth(name: String = "CookieAuth", sessionCookie: String = "JSESSIONID", configure: Consumer = Consumer {}): OpenApiSchemaBuilder = + fun withCookieAuth( + name: String = "CookieAuth", + sessionCookie: String = "JSESSIONID", + configure: Consumer = Consumer {}, + ): OpenApiSchemaBuilder = withSecurityScheme(name, CookieAuth(name = sessionCookie).also { configure.accept(it) }) /** Add OpenID Connect authentication scheme */ @JvmOverloads - fun withOpenID(name: String, openIdConnectUrl: String, configure: Consumer = Consumer {}): OpenApiSchemaBuilder = + fun withOpenID( + name: String, + openIdConnectUrl: String, + configure: Consumer = Consumer {}, + ): OpenApiSchemaBuilder = withSecurityScheme(name, OpenID(openIdConnectUrl = openIdConnectUrl).also { configure.accept(it) }) /** Add OAuth2 authentication scheme */ @JvmOverloads - fun withOAuth2(name: String, description: String, configure: Consumer = Consumer {}): OpenApiSchemaBuilder = + fun withOAuth2( + name: String, + description: String, + configure: Consumer = Consumer {}, + ): OpenApiSchemaBuilder = withSecurityScheme(name, OAuth2(description = description).also { configure.accept(it) }) /** Add a global security requirement */ @JvmOverloads - fun withGlobalSecurity(name: String, configure: Consumer = Consumer {}): OpenApiSchemaBuilder = apply { - val security = Security(name = name).also { configure.accept(it) } - val securityArray = root.get("security") as? ArrayNode ?: createArrayNode().also { root.set("security", it) } - val entry = createObjectNode() - val scopesArray = createArrayNode() - security.scopes.forEach { scopesArray.add(it) } - entry.set(security.name, scopesArray) - securityArray.add(entry) - } + fun withGlobalSecurity( + name: String, + configure: Consumer = Consumer {}, + ): OpenApiSchemaBuilder = + apply { + val security = Security(name = name).also { configure.accept(it) } + val securityArray = root.get("security") as? ArrayNode + ?: createArrayNode().also { root.set("security", it) } + securityArray.addSecurityRequirement(security.name, security.scopes) + } fun path(path: String): PathItemBuilder { - if (!paths.has(path)) { - paths.set(path, createObjectNode()) - } - return PathItemBuilder(paths.get(path) as ObjectNode, refCollector) + val pathItem = paths.get(path) as? ObjectNode + ?: createObjectNode().also { paths.set(path, it) } + return PathItemBuilder(pathItem = pathItem, refCollector = refCollector) } + fun hasOperation(path: String, method: String): Boolean = + (paths.get(path) as? ObjectNode)?.has(method) == true + fun addComponentSchema(name: String, schema: ResultScheme) { refCollector(schema.references) componentSchemas.set(name, schema.json) @@ -126,7 +165,7 @@ class OpenApiSchemaBuilder { fun resolveComponentReferences(resolver: ComponentSchemaResolver) { val maxIterations = 1000 - val generatedComponents = TreeMap?> { a, b -> a.compareTo(b) } + val generatedComponents = TreeMap?>() var iteration = 0 while (generatedComponents.size < componentReferences.size) { @@ -149,7 +188,11 @@ class OpenApiSchemaBuilder { } val (json, references) = resolver.resolve(componentReference) - componentReferences.putAll(references.associateBy { it.fullName }) + references.forEach { reference -> + if (mergeComponentReference(reference)) { + generatedComponents.remove(reference.fullName) + } + } generatedComponents[name] = componentReference to json } } @@ -158,23 +201,31 @@ class OpenApiSchemaBuilder { val simpleNameToFullName = mutableMapOf() - generatedComponents - .mapNotNull { it.value } - .forEach { (type, json) -> - val existing = simpleNameToFullName[type.simpleName] - if (existing != null && existing != type.fullName) { - throw IllegalStateException( - "Component schema name collision: '${type.simpleName}' maps to both '$existing' and '${type.fullName}'. " + - "Use @OpenApiName to provide a unique name for one of the conflicting types." - ) - } - simpleNameToFullName[type.simpleName] = type.fullName - if (!hasComponentSchema(type.simpleName)) { - componentSchemas.set(type.simpleName, json) - } + for ((_, component) in generatedComponents) { + val (type, json) = component ?: continue + val existing = simpleNameToFullName[type.simpleName] + if (existing != null && existing != type.fullName) { + throw IllegalStateException( + "Component schema name collision: '${type.simpleName}' maps to both '$existing' and '${type.fullName}'. " + + "Use @OpenApiName to provide a unique name for one of the conflicting types." + ) + } + simpleNameToFullName[type.simpleName] = type.fullName + if (!hasComponentSchema(type.simpleName)) { + componentSchemas.set(type.simpleName, json) } + } } + private fun mergeComponentReference(reference: OpenApiType): Boolean = + when (val existing = componentReferences[reference.fullName]) { + null -> { + componentReferences[reference.fullName] = reference + false + } + else -> existing.mergeExtraFrom(reference) + } + private fun buildRoot(): ObjectNode { root.set("paths", paths) val components = root.get("components") as? ObjectNode ?: createObjectNode() @@ -183,9 +234,11 @@ class OpenApiSchemaBuilder { return root } - fun toJson(): String = buildRoot().toPrettyString() + fun toJson(): String = + buildRoot().toPrettyString() - fun toCompactJson(): String = buildRoot().toString() + fun toCompactJson(): String = + buildRoot().toString() companion object { @JvmStatic @@ -207,7 +260,6 @@ class OpenApiSchemaBuilder { schemas?.properties()?.forEach { (schemaName, schemaValue) -> builder.componentSchemas.set(schemaName, schemaValue.deepCopy()) } - // Preserve non-schema component entries (securitySchemes, etc.) componentsNode.properties() .filter { it.key != "schemas" } .forEach { (compKey, compValue) -> @@ -232,9 +284,12 @@ annotation class OpenApiSchemaDsl class SchemaBuilder { private val schema = createObjectNode() - fun type(type: String): SchemaBuilder = apply { schema.put("type", type) } - fun format(format: String): SchemaBuilder = apply { schema.put("format", format) } - fun ref(ref: String): SchemaBuilder = apply { schema.put($$"$ref", ref) } + fun type(type: String): SchemaBuilder = + apply { schema.put("type", type) } + fun format(format: String): SchemaBuilder = + apply { schema.put("format", format) } + fun ref(ref: String): SchemaBuilder = + apply { schema.put($$"$ref", ref) } internal fun build(): ObjectNode = schema } @@ -242,22 +297,23 @@ class SchemaBuilder { @OpenApiSchemaDsl class PathItemBuilder( private val pathItem: ObjectNode, - private val refCollector: (Set) -> Unit = {}, + private val refCollector: (Set) -> Unit = {}, ) { fun operation(method: String, configure: OperationBuilder.() -> Unit) { val existing = pathItem.get(method) as? ObjectNode - val builder = OperationBuilder(refCollector, existing) + val builder = OperationBuilder(refCollector = refCollector, existing = existing) builder.configure() pathItem.set(method, builder.build()) } - fun operation(method: String, configure: Consumer) = operation(method) { configure.accept(this) } + fun operation(method: String, configure: Consumer) = + operation(method) { configure.accept(this) } } @OpenApiSchemaDsl class OperationBuilder( - private val refCollector: (Set) -> Unit = {}, + private val refCollector: (Set) -> Unit = {}, existing: ObjectNode? = null, ) { @@ -279,12 +335,11 @@ class OperationBuilder( } fun tags(vararg tags: String) { - tagsArray = createArrayNode() - tags.forEach { tagsArray.add(it) } + tags(tags.asList()) } fun tags(tags: Collection) { - tagsArray = createArrayNode() + this.tagsArray = createArrayNode() tags.forEach { tagsArray.add(it) } } @@ -301,7 +356,7 @@ class OperationBuilder( } fun deprecated(value: Boolean) { - deprecatedValue = value + this.deprecatedValue = value } fun addTag(tag: String) { @@ -313,46 +368,51 @@ class OperationBuilder( } fun parameters(configure: ParametersBuilder.() -> Unit) { - val builder = ParametersBuilder(refCollector, parametersArray) + val builder = ParametersBuilder(refCollector = refCollector, existing = parametersArray) builder.configure() - parametersArray = builder.build() + this.parametersArray = builder.build() } fun requestBody(configure: RequestBodyBuilder.() -> Unit) { - val builder = RequestBodyBuilder(refCollector, requestBodyObject) + val builder = RequestBodyBuilder(refCollector = refCollector, existing = requestBodyObject) builder.configure() val built = builder.build() if (built.size() > 0) { - requestBodyObject = built + this.requestBodyObject = built } } fun responses(configure: ResponsesBuilder.() -> Unit) { - val builder = ResponsesBuilder(refCollector, responsesObject) + val builder = ResponsesBuilder(refCollector = refCollector, existing = responsesObject) builder.configure() - responsesObject = builder.build() + this.responsesObject = builder.build() } fun callbacks(configure: CallbacksBuilder.() -> Unit) { - val builder = CallbacksBuilder(refCollector, callbacksObject) + val builder = CallbacksBuilder(refCollector = refCollector, existing = callbacksObject) builder.configure() val built = builder.build() if (built.size() > 0) { - callbacksObject = built + this.callbacksObject = built } } fun security(configure: SecurityBuilder.() -> Unit) { val builder = SecurityBuilder(securityArray) builder.configure() - securityArray = builder.build() + this.securityArray = builder.build() } - fun parameters(configure: Consumer) = parameters { configure.accept(this) } - fun requestBody(configure: Consumer) = requestBody { configure.accept(this) } - fun responses(configure: Consumer) = responses { configure.accept(this) } - fun callbacks(configure: Consumer) = callbacks { configure.accept(this) } - fun security(configure: Consumer) = security { configure.accept(this) } + fun parameters(configure: Consumer) = + parameters { configure.accept(this) } + fun requestBody(configure: Consumer) = + requestBody { configure.accept(this) } + fun responses(configure: Consumer) = + responses { configure.accept(this) } + fun callbacks(configure: Consumer) = + callbacks { configure.accept(this) } + fun security(configure: Consumer) = + security { configure.accept(this) } internal fun build(): ObjectNode { val result = createObjectNode() @@ -361,7 +421,6 @@ class OperationBuilder( result.set("tags", tagsArray) } - // Copy properties set directly on operation (summary, description, operationId) for (entry in operation.properties()) { result.set(entry.key, entry.value) } @@ -388,7 +447,7 @@ class OperationBuilder( @OpenApiSchemaDsl class ParametersBuilder( - private val refCollector: (Set) -> Unit = {}, + private val refCollector: (Set) -> Unit = {}, existing: ArrayNode? = null, ) { @@ -413,28 +472,27 @@ class ParametersBuilder( if (required) param.put("required", true) if (deprecated) param.put("deprecated", true) if (allowEmptyValue) param.put("allowEmptyValue", true) - if (schema.references.isNotEmpty()) { - val mediaTypeNode = createObjectNode() - mediaTypeNode.set("schema", schemaJson) - val contentNode = createObjectNode() - contentNode.set("application/json", mediaTypeNode) - param.set("content", contentNode) - } else { - param.set("schema", schemaJson) + when { + schema.references.isNotEmpty() -> { + val mediaTypeNode = createObjectNode() + mediaTypeNode.set("schema", schemaJson) + val contentNode = createObjectNode() + contentNode.set("application/json", mediaTypeNode) + param.set("content", contentNode) + } + else -> param.set("schema", schemaJson) } if (example != null) { param.put("example", example) } - // Replace existing parameter with same name+in, or append val existingIndex = (0 until parameters.size()).firstOrNull { i -> val existing = parameters.get(i) as? ObjectNode existing?.get("name")?.asText() == name && existing?.get("in")?.asText() == location } - if (existingIndex != null) { - parameters.set(existingIndex, param) - } else { - parameters.add(param) + when { + existingIndex != null -> parameters.set(existingIndex, param) + else -> parameters.add(param) } } @@ -451,7 +509,10 @@ class ParametersBuilder( parameter( name = name, location = location, - schema = ResultScheme(SchemaBuilder().apply(schema).build(), emptySet()), + schema = ResultScheme( + json = SchemaBuilder().apply(schema).build(), + references = emptySet(), + ), description = description, required = required, deprecated = deprecated, @@ -469,14 +530,23 @@ class ParametersBuilder( allowEmptyValue: Boolean, example: String?, schema: Consumer, - ) = parameter(name, location, description, required, deprecated, allowEmptyValue, example) { schema.accept(this) } + ) = + parameter( + name = name, + location = location, + description = description, + required = required, + deprecated = deprecated, + allowEmptyValue = allowEmptyValue, + example = example, + ) { schema.accept(this) } internal fun build(): ArrayNode = parameters } @OpenApiSchemaDsl class RequestBodyBuilder( - private val refCollector: (Set) -> Unit = {}, + private val refCollector: (Set) -> Unit = {}, existing: ObjectNode? = null, ) { @@ -495,33 +565,30 @@ class RequestBodyBuilder( } fun content(configure: ContentBuilder.() -> Unit) { - val builder = ContentBuilder(refCollector, contentObject) + val builder = ContentBuilder(refCollector = refCollector, existing = contentObject) builder.configure() val built = builder.build() if (built.size() > 0) { - contentObject = built + this.contentObject = built } } - fun content(configure: Consumer) = content { configure.accept(this) } + fun content(configure: Consumer) = + content { configure.accept(this) } internal fun build(): ObjectNode { val result = createObjectNode() - // description first if (requestBody.has("description")) { result.set("description", requestBody.get("description")) } - // then content contentObject?.let { result.set("content", it) } - // If no content and no description, return empty (will be skipped by caller) if (result.size() == 0) { return result } - // required is only added when there's already description or content if (requestBody.has("required")) { result.set("required", requestBody.get("required")) } @@ -532,7 +599,7 @@ class RequestBodyBuilder( @OpenApiSchemaDsl class ContentBuilder( - private val refCollector: (Set) -> Unit = {}, + private val refCollector: (Set) -> Unit = {}, existing: ObjectNode? = null, ) { @@ -540,12 +607,13 @@ class ContentBuilder( fun mediaType(mimeType: String, configure: MediaTypeBuilder.() -> Unit) { val existingMediaType = content.get(mimeType) as? ObjectNode - val builder = MediaTypeBuilder(refCollector, existingMediaType) + val builder = MediaTypeBuilder(refCollector = refCollector, existing = existingMediaType) builder.configure() content.set(mimeType, builder.build()) } - fun mediaType(mimeType: String, configure: Consumer) = mediaType(mimeType) { configure.accept(this) } + fun mediaType(mimeType: String, configure: Consumer) = + mediaType(mimeType) { configure.accept(this) } internal fun build(): ObjectNode = content } @@ -553,17 +621,11 @@ class ContentBuilder( interface ExampleHolder { fun example(value: String) fun exampleJson(value: JsonNode) - - fun applyExamples(exampleObjects: List) { - val generatorResult = ExampleGenerator.generateFromExamples(exampleObjects.map { it.toExampleProperty() }) - generatorResult.simpleValue?.let { example(it) } - ?: generatorResult.jsonElement?.let { exampleJson(it) } - } } @OpenApiSchemaDsl class MediaTypeBuilder( - private val refCollector: (Set) -> Unit = {}, + private val refCollector: (Set) -> Unit = {}, existing: ObjectNode? = null, ) : ExampleHolder { @@ -574,22 +636,24 @@ class MediaTypeBuilder( fun schema(resolved: ResultScheme) { refCollector(resolved.references) - schemaObject = resolved.json + this.schemaObject = resolved.json } fun schema(configure: SchemaBuilder.() -> Unit) { - schemaObject = SchemaBuilder().apply(configure).build() + this.schemaObject = SchemaBuilder().apply(configure).build() } - fun schema(configure: Consumer) = schema { configure.accept(this) } + fun schema(configure: Consumer) = + schema { configure.accept(this) } fun objectSchema(configure: ObjectSchemaBuilder.() -> Unit) { val builder = ObjectSchemaBuilder(refCollector) builder.configure() - schemaObject = builder.build() + this.schemaObject = builder.build() } - fun objectSchema(configure: Consumer) = objectSchema { configure.accept(this) } + fun objectSchema(configure: Consumer) = + objectSchema { configure.accept(this) } override fun example(value: String) { mediaType.put("example", value) @@ -618,7 +682,7 @@ class MediaTypeBuilder( @OpenApiSchemaDsl class ObjectSchemaBuilder( - private val refCollector: (Set) -> Unit = {}, + private val refCollector: (Set) -> Unit = {}, ) : ExampleHolder { private val properties = createObjectNode() @@ -635,7 +699,8 @@ class ObjectSchemaBuilder( properties.set(name, SchemaBuilder().apply(schema).build()) } - fun property(name: String, schema: Consumer) = property(name) { schema.accept(this) } + fun property(name: String, schema: Consumer) = + property(name) { schema.accept(this) } fun property(name: String, type: String, format: String?) { val schema = createObjectNode() @@ -659,7 +724,8 @@ class ObjectSchemaBuilder( properties.set(name, schema) } - fun arrayProperty(name: String, items: Consumer) = arrayProperty(name) { items.accept(this) } + fun arrayProperty(name: String, items: Consumer) = + arrayProperty(name) { items.accept(this) } fun arrayProperty(name: String, itemType: String, itemFormat: String?) { val itemSchema = createObjectNode() @@ -673,30 +739,31 @@ class ObjectSchemaBuilder( fun additionalProperties(schema: ResultScheme) { refCollector(schema.references) - additionalPropertiesObject = schema.json + this.additionalPropertiesObject = schema.json } fun additionalProperties(schema: SchemaBuilder.() -> Unit) { - additionalPropertiesObject = SchemaBuilder().apply(schema).build() + this.additionalPropertiesObject = SchemaBuilder().apply(schema).build() } - fun additionalProperties(schema: Consumer) = additionalProperties { schema.accept(this) } + fun additionalProperties(schema: Consumer) = + additionalProperties { schema.accept(this) } fun additionalProperties(type: String?, format: String?) { val schema = createObjectNode() type?.let { schema.put("type", it) } format?.let { schema.put("format", it) } - additionalPropertiesObject = schema + this.additionalPropertiesObject = schema } override fun example(value: String) { - exampleValue = value - exampleJsonValue = null + this.exampleValue = value + this.exampleJsonValue = null } override fun exampleJson(value: JsonNode) { - exampleJsonValue = value - exampleValue = null + this.exampleJsonValue = value + this.exampleValue = null } internal fun build(): ObjectNode { @@ -720,7 +787,7 @@ class ObjectSchemaBuilder( @OpenApiSchemaDsl class ResponsesBuilder( - private val refCollector: (Set) -> Unit = {}, + private val refCollector: (Set) -> Unit = {}, existing: ObjectNode? = null, ) { @@ -728,19 +795,20 @@ class ResponsesBuilder( fun response(status: String, configure: ResponseBuilder.() -> Unit) { val existingResponse = responses.get(status) as? ObjectNode - val builder = ResponseBuilder(refCollector, existingResponse) + val builder = ResponseBuilder(refCollector = refCollector, existing = existingResponse) builder.configure() responses.set(status, builder.build()) } - fun response(status: String, configure: Consumer) = response(status) { configure.accept(this) } + fun response(status: String, configure: Consumer) = + response(status) { configure.accept(this) } internal fun build(): ObjectNode = responses } @OpenApiSchemaDsl class ResponseBuilder( - private val refCollector: (Set) -> Unit = {}, + private val refCollector: (Set) -> Unit = {}, existing: ObjectNode? = null, ) { @@ -755,25 +823,27 @@ class ResponseBuilder( } fun content(configure: ContentBuilder.() -> Unit) { - val builder = ContentBuilder(refCollector, contentObject) + val builder = ContentBuilder(refCollector = refCollector, existing = contentObject) builder.configure() val built = builder.build() if (built.size() > 0) { - contentObject = built + this.contentObject = built } } fun headers(configure: HeadersBuilder.() -> Unit) { - val builder = HeadersBuilder(refCollector, headersObject) + val builder = HeadersBuilder(refCollector = refCollector, existing = headersObject) builder.configure() val built = builder.build() if (built.size() > 0) { - headersObject = built + this.headersObject = built } } - fun content(configure: Consumer) = content { configure.accept(this) } - fun headers(configure: Consumer) = headers { configure.accept(this) } + fun content(configure: Consumer) = + content { configure.accept(this) } + fun headers(configure: Consumer) = + headers { configure.accept(this) } internal fun build(): ObjectNode { val result = createObjectNode() @@ -788,7 +858,7 @@ class ResponseBuilder( @OpenApiSchemaDsl class HeadersBuilder( - private val refCollector: (Set) -> Unit = {}, + private val refCollector: (Set) -> Unit = {}, existing: ObjectNode? = null, ) { @@ -834,7 +904,10 @@ class HeadersBuilder( ) { header( name = name, - schema = ResultScheme(SchemaBuilder().apply(schema).build(), emptySet()), + schema = ResultScheme( + json = SchemaBuilder().apply(schema).build(), + references = emptySet(), + ), description = description, required = required, deprecated = deprecated, @@ -851,46 +924,53 @@ class HeadersBuilder( allowEmptyValue: Boolean, example: String?, schema: Consumer, - ) = header(name, description, required, deprecated, allowEmptyValue, example) { schema.accept(this) } + ) = + header( + name = name, + description = description, + required = required, + deprecated = deprecated, + allowEmptyValue = allowEmptyValue, + example = example, + ) { schema.accept(this) } internal fun build(): ObjectNode = headers } @OpenApiSchemaDsl class CallbacksBuilder( - private val refCollector: (Set) -> Unit = {}, + private val refCollector: (Set) -> Unit = {}, existing: ObjectNode? = null, ) { private val callbacks = existing ?: createObjectNode() fun callback(name: String, url: String, method: String, configure: CallbackOperationBuilder.() -> Unit) { - val eventObject = if (callbacks.has(name)) { - callbacks.get(name) as ObjectNode - } else { - createObjectNode().also { callbacks.set(name, it) } + val eventObject = when { + callbacks.has(name) -> callbacks.get(name) as ObjectNode + else -> createObjectNode().also { callbacks.set(name, it) } } - val urlObject = if (eventObject.has(url)) { - eventObject.get(url) as ObjectNode - } else { - createObjectNode().also { eventObject.set(url, it) } + val urlObject = when { + eventObject.has(url) -> eventObject.get(url) as ObjectNode + else -> createObjectNode().also { eventObject.set(url, it) } } val existingOp = urlObject.get(method) as? ObjectNode - val builder = CallbackOperationBuilder(refCollector, existingOp) + val builder = CallbackOperationBuilder(refCollector = refCollector, existing = existingOp) builder.configure() urlObject.set(method, builder.build()) } - fun callback(name: String, url: String, method: String, configure: Consumer) = callback(name, url, method) { configure.accept(this) } + fun callback(name: String, url: String, method: String, configure: Consumer) = + callback(name = name, url = url, method = method) { configure.accept(this) } internal fun build(): ObjectNode = callbacks } @OpenApiSchemaDsl class CallbackOperationBuilder( - private val refCollector: (Set) -> Unit = {}, + private val refCollector: (Set) -> Unit = {}, existing: ObjectNode? = null, ) { @@ -915,22 +995,24 @@ class CallbackOperationBuilder( } fun requestBody(configure: RequestBodyBuilder.() -> Unit) { - val builder = RequestBodyBuilder(refCollector, requestBodyObject) + val builder = RequestBodyBuilder(refCollector = refCollector, existing = requestBodyObject) builder.configure() val built = builder.build() if (built.size() > 0) { - requestBodyObject = built + this.requestBodyObject = built } } fun responses(configure: ResponsesBuilder.() -> Unit) { - val builder = ResponsesBuilder(refCollector, responsesObject) + val builder = ResponsesBuilder(refCollector = refCollector, existing = responsesObject) builder.configure() - responsesObject = builder.build() + this.responsesObject = builder.build() } - fun requestBody(configure: Consumer) = requestBody { configure.accept(this) } - fun responses(configure: Consumer) = responses { configure.accept(this) } + fun requestBody(configure: Consumer) = + requestBody { configure.accept(this) } + fun responses(configure: Consumer) = + responses { configure.accept(this) } internal fun build(): ObjectNode { val result = createObjectNode() @@ -952,12 +1034,16 @@ class SecurityBuilder(existing: ArrayNode? = null) { private val security = existing ?: createArrayNode() fun securityRequirement(name: String, vararg scopes: String) { - val entry = createObjectNode() - val scopesArray = createArrayNode() - scopes.forEach { scopesArray.add(it) } - entry.set(name, scopesArray) - security.add(entry) + security.addSecurityRequirement(name, scopes.asList()) } internal fun build(): ArrayNode = security } + +private fun ArrayNode.addSecurityRequirement(name: String, scopes: Iterable) { + val entry = createObjectNode() + val scopesArray = createArrayNode() + scopes.forEach { scopesArray.add(it) } + entry.set(name, scopesArray) + add(entry) +} diff --git a/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt new file mode 100644 index 00000000..d807b70d --- /dev/null +++ b/openapi-generator/src/main/kotlin/io/javalin/openapi/schema/OpenApiSchemaGenerator.kt @@ -0,0 +1,342 @@ +package io.javalin.openapi.schema + +import io.javalin.introspection.ClassDefinition +import io.javalin.openapi.NULL_STRING +import io.javalin.openapi.OpenApiStatus +import io.javalin.openapi.OpenApiOperation.AUTO_GENERATE +import io.javalin.openapi.experimental.OpenApiType +import io.javalin.openapi.experimental.SchemaGenerationContext +import io.javalin.openapi.experimental.StructureType.ARRAY +import io.javalin.openapi.experimental.processor.generators.ExampleGenerator +import io.javalin.openapi.experimental.processor.generators.ResultScheme +import java.util.Locale +import java.util.TreeMap + +class OpenApiSchemaGenerator( + private val context: SchemaGenerationContext, + private val title: String, + private val version: String, + private val defaultStatusDescription: (String) -> String? = { OpenApiStatus.reasonPhrase(it) }, +) { + + fun generateSchema(routes: List>): String = + generateRouteSchema(routes.map(OpenApiRouteDefinition::from)) + + private fun generateRouteSchema(routes: List): String { + val schema = + OpenApiSchemaBuilder() + .openApiVersion("3.1.0") + .info { it.title(title).version(version) } + + for (route in routes.sortedBy { it.formattedPath }) { + if (route.ignore) { + continue + } + + val pathBuilder = schema.path(route.formattedPath) + + for (method in route.methods.sorted()) { + pathBuilder.operation(method.lowercase()) { + tags(route.tags) + summary(route.summary) + description(route.description) + operationId(generateOperationId(method, route).takeIf { it != NULL_STRING }) + + buildParameters(route) + buildRequestBody(route.requestBody) + buildResponses(route.responses) + buildCallbacks(route.callbacks) + + if (route.deprecated) { + deprecated(true) + } + + val securities = route.security + if (securities.isNotEmpty()) { + security { + for (security in securities.sortedBy { it.name }) { + securityRequirement(security.name, *security.scopes.toTypedArray()) + } + } + } + } + } + } + + schema.resolveComponentReferences { type -> + context.typeSchemaGenerator.createTypeSchema(type = type, inlineRefs = false) + } + return schema.toJson() + } + + fun generateVersionedSchemas(routes: List>): Map = + generateVersionedRouteSchemas(routes.map(OpenApiRouteDefinition::from)) + + private fun generateVersionedRouteSchemas(routes: List): Map = + routes + .flatMap { route -> route.versions.map { version -> version to route } } + .groupBy({ it.first }, { it.second }) + .mapValues { (_, versionRoutes) -> generateRouteSchema(versionRoutes.toSet().toList()) } + + private fun OperationBuilder.buildParameters(route: OpenApiRouteDefinition) { + parameters { + val parametersByLocation = linkedMapOf( + In.COOKIE to route.cookies, + In.HEADER to route.headers, + In.PATH to route.pathParameters, + In.QUERY to route.queryParameters, + ) + + parametersByLocation.forEach { (location, parameters) -> + parameters.forEach { parameter -> + parameter( + name = parameter.name, + location = location.identifier, + schema = createTypeDescriptionWithReferences(parameter.type), + description = parameter.description, + required = parameter.required || location == In.PATH, + deprecated = parameter.deprecated, + allowEmptyValue = parameter.allowEmptyValue, + example = parameter.example, + ) + } + } + } + } + + private fun OperationBuilder.buildRequestBody(requestBody: OpenApiRequestBodyDefinition?) { + if (requestBody == null) { + return + } + requestBody { + description(requestBody.description) + content { addResolvedContent(requestBody.content) } + if (requestBody.required) { + required(true) + } + } + } + + private fun OperationBuilder.buildResponses(responses: List) { + responses { + for (response in responses.sortedBy { it.status }) { + response(response.status) { + description(descriptionOf(response)) + content { addResolvedContent(response.content) } + headers { + response.headers.forEach { header -> + header( + name = header.name, + schema = createTypeDescriptionWithReferences(header.type), + description = header.description, + required = header.required, + deprecated = header.deprecated, + allowEmptyValue = header.allowEmptyValue, + example = header.example, + ) + } + } + } + } + } + } + + private fun OperationBuilder.buildCallbacks(callbacks: List) { + if (callbacks.isEmpty()) { + return + } + + callbacks { + callbacks.forEach { callback -> + callback( + name = callback.name, + url = callback.url, + method = callback.method.lowercase(), + ) { + summary(callback.summary) + description(callback.description) + val callbackBody = callback.requestBody + requestBody { + description(callbackBody?.description) + content { addResolvedContent(callbackBody?.content.orEmpty()) } + if (callbackBody?.required == true) { + required(true) + } + } + responses { + for (response in callback.responses.sortedBy { it.status }) { + response(response.status) { + description(descriptionOf(response)) + content { addResolvedContent(response.content) } + } + } + } + } + } + } + } + + private fun ContentBuilder.addResolvedContent(contents: List) { + val resolvedEntries = TreeMap Unit>() + + for (content in contents) { + val resolved = resolveMediaType(content) ?: continue + resolvedEntries[resolved.first] = resolved.second + } + + resolvedEntries.forEach { (mimeType, configure) -> mediaType(mimeType, configure) } + } + + enum class In(val identifier: String) { + QUERY("query"), + HEADER("header"), + PATH("path"), + COOKIE("cookie"), + } + + private fun generateOperationId(method: String, route: OpenApiRouteDefinition, pathParamPrefix: String = "By"): String { + if (route.operationId != AUTO_GENERATE) { + return route.operationId + } + + val path = route.path.split('/').joinToString(separator = "") { pathPart -> + when { + pathPart.startsWith('{') || pathPart.startsWith('<') -> { + val parameterName = + pathPart + .drop(1) + .dropLast(1) + .split('-') + .joinToString(separator = "") { it.capitalise() } + pathParamPrefix + parameterName + } + else -> { + pathPart + .split('-') + .joinToString(separator = "") { it.capitalise() } + } + } + } + return method.lowercase() + path + } + + private fun String.capitalise(): String = + replaceFirstChar { it.titlecase(Locale.getDefault()) } + + private fun resolveMediaType(content: OpenApiContentDefinition): Pair Unit>? { + val source = content.resolvedSource + var type = content.type + var mimeType = content.mimeType + + if (mimeType == null) { + when { + source == null -> { + mimeType = type + type = null + } + else -> mimeType = detectContentType(source) + } + } + + if (mimeType == null) { + context.reportWarning( + """ + OpenApi generator cannot find matching mime type defined. + Content: + $content + """.trimIndent() + ) + return null + } + + val resolvedType = type + val format = content.format + val properties = content.properties.takeIf { it.isNotEmpty() } + val additionalProperties = content.additionalProperties + + val configure: MediaTypeBuilder.() -> Unit = { + when { + properties == null && additionalProperties == null && source != null -> + schema(createTypeDescriptionWithReferences(source)) + + properties == null && additionalProperties == null -> + schema { + resolvedType?.let { type(it) } + format?.let { format(it) } + } + + else -> objectSchema { + properties?.let { buildProperties(it) } + additionalProperties?.let { buildAdditionalProperties(it) } + } + } + + applyExample(content) + } + + return mimeType to configure + } + + private fun ExampleHolder.applyExample(content: OpenApiContentDefinition) { + content.example?.let { example(it) } + content.exampleObjects.takeIf { it.isNotEmpty() }?.let { examples -> + val result = ExampleGenerator.generateFromExamples(examples) + result.simpleValue?.let { example(it) } + result.jsonElement?.let { exampleJson(it) } + } + } + + private fun ObjectSchemaBuilder.buildProperties(properties: List) { + for (property in properties) { + val source = property.resolvedSource + + when { + property.isArray && source != null -> + arrayProperty(property.name, createTypeDescriptionWithReferences(source)) + property.isArray -> + arrayProperty(property.name, requireNotNull(property.type), property.format) + source != null -> + property(property.name, createTypeDescriptionWithReferences(source)) + else -> + property(property.name, requireNotNull(property.type), property.format) + } + } + } + + private fun ObjectSchemaBuilder.buildAdditionalProperties(additionalProperties: OpenApiContentDefinition) { + val source = additionalProperties.resolvedSource + + when { + source != null -> additionalProperties(createTypeDescriptionWithReferences(source)) + else -> additionalProperties(additionalProperties.type, additionalProperties.format) + } + + applyExample(additionalProperties) + } + + private fun detectContentType(from: ClassDefinition): String { + val model = context.toOpenApiType(from) + val isBinary = + (model.structureType == ARRAY && model.simpleName == "Byte") || + model.simpleName == "[B" || + model.simpleName == "File" + + return when { + isBinary -> "application/octet-stream" + model.structureType == ARRAY -> "application/json" + model.simpleName == "String" -> "text/plain" + else -> "application/json" + } + } + + private fun createTypeDescriptionWithReferences(from: ClassDefinition): ResultScheme { + val model: OpenApiType = context.toOpenApiType(from) + return context.typeSchemaGenerator.createEmbeddedTypeDescription(model) + } + + private fun descriptionOf(response: OpenApiResponseDefinition): String = + response.description + ?: defaultStatusDescription(response.status) + ?: "" + +} diff --git a/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiRouteDefinitionTest.kt b/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiRouteDefinitionTest.kt new file mode 100644 index 00000000..b47f9bda --- /dev/null +++ b/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiRouteDefinitionTest.kt @@ -0,0 +1,107 @@ +package io.javalin.openapi.schema + +import io.javalin.introspection.AnnotationSet +import io.javalin.introspection.AnnotationProjection +import io.javalin.introspection.ClassDefinition +import io.javalin.introspection.EnumConstant +import io.javalin.introspection.InternalIntrospectionApi +import io.javalin.introspection.PropertyProjection +import io.javalin.openapi.experimental.EmbeddedTypeProcessor +import io.javalin.openapi.experimental.OpenApiType +import io.javalin.openapi.experimental.SchemaGenerationContext +import io.javalin.openapi.experimental.SimpleType +import io.javalin.openapi.experimental.StructureType +import io.javalin.openapi.experimental.processor.generators.TypeSchemaGenerator +import io.javalin.openapi.experimental.processor.shared.jsonMapper +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +internal class OpenApiRouteDefinitionTest { + + @Test + fun `schemas resolved content properties without an unused fallback type`() { + val source = StubClassDefinition() + + val json = OpenApiSchemaGenerator(TestSchemaContext(), "API", "1.0").generateSchema( + listOf( + mapOf( + "path" to "/owners", + "methods" to listOf("GET"), + "operationId" to "getOwners", + "responses" to listOf( + mapOf( + "status" to "200", + "content" to listOf( + mapOf( + "mimeType" to "application/json", + "properties" to listOf( + mapOf( + "from" to source, + "name" to "owner", + "isArray" to false, + ) + ), + ) + ), + ) + ), + ) + ) + ) + + val document = jsonMapper.readTree(json) + val ownerSchema = document + .path("paths") + .path("/owners") + .path("get") + .path("responses") + .path("200") + .path("content") + .path("application/json") + .path("schema") + .path("properties") + .path("owner") + + assertThat(ownerSchema.path("\$ref").asText()) + .isEqualTo("#/components/schemas/Owner") + } + + private class StubClassDefinition : ClassDefinition( + simpleName = "Owner", + fullName = "example.Owner", + ) { + @OptIn(InternalIntrospectionApi::class) + override val source: Any = Unit + + override fun isEnum(): Boolean = false + override fun getEnumConstants(): List = emptyList() + override fun getProperties(): List = emptyList() + override fun getAnnotations(): AnnotationSet = error("Not used by route definition decoding") + } + + private class TestSchemaContext : SchemaGenerationContext { + override val typeSchemaGenerator: TypeSchemaGenerator = TypeSchemaGenerator(this) + override val simpleTypeMappings: Map = emptyMap() + override val embeddedTypeProcessors: List = emptyList() + + override fun isEnum(type: OpenApiType): Boolean = false + override fun annotationsOf(type: OpenApiType): AnnotationSet = EmptyAnnotations + override fun propertiesOf(type: OpenApiType): List = emptyList() + override fun enumConstantsOf(type: OpenApiType): List = emptyList() + + override fun toOpenApiType(raw: ClassDefinition): OpenApiType = + OpenApiType( + simpleName = raw.simpleName, + fullName = raw.fullName, + generics = raw.generics.map(::toOpenApiType), + structureType = StructureType.valueOf(raw.structureType.name), + ) + } + + private object EmptyAnnotations : AnnotationSet { + override fun all(): List = emptyList() + override fun find(type: Class): AnnotationProjection? = null + override fun findAll(type: Class): List = emptyList() + override fun contains(simpleName: String): Boolean = false + } +} diff --git a/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilderTest.kt b/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilderTest.kt index c1dad709..9d965f0b 100644 --- a/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilderTest.kt +++ b/openapi-generator/src/test/kotlin/io/javalin/openapi/schema/OpenApiSchemaBuilderTest.kt @@ -1,7 +1,8 @@ package io.javalin.openapi.schema import com.fasterxml.jackson.databind.JsonNode -import io.javalin.openapi.experimental.ClassDefinition +import io.javalin.openapi.experimental.CustomProperty +import io.javalin.openapi.experimental.OpenApiType import io.javalin.openapi.experimental.processor.generators.ResultScheme import io.javalin.openapi.experimental.processor.shared.createArrayNode import io.javalin.openapi.experimental.processor.shared.createObjectNode @@ -46,6 +47,19 @@ internal class OpenApiSchemaBuilderTest { .containsEntry("title", "API") } + @Test + fun `should fill missing required info fields`() { + val json = OpenApiSchemaBuilder() + .openApiVersion("3.1.0") + .info { it.title("API") } + .ensureInfo(version = "1.0") + .toJson() + + assertThatJson(json) + .inPath("$.info") + .isEqualTo(json("""{ "title": "API", "version": "1.0" }""")) + } + @Test fun `should merge info across multiple calls`() { val json = OpenApiSchemaBuilder() @@ -711,7 +725,7 @@ internal class OpenApiSchemaBuilderTest { fun `should resolve component references`() { val schema = builder() - val addressDef = ClassDefinition(simpleName = "Address", fullName = "com.example.Address") + val addressDef = OpenApiType(simpleName = "Address", fullName = "com.example.Address") val addressSchema = ResultScheme(createObjectNode().apply { put("type", "object") set("properties", createObjectNode().apply { @@ -719,7 +733,6 @@ internal class OpenApiSchemaBuilderTest { }) }, emptySet()) - // Add a path that references Address via $ref schema.path("/users").operation("get") { responses { response("200") { @@ -744,14 +757,36 @@ internal class OpenApiSchemaBuilderTest { .containsEntry("type", "string") } + @Test + fun `should retain metadata when a matching reference is collected later`() { + val string = OpenApiType(simpleName = "String", fullName = "java.lang.String") + val discriminator = CustomProperty(name = "type", type = string) + val withMetadata = OpenApiType( + simpleName = "Circle", + fullName = "com.example.Circle", + extra = mutableListOf(discriminator), + ) + val withoutMetadata = OpenApiType( + simpleName = "Circle", + fullName = "com.example.Circle", + ) + val schema = builder() + + schema.addComponentSchema("First", ResultScheme(createObjectNode(), setOf(withMetadata))) + schema.addComponentSchema("Second", ResultScheme(createObjectNode(), setOf(withoutMetadata))) + schema.resolveComponentReferences { type -> + check(type.extra.contains(discriminator)) + ResultScheme(createObjectNode(), emptySet()) + } + } + @Test fun `should resolve transitive component references`() { val schema = builder() - val userDef = ClassDefinition(simpleName = "User", fullName = "com.example.User") - val addressDef = ClassDefinition(simpleName = "Address", fullName = "com.example.Address") + val userDef = OpenApiType(simpleName = "User", fullName = "com.example.User") + val addressDef = OpenApiType(simpleName = "Address", fullName = "com.example.Address") - // User references Address val userSchema = ResultScheme(createObjectNode().apply { put("type", "object") set("properties", createObjectNode().apply { @@ -798,7 +833,7 @@ internal class OpenApiSchemaBuilderTest { fun `should skip java-lang-Object references`() { val schema = builder() - val objectDef = ClassDefinition(simpleName = "Object", fullName = "java.lang.Object") + val objectDef = OpenApiType(simpleName = "Object", fullName = "java.lang.Object") schema.path("/test").operation("get") { responses { @@ -826,8 +861,8 @@ internal class OpenApiSchemaBuilderTest { fun `should resolve circular references between types`() { val schema = builder() - val aDef = ClassDefinition(simpleName = "A", fullName = "com.example.A") - val bDef = ClassDefinition(simpleName = "B", fullName = "com.example.B") + val aDef = OpenApiType(simpleName = "A", fullName = "com.example.A") + val bDef = OpenApiType(simpleName = "B", fullName = "com.example.B") schema.path("/test").operation("get") { responses { @@ -861,7 +896,7 @@ internal class OpenApiSchemaBuilderTest { fun `should resolve deep transitive chain`() { val schema = builder() - val aDef = ClassDefinition(simpleName = "A", fullName = "com.example.A") + val aDef = OpenApiType(simpleName = "A", fullName = "com.example.A") schema.path("/test").operation("get") { responses { @@ -879,9 +914,8 @@ internal class OpenApiSchemaBuilderTest { } } - // A -> B -> C (chain of 3) - val bDef = ClassDefinition(simpleName = "B", fullName = "com.example.B") - val cDef = ClassDefinition(simpleName = "C", fullName = "com.example.C") + val bDef = OpenApiType(simpleName = "B", fullName = "com.example.B") + val cDef = OpenApiType(simpleName = "C", fullName = "com.example.C") schema.resolveComponentReferences { type -> when (type.fullName) { @@ -929,6 +963,17 @@ internal class OpenApiSchemaBuilderTest { assertThatJson(roundTripped).inPath("$.paths['/users'].get.summary").isEqualTo("List users") } + @Test + fun `should identify operations after fromJson`() { + val original = builder() + original.path("/users").operation("get") { } + + val schema = OpenApiSchemaBuilder.fromJson(original.toJson()) + + assert(schema.hasOperation("/users", "get")) + assert(!schema.hasOperation("/users", "post")) + } + @Test fun `should modify schema after fromJson`() { val original = """{"openapi":"3.1.0","info":{"title":"API","version":"1.0"},"paths":{},"components":{"schemas":{}}}""" @@ -1168,7 +1213,7 @@ internal class OpenApiSchemaBuilderTest { @Test fun `should use content wrapper for parameter with complex type references`() { - val sorterDef = ClassDefinition(simpleName = "Sorter", fullName = "com.example.Sorter") + val sorterDef = OpenApiType(simpleName = "Sorter", fullName = "com.example.Sorter") val schema = builder() schema.path("/users").operation("get") { parameters { @@ -1496,7 +1541,6 @@ internal class OpenApiSchemaBuilderTest { val json = schema.toJson() - // Compile-time data preserved assertThatJson(json).inPath("$.paths['/users'].get.tags").isArray.containsExactly("users") assertThatJson(json).inPath("$.paths['/users'].get.summary").isEqualTo("Get users") assertThatJson(json).inPath("$.paths['/users'].get.operationId").isEqualTo("getUsers") @@ -1505,7 +1549,6 @@ internal class OpenApiSchemaBuilderTest { assertThatJson(json).inPath("$.paths['/users'].get.responses['200'].content['application/json'].schema.\$ref") .isEqualTo("#/components/schemas/User") - // Runtime additions assertThatJson(json).inPath("$.servers[0].url").isEqualTo("https://api.example.com") assertThatJson(json).inPath("$.components.securitySchemes.BearerAuth.scheme").isEqualTo("bearer") assertThatJson(json).inPath("$.paths['/users'].get.security[0].BearerAuth").isArray.isEmpty() diff --git a/openapi-ksp/build.gradle.kts b/openapi-ksp/build.gradle.kts new file mode 100644 index 00000000..d97d3671 --- /dev/null +++ b/openapi-ksp/build.gradle.kts @@ -0,0 +1,14 @@ +description = "Javalin OpenAPI KSP | Kotlin Symbol Processing backend for OpenAPI schema generation (experimental)" + +dependencies { + api(project(":openapi-generator")) + api(project(":introspection:introspection-ksp")) + api(libs.ksp.symbol.processing.api) + + testImplementation(libs.kctfork.core) + testImplementation(libs.kctfork.ksp) + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.junit.jupiter.engine) + testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.assertj.core) +} diff --git a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt new file mode 100644 index 00000000..1774cca0 --- /dev/null +++ b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/KspSchemaContext.kt @@ -0,0 +1,44 @@ +package io.javalin.openapi.ksp + +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.symbol.KSAnnotated +import io.javalin.introspection.AnnotationSet +import io.javalin.introspection.PropertyProjection +import io.javalin.introspection.TypeIntrospector +import io.javalin.introspection.ksp.KspTypeIntrospector +import io.javalin.openapi.OpenApiByFields +import io.javalin.openapi.experimental.IntrospectorSchemaContext +import io.javalin.openapi.experimental.OpenApiType +import io.javalin.openapi.experimental.SimpleType +import io.javalin.openapi.experimental.defaults.createDefaultSimpleTypeMappings + +internal class KspSchemaContext( + resolver: Resolver, + private val logger: KSPLogger, + simpleTypeMappings: Map = createDefaultSimpleTypeMappings(), +) : IntrospectorSchemaContext(simpleTypeMappings) { + + private val kspTypeIntrospector = KspTypeIntrospector(resolver) + override val introspector: TypeIntrospector = kspTypeIntrospector + + fun annotationsOf(annotated: KSAnnotated): AnnotationSet = + kspTypeIntrospector.annotationsOf(annotated) + + override fun propertiesOf(type: OpenApiType): List { + if (annotationsOf(type).find(OpenApiByFields::class.java)?.get("only")?.asBoolean() == true) { + logger.error( + "KSP does not support @OpenApiByFields(only = true). " + + "Use APT/Kapt for field-only schema generation, or remove only = true." + ) + return emptyList() + } + + return super.propertiesOf(type) + } + + override fun reportWarning(message: String) { + logger.warn(message) + } + +} diff --git a/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt new file mode 100644 index 00000000..aae0f902 --- /dev/null +++ b/openapi-ksp/src/main/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessor.kt @@ -0,0 +1,121 @@ +package io.javalin.openapi.ksp + +import com.google.devtools.ksp.processing.CodeGenerator +import com.google.devtools.ksp.processing.Dependencies +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSClassDeclaration +import io.javalin.openapi.JsonSchema +import io.javalin.openapi.OpenApi +import io.javalin.openapi.OpenApis +import io.javalin.openapi.experimental.OPENAPI_INFO_TITLE +import io.javalin.openapi.experimental.OPENAPI_INFO_VERSION +import io.javalin.openapi.schema.OpenApiSchemaGenerator + +class OpenApiSymbolProcessor( + private val codeGenerator: CodeGenerator, + private val logger: KSPLogger, + private val options: Map, +) : SymbolProcessor { + + private val writtenResources = mutableSetOf() + + override fun process(resolver: Resolver): List { + generateJsonSchemes(resolver) + generateOpenApiDocuments(resolver) + return emptyList() + } + + private fun generateJsonSchemes(resolver: Resolver) { + val context = KspSchemaContext(resolver = resolver, logger = logger) + val generated = + resolver + .getSymbolsWithAnnotation(JsonSchema::class.qualifiedName!!) + .filterIsInstance() + .mapNotNull { declaration -> + val type = context.introspect(declaration.asStarProjectedType()) + val generateResource = + context + .annotationsOf(type) + .find(JsonSchema::class.java) + ?.get("generateResource") + ?.asBoolean() + if (generateResource == false) { + return@mapNotNull null + } + + val json = + context + .typeSchemaGenerator + .createTypeSchema(type, inlineRefs = true) + .toJsonSchemaString() + val resourceName = declaration.qualifiedName?.asString() ?: type.fullName + writeResourceOnce("json-schemes/$resourceName", json) + resourceName + } + .toList() + + if (generated.isNotEmpty()) { + writeResourceOnce("json-schemes/index", generated.joinToString(separator = "\n")) + } + } + + private fun generateOpenApiDocuments(resolver: Resolver) { + val context = KspSchemaContext(resolver = resolver, logger = logger) + val routes = + (resolver.getSymbolsWithAnnotation(OpenApi::class.qualifiedName!!) + + resolver.getSymbolsWithAnnotation(OpenApis::class.qualifiedName!!)) + .distinct() + .flatMap { annotated -> + context + .annotationsOf(annotated) + .findAll(OpenApi::class.java) + .map { it.values } + } + .toList() + + if (routes.isEmpty()) { + return + } + + val generator = OpenApiSchemaGenerator( + context = context, + title = options[OPENAPI_INFO_TITLE] ?: "", + version = options[OPENAPI_INFO_VERSION] ?: "", + ) + + val resourceNames = + generator + .generateVersionedSchemas(routes) + .map { (version, json) -> + val resourceName = "openapi-${version.replace(" ", "-")}.json" + writeResourceOnce("openapi-plugin/$resourceName", json) + resourceName + } + + writeResourceOnce("openapi-plugin/.index", resourceNames.joinToString(separator = "\n")) + } + + private fun writeResourceOnce(path: String, content: String) { + if (!writtenResources.add(path)) { + return + } + + codeGenerator.createNewFileByPath(Dependencies(aggregating = true), path, extensionName = "") + .use { it.write(content.toByteArray()) } + } + +} + +class OpenApiSymbolProcessorProvider : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor = + OpenApiSymbolProcessor( + codeGenerator = environment.codeGenerator, + logger = environment.logger, + options = environment.options, + ) +} diff --git a/openapi-ksp/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider b/openapi-ksp/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider new file mode 100644 index 00000000..44b60885 --- /dev/null +++ b/openapi-ksp/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider @@ -0,0 +1 @@ +io.javalin.openapi.ksp.OpenApiSymbolProcessorProvider diff --git a/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/KspSchemaContextTest.kt b/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/KspSchemaContextTest.kt new file mode 100644 index 00000000..a9974b06 --- /dev/null +++ b/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/KspSchemaContextTest.kt @@ -0,0 +1,70 @@ +package io.javalin.openapi.ksp + +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider +import com.google.devtools.ksp.symbol.KSAnnotated +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.SourceFile +import com.tschuchort.compiletesting.symbolProcessorProviders +import com.tschuchort.compiletesting.useKsp2 +import org.assertj.core.api.Assertions.assertThat +import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCompilerApi::class) +class KspSchemaContextTest { + + private fun withResolver(block: (Resolver, KSPLogger) -> R): R { + var result: Result? = null + val provider = object : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor = + object : SymbolProcessor { + override fun process(resolver: Resolver): List { + if (result == null) result = runCatching { block(resolver, environment.logger) } + return emptyList() + } + } + } + val compilation = KotlinCompilation().apply { + useKsp2() + sources = listOf(SourceFile.kotlin("Trigger.kt", "package trigger\nclass Trigger")) + symbolProcessorProviders = mutableListOf(provider) + inheritClassPath = true + messageOutputStream = System.out + } + val compiled = compilation.compile() + check(compiled.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${compiled.messages}" } + return (result ?: error("KSP processor did not run")).getOrThrow() + } + + @Test + fun `generates a component schema end-to-end through the shared generator`() { + withResolver { resolver, logger -> + val context = KspSchemaContext(resolver, logger) + val declaration = resolver.getClassDeclarationByName( + resolver.getKSNameFromString("io.javalin.openapi.ksp.User") + )!! + val user = declaration + .asStarProjectedType() + + val schema = context.componentSchema(context.introspect(user)).json + val properties = schema.path("properties") + + assertThat(schema.path("type").asText()).isEqualTo("object") + assertThat(properties.path("id").path("type").asText()).isEqualTo("string") + assertThat(properties.path("age").path("type").asText()).isEqualTo("integer") + assertThat(properties.path("age").path("format").asText()).isEqualTo("int32") + assertThat(properties.path("tags").path("type").asText()).isEqualTo("array") + assertThat(properties.path("tags").path("items").path("type").asText()).isEqualTo("string") + } + } +} + +class User( + val id: String, + val age: Int, + val tags: List, +) diff --git a/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt b/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt new file mode 100644 index 00000000..702e7914 --- /dev/null +++ b/openapi-ksp/src/test/kotlin/io/javalin/openapi/ksp/OpenApiSymbolProcessorTest.kt @@ -0,0 +1,570 @@ +package io.javalin.openapi.ksp + +import com.google.devtools.ksp.processing.CodeGenerator +import com.google.devtools.ksp.processing.Dependencies +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider +import com.google.devtools.ksp.symbol.KSAnnotated +import com.tschuchort.compiletesting.CompilationResult +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.SourceFile +import com.tschuchort.compiletesting.kspSourcesDir +import com.tschuchort.compiletesting.symbolProcessorProviders +import com.tschuchort.compiletesting.useKsp2 +import io.javalin.openapi.experimental.processor.shared.jsonMapper +import org.assertj.core.api.Assertions.assertThat +import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCompilerApi::class) +class OpenApiSymbolProcessorTest { + + @Test + fun `writes a json-scheme resource for a JsonSchema type via the shared generator`() { + val (compilation, result) = compileWithKsp( + SourceFile.kotlin( + "Widget.kt", + """ + package app + import io.javalin.openapi.JsonSchema + @JsonSchema + class Widget(val name: String, val size: Int) + """.trimIndent() + ) + ) + check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } + + val doc = compilation.generatedJson("app.Widget") + assertThat(doc.path("type").asText()).isEqualTo("object") + assertThat(doc.path("properties").path("name").path("type").asText()).isEqualTo("string") + assertThat(doc.path("properties").path("size").path("type").asText()).isEqualTo("integer") + assertThat(doc.path("properties").path("size").path("format").asText()).isEqualTo("int32") + } + + @Test + fun `writes an openapi document for an OpenApi route via the shared generator`() { + val (compilation, result) = compileWithKsp( + SourceFile.kotlin( + "Routes.kt", + """ + package app + import io.javalin.openapi.HttpMethod + import io.javalin.openapi.OpenApiStatus + import io.javalin.openapi.OpenApi + import io.javalin.openapi.OpenApiContent + import io.javalin.openapi.OpenApiResponse + + class Account(val id: String, val age: Int) + + class Routes { + @OpenApi( + path = "/account", + methods = [HttpMethod.GET], + responses = [OpenApiResponse(status = OpenApiStatus.OK, content = [OpenApiContent(from = Account::class)])] + ) + fun getAccount() {} + } + """.trimIndent() + ) + ) + check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } + + val doc = compilation.generatedJson("openapi-default.json") + assertThat(doc.path("openapi").asText()).isEqualTo("3.1.0") + + val operation = doc.path("paths").path("/account").path("get") + assertThat(operation.isMissingNode).isFalse() + + assertThat(operation.path("responses").path("200").path("description").asText()).isEqualTo("OK") + + val schemaRef = operation.path("responses").path("200").path("content").path("application/json").path("schema").path("\$ref").asText() + assertThat(schemaRef).isEqualTo("#/components/schemas/Account") + + val account = doc.path("components").path("schemas").path("Account") + assertThat(account.path("type").asText()).isEqualTo("object") + assertThat(account.path("properties").path("id").path("type").asText()).isEqualTo("string") + assertThat(account.path("properties").path("age").path("type").asText()).isEqualTo("integer") + } + + @Test + fun `writes every repeated OpenApi route on the same declaration`() { + val (compilation, result) = compileWithKsp( + SourceFile.kotlin( + "RepeatedRoutes.kt", + """ + package app + import io.javalin.openapi.HttpMethod + import io.javalin.openapi.OpenApi + + class Routes { + @OpenApi(path = "/first", methods = [HttpMethod.GET]) + @OpenApi(path = "/second", methods = [HttpMethod.POST]) + fun routes() {} + } + """.trimIndent() + ) + ) + check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } + + val paths = compilation.generatedJson("openapi-default.json").path("paths") + assertThat(paths.path("/first").path("get").isMissingNode).isFalse() + assertThat(paths.path("/second").path("post").isMissingNode).isFalse() + } + + @Test + fun `auto-discovers discriminator subtypes via DiscriminatorMappingName`() { + val (compilation, result) = compileWithKsp( + SourceFile.kotlin( + "Shapes.kt", + """ + package app + import io.javalin.openapi.Discriminator + import io.javalin.openapi.DiscriminatorMappingName + import io.javalin.openapi.DiscriminatorProperty + import io.javalin.openapi.HttpMethod + import io.javalin.openapi.OneOf + import io.javalin.openapi.OpenApi + import io.javalin.openapi.OpenApiContent + import io.javalin.openapi.OpenApiResponse + import io.javalin.openapi.OpenApiStatus + + @OneOf(discriminator = Discriminator(property = DiscriminatorProperty(name = "type", type = String::class, injectInMappings = true))) + sealed interface Shape + + @DiscriminatorMappingName("circle") + data class Circle(val radius: Int) : Shape + + @DiscriminatorMappingName("square") + data class Square(val side: Int) : Shape + + data class ShapeEnvelope(val direct: Circle, val shape: Shape) + + class Shapes { + @OpenApi( + path = "/shape", + methods = [HttpMethod.GET], + responses = [OpenApiResponse(status = OpenApiStatus.OK, content = [OpenApiContent(from = ShapeEnvelope::class)])] + ) + fun shape() {} + } + """.trimIndent() + ) + ) + check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } + + val shape = compilation.generatedJson("openapi-default.json").path("components").path("schemas").path("Shape") + + val refs = shape.path("oneOf").map { it.path("\$ref").asText() } + assertThat(refs).containsExactlyInAnyOrder( + "#/components/schemas/Circle", + "#/components/schemas/Square", + ) + + assertThat(shape.path("discriminator").path("propertyName").asText()).isEqualTo("type") + assertThat(shape.path("discriminator").path("mapping").path("circle").asText()).isEqualTo("#/components/schemas/Circle") + assertThat(shape.path("discriminator").path("mapping").path("square").asText()).isEqualTo("#/components/schemas/Square") + assertThat( + compilation.generatedJson("openapi-default.json") + .path("components") + .path("schemas") + .path("Circle") + .path("properties") + .path("type") + .path("type") + .asText() + ).isEqualTo("string") + } + + @Test + fun `uses source names for JsonSchema resources when OpenApiName overrides the schema name`() { + val (compilation, result) = compileWithKsp( + SourceFile.kotlin( + "Renamed.kt", + """ + package app + import io.javalin.openapi.JsonSchema + import io.javalin.openapi.OpenApiName + + @JsonSchema + @OpenApiName("Renamed") + class Original(val value: String) + """.trimIndent() + ) + ) + check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } + + assertThat(compilation.generatedJson("app.Original").path("properties").has("value")).isTrue() + assertThat(compilation.generatedFile("index").readText()).isEqualTo("app.Original") + } + + @Test + fun `keeps primitive redirects required`() { + val (compilation, result) = compileWithKsp( + SourceFile.kotlin( + "Redirect.kt", + """ + package app + import io.javalin.openapi.HttpMethod + import io.javalin.openapi.OpenApi + import io.javalin.openapi.OpenApiContent + import io.javalin.openapi.OpenApiPropertyType + import io.javalin.openapi.OpenApiResponse + import io.javalin.openapi.OpenApiStatus + import java.time.Instant + + class Redirect(@get:OpenApiPropertyType(definedBy = Long::class) val createdAt: Instant) + + class Routes { + @OpenApi( + path = "/redirect", + methods = [HttpMethod.GET], + responses = [OpenApiResponse(status = OpenApiStatus.OK, content = [OpenApiContent(from = Redirect::class)])] + ) + fun redirect() {} + } + """.trimIndent() + ) + ) + check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } + + val redirect = compilation.generatedJson("openapi-default.json").path("components").path("schemas").path("Redirect") + assertThat(redirect.path("properties").path("createdAt").path("type").asText()).isEqualTo("integer") + assertThat(redirect.path("required").map { it.asText() }).contains("createdAt") + } + + @Test + fun `does not leak inlined sub-schemas from the JsonSchema pass into OpenApi refs`() { + val (compilation, result) = compileWithKsp( + SourceFile.kotlin( + "Shared.kt", + """ + package app + import io.javalin.openapi.HttpMethod + import io.javalin.openapi.JsonSchema + import io.javalin.openapi.OpenApi + import io.javalin.openapi.OpenApiContent + import io.javalin.openapi.OpenApiResponse + import io.javalin.openapi.OpenApiStatus + + data class Address(val city: String) + + @JsonSchema + class Profile(val address: Address) + + data class Account(val address: Address) + + class Routes { + @OpenApi( + path = "/account", + methods = [HttpMethod.GET], + responses = [OpenApiResponse(status = OpenApiStatus.OK, content = [OpenApiContent(from = Account::class)])] + ) + fun getAccount() {} + } + """.trimIndent() + ) + ) + check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } + + val schemas = compilation.generatedJson("openapi-default.json").path("components").path("schemas") + val addressProperty = schemas.path("Account").path("properties").path("address") + + assertThat(addressProperty.path("\$ref").asText()).isEqualTo("#/components/schemas/Address") + assertThat(addressProperty.has("properties")).isFalse() + assertThat(schemas.path("Address").path("properties").path("city").path("type").asText()).isEqualTo("string") + } + + @Test + fun `fails loudly for OpenApiByFields only true`() { + val (_, result) = compileWithKsp( + SourceFile.kotlin( + "FieldsOnly.kt", + """ + package app + import io.javalin.openapi.JsonSchema + import io.javalin.openapi.OpenApiByFields + + @JsonSchema + @OpenApiByFields(only = true) + class FieldsOnly(val value: String) + """.trimIndent() + ) + ) + + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.COMPILATION_ERROR) + assertThat(result.messages).contains("KSP does not support @OpenApiByFields(only = true)") + } + + @Test + fun `honors getter-site OpenApiIgnore annotations`() { + val (compilation, result) = compileWithKsp( + SourceFile.kotlin( + "GetterIgnored.kt", + """ + package app + import io.javalin.openapi.JsonSchema + import io.javalin.openapi.OpenApiIgnore + + @JsonSchema + class GetterIgnored(@get:OpenApiIgnore val secret: String, val kept: String) + """.trimIndent() + ) + ) + check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } + + val properties = compilation.generatedJson("app.GetterIgnored").path("properties") + assertThat(properties.has("kept")).isTrue() + assertThat(properties.has("secret")).isFalse() + } + + @Test + fun `does not publish private Kotlin vals as schema properties`() { + val (compilation, result) = compileWithKsp( + SourceFile.kotlin( + "PrivateDto.kt", + """ + package app + import io.javalin.openapi.JsonSchema + + @JsonSchema + class PrivateDto(val visible: String, private val secret: String) + """.trimIndent() + ) + ) + check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } + + val properties = compilation.generatedJson("app.PrivateDto").path("properties") + assertThat(properties.has("visible")).isTrue() + assertThat(properties.has("secret")).isFalse() + } + + @Test + fun `maps ByteArray to binary string schema`() { + val (compilation, result) = compileWithKsp( + SourceFile.kotlin( + "BytesDto.kt", + """ + package app + import io.javalin.openapi.JsonSchema + + @JsonSchema + class BytesDto(val payload: ByteArray) + """.trimIndent() + ) + ) + check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } + + val payload = compilation.generatedJson("app.BytesDto").path("properties").path("payload") + assertThat(payload.path("type").asText()).isEqualTo("string") + assertThat(payload.path("format").asText()).isEqualTo("binary") + } + + @Test + fun `deduplicates custom annotation extras across KSP property and getter sources`() { + val (compilation, result) = compileWithKsp( + SourceFile.kotlin( + "DuplicateCustomAnnotationDto.kt", + """ + package app + import io.javalin.openapi.CustomAnnotation + import io.javalin.openapi.JsonSchema + + @CustomAnnotation + @Target(AnnotationTarget.PROPERTY, AnnotationTarget.PROPERTY_GETTER) + annotation class KspExtra(val xDeduped: String) + + @JsonSchema + class DuplicateCustomAnnotationDto( + @KspExtra("property") + @get:KspExtra("getter") + val value: String + ) + """.trimIndent() + ) + ) + check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } + + val value = compilation.generatedJson("app.DuplicateCustomAnnotationDto") + .path("properties") + .path("value") + + assertThat(value.path("xDeduped").asText()).isEqualTo("property") + } + + @Test + fun `inherits custom schema annotations from Kotlin superclasses`() { + val (compilation, result) = compileWithKsp( + SourceFile.kotlin( + "InheritedExtra.kt", + """ + package app + import io.javalin.openapi.CustomAnnotation + import io.javalin.openapi.JsonSchema + import java.lang.annotation.Inherited + + @Inherited + @CustomAnnotation + @Target(AnnotationTarget.CLASS) + annotation class InheritedExtra(val inherited: String) + + @InheritedExtra("yes") + open class Parent + + @JsonSchema + class Child(val name: String) : Parent() + """.trimIndent() + ) + ) + check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } + + val child = compilation.generatedJson("app.Child") + assertThat(child.path("inherited").asText()).isEqualTo("yes") + } + + @Test + fun `does not fail when another processor creates OpenApi routes in a later KSP round`() { + val (_, result) = compileWithKsp( + SourceFile.kotlin( + "InitialRoutes.kt", + """ + package app + import io.javalin.openapi.HttpMethod + import io.javalin.openapi.OpenApi + + class InitialRoutes { + @OpenApi(path = "/initial", methods = [HttpMethod.GET]) + fun initial() {} + } + """.trimIndent() + ), + providers = mutableListOf(OpenApiSymbolProcessorProvider(), LaterRoundRouteProcessorProvider()) + ) + + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) + assertThat(result.messages).doesNotContain("FileAlreadyExistsException") + } + + @Test + fun `honors JsonSchema requireNonNulls false`() { + val (compilation, result) = compileWithKsp( + SourceFile.kotlin( + "OptionalSchema.kt", + """ + package app + import io.javalin.openapi.JsonSchema + + @JsonSchema(requireNonNulls = false) + class OptionalSchema(val name: String, val age: Int) + """.trimIndent() + ) + ) + check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } + + val document = compilation.generatedJson("app.OptionalSchema") + assertThat(document.path("\$schema").asText()).isEqualTo("https://json-schema.org/draft/2020-12/schema") + assertThat(document.has("required")).isFalse() + assertThat(document.path("properties").path("name").path("type").asText()).isEqualTo("string") + assertThat(document.path("properties").path("age").path("type").asText()).isEqualTo("integer") + } + + @Test + fun `honors JsonSchema generateResource false`() { + val (compilation, result) = compileWithKsp( + SourceFile.kotlin( + "DisabledSchema.kt", + """ + package app + import io.javalin.openapi.JsonSchema + + @JsonSchema(generateResource = false) + class DisabledSchema(val ignored: String) + """.trimIndent() + ) + ) + check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } + + val generatedNames = compilation.kspSourcesDir.walkTopDown().map { it.name }.toList() + assertThat(generatedNames).doesNotContain("app.DisabledSchema") + } + + @Test + fun `inlines nested types in standalone JsonSchema resources`() { + val (compilation, result) = compileWithKsp( + SourceFile.kotlin( + "NestedSchema.kt", + """ + package app + import io.javalin.openapi.JsonSchema + + class NestedChild(val value: String) + + @JsonSchema + class NestedSchema(val child: NestedChild) + """.trimIndent() + ) + ) + check(result.exitCode == KotlinCompilation.ExitCode.OK) { "KSP compilation failed: ${result.messages}" } + + val child = compilation.generatedJson("app.NestedSchema").path("properties").path("child") + assertThat(child.path("type").asText()).isEqualTo("object") + assertThat(child.path("properties").path("value").path("type").asText()).isEqualTo("string") + assertThat(child.path("required").map { it.asText() }).containsExactly("value") + } + + private fun compileWithKsp( + vararg sources: SourceFile, + providers: List = listOf(OpenApiSymbolProcessorProvider()), + ): Pair { + val compilation = KotlinCompilation().apply { + useKsp2() + this.sources = sources.toList() + symbolProcessorProviders = providers.toMutableList() + inheritClassPath = true + messageOutputStream = System.out + } + + return compilation to compilation.compile() + } + + private fun KotlinCompilation.generatedJson(name: String) = + jsonMapper.readTree(generatedFile(name).readText()) + + private fun KotlinCompilation.generatedFile(name: String) = + kspSourcesDir.walkTopDown().firstOrNull { it.name == name } + ?: error("generated file $name not found. Output tree:\n" + kspSourcesDir.walkTopDown().joinToString("\n")) +} + +private class LaterRoundRouteProcessorProvider : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor = + LaterRoundRouteProcessor(environment.codeGenerator) +} + +private class LaterRoundRouteProcessor(private val codeGenerator: CodeGenerator) : SymbolProcessor { + private var generated = false + + override fun process(resolver: Resolver): List { + if (!generated) { + generated = true + codeGenerator.createNewFile(Dependencies(aggregating = true), "app", "GeneratedRoutes") + .use { + it.write( + """ + package app + + import io.javalin.openapi.HttpMethod + import io.javalin.openapi.OpenApi + + class GeneratedRoutes { + @OpenApi(path = "/generated", methods = [HttpMethod.GET]) + fun generated() {} + } + """.trimIndent().toByteArray() + ) + } + } + + return emptyList() + } +} diff --git a/openapi-specification/src/main/kotlin/io/javalin/openapi/OpenApiPluginRouteHandler.kt b/openapi-specification/src/main/kotlin/io/javalin/openapi/OpenApiPluginRouteHandler.kt new file mode 100644 index 00000000..c2a0e9df --- /dev/null +++ b/openapi-specification/src/main/kotlin/io/javalin/openapi/OpenApiPluginRouteHandler.kt @@ -0,0 +1,3 @@ +package io.javalin.openapi + +interface OpenApiPluginRouteHandler diff --git a/openapi-specification/src/main/kotlin/io/javalin/openapi/OpenApiStatus.kt b/openapi-specification/src/main/kotlin/io/javalin/openapi/OpenApiStatus.kt new file mode 100644 index 00000000..851e33a8 --- /dev/null +++ b/openapi-specification/src/main/kotlin/io/javalin/openapi/OpenApiStatus.kt @@ -0,0 +1,136 @@ +package io.javalin.openapi + +object OpenApiStatus { + + const val CONTINUE = "100" + const val SWITCHING_PROTOCOLS = "101" + const val PROCESSING = "102" + const val EARLY_HINTS = "103" + const val OK = "200" + const val CREATED = "201" + const val ACCEPTED = "202" + const val NON_AUTHORITATIVE_INFORMATION = "203" + const val NO_CONTENT = "204" + const val RESET_CONTENT = "205" + const val PARTIAL_CONTENT = "206" + const val MULTI_STATUS = "207" + const val ALREADY_REPORTED = "208" + const val IM_USED = "226" + const val MULTIPLE_CHOICES = "300" + const val MOVED_PERMANENTLY = "301" + const val FOUND = "302" + const val SEE_OTHER = "303" + const val NOT_MODIFIED = "304" + const val USE_PROXY = "305" + const val TEMPORARY_REDIRECT = "307" + const val PERMANENT_REDIRECT = "308" + const val BAD_REQUEST = "400" + const val UNAUTHORIZED = "401" + const val PAYMENT_REQUIRED = "402" + const val FORBIDDEN = "403" + const val NOT_FOUND = "404" + const val METHOD_NOT_ALLOWED = "405" + const val NOT_ACCEPTABLE = "406" + const val PROXY_AUTHENTICATION_REQUIRED = "407" + const val REQUEST_TIMEOUT = "408" + const val CONFLICT = "409" + const val GONE = "410" + const val LENGTH_REQUIRED = "411" + const val PRECONDITION_FAILED = "412" + const val CONTENT_TOO_LARGE = "413" + const val URI_TOO_LONG = "414" + const val UNSUPPORTED_MEDIA_TYPE = "415" + const val RANGE_NOT_SATISFIABLE = "416" + const val EXPECTATION_FAILED = "417" + const val IM_A_TEAPOT = "418" + const val ENHANCE_YOUR_CALM = "420" + const val MISDIRECTED_REQUEST = "421" + const val UNPROCESSABLE_CONTENT = "422" + const val LOCKED = "423" + const val FAILED_DEPENDENCY = "424" + const val TOO_EARLY = "425" + const val UPGRADE_REQUIRED = "426" + const val PRECONDITION_REQUIRED = "428" + const val TOO_MANY_REQUESTS = "429" + const val REQUEST_HEADER_FIELDS_TOO_LARGE = "431" + const val UNAVAILABLE_FOR_LEGAL_REASONS = "451" + const val CLIENT_CLOSED_REQUEST = "499" + const val INTERNAL_SERVER_ERROR = "500" + const val NOT_IMPLEMENTED = "501" + const val BAD_GATEWAY = "502" + const val SERVICE_UNAVAILABLE = "503" + const val GATEWAY_TIMEOUT = "504" + const val HTTP_VERSION_NOT_SUPPORTED = "505" + const val INSUFFICIENT_STORAGE = "507" + const val LOOP_DETECTED = "508" + const val NETWORK_AUTHENTICATION_REQUIRED = "511" + + fun reasonPhrase(status: String): String? = + REASON_PHRASES[status] ?: status.toIntOrNull()?.let { "Unknown HTTP code" } + + private val REASON_PHRASES: Map = mapOf( + CONTINUE to "Continue", + SWITCHING_PROTOCOLS to "Switching Protocols", + PROCESSING to "Processing", + EARLY_HINTS to "Early Hints", + OK to "OK", + CREATED to "Created", + ACCEPTED to "Accepted", + NON_AUTHORITATIVE_INFORMATION to "Non-Authoritative Information", + NO_CONTENT to "No Content", + RESET_CONTENT to "Reset Content", + PARTIAL_CONTENT to "Partial Content", + MULTI_STATUS to "Multi-Status", + ALREADY_REPORTED to "Already Reported", + IM_USED to "IM Used", + MULTIPLE_CHOICES to "Multiple Choices", + MOVED_PERMANENTLY to "Moved Permanently", + FOUND to "Found", + SEE_OTHER to "See Other", + NOT_MODIFIED to "Not Modified", + USE_PROXY to "Use Proxy", + TEMPORARY_REDIRECT to "Temporary Redirect", + PERMANENT_REDIRECT to "Permanent Redirect", + BAD_REQUEST to "Bad Request", + UNAUTHORIZED to "Unauthorized", + PAYMENT_REQUIRED to "Payment Required", + FORBIDDEN to "Forbidden", + NOT_FOUND to "Not Found", + METHOD_NOT_ALLOWED to "Method Not Allowed", + NOT_ACCEPTABLE to "Not Acceptable", + PROXY_AUTHENTICATION_REQUIRED to "Proxy Authentication Required", + REQUEST_TIMEOUT to "Request Timeout", + CONFLICT to "Conflict", + GONE to "Gone", + LENGTH_REQUIRED to "Length Required", + PRECONDITION_FAILED to "Precondition Failed", + CONTENT_TOO_LARGE to "Content Too Large", + URI_TOO_LONG to "URI Too Long", + UNSUPPORTED_MEDIA_TYPE to "Unsupported Media Type", + RANGE_NOT_SATISFIABLE to "Range Not Satisfiable", + EXPECTATION_FAILED to "Expectation Failed", + IM_A_TEAPOT to "I'm a teapot", + ENHANCE_YOUR_CALM to "Enhance your Calm", + MISDIRECTED_REQUEST to "Misdirected Request", + UNPROCESSABLE_CONTENT to "Unprocessable Content", + LOCKED to "Locked", + FAILED_DEPENDENCY to "Failed Dependency", + TOO_EARLY to "Too Early", + UPGRADE_REQUIRED to "Upgrade Required", + PRECONDITION_REQUIRED to "Precondition Required", + TOO_MANY_REQUESTS to "Too Many Requests", + REQUEST_HEADER_FIELDS_TOO_LARGE to "Request Header Fields Too Large", + UNAVAILABLE_FOR_LEGAL_REASONS to "Unavailable For Legal Reasons", + CLIENT_CLOSED_REQUEST to "Client Closed Request", + INTERNAL_SERVER_ERROR to "Internal Server Error", + NOT_IMPLEMENTED to "Not Implemented", + BAD_GATEWAY to "Bad Gateway", + SERVICE_UNAVAILABLE to "Service Unavailable", + GATEWAY_TIMEOUT to "Gateway Timeout", + HTTP_VERSION_NOT_SUPPORTED to "HTTP Version Not Supported", + INSUFFICIENT_STORAGE to "Insufficient Storage", + LOOP_DETECTED to "Loop Detected", + NETWORK_AUTHENTICATION_REQUIRED to "Network Authentication Required", + ) + +} diff --git a/openapi-specification/src/test/kotlin/io/javalin/openapi/OpenApiStatusTest.kt b/openapi-specification/src/test/kotlin/io/javalin/openapi/OpenApiStatusTest.kt new file mode 100644 index 00000000..835774a1 --- /dev/null +++ b/openapi-specification/src/test/kotlin/io/javalin/openapi/OpenApiStatusTest.kt @@ -0,0 +1,14 @@ +package io.javalin.openapi + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +internal class OpenApiStatusTest { + + @Test + fun `returns known unknown and non-numeric status phrases`() { + assertThat(OpenApiStatus.reasonPhrase(OpenApiStatus.OK)).isEqualTo("OK") + assertThat(OpenApiStatus.reasonPhrase("599")).isEqualTo("Unknown HTTP code") + assertThat(OpenApiStatus.reasonPhrase("default")).isNull() + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 244aa2ae..5734f5ff 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -4,10 +4,20 @@ include( "openapi-specification", "openapi-generator", "openapi-annotation-processor", + "openapi-dynamic", + "openapi-ksp", + "introspection", + "introspection:introspection-api", + "introspection:introspection-runtime", + "introspection:introspection-jap", + "introspection:introspection-ksp", + "introspection:introspection-test", "javalin-plugins", "javalin-plugins:javalin-openapi-plugin", "javalin-plugins:javalin-swagger-plugin", "javalin-plugins:javalin-redoc-plugin", + "javalin-plugins:javalin-openapi-dynamic-hook", "examples", - "examples:javalin-gradle-kotlin" -) \ No newline at end of file + "examples:javalin-gradle-kotlin", + "examples:javalin-ksp-kotlin" +)