diff --git a/README.md b/README.md index 65dd108..45d3e98 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,76 @@ compile "cc.vileda:kotlin-openapi3-dsl:1.5.0" for a complete example [look at the test](src/test/kotlin/cc/vileda/openapi/dsl/OpenApiDslTest.kt) +### reusable components + +The `components` block supports every OpenAPI 3.0 component type: schemas, +responses, parameters, examples, request bodies, headers, security schemes, +links, and callbacks. Reference helpers accept component names and create the +canonical `#/components/...` reference: + +```kotlin +components { + parameter("PageSize") { + name = "pageSize" + `in` = "query" + } + response("NotFound") { + description = "resource not found" + } +} + +paths { + path("/items") { + get { + parameterRef("PageSize") + responses { + responseRef("404", "NotFound") + } + } + } +} +``` + +### recursive schemas + +`mediaTypeRef()` and `mediaTypeArrayOfRef()` automatically add `T` and +all transitively referenced models to `components.schemas`. An explicit +components block is not required: + +```kotlin +data class ErrorDetail(val message: String) +data class ErrorResponse(val detail: ErrorDetail) + +content { + mediaTypeRef("application/json") +} +``` + +`components { schema() }` also registers transitive models. Explicit schema +customisations take precedence over automatically discovered schemas. + +### Kotlin required properties + +Required-property inference is disabled by default. Enable it for one DSL build: + +```kotlin +openapiDsl( + OpenApiDslConfig(inferRequiredFromKotlinNullability = true) +) { + // ... +} +``` + +Non-null Kotlin primary-constructor properties without default values are then +added to the schema's `required` list. Nullable and defaulted properties remain +optional. Explicit Swagger `requiredMode` annotations take precedence. + +## output + +`asJsonString()` and `asFile()` preserve the insertion order used by the DSL. +Use `asJsonString(pretty = true)` for formatted JSON without writing a file. +`asJson()` returns a `JSONObject`, whose key order is intentionally unspecified. + ## license ``` Copyright 2017 Tristan Leo diff --git a/build.gradle b/build.gradle index 5d894b1..41dd6e6 100644 --- a/build.gradle +++ b/build.gradle @@ -16,7 +16,7 @@ apply plugin: 'maven-publish' apply plugin: 'signing' group 'cc.vileda' -version '1.5.0' +version '1.6.0' repositories { mavenCentral() diff --git a/src/main/kotlin/cc/vileda/openapi/dsl/ComponentsDsl.kt b/src/main/kotlin/cc/vileda/openapi/dsl/ComponentsDsl.kt new file mode 100644 index 0000000..aefb625 --- /dev/null +++ b/src/main/kotlin/cc/vileda/openapi/dsl/ComponentsDsl.kt @@ -0,0 +1,69 @@ +package cc.vileda.openapi.dsl + +import io.swagger.v3.oas.models.Components +import io.swagger.v3.oas.models.Operation +import io.swagger.v3.oas.models.callbacks.Callback +import io.swagger.v3.oas.models.examples.Example +import io.swagger.v3.oas.models.headers.Header +import io.swagger.v3.oas.models.links.Link +import io.swagger.v3.oas.models.media.MediaType +import io.swagger.v3.oas.models.parameters.Parameter +import io.swagger.v3.oas.models.parameters.RequestBody +import io.swagger.v3.oas.models.responses.ApiResponse +import io.swagger.v3.oas.models.responses.ApiResponses + +fun Components.response(name: String, init: ApiResponse.() -> Unit) { + addResponses(name, ApiResponse().apply(init)) +} + +fun Components.parameter(name: String, init: Parameter.() -> Unit) { + addParameters(name, Parameter().apply(init)) +} + +fun Components.example(name: String, init: Example.() -> Unit) { + addExamples(name, Example().apply(init)) +} + +fun Components.requestBody(name: String, init: RequestBody.() -> Unit) { + addRequestBodies(name, RequestBody().apply(init)) +} + +fun Components.header(name: String, init: Header.() -> Unit) { + addHeaders(name, Header().apply(init)) +} + +fun Components.link(name: String, init: Link.() -> Unit) { + addLinks(name, Link().apply(init)) +} + +fun Components.callback(name: String, init: Callback.() -> Unit) { + addCallbacks(name, Callback().apply(init)) +} + +fun Operation.parameterRef(name: String) { + addParametersItem(Parameter().apply { `$ref` = name }) +} + +fun ApiResponses.responseRef(status: String, name: String) { + addApiResponse(status, ApiResponse().apply { `$ref` = name }) +} + +fun Operation.requestBodyRef(name: String) { + requestBody = RequestBody().apply { `$ref` = name } +} + +fun MediaType.exampleRef(name: String, componentName: String = name) { + addExamples(name, Example().apply { `$ref` = componentName }) +} + +fun ApiResponse.headerRef(name: String, componentName: String = name) { + addHeaderObject(name, Header().apply { `$ref` = componentName }) +} + +fun ApiResponse.linkRef(name: String, componentName: String = name) { + addLink(name, Link().apply { `$ref` = componentName }) +} + +fun Operation.callbackRef(name: String, componentName: String = name) { + addCallback(name, Callback().apply { `$ref` = componentName }) +} diff --git a/src/main/kotlin/cc/vileda/openapi/dsl/KotlinRequiredProperties.kt b/src/main/kotlin/cc/vileda/openapi/dsl/KotlinRequiredProperties.kt new file mode 100644 index 0000000..a22cbd7 --- /dev/null +++ b/src/main/kotlin/cc/vileda/openapi/dsl/KotlinRequiredProperties.kt @@ -0,0 +1,121 @@ +package cc.vileda.openapi.dsl + +import com.fasterxml.jackson.annotation.JsonProperty +import io.swagger.v3.oas.annotations.media.Schema as SchemaAnnotation +import io.swagger.v3.oas.models.media.Schema +import kotlin.reflect.KClass +import kotlin.reflect.KParameter +import kotlin.reflect.KProperty1 +import kotlin.reflect.KType +import kotlin.reflect.full.findAnnotation +import kotlin.reflect.full.memberProperties +import kotlin.reflect.full.primaryConstructor +import kotlin.reflect.jvm.javaField +import kotlin.reflect.jvm.javaGetter + +internal fun inferRequiredProperties( + rootType: Class<*>, + rootName: String, + schemas: Map> +) { + RequiredPropertyInference(schemas).visit(rootType.kotlin, rootName) +} + +private class RequiredPropertyInference( + private val schemas: Map> +) { + private val visited = mutableSetOf>() + + fun visit(type: KClass<*>, schemaName: String? = null) { + if (!visited.add(type) || type.java.getAnnotation(Metadata::class.java) == null) return + + val schema = schemas[schemaName ?: componentName(type)] ?: return + val properties = type.memberProperties.associateBy { it.name } + val constructor = type.primaryConstructor ?: return + + constructor.parameters + .filter { it.kind == KParameter.Kind.VALUE } + .forEach { parameter -> + val property = parameter.name?.let(properties::get) + val annotations = schemaAnnotations(parameter, property) + val propertyName = schemaPropertyName(parameter, property, schema, annotations) + if (propertyName != null && shouldBeRequired(parameter, annotations)) { + if (schema.required?.contains(propertyName) != true) { + schema.addRequiredItem(propertyName) + } + } + visitNestedTypes(parameter.type) + } + } + + private fun visitNestedTypes(type: KType) { + val classifier = type.classifier as? KClass<*> ?: return + val javaType = classifier.java + if (!javaType.isArray && + !Iterable::class.java.isAssignableFrom(javaType) && + !Map::class.java.isAssignableFrom(javaType) + ) { + visit(classifier) + } + type.arguments.forEach { argument -> argument.type?.let(::visitNestedTypes) } + } + + private fun schemaPropertyName( + parameter: KParameter, + property: KProperty1?, + schema: Schema<*>, + annotations: List + ): String? { + val declaredName = parameter.name ?: return null + val candidates = buildList { + jsonPropertyNames(parameter, property).forEach(::add) + annotations.mapNotNullTo(this) { it.name.takeIf(String::isNotBlank) } + add(declaredName) + } + return candidates.firstOrNull { schema.properties?.containsKey(it) == true } + } + + private fun shouldBeRequired( + parameter: KParameter, + annotations: List + ): Boolean { + return when (annotations + .map { it.requiredMode } + .firstOrNull { it != SchemaAnnotation.RequiredMode.AUTO }) { + SchemaAnnotation.RequiredMode.REQUIRED -> true + SchemaAnnotation.RequiredMode.NOT_REQUIRED -> false + else -> !parameter.type.isMarkedNullable && !parameter.isOptional + } + } + + private fun schemaAnnotations( + parameter: KParameter, + property: KProperty1? + ): List { + return listOfNotNull( + parameter.findAnnotation(), + property?.findAnnotation(), + property?.javaField?.getAnnotation(SchemaAnnotation::class.java), + property?.javaGetter?.getAnnotation(SchemaAnnotation::class.java) + ) + } + + private fun jsonPropertyNames( + parameter: KParameter, + property: KProperty1? + ): List { + return listOfNotNull( + parameter.findAnnotation()?.value, + property?.findAnnotation()?.value, + property?.javaField?.getAnnotation(JsonProperty::class.java)?.value, + property?.javaGetter?.getAnnotation(JsonProperty::class.java)?.value + ).filter(String::isNotBlank) + } + + private fun componentName(type: KClass<*>): String { + return type.java.getAnnotation(SchemaAnnotation::class.java) + ?.name + ?.takeIf(String::isNotBlank) + ?: type.java.simpleName + } +} diff --git a/src/main/kotlin/cc/vileda/openapi/dsl/OpenApiDsl.kt b/src/main/kotlin/cc/vileda/openapi/dsl/OpenApiDsl.kt index e8bc10b..3ea25c5 100644 --- a/src/main/kotlin/cc/vileda/openapi/dsl/OpenApiDsl.kt +++ b/src/main/kotlin/cc/vileda/openapi/dsl/OpenApiDsl.kt @@ -1,6 +1,5 @@ package cc.vileda.openapi.dsl -import io.swagger.v3.core.converter.ModelConverters import io.swagger.v3.core.util.Json import io.swagger.v3.oas.models.* import io.swagger.v3.oas.models.examples.Example @@ -18,30 +17,41 @@ import io.swagger.v3.oas.models.tags.Tag import io.swagger.v3.parser.OpenAPIV3Parser import org.json.JSONObject import java.io.File -import java.math.BigDecimal import java.nio.file.Files -import java.time.LocalDate -import java.time.LocalDateTime -import java.util.* fun openapiDsl(init: OpenAPI.() -> Unit): OpenAPI { - val openapi3 = OpenAPI() - openapi3.init() - return openapi3 + return openapiDsl(OpenApiDslConfig(), init) } -internal fun validatedJson(api: OpenAPI): JSONObject { - val json = Json.mapper().writeValueAsString(api) +fun openapiDsl(config: OpenApiDslConfig, init: OpenAPI.() -> Unit): OpenAPI { + return buildOpenApi(config, init) +} + +private fun validatedJsonString(api: OpenAPI, pretty: Boolean): String { + val mapper = Json.mapper() + val json = if (pretty) { + mapper.writerWithDefaultPrettyPrinter().writeValueAsString(api) + } else { + mapper.writeValueAsString(api) + } OpenAPIV3Parser().read(toFile(json).absolutePath) - return JSONObject(json) + return json +} + +internal fun validatedJson(api: OpenAPI): JSONObject { + return JSONObject(validatedJsonString(api, pretty = false)) } fun OpenAPI.asJson(): JSONObject { return validatedJson(this) } +fun OpenAPI.asJsonString(pretty: Boolean = false): String { + return validatedJsonString(this, pretty) +} + fun OpenAPI.asFile(): File { - return toFile(asJson().toString(2)) + return toFile(asJsonString(pretty = true)) } private fun toFile(json: String): File { @@ -113,26 +123,20 @@ fun OpenAPI.components(init: Components.() -> Unit) { } inline fun Components.schema(init: Schema<*>.() -> Unit) { - schemas = schemas ?: mutableMapOf() - val schema = findSchema() ?: throw Exception("could not find schema") - schema.init() - schemas.put(T::class.java.simpleName, schema) + val resolved = resolveTypeSchemas(T::class.java) + ?: throw IllegalArgumentException("could not resolve schema for ${T::class.qualifiedName}") + resolved.root.init() + addResolvedSchemas(resolved) } inline fun Components.schema() { - schemas = schemas ?: mutableMapOf() - val schema = findSchema() - schemas.put(T::class.java.simpleName, schema) + val resolved = resolveTypeSchemas(T::class.java) + ?: throw IllegalArgumentException("could not resolve schema for ${T::class.qualifiedName}") + addResolvedSchemas(resolved) } fun Components.securityScheme(name: String, init: SecurityScheme.() -> Unit) { - val security = SecurityScheme() - security.init() - securitySchemes = securitySchemes ?: mutableMapOf() - - // Security schemes will not validate with a name value. Use https://editor.swagger.io to validate. - // Use the type as the name. see https://swagger.io/docs/specification/authentication/ - addSecuritySchemes(name, security) + addSecuritySchemes(name, SecurityScheme().apply(init)) } fun SecurityScheme.flows(init: OAuthFlows.() -> Unit) { @@ -303,9 +307,10 @@ inline fun mediaType(): MediaType { } inline fun mediaTypeRef(): MediaType { + val referenceName = registerSchemaReference(T::class.java) val mediaType = MediaType() mediaType.schema = Schema() - mediaType.schema.`$ref` = T::class.java.simpleName + mediaType.schema.`$ref` = referenceName return mediaType } @@ -366,30 +371,11 @@ inline fun Content.mediaTypeArrayOf(name: String) { } inline fun findSchema(): Schema<*>? { - return getEnumSchema() ?: when (T::class) { - String::class -> StringSchema() - Boolean::class -> BooleanSchema() - java.lang.Boolean::class -> BooleanSchema() - Int::class -> IntegerSchema() - Integer::class -> IntegerSchema() - List::class -> ArraySchema() - Long::class -> IntegerSchema().format("int64") - BigDecimal::class -> IntegerSchema().format("") - Date::class -> DateSchema() - LocalDate::class -> DateSchema() - LocalDateTime::class -> DateTimeSchema() - else -> ModelConverters.getInstance().read(T::class.java)[T::class.java.simpleName] - } + return resolveTypeSchemas(T::class.java)?.root } inline fun getEnumSchema(): Schema<*>? { - val values = T::class.java.enumConstants ?: return null - - val schema = StringSchema() - for (enumVal in values) { - schema.addEnumItem(enumVal.toString()) - } - return schema + return enumSchema(T::class.java) } fun MediaType.extension(name: String, value: Any) { diff --git a/src/main/kotlin/cc/vileda/openapi/dsl/OpenApiDslConfig.kt b/src/main/kotlin/cc/vileda/openapi/dsl/OpenApiDslConfig.kt new file mode 100644 index 0000000..664c489 --- /dev/null +++ b/src/main/kotlin/cc/vileda/openapi/dsl/OpenApiDslConfig.kt @@ -0,0 +1,10 @@ +package cc.vileda.openapi.dsl + +/** Options applied to one [openapiDsl] build. */ +data class OpenApiDslConfig( + /** + * Adds non-null Kotlin primary-constructor properties without default values + * to the generated schema's `required` list. + */ + val inferRequiredFromKotlinNullability: Boolean = false +) diff --git a/src/main/kotlin/cc/vileda/openapi/dsl/SchemaRegistry.kt b/src/main/kotlin/cc/vileda/openapi/dsl/SchemaRegistry.kt new file mode 100644 index 0000000..8356a07 --- /dev/null +++ b/src/main/kotlin/cc/vileda/openapi/dsl/SchemaRegistry.kt @@ -0,0 +1,134 @@ +package cc.vileda.openapi.dsl + +import io.swagger.v3.core.converter.ModelConverters +import io.swagger.v3.oas.models.Components +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.media.ArraySchema +import io.swagger.v3.oas.models.media.BooleanSchema +import io.swagger.v3.oas.models.media.DateSchema +import io.swagger.v3.oas.models.media.DateTimeSchema +import io.swagger.v3.oas.models.media.IntegerSchema +import io.swagger.v3.oas.models.media.Schema +import io.swagger.v3.oas.models.media.StringSchema +import java.math.BigDecimal +import java.time.LocalDate +import java.time.LocalDateTime +import java.util.ArrayDeque +import java.util.Date +import java.util.LinkedHashMap + +@PublishedApi +internal data class ResolvedTypeSchemas( + val rootName: String, + val root: Schema<*>, + val schemas: Map> +) + +private class SchemaBuildScope(val config: OpenApiDslConfig) { + private val resolvedTypes = LinkedHashMap, ResolvedTypeSchemas>() + + fun register(type: Class<*>): String { + resolvedTypes[type]?.let { return it.rootName } + val resolved = resolveTypeSchemas(type) ?: return type.simpleName + resolvedTypes[type] = resolved + return resolved.rootName + } + + fun commitTo(api: OpenAPI) { + if (resolvedTypes.isEmpty()) return + + val components = api.components ?: Components().also { api.components = it } + resolvedTypes.values.forEach { resolved -> + resolved.schemas.forEach { (name, schema) -> + if (components.schemas?.containsKey(name) != true) { + components.addSchemas(name, schema) + } + } + } + } +} + +private val schemaScopes = ThreadLocal>() + +private val defaultConfig = OpenApiDslConfig() + +private fun currentConfig(): OpenApiDslConfig { + return schemaScopes.get()?.peekLast()?.config ?: defaultConfig +} + +internal fun buildOpenApi(config: OpenApiDslConfig, init: OpenAPI.() -> Unit): OpenAPI { + val stack = schemaScopes.get() ?: ArrayDeque().also(schemaScopes::set) + val scope = SchemaBuildScope(config) + stack.addLast(scope) + + return try { + OpenAPI().also { api -> + api.init() + scope.commitTo(api) + } + } finally { + stack.removeLast() + if (stack.isEmpty()) schemaScopes.remove() + } +} + +@PublishedApi +internal fun registerSchemaReference(type: Class<*>): String { + return schemaScopes.get()?.peekLast()?.register(type) ?: type.simpleName +} + +@PublishedApi +internal fun resolveTypeSchemas(type: Class<*>): ResolvedTypeSchemas? { + val knownSchema = knownSchema(type) + val result = if (knownSchema != null) { + ResolvedTypeSchemas( + rootName = type.simpleName, + root = knownSchema, + schemas = linkedMapOf(type.simpleName to knownSchema) + ) + } else { + val resolved = ModelConverters.getInstance().readAllAsResolvedSchema(type) ?: return null + val root = resolved.schema ?: return null + val schemas = LinkedHashMap>() + resolved.referencedSchemas?.forEach { (name, schema) -> schemas[name] = schema } + val rootName = schemas.entries.firstOrNull { (_, schema) -> + schema === root || schema == root + }?.key ?: type.simpleName + schemas.putIfAbsent(rootName, root) + ResolvedTypeSchemas(rootName, root, schemas) + } + + if (currentConfig().inferRequiredFromKotlinNullability) { + inferRequiredProperties(type, result.rootName, result.schemas) + } + return result +} + +@PublishedApi +internal fun Components.addResolvedSchemas(resolved: ResolvedTypeSchemas) { + resolved.schemas.forEach { (name, schema) -> + if (schemas?.containsKey(name) != true) addSchemas(name, schema) + } +} + +@PublishedApi +internal fun enumSchema(type: Class<*>): Schema<*>? { + val values = type.enumConstants ?: return null + return StringSchema().apply { + values.forEach { addEnumItem(it.toString()) } + } +} + +private fun knownSchema(type: Class<*>): Schema<*>? { + return enumSchema(type) ?: when (type) { + String::class.java -> StringSchema() + Boolean::class.java, Boolean::class.javaObjectType -> BooleanSchema() + Int::class.java, Int::class.javaObjectType -> IntegerSchema() + List::class.java -> ArraySchema() + Long::class.java, Long::class.javaObjectType -> IntegerSchema().format("int64") + BigDecimal::class.java -> IntegerSchema().format("") + Date::class.java, LocalDate::class.java -> DateSchema() + LocalDateTime::class.java -> DateTimeSchema() + else -> null + } +} diff --git a/src/test/kotlin/cc/vileda/openapi/dsl/ComponentsDslTest.kt b/src/test/kotlin/cc/vileda/openapi/dsl/ComponentsDslTest.kt new file mode 100644 index 0000000..7676b6e --- /dev/null +++ b/src/test/kotlin/cc/vileda/openapi/dsl/ComponentsDslTest.kt @@ -0,0 +1,165 @@ +package cc.vileda.openapi.dsl + +import io.kotlintest.shouldBe +import io.kotlintest.shouldNotBe +import io.kotlintest.specs.StringSpec +import io.swagger.v3.oas.models.PathItem + +class ComponentsDslTest : StringSpec() { + init { + val api = openapiDsl { + openapi = "3.0.0" + info { title = "components-test"; version = "1.0" } + + components { + parameter("pageSize") { + name = "pageSize" + `in` = "query" + description = "number of results per page" + } + response("NotFound") { + description = "resource not found" + } + requestBody("ItemBody") { + description = "item payload" + required = true + } + example("fooExample") { + summary = "a foo example" + value = "foo" + } + header("RateLimit") { + description = "calls remaining per hour" + } + link("UserLink") { + operationId = "getUser" + description = "linked user" + } + callback("onEvent") { + } + } + + paths { + path("/items") { + get { + parameterRef("pageSize") + requestBodyRef("ItemBody") + callbackRef("onEvent") + responses { + responseRef("404", "NotFound") + response("200") { + description = "OK" + headerRef("RateLimit") + linkRef("UserLink") + content { + mediaTypeRef("application/json") { + exampleRef("fooExample") + } + } + } + } + } + } + } + } + + val getOp = api.paths["/items"]!! + .readOperationsMap()!![PathItem.HttpMethod.GET]!! + + "Components.parameter registers a named parameter entry" { + val p = api.components.parameters["pageSize"] + p shouldNotBe null + p!!.description shouldBe "number of results per page" + } + + "Components.response registers a named response entry" { + val r = api.components.responses["NotFound"] + r shouldNotBe null + r!!.description shouldBe "resource not found" + } + + "Components.requestBody registers a named requestBody entry" { + val rb = api.components.requestBodies["ItemBody"] + rb shouldNotBe null + rb!!.description shouldBe "item payload" + } + + "Components.example registers a named example entry" { + val ex = api.components.examples["fooExample"] + ex shouldNotBe null + ex!!.summary shouldBe "a foo example" + } + + "Components.header registers a named header entry" { + val h = api.components.headers["RateLimit"] + h shouldNotBe null + h!!.description shouldBe "calls remaining per hour" + } + + "Components.link registers a named link entry" { + val lnk = api.components.links["UserLink"] + lnk shouldNotBe null + lnk!!.operationId shouldBe "getUser" + } + + "Components.callback registers a named callback entry" { + api.components.callbacks shouldNotBe null + api.components.callbacks["onEvent"] shouldNotBe null + } + + "parameterRef emits canonical #/components/parameters/ ref" { + val ref = getOp.parameters.first().`$ref` + ref shouldBe "#/components/parameters/pageSize" + } + + "responseRef emits canonical #/components/responses/ ref" { + val ref = getOp.responses["404"]!!.`$ref` + ref shouldBe "#/components/responses/NotFound" + } + + "requestBodyRef emits canonical #/components/requestBodies/ ref" { + val ref = getOp.requestBody.`$ref` + ref shouldBe "#/components/requestBodies/ItemBody" + } + + "exampleRef in MediaType emits canonical #/components/examples/ ref" { + val ref = getOp.responses["200"]!! + .content!!["application/json"]!! + .examples!!["fooExample"]!! + .`$ref` + ref shouldBe "#/components/examples/fooExample" + } + + "headerRef in ApiResponse emits canonical #/components/headers/ ref" { + val ref = getOp.responses["200"]!! + .headers!!["RateLimit"]!! + .`$ref` + ref shouldBe "#/components/headers/RateLimit" + } + + "linkRef in ApiResponse emits canonical #/components/links/ ref" { + val ref = getOp.responses["200"]!! + .links!!["UserLink"]!! + .`$ref` + ref shouldBe "#/components/links/UserLink" + } + + "callbackRef on Operation emits canonical #/components/callbacks/ ref" { + val ref = getOp.callbacks!!["onEvent"]!!.`$ref` + ref shouldBe "#/components/callbacks/onEvent" + } + + "doc with all seven component categories serialises to JSON with a components object" { + val json = validatedJson(api) + json.has("components") shouldBe true + val comps = json.getJSONObject("components") + comps.has("parameters") shouldBe true + comps.has("responses") shouldBe true + comps.has("requestBodies") shouldBe true + comps.has("examples") shouldBe true + comps.has("headers") shouldBe true + comps.has("links") shouldBe true + comps.has("callbacks") shouldBe true + } + } +} diff --git a/src/test/kotlin/cc/vileda/openapi/dsl/OpenApiDslConfigTest.kt b/src/test/kotlin/cc/vileda/openapi/dsl/OpenApiDslConfigTest.kt new file mode 100644 index 0000000..3a3c207 --- /dev/null +++ b/src/test/kotlin/cc/vileda/openapi/dsl/OpenApiDslConfigTest.kt @@ -0,0 +1,178 @@ +package cc.vileda.openapi.dsl + +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonProperty +import io.kotlintest.shouldBe +import io.kotlintest.shouldNotBe +import io.kotlintest.specs.StringSpec +import io.swagger.v3.oas.annotations.media.Schema as SchemaAnnotation +import io.swagger.v3.oas.models.OpenAPI +import java.util.concurrent.Callable +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors + +private data class ReqOffBase(val active: String, val inactive: String?) + +private data class ReqMixBase( + val name: String, + val tag: String?, + val label: String = "x" +) + +private data class ReqChild(val childId: Int, val childTag: String?) +private data class ReqParent(val name: String, val child: ReqChild) + +private data class ReqRenamed( + @JsonProperty("display_name") val displayName: String, + val count: Int +) + +private data class ReqExplicit( + @SchemaAnnotation(requiredMode = SchemaAnnotation.RequiredMode.REQUIRED) + val nullableProp: String?, + @SchemaAnnotation(requiredMode = SchemaAnnotation.RequiredMode.NOT_REQUIRED) + val nonNullProp: String +) + +private data class ReqWithIgnored( + val active: String, + @JsonIgnore val secret: String +) + +private data class ReqListItem(val code: String, val label: String?) +private data class ReqListContainer(val items: List) + +private data class ReqMapValue(val v: String, val extra: String?) +private data class ReqMapContainer(val entries: Map) + +private data class ReqNestedOuter(val outerProp: String) +private data class ReqNestedInner(val innerProp: String) + +private data class ReqParallelOn(val mustBeRequired: String, val mayBeNull: String?) +private data class ReqParallelOff(val nonNull: String, val alsoNull: String?) + +private data class ReqSequential(val nonNullProp: String, val nullableProp: String?) + +private val inferOn = OpenApiDslConfig(inferRequiredFromKotlinNullability = true) + +private inline fun schemasWithInference(): Map>? = + openapiDsl(inferOn) { + info { title = "t"; version = "1.0" } + components { schema() } + }.components?.schemas + +class OpenApiDslConfigTest : StringSpec() { + init { + + "default openapiDsl config does not add required properties from Kotlin nullability" { + val api = openapiDsl { + info { title = "t"; version = "1.0" } + components { schema() } + } + api.components?.schemas?.get("ReqOffBase")?.required shouldBe null + } + + "opt-in inference marks only non-null non-defaulted constructor props as required" { + val required = schemasWithInference()!!["ReqMixBase"]?.required + required shouldBe listOf("name") + } + + "inference recurses into referenced child models and applies the same rules" { + val schemas = schemasWithInference()!! + // OpenAPI required order is unspecified; compare as sets + schemas["ReqParent"]?.required?.toSet() shouldBe setOf("name", "child") + schemas["ReqChild"]?.required shouldBe listOf("childId") + } + + "@JsonIgnore property is absent from schema properties and therefore absent from required" { + val required = schemasWithInference()!!["ReqWithIgnored"]?.required + required shouldBe listOf("active") + } + + "inference uses the @JsonProperty-renamed schema key, not the Kotlin declaration name" { + val required = schemasWithInference()!!["ReqRenamed"]?.required + required shouldNotBe null + // @JsonProperty propagates to the backing field; Jackson generates "display_name" in the schema + required!!.contains("display_name") shouldBe true + required.contains("displayName") shouldBe false + required.contains("count") shouldBe true + } + + "@Schema REQUIRED and NOT_REQUIRED override inference for their respective properties" { + val required = schemasWithInference()!!["ReqExplicit"]?.required + required?.contains("nullableProp") shouldBe true // nullable but forced required + required?.contains("nonNullProp") shouldBe false // non-null but forced not-required + } + + "inference recurses into the element type of a List property" { + val schemas = schemasWithInference()!! + schemas["ReqListContainer"]?.required shouldBe listOf("items") + schemas["ReqListItem"]?.required shouldBe listOf("code") + } + + "inference recurses into the value type of a Map property" { + val schemas = schemasWithInference()!! + schemas["ReqMapContainer"]?.required shouldBe listOf("entries") + schemas["ReqMapValue"]?.required shouldBe listOf("v") + } + + "nested DSL builds each honour their own config independently" { + var innerApi: OpenAPI? = null + val outerApi = openapiDsl(OpenApiDslConfig(inferRequiredFromKotlinNullability = false)) { + info { title = "outer"; version = "1.0" } + // Only outer scope (inference=false) is on the ThreadLocal stack here + components { schema() } + // Inner build pushes its own scope; peekLast() returns it (inference=true) + innerApi = openapiDsl(OpenApiDslConfig(inferRequiredFromKotlinNullability = true)) { + info { title = "inner"; version = "1.0" } + components { schema() } + } + } + outerApi.components?.schemas?.get("ReqNestedOuter")?.required shouldBe null + innerApi!!.components?.schemas?.get("ReqNestedInner")?.required shouldBe listOf("innerProp") + } + + "parallel builds with opposite configs remain isolated per thread" { + val pool = Executors.newFixedThreadPool(2) + // latch releases both threads simultaneously to maximise ThreadLocal scope overlap + val latch = CountDownLatch(1) + try { + val futOn = pool.submit(Callable { + latch.await() + openapiDsl(OpenApiDslConfig(inferRequiredFromKotlinNullability = true)) { + info { title = "on"; version = "1.0" } + components { schema() } + } + }) + val futOff = pool.submit(Callable { + latch.await() + openapiDsl(OpenApiDslConfig(inferRequiredFromKotlinNullability = false)) { + info { title = "off"; version = "1.0" } + components { schema() } + } + }) + latch.countDown() + val apiOn = futOn.get() + val apiOff = futOff.get() + apiOn.components?.schemas?.get("ReqParallelOn")?.required + ?.contains("mustBeRequired") shouldBe true + apiOff.components?.schemas?.get("ReqParallelOff")?.required shouldBe null + } finally { + pool.shutdownNow() + } + } + + "same model class resolved sequentially with opposite configs does not leak required from first build into second" { + val apiOn = openapiDsl(OpenApiDslConfig(inferRequiredFromKotlinNullability = true)) { + info { title = "seq-on"; version = "1.0" } + components { schema() } + } + val apiOff = openapiDsl(OpenApiDslConfig(inferRequiredFromKotlinNullability = false)) { + info { title = "seq-off"; version = "1.0" } + components { schema() } + } + apiOn.components?.schemas?.get("ReqSequential")?.required?.contains("nonNullProp") shouldBe true + apiOff.components?.schemas?.get("ReqSequential")?.required shouldBe null + } + } +} diff --git a/src/test/kotlin/cc/vileda/openapi/dsl/OpenApiDslTest.kt b/src/test/kotlin/cc/vileda/openapi/dsl/OpenApiDslTest.kt index 981c38f..40ae351 100644 --- a/src/test/kotlin/cc/vileda/openapi/dsl/OpenApiDslTest.kt +++ b/src/test/kotlin/cc/vileda/openapi/dsl/OpenApiDslTest.kt @@ -8,6 +8,8 @@ import io.swagger.v3.oas.models.PathItem import io.swagger.v3.oas.models.media.* import io.swagger.v3.oas.models.parameters.Parameter import io.swagger.v3.oas.models.security.SecurityScheme +import io.swagger.v3.oas.models.responses.ApiResponse +import org.json.JSONObject import java.math.BigDecimal import java.time.LocalDate import java.time.LocalDateTime @@ -200,5 +202,54 @@ class OpenApi3BuilderTest : StringSpec() { schema shouldBe instanceOf(ArraySchema()::class) (schema as ArraySchema).items.`$ref` shouldBe "#/components/schemas/ExampleSchema" } + + val standardResponses = linkedMapOf( + "400" to ApiResponse().apply { description = "Bad Request" }, + "401" to ApiResponse().apply { description = "Unauthorized" }, + "500" to ApiResponse().apply { description = "Internal Server Error" } + ) + val orderingApi = openapiDsl { + info { title = "ordering-test"; version = "1.0" } + paths { + path("/items") { + get { + responses { + response("204") { description = "No Content" } + putAll(standardResponses) + } + } + } + } + } + + // Regression for issue #146: JSONObject scrambles key order; Jackson must preserve it + "asJsonString should preserve 204-first insertion order when putAll adds shared responses" { + val json = orderingApi.asJsonString() + val idx204 = json.indexOf("\"204\"") + val idx400 = json.indexOf("\"400\"") + val idx401 = json.indexOf("\"401\"") + val idx500 = json.indexOf("\"500\"") + (idx204 in 0 until idx400) shouldBe true + (idx400 < idx401) shouldBe true + (idx401 < idx500) shouldBe true + } + + "asFile should preserve 204-first insertion order when putAll adds shared responses" { + val content = orderingApi.asFile().readText() + val idx204 = content.indexOf("\"204\"") + val idx400 = content.indexOf("\"400\"") + val idx401 = content.indexOf("\"401\"") + val idx500 = content.indexOf("\"500\"") + (idx204 in 0 until idx400) shouldBe true + (idx400 < idx401) shouldBe true + (idx401 < idx500) shouldBe true + } + + "asFile pretty output is valid parseable JSON" { + val content = orderingApi.asFile().readText() + val parsed = JSONObject(content) + parsed.has("openapi") shouldBe true + parsed.has("paths") shouldBe true + } } } \ No newline at end of file diff --git a/src/test/kotlin/cc/vileda/openapi/dsl/SchemaRegistryTest.kt b/src/test/kotlin/cc/vileda/openapi/dsl/SchemaRegistryTest.kt new file mode 100644 index 0000000..077d8e2 --- /dev/null +++ b/src/test/kotlin/cc/vileda/openapi/dsl/SchemaRegistryTest.kt @@ -0,0 +1,230 @@ +package cc.vileda.openapi.dsl + +import io.kotlintest.shouldBe +import io.kotlintest.shouldNotBe +import io.kotlintest.specs.StringSpec +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.PathItem +import io.swagger.v3.oas.models.media.ArraySchema +import java.util.concurrent.Callable +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors + +data class RegistryLeaf(val value: String) +data class RegistryBranch(val leaf: RegistryLeaf, val count: Int) +data class RegistryRoot(val branch: RegistryBranch, val label: String) + +data class IsolatedNode(val code: String) + +@io.swagger.v3.oas.annotations.media.Schema(name = "RenamedWidget") +data class AnnotatedWidget(val id: Int) + +/** Builds an [OpenAPI] with a single GET /t -> 200 response using [mediaTypeRef]. */ +private inline fun apiWithMediaTypeRef(): OpenAPI = openapiDsl { + info { title = "t"; version = "1.0" } + paths { + path("/t") { + get { + responses { + response("200") { + content { + mediaTypeRef("application/json") + } + } + } + } + } + } +} + +class SchemaRegistryTest : StringSpec() { + init { + "mediaTypeRef without components block registers root and all transitive types" { + val schemas = apiWithMediaTypeRef().components?.schemas + schemas shouldNotBe null + schemas!!["RegistryRoot"] shouldNotBe null + schemas["RegistryBranch"] shouldNotBe null + schemas["RegistryLeaf"] shouldNotBe null + } + + "mediaTypeRef schema carries canonical dollar-ref pointing to root component key" { + val schema = apiWithMediaTypeRef() + .paths["/t"]!! + .readOperationsMap()!![PathItem.HttpMethod.GET]!! + .responses["200"]!!.content!!["application/json"]!!.schema + schema.`$ref` shouldBe "#/components/schemas/RegistryRoot" + } + + "mediaTypeArrayOfRef registers all transitive types and emits array schema with ref items" { + val api = openapiDsl { + info { title = "t"; version = "1.0" } + paths { + path("/t") { + get { + responses { + response("200") { + content { + mediaTypeArrayOfRef("application/json") + } + } + } + } + } + } + } + val schemas = api.components?.schemas + schemas shouldNotBe null + schemas!!["RegistryRoot"] shouldNotBe null + schemas["RegistryBranch"] shouldNotBe null + schemas["RegistryLeaf"] shouldNotBe null + val outerSchema = api.paths["/t"]!! + .readOperationsMap()!![PathItem.HttpMethod.GET]!! + .responses["200"]!!.content!!["application/json"]!!.schema + outerSchema.type shouldBe "array" + (outerSchema as ArraySchema).items.`$ref` shouldBe "#/components/schemas/RegistryRoot" + } + + "components schema block registers root and all transitive types" { + val schemas = openapiDsl { + info { title = "t"; version = "1.0" } + components { schema() } + }.components?.schemas + schemas shouldNotBe null + schemas!!["RegistryRoot"] shouldNotBe null + schemas["RegistryBranch"] shouldNotBe null + schemas["RegistryLeaf"] shouldNotBe null + } + + "explicitly customised root schema is not overwritten by auto-commit" { + val api = openapiDsl { + info { title = "t"; version = "1.0" } + components { + schema { description = "intentional custom description" } + } + paths { + path("/t") { + get { + responses { + response("200") { + content { mediaTypeRef("application/json") } + } + } + } + } + } + } + api.components?.schemas?.get("RegistryRoot")?.description shouldBe "intentional custom description" + } + + "second schema call for the same type does not overwrite prior explicit root customization" { + val api = openapiDsl { + info { title = "t"; version = "1.0" } + components { + schema { description = "pinned description" } + schema() + } + } + api.components?.schemas?.get("RegistryRoot")?.description shouldBe "pinned description" + } + + "Schema name annotation determines the component key and the dollar-ref value" { + val api = apiWithMediaTypeRef() + api.components?.schemas?.get("RenamedWidget") shouldNotBe null + api.components?.schemas?.get("AnnotatedWidget") shouldBe null + api.paths["/t"]!! + .readOperationsMap()!![PathItem.HttpMethod.GET]!! + .responses["200"]!!.content!!["application/json"]!!.schema + .`$ref` shouldBe "#/components/schemas/RenamedWidget" + } + + "sequential openapiDsl builds do not leak schemas from one spec to the next" { + val api1 = apiWithMediaTypeRef() + val api2 = apiWithMediaTypeRef() + api1.components?.schemas?.get("RegistryRoot") shouldNotBe null + api2.components?.schemas?.get("IsolatedNode") shouldNotBe null + api2.components?.schemas?.get("RegistryRoot") shouldBe null + } + + "exception during build cleans up the scope so no types leak into the next build" { + val result = runCatching { + openapiDsl { + info { title = "failing"; version = "1.0" } + paths { + path("/fail") { + get { + responses { + response("200") { + content { mediaTypeRef("application/json") } + } + } + } + } + } + throw RuntimeException("deliberate failure") + } + } + result.isFailure shouldBe true + result.exceptionOrNull()?.message shouldBe "deliberate failure" + openapiDsl { info { title = "clean"; version = "1.0" } } + .components?.schemas?.get("RegistryRoot") shouldBe null + } + + "parallel builds with distinct root types remain isolated per thread" { + val pool = Executors.newFixedThreadPool(2) + // latch releases both threads simultaneously to maximise scope overlap + val latch = CountDownLatch(1) + try { + val futRoot = pool.submit(Callable { latch.await(); apiWithMediaTypeRef() }) + val futNode = pool.submit(Callable { latch.await(); apiWithMediaTypeRef() }) + latch.countDown() + val apiRoot = futRoot.get() + val apiNode = futNode.get() + apiRoot.components?.schemas?.get("RegistryRoot") shouldNotBe null + apiRoot.components?.schemas?.get("RegistryBranch") shouldNotBe null + apiRoot.components?.schemas?.get("RegistryLeaf") shouldNotBe null + apiNode.components?.schemas?.get("IsolatedNode") shouldNotBe null + apiNode.components?.schemas?.get("RegistryRoot") shouldBe null + } finally { + pool.shutdownNow() + } + } + + "nested openapiDsl builds isolate each spec to its own type set" { + var innerApi: OpenAPI? = null + val outerApi = openapiDsl { + info { title = "outer"; version = "1.0" } + paths { + path("/outer") { + get { + responses { + response("200") { + content { mediaTypeRef("application/json") } + } + } + } + } + } + // inner build runs on the same thread while the outer scope is live on the stack + innerApi = openapiDsl { + info { title = "inner"; version = "1.0" } + paths { + path("/inner") { + get { + responses { + response("200") { + content { mediaTypeRef("application/json") } + } + } + } + } + } + } + } + outerApi.components?.schemas?.get("RegistryRoot") shouldNotBe null + outerApi.components?.schemas?.get("IsolatedNode") shouldBe null + val inner = innerApi!! + inner.components?.schemas?.get("IsolatedNode") shouldNotBe null + inner.components?.schemas?.get("RegistryRoot") shouldBe null + } + } +}