diff --git a/core/build.gradle.kts b/core/build.gradle.kts index ac406838..36ec1ba9 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + plugins { kotlin("jvm") `maven-publish` @@ -8,10 +10,17 @@ kotlin { jvmToolchain(21) } +// todo: remove when https://github.com/JLLeitschuh/ktlint-gradle/issues/912 resolved +ktlint { + version.set("1.8.0") +} + + dependencies { implementation("io.swagger.parser.v3:swagger-parser:2.1.39") implementation("com.squareup:kotlinpoet:2.2.0") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.7.1") + implementation("io.arrow-kt:arrow-core:2.2.1.1") testImplementation(kotlin("test")) } @@ -26,3 +35,7 @@ publishing { } } } +val compileKotlin: KotlinCompile by tasks +compileKotlin.compilerOptions { + freeCompilerArgs.add("-Xcontext-parameters") +} diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt b/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt new file mode 100644 index 00000000..35cd2e0c --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt @@ -0,0 +1,107 @@ +package com.avsystem.justworks.core.model + +/** + * Intermediate model representing a fully parsed OpenAPI specification. + * + * Produced by [com.avsystem.justworks.core.parser.SpecParser] and consumed by the + * code generators. Bridges the raw Swagger Parser OAS model and the generated + * Kotlin client/model source files. + */ +data class ApiSpec( + val title: String, + val version: String, + val endpoints: List, + val schemas: List, + val enums: List, +) + +data class Endpoint( + val path: String, + val method: HttpMethod, + val operationId: String, + val summary: String?, + val tags: List, + val parameters: List, + val requestBody: RequestBody?, + val responses: Map, +) + +enum class HttpMethod { + GET, + POST, + PUT, + DELETE, + PATCH; + + companion object { + fun parse(name: String): HttpMethod? = entries.find { it.name.equals(name, true) } + } +} + +data class Parameter( + val name: String, + val location: ParameterLocation, + val required: Boolean, + val schema: TypeRef, + val description: String?, +) + +// todo: add cookie +enum class ParameterLocation { + PATH, + QUERY, + HEADER; + + companion object { + fun parse(name: String): ParameterLocation? = entries.find { it.name.equals(name, true) } + } +} + +data class RequestBody( + val required: Boolean, + val contentType: String, + val schema: TypeRef, +) + +data class Response( + val statusCode: String, + val description: String?, + val schema: TypeRef?, +) + +data class SchemaModel( + val name: String, + val description: String?, + val properties: List, + val requiredProperties: Set, + val allOf: List?, + val oneOf: List?, + val anyOf: List?, + val discriminator: Discriminator?, +) + +data class PropertyModel( + val name: String, + val type: TypeRef, + val description: String?, + val nullable: Boolean, + val defaultValue: Any? = null, +) + +data class EnumModel( + val name: String, + val description: String?, + val type: EnumBackingType, + val values: List, +) + +enum class EnumBackingType { + STRING, + INTEGER; + + companion object { + fun parse(name: String): EnumBackingType? = entries.find { it.name.equals(name, true) } + } +} + +data class Discriminator(val propertyName: String, val mapping: Map) diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/model/TypeRef.kt b/core/src/main/kotlin/com/avsystem/justworks/core/model/TypeRef.kt new file mode 100644 index 00000000..82387255 --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/model/TypeRef.kt @@ -0,0 +1,21 @@ +package com.avsystem.justworks.core.model + +sealed interface TypeRef { + data class Primitive(val type: PrimitiveType) : TypeRef + + data class Array(val items: TypeRef) : TypeRef + + data class Reference(val schemaName: String) : TypeRef + + data class Map(val valueType: TypeRef) : TypeRef + + data class Inline( + val properties: List, + val requiredProperties: Set, + val contextHint: String, // "request"|"response"|property name for context-aware naming + ) : TypeRef + + data object Unknown : TypeRef +} + +enum class PrimitiveType { STRING, INT, LONG, DOUBLE, FLOAT, BOOLEAN, BYTE_ARRAY, DATE_TIME, DATE } diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt new file mode 100644 index 00000000..a9239d57 --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -0,0 +1,403 @@ +package com.avsystem.justworks.core.parser + +import arrow.core.fold +import arrow.core.merge +import arrow.core.raise.context.Raise +import arrow.core.raise.context.ensure +import arrow.core.raise.context.ensureNotNull +import arrow.core.raise.either +import arrow.core.raise.nullable +import com.avsystem.justworks.core.model.ApiSpec +import com.avsystem.justworks.core.model.Discriminator +import com.avsystem.justworks.core.model.Endpoint +import com.avsystem.justworks.core.model.EnumBackingType +import com.avsystem.justworks.core.model.EnumModel +import com.avsystem.justworks.core.model.HttpMethod +import com.avsystem.justworks.core.model.Parameter +import com.avsystem.justworks.core.model.ParameterLocation +import com.avsystem.justworks.core.model.PrimitiveType +import com.avsystem.justworks.core.model.PropertyModel +import com.avsystem.justworks.core.model.RequestBody +import com.avsystem.justworks.core.model.Response +import com.avsystem.justworks.core.model.SchemaModel +import com.avsystem.justworks.core.model.TypeRef +import io.swagger.parser.OpenAPIParser +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.PathItem +import io.swagger.v3.oas.models.media.Schema +import io.swagger.v3.parser.core.models.ParseOptions +import java.io.File +import java.util.IdentityHashMap +import io.swagger.v3.oas.models.parameters.Parameter as SwaggerParameter + +/** + * Result of parsing an OpenAPI specification file. + * + * Use pattern matching to handle both outcomes: + * ```kotlin + * when (val result = SpecParser.parse(file)) { + * is ParseResult.Success -> result.apiSpec + * is ParseResult.Failure -> handleErrors(result.errors) + * } + * ``` + * + * Both [Success] and [Failure] may carry [warnings] about non-fatal issues + * encountered during parsing or validation. + */ +sealed interface ParseResult { + data class Success(val apiSpec: ApiSpec, val warnings: List = emptyList()) : ParseResult + + data class Failure(val errors: List, val warnings: List = emptyList()) : ParseResult +} + +object SpecParser { + /** + * Parses an OpenAPI 3.0 specification file into an [ApiSpec] intermediate model. + * + * Accepts YAML or JSON files. Swagger 2.0 specs are automatically converted to + * OpenAPI 3.0 by the underlying Swagger Parser before model extraction. + * + * Uses Arrow [either] for the internal parse pipeline; the result is collapsed + * to [ParseResult] via [arrow.core.merge] so callers always receive a [ParseResult] + * and never an [arrow.core.Either]. + * + * @param specFile path to the OpenAPI or Swagger 2.0 specification file + * @return [ParseResult.Success] with the parsed model and any warnings, or + * [ParseResult.Failure] with a non-empty list of error messages + */ + fun parse(specFile: File): ParseResult = either { + val parseOptions = ParseOptions().apply { + isResolve = true + isResolveFully = true + isResolveCombinators = false + } + + val swaggerResult = OpenAPIParser().readLocation(specFile.absolutePath, null, parseOptions) + val openApi = swaggerResult.openAPI + val swaggerMessages = swaggerResult.messages.orEmpty() + + ensureNotNull(openApi) { + ParseResult.Failure(swaggerMessages.ifEmpty { listOf("Failed to parse spec: ${specFile.name}") }) + } + + val validationIssues = SpecValidator.validate(openApi) + val (errors, warnings) = validationIssues.partition { it is SpecValidator.ValidationIssue.Error } + val allWarnings = warnings.map { it.message } + swaggerMessages + + ensure(errors.isEmpty()) { + ParseResult.Failure(errors.map { it.message }, allWarnings) + } + + ParseResult.Success(openApi.toApiSpec(), warnings = allWarnings) + }.merge() + + private typealias ComponentSchemaIdentity = IdentityHashMap, String> + private typealias ComponentSchemas = MutableMap> + + context(_: Raise) + private fun OpenAPI.toApiSpec(): ApiSpec { + val allSchemas = components?.schemas.orEmpty() + + val componentSchemaIdentity = ComponentSchemaIdentity(allSchemas.size).apply { + allSchemas.forEach { (name, schema) -> this[schema] = name } + } + + val componentSchemas: ComponentSchemas = allSchemas.toMutableMap() + + context(componentSchemaIdentity, componentSchemas) { + val endpoints = extractEndpoints(paths.orEmpty()) + + val (enumModels, schemaModels) = allSchemas.fold( + emptyList() to emptyList(), + ) { (accEnum, accModels), (name, schema) -> + if (schema.isEnumSchema) { + accEnum + extractEnumModel(name, schema) to accModels + } else { + accEnum to accModels + extractSchemaModel(name, schema) + } + } + + return ApiSpec( + title = info?.title ?: "Untitled", + version = info?.version ?: "0.0.0", + endpoints = endpoints, + schemas = schemaModels, + enums = enumModels, + ) + } + } + + context(_: ComponentSchemaIdentity, _: ComponentSchemas) + private fun extractEndpoints(paths: Map): List = paths + .asSequence() + .flatMap { (path, pathItem) -> + pathItem + .readOperationsMap() + .asSequence() + .mapNotNull { (method, value) -> HttpMethod.parse(method.name)?.let { it to value } } + .map { (method, operation) -> + val operationId = operation.operationId ?: generateOperationId(method, path) + + val mergedParams = (operation.parameters.orEmpty() + pathItem.parameters.orEmpty()) + .distinctBy { "${it.name}:${it.`in`}" } + .map { it.toParameter() } + + val requestBody = nullable { + val body = operation.requestBody.bind() + val content = body.content.bind() + val schema = content[JSON_CONTENT_TYPE]?.schema.bind() + RequestBody( + required = body.required ?: false, + contentType = JSON_CONTENT_TYPE, + schema = schema.toTypeRef("${operationId.replaceFirstChar { it.uppercase() }}Request"), + ) + } + + val responses = operation.responses + .orEmpty() + .mapValues { (code, resp) -> + Response( + statusCode = code, + description = resp.description, + schema = resp.content + ?.get(JSON_CONTENT_TYPE) + ?.schema + ?.toTypeRef("${operationId.replaceFirstChar { it.uppercase() }}Response"), + ) + } + + Endpoint( + path = path, + method = method, + operationId = operationId, + summary = operation.summary, + tags = operation.tags.orEmpty(), + parameters = mergedParams, + requestBody = requestBody, + responses = responses, + ) + } + }.toList() + + context(_: ComponentSchemaIdentity, _: ComponentSchemas) + private fun SwaggerParameter.toParameter(): Parameter = Parameter( + name = name ?: "", + location = ParameterLocation.parse(`in`) ?: ParameterLocation.QUERY, + required = required ?: false, + schema = schema?.toTypeRef() ?: TypeRef.Primitive(PrimitiveType.STRING), + description = description, + ) + + // --- Schema extraction --- + + context(_: Raise, _: ComponentSchemaIdentity, _: ComponentSchemas) + private fun extractSchemaModel(name: String, schema: Schema<*>): SchemaModel { + val allOf = schema.allOf?.mapNotNull { it.resolveName() } + + val (oneOf, discriminatorFromWrapper) = detectAndUnwrapOneOfWrappers(schema) // may register new schemas + ?: (schema.oneOf?.mapNotNull { it.resolveName() } to null) + + val anyOf = schema.anyOf?.mapNotNull { it.resolveName() } + + ensure(oneOf.isNullOrEmpty() || anyOf.isNullOrEmpty()) { + ParseResult.Failure(listOf("Schema '$name' has both oneOf and anyOf. Use one combinator only.")) + } + + val (properties, requiredProps) = + if (!schema.allOf.isNullOrEmpty()) { + extractAllOfProperties(name, schema) + } else { + val requiredProps = schema.required.orEmpty().toSet() + val props = schema + .propertyModels(requiredProps) { propName -> "$name.${propName.toPascalCase()}" } + .values + .toList() + props to requiredProps + } + + val discriminator = discriminatorFromWrapper ?: nullable { + val disc = schema.discriminator.bind() + val propertyName = disc.propertyName.bind() + Discriminator(propertyName = propertyName, mapping = disc.mapping.orEmpty()) + } + + return SchemaModel( + name = name, + description = schema.description, + properties = properties, + requiredProperties = requiredProps, + allOf = allOf?.let { it.map(TypeRef::Reference).ifEmpty { null } }, + oneOf = oneOf?.let { it.map(TypeRef::Reference).ifEmpty { null } }, + anyOf = anyOf?.let { it.map(TypeRef::Reference).ifEmpty { null } }, + discriminator = discriminator, + ) + } + + private fun extractEnumModel(name: String, schema: Schema<*>): EnumModel = EnumModel( + name = name, + description = schema.description, + type = EnumBackingType.parse(schema.type) ?: EnumBackingType.STRING, + values = schema.enum.map { it.toString() }, + ) + + // --- allOf property merging --- + + context(componentSchemaIdentity: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) + private fun extractAllOfProperties(parentName: String, schema: Schema<*>): Pair, Set> { + val topRequired = schema.required.orEmpty().toSet() + val contextCreator: (String) -> String? = { propName -> "$parentName.${propName.toPascalCase()}" } + + val (required, properties) = schema.allOf + .orEmpty() + .fold(topRequired to emptyMap()) { (accRequired, accProperties), subSchema -> + val resolvedSchema = subSchema.resolveSubSchema() + val mergedRequired = accRequired + resolvedSchema.required.orEmpty().toSet() + mergedRequired to accProperties + resolvedSchema.propertyModels(mergedRequired, contextCreator) + } + + val topLevelProperties = schema.propertyModels(required, contextCreator) + val finalProperties = + properties.plus(topLevelProperties).values.map { prop -> prop.copy(nullable = prop.name !in required) } + + return finalProperties to required + } + + context(_: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) + private fun Schema<*>.resolveSubSchema(): Schema<*> = resolveName()?.let { componentSchemas[it] } ?: this + + /** + * Detects and unwraps the oneOf wrapper pattern where each variant is a single-property + * object schema with the property name serving as the discriminator. + * + * Detection criteria (all must be true): + * - Schema has oneOf list + * - No explicit discriminator is defined + * - Every oneOf variant is an object schema (not a $ref) + * - Every variant has exactly one property + * - The property value is either a $ref or an inline object + * + * Returns: Pair of (unwrapped oneOf refs, synthetic discriminator) or null if pattern not matched. + */ + context(componentSchemaIdentity: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) + private fun detectAndUnwrapOneOfWrappers(schema: Schema<*>): Pair, Discriminator>? = nullable { + ensure(!schema.oneOf.isNullOrEmpty() && schema.discriminator == null) + + val variants = schema.oneOf.orEmpty() + ensure(variants.all { it.isInlineObject }) + + val unwrapped = variants + .associate { + val (propertyName, propertySchema) = ensureNotNull( + it.properties?.entries?.singleOrNull(), + ) + + val schemaName = ensureNotNull( + propertySchema.resolveName() ?: propertyName + .takeIf { propertySchema.isInlineObject } + ?.also { name -> + componentSchemas[name] = propertySchema + componentSchemaIdentity[propertySchema] = name + }, + ) + + propertyName to schemaName + } + + ensure(unwrapped.size == variants.size) + + val mapping = unwrapped.mapValues { (_, schemaName) -> "$SCHEMA_PREFIX$schemaName" } + unwrapped.values.toList() to Discriminator(propertyName = "type", mapping = mapping) + } + + context(_: ComponentSchemaIdentity, _: ComponentSchemas) + private fun Schema<*>.toTypeRef(contextName: String? = null): TypeRef = contextName?.let { toInlineTypeRef(it) } + ?: (resolveName() ?: allOf?.singleOrNull()?.resolveName())?.let(TypeRef::Reference) + ?: TypeRef.Unknown.takeIf { (allOf?.size ?: 0) > 1 } + ?: when (type) { + "string" -> STRING_FORMAT_MAP[format] ?: TypeRef.Primitive(PrimitiveType.STRING) + + "integer" -> INTEGER_FORMAT_MAP[format] ?: TypeRef.Primitive(PrimitiveType.INT) + + "number" -> NUMBER_FORMAT_MAP[format] ?: TypeRef.Primitive(PrimitiveType.DOUBLE) + + "boolean" -> TypeRef.Primitive(PrimitiveType.BOOLEAN) + + "array" -> TypeRef.Array(items?.toTypeRef(contextName?.let { "${it}Item" }) ?: TypeRef.Unknown) + + "object" -> when (val ap = additionalProperties) { + is Schema<*> -> TypeRef.Map(ap.toTypeRef()) + is Boolean -> if (ap) TypeRef.Map(TypeRef.Unknown) else TypeRef.Unknown + else -> title?.let(TypeRef::Reference) ?: TypeRef.Unknown + } + + else -> TypeRef.Unknown + } + + context(_: ComponentSchemaIdentity, _: ComponentSchemas) + private fun Schema<*>.toInlineTypeRef(contextName: String): TypeRef? = takeIf { isInlineObject }?.let { + val required = required.orEmpty().toSet() + TypeRef.Inline( + properties = propertyModels(required) { "$contextName.${it.toPascalCase()}" }.values.toList(), + requiredProperties = required, + contextHint = contextName, + ) + } + + context(componentSchemaIdentity: ComponentSchemaIdentity) + private fun Schema<*>.resolveName(): String? = `$ref`?.removePrefix(SCHEMA_PREFIX) ?: componentSchemaIdentity[this] + + context(componentSchemaIdentity: ComponentSchemaIdentity) + private val Schema<*>.isInlineObject + get(): Boolean = `$ref` == null && + this !in componentSchemaIdentity && type == "object" && !properties.isNullOrEmpty() + + private val Schema<*>.isEnumSchema get(): Boolean = !enum.isNullOrEmpty() + + context(_: ComponentSchemaIdentity, _: ComponentSchemas) + private fun Schema<*>.propertyModels(required: Set, createContext: (String) -> String? = { null }) = + properties + .orEmpty() + .mapValues { (propName, propSchema) -> + PropertyModel( + name = propName, + type = propSchema.toTypeRef(createContext(propName)), + description = propSchema.description, + nullable = propName !in required, + defaultValue = propSchema.default, + ) + } + + private fun generateOperationId(method: HttpMethod, path: String): String { + val segments = path + .split("/") + .filter { it.isNotEmpty() } + .joinToString("") { segment -> + if (segment.startsWith("{") && segment.endsWith("}")) { + "By${segment.removePrefix("{").removeSuffix("}").toPascalCase()}" + } else { + segment.toPascalCase() + } + } + return method.name.lowercase() + segments + } + + private fun String.toPascalCase(): String = + split("-", "_", ".").joinToString("") { part -> part.replaceFirstChar { it.uppercase() } } + + private const val JSON_CONTENT_TYPE = "application/json" + private const val SCHEMA_PREFIX = "#/components/schemas/" + + private val STRING_FORMAT_MAP = mapOf( + "byte" to TypeRef.Primitive(PrimitiveType.BYTE_ARRAY), + "date-time" to TypeRef.Primitive(PrimitiveType.DATE_TIME), + "date" to TypeRef.Primitive(PrimitiveType.DATE), + ) + + private val INTEGER_FORMAT_MAP = mapOf( + "int64" to TypeRef.Primitive(PrimitiveType.LONG), + ) + + private val NUMBER_FORMAT_MAP = mapOf( + "float" to TypeRef.Primitive(PrimitiveType.FLOAT), + ) +} diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecValidator.kt b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecValidator.kt new file mode 100644 index 00000000..8ccc15df --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecValidator.kt @@ -0,0 +1,61 @@ +package com.avsystem.justworks.core.parser + +import arrow.core.raise.ExperimentalRaiseAccumulateApi +import arrow.core.raise.context.accumulate +import arrow.core.raise.context.ensureNotNullOrAccumulate +import arrow.core.raise.context.ensureOrAccumulate +import arrow.core.raise.fold +import io.swagger.v3.oas.models.OpenAPI + +object SpecValidator { + sealed class ValidationIssue { + abstract val message: String + + class Error(override val message: String) : ValidationIssue() + + class Warning(override val message: String) : ValidationIssue() + } + + /** + * Validates a parsed OpenAPI model for required fields and unsupported constructs. + * + * Collects all issues without short-circuiting (using Arrow [accumulate]) so that + * callers receive the full list of problems in a single call. + * + * Returned issues are either [ValidationIssue.Error] (spec is unusable) or + * [ValidationIssue.Warning] (spec can be processed but some features will be ignored). + * + * @param openApi the parsed OpenAPI model from Swagger Parser + * @return list of [ValidationIssue]; empty when the spec is fully valid + */ + @OptIn(ExperimentalRaiseAccumulateApi::class) + fun validate(openApi: OpenAPI): List = fold( + { + accumulate { + ensureNotNullOrAccumulate(openApi.info) { + ValidationIssue.Error("Spec is missing required 'info' section") + } + + ensureOrAccumulate(!openApi.paths.isNullOrEmpty()) { + ValidationIssue.Warning("Spec has no paths defined") + } + // Detect unsupported constructs for v1 + openApi.paths?.values?.forEach { pathItem -> + pathItem.readOperationsMap()?.values?.forEach { operation -> + ensureOrAccumulate(operation.callbacks.isNullOrEmpty()) { + ValidationIssue.Warning("Callbacks are not supported in v1 and will be ignored") + } + } + } + + openApi.components?.links?.let { links -> + ensureOrAccumulate(links.isEmpty()) { + ValidationIssue.Warning("Links are not supported in v1 and will be ignored") + } + } + } + }, + { it }, + { emptyList() }, + ) +} diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserPolymorphicTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserPolymorphicTest.kt new file mode 100644 index 00000000..908a836d --- /dev/null +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserPolymorphicTest.kt @@ -0,0 +1,59 @@ +package com.avsystem.justworks.core.parser + +import com.avsystem.justworks.core.model.TypeRef +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.test.fail + +class SpecParserPolymorphicTest : SpecParserTestBase() { + @Test + fun `allOf schema has merged properties from referenced schema`() { + val spec = parseSpec(loadResource("polymorphic-spec.yaml")) + + val extendedDog = + spec.schemas.find { it.name == "ExtendedDog" } + ?: fail("ExtendedDog schema not found. Schemas: ${spec.schemas.map { it.name }}") + + val propNames = extendedDog.properties.map { it.name }.toSet() + + // ExtendedDog allOf merges Dog properties (name, breed) + inline (tricks) + assertTrue("tricks" in propNames, "Expected 'tricks' from inline. Properties: $propNames") + assertTrue( + "name" in propNames, + "Expected 'name' from Dog via allOf merge. Properties: $propNames. " + + "allOf: ${extendedDog.allOf}, required: ${extendedDog.requiredProperties}", + ) + assertTrue("breed" in propNames, "Expected 'breed' from Dog. Properties: $propNames") + } + + @Test + fun `oneOf schema preserves oneOf refs`() { + val spec = parseSpec(loadResource("polymorphic-spec.yaml")) + + val shape = + spec.schemas.find { it.name == "Shape" } + ?: fail("Shape schema not found") + + val oneOf = assertNotNull(shape.oneOf, "Shape should have oneOf") + val refNames = oneOf.filterIsInstance().map { it.schemaName } + assertTrue("Circle" in refNames, "Expected Circle in oneOf refs. Refs: $refNames") + assertTrue("Square" in refNames, "Expected Square in oneOf refs. Refs: $refNames") + } + + @Test + fun `discriminator is preserved in parsed model`() { + val spec = parseSpec(loadResource("polymorphic-spec.yaml")) + + val shape = + spec.schemas.find { it.name == "Shape" } + ?: fail("Shape schema not found") + + val discriminator = assertNotNull(shape.discriminator, "Shape should have discriminator") + assertEquals("shapeType", discriminator.propertyName) + assertTrue(discriminator.mapping.isNotEmpty(), "Discriminator mapping should not be empty") + assertEquals("#/components/schemas/Circle", discriminator.mapping["circle"]) + assertEquals("#/components/schemas/Square", discriminator.mapping["square"]) + } +} diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt new file mode 100644 index 00000000..fc9871a3 --- /dev/null +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt @@ -0,0 +1,380 @@ +package com.avsystem.justworks.core.parser + +import com.avsystem.justworks.core.model.ApiSpec +import com.avsystem.justworks.core.model.EnumBackingType +import com.avsystem.justworks.core.model.HttpMethod +import com.avsystem.justworks.core.model.ParameterLocation +import com.avsystem.justworks.core.model.PrimitiveType +import com.avsystem.justworks.core.model.TypeRef +import org.junit.jupiter.api.TestInstance +import java.io.File +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.test.fail + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class SpecParserTest : SpecParserTestBase() { + private lateinit var petstore: ApiSpec + + @BeforeTest + fun setUp() { + if (!::petstore.isInitialized) { + petstore = parseSpec(loadResource("petstore.yaml")) + } + } + + private fun parseSpecErrors(file: File): List { + val result = SpecParser.parse(file) + check(result is ParseResult.Failure) { "Expected failure" } + return result.errors + } + + // -- SPEC-01: OpenAPI 3.0 parsing -- + + @Test + fun `parse petstore yaml produces Success with endpoints`() { + assertEquals(3, petstore.endpoints.size, "Expected 3 endpoints") + } + + @Test + fun `parse petstore yaml produces schemas`() { + val schemaNames = petstore.schemas.map { it.name }.toSet() + assertTrue("Pet" in schemaNames, "Pet schema missing") + assertTrue("NewPet" in schemaNames, "NewPet schema missing") + assertTrue("Error" in schemaNames, "Error schema missing") + } + + @Test + fun `parse petstore yaml produces enums`() { + val petStatus = petstore.enums.find { it.name == "PetStatus" } + assertNotNull(petStatus, "PetStatus enum missing") + assertEquals(EnumBackingType.STRING, petStatus.type) + assertEquals(listOf("available", "pending", "sold"), petStatus.values) + } + + @Test + fun `parsed Pet schema has correct properties`() { + val pet = + petstore.schemas.find { it.name == "Pet" } + ?: fail("Pet schema not found") + + val propMap = pet.properties.associateBy { it.name } + assertEquals(4, propMap.size, "Pet should have 4 properties: id, name, tag, status") + + // id: integer (int64) + val idType = propMap["id"]?.type + assertIs(idType) + assertEquals(PrimitiveType.LONG, idType.type) + + // name: string + val nameType = propMap["name"]?.type + assertIs(nameType) + assertEquals(PrimitiveType.STRING, nameType.type) + + // tag: string, nullable (not required) + val tagProp = propMap["tag"] + assertNotNull(tagProp) + assertIs(tagProp.type) + assertTrue(tagProp.nullable, "tag should be nullable (not required)") + + // status: reference to PetStatus + val statusType = propMap["status"]?.type + assertIs(statusType) + assertEquals("PetStatus", statusType.schemaName) + } + + @Test + fun `parsed GET pets endpoint has query parameter limit with INT type`() { + val listPets = + petstore.endpoints.find { it.operationId == "listPets" } + ?: fail("listPets endpoint not found") + + assertEquals(HttpMethod.GET, listPets.method) + assertEquals("/pets", listPets.path) + + val limitParam = + listPets.parameters.find { it.name == "limit" } + ?: fail("limit parameter not found") + assertEquals(ParameterLocation.QUERY, limitParam.location) + val limitType = assertIs(limitParam.schema) + assertEquals(PrimitiveType.INT, limitType.type) + } + + @Test + fun `parsed GET pets petId has path parameter`() { + val getPet = + petstore.endpoints.find { it.operationId == "getPetById" } + ?: fail("getPetById endpoint not found") + + assertEquals(HttpMethod.GET, getPet.method) + + val petIdParam = + getPet.parameters.find { it.name == "petId" } + ?: fail("petId parameter not found") + assertEquals(ParameterLocation.PATH, petIdParam.location) + assertTrue(petIdParam.required, "Path parameter should be required") + } + + @Test + fun `parsed POST pets has requestBody referencing NewPet`() { + val createPet = + petstore.endpoints.find { it.operationId == "createPet" } + ?: fail("createPet endpoint not found") + + assertEquals(HttpMethod.POST, createPet.method) + + val body = assertNotNull(createPet.requestBody, "createPet should have a request body") + assertTrue(body.required, "Request body should be required") + assertEquals("application/json", body.contentType) + + val bodyType = assertIs(body.schema) + assertEquals("NewPet", bodyType.schemaName) + } + + @Test + fun `parsed endpoints have tags`() { + val listPets = petstore.endpoints.find { it.operationId == "listPets" }!! + + assertTrue(listPets.tags.contains("pets"), "listPets should have 'pets' tag") + } + + @Test + fun `parsed GET pets response is array of Pet`() { + val listPets = petstore.endpoints.find { it.operationId == "listPets" }!! + + val okResponse = + listPets.responses["200"] + ?: fail("200 response not found") + val schema = assertNotNull(okResponse.schema, "200 response should have a schema") + val arrayType = assertIs(schema) + val itemType = assertIs(arrayType.items) + assertEquals("Pet", itemType.schemaName) + } + + // -- SPEC-02: $ref resolution -- + + @Test + fun `parse refs spec resolves all references`() { + val spec = parseSpec(loadResource("refs-spec.yaml")) + + // All schema names that are referenced should exist in schemas + val allSchemaNames = (spec.schemas.map { it.name } + spec.enums.map { it.name }).toSet() + + // Collect all TypeRef.Reference from endpoints and schemas + val allRefs = mutableSetOf() + spec.endpoints.forEach { endpoint -> + endpoint.responses.values.forEach { resp -> + collectRefs(resp.schema, allRefs) + } + endpoint.requestBody?.let { collectRefs(it.schema, allRefs) } + } + spec.schemas.forEach { schema -> + schema.properties.forEach { prop -> + collectRefs(prop.type, allRefs) + } + } + + // Every referenced schema name should exist in the model + allRefs.forEach { refName -> + assertTrue( + refName in allSchemaNames, + "Referenced schema '$refName' not found in parsed model schemas: $allSchemaNames", + ) + } + } + + @Test + fun `refs spec nested references are resolved in model`() { + val spec = parseSpec(loadResource("refs-spec.yaml")) + + // Order -> Item -> ItemDetails (chain of refs) + val order = + spec.schemas.find { it.name == "Order" } + ?: fail("Order schema not found") + val itemProp = + order.properties.find { it.name == "item" } + ?: fail("item property not found on Order") + + // After resolveFully, the item property may be inlined or a reference + // Either way, ItemDetails should exist as a named schema + val itemDetails = spec.schemas.find { it.name == "ItemDetails" } + assertNotNull(itemDetails, "ItemDetails schema should exist (nested ref resolved)") + } + + @Test + fun `refs spec parameter ref is resolved`() { + val spec = parseSpec(loadResource("refs-spec.yaml")) + + val listOrders = + spec.endpoints.find { it.operationId == "listOrders" } + ?: fail("listOrders endpoint not found") + + // The $ref parameter (LimitParam) should be resolved to an actual parameter + val limitParam = + listOrders.parameters.find { it.name == "limit" } + ?: fail("limit parameter not found -- \$ref parameter not resolved") + assertEquals(ParameterLocation.QUERY, limitParam.location) + } + + // -- SPEC-03: Error reporting -- + + @Test + fun `parse invalid spec returns Failure`() { + val result = SpecParser.parse(loadResource("invalid-spec.yaml")) + assertIs(result) + } + + @Test + fun `parse invalid spec has descriptive error messages`() { + val errors = parseSpecErrors(loadResource("invalid-spec.yaml")) + + assertTrue(errors.isNotEmpty(), "Failure should have error messages") + // Errors should be human-readable, not empty or codes-only + errors.forEach { error -> + assertTrue(error.length > 5, "Error message too short to be useful: '$error'") + } + } + + // -- SPEC-04: Swagger 2.0 auto-conversion -- + + @Test + fun `parse swagger 2 json returns Success`() { + val result = SpecParser.parse(loadResource("petstore-v2.json")) + assertIs(result) + } + + @Test + fun `swagger 2 spec produces endpoints and schemas`() { + val spec = parseSpec(loadResource("petstore-v2.json")) + + assertTrue(spec.endpoints.isNotEmpty(), "v2 spec should produce endpoints") + assertTrue( + spec.schemas.isNotEmpty() || spec.enums.isNotEmpty(), + "v2 spec should produce schemas or enums", + ) + + // Should have at least the 2 endpoints from the v2 spec + assertTrue(spec.endpoints.size >= 2, "v2 spec should have at least 2 endpoints") + + // Should have Pet schema + val pet = spec.schemas.find { it.name == "Pet" } + assertNotNull(pet, "v2 spec should have Pet schema after conversion") + } + + // -- ANYF-01 through ANYF-05: anyOf support -- + + @Test + fun `anyOf without discriminator parses successfully`() { + val spec = parseSpec(loadResource("anyof-spec.yaml")) + + val unionPayment = spec.schemas.find { it.name == "UnionPayment" } + assertNotNull(unionPayment, "UnionPayment schema should exist") + val anyOf = assertNotNull(unionPayment.anyOf, "UnionPayment should have anyOf") + assertEquals(2, anyOf.size, "UnionPayment should have 2 anyOf variants") + assertEquals(null, unionPayment.discriminator, "UnionPayment should have no discriminator") + } + + @Test + fun `anyOf with discriminator parses successfully`() { + val spec = parseSpec(loadResource("anyof-valid-spec.yaml")) + + val payment = spec.schemas.find { it.name == "Payment" } + assertNotNull(payment, "Payment schema should exist") + val anyOf = assertNotNull(payment.anyOf, "Payment should have anyOf") + assertEquals(2, anyOf.size, "Payment should have 2 anyOf variants") + val discriminator = assertNotNull(payment.discriminator, "Payment should have discriminator") + assertEquals("paymentType", discriminator.propertyName) + } + + @Test + fun `mixed anyOf and oneOf raises error`() { + val errors = parseSpecErrors(loadResource("mixed-combinator-spec.yaml")) + + val errorMessages = errors.joinToString("\n") + assertTrue( + "both oneOf and anyOf" in errorMessages, + "Expected error about mixed combinators, got: $errorMessages", + ) + } + + // -- allOf property reference resolution -- + + @Test + fun `property with allOf reference resolves to referenced type`() { + val spec = + """ + openapi: 3.0.0 + info: + title: Test + version: 1.0.0 + paths: {} + components: + schemas: + TaskConfig: + type: object + properties: + timeout: + type: integer + Task: + type: object + properties: + name: + type: string + config: + allOf: + - ${'$'}ref: '#/components/schemas/TaskConfig' + required: + - name + """.trimIndent() + + val apiSpec = parseSpec(spec.toTempFile()) + + val task = apiSpec.schemas.find { it.name == "Task" } + assertNotNull(task) + + val configProp = task.properties.find { it.name == "config" } + assertNotNull(configProp) + + // Should be Reference("TaskConfig"), not Primitive(STRING) + val configType = assertIs(configProp.type) + assertEquals("TaskConfig", configType.schemaName) + } + + // -- Helpers -- + + private fun String.toTempFile(): File { + val tempFile = File.createTempFile("test-spec-", ".yaml") + tempFile.deleteOnExit() + tempFile.writeText(this) + return tempFile + } + + private fun collectRefs(typeRef: TypeRef?, refs: MutableSet) { + when (typeRef) { + is TypeRef.Reference -> { + refs.add(typeRef.schemaName) + } + + is TypeRef.Array -> { + collectRefs(typeRef.items, refs) + } + + is TypeRef.Map -> { + collectRefs(typeRef.valueType, refs) + } + + is TypeRef.Inline -> { + // Recursively collect refs from inline schema properties + typeRef.properties.forEach { prop -> + collectRefs(prop.type, refs) + } + } + + is TypeRef.Primitive, TypeRef.Unknown, null -> {} + } + } +} diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTestBase.kt b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTestBase.kt new file mode 100644 index 00000000..e0d5fa16 --- /dev/null +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTestBase.kt @@ -0,0 +1,19 @@ +package com.avsystem.justworks.core.parser + +import com.avsystem.justworks.core.model.ApiSpec +import java.io.File +import kotlin.test.fail + +abstract class SpecParserTestBase { + protected fun loadResource(name: String): File { + val url = + javaClass.getResource("/$name") + ?: fail("Test resource not found: $name") + return File(url.toURI()) + } + + protected fun parseSpec(file: File): ApiSpec = when (val result = SpecParser.parse(file)) { + is ParseResult.Success -> result.apiSpec + is ParseResult.Failure -> fail("Expected success but got errors: ${result.errors}") + } +} diff --git a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecValidatorTest.kt b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecValidatorTest.kt new file mode 100644 index 00000000..67de584d --- /dev/null +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecValidatorTest.kt @@ -0,0 +1,77 @@ +package com.avsystem.justworks.core.parser + +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.PathItem +import io.swagger.v3.oas.models.Paths +import io.swagger.v3.oas.models.info.Info +import kotlin.test.Test +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class SpecValidatorTest { + // -- VALID-01: Valid spec -- + + @Test + fun `valid OpenAPI object produces no errors`() { + val openApi = + OpenAPI().apply { + info = + Info().apply { + title = "Test API" + version = "1.0.0" + } + paths = + Paths().apply { + addPathItem("/test", PathItem()) + } + } + + val errors = SpecValidator.validate(openApi) + assertTrue(errors.isEmpty(), "Valid spec should produce no errors, got: $errors") + } + + // -- VALID-02: Missing required fields -- + + @Test + fun `OpenAPI with null info produces errors`() { + val openApi = + OpenAPI().apply { + info = null + paths = + Paths().apply { + addPathItem("/test", PathItem()) + } + } + + val issues = SpecValidator.validate(openApi) + assertTrue(issues.isNotEmpty(), "Missing info should produce issues") + assertTrue( + issues.any { it.message.contains("info", ignoreCase = true) }, + "Error should mention 'info': $issues", + ) + } + + // -- VALID-03: No paths warning -- + + @Test + fun `OpenAPI with no paths produces warning`() { + val openApi = + OpenAPI().apply { + info = + Info().apply { + title = "Empty API" + version = "1.0.0" + } + paths = null + } + + val issues = SpecValidator.validate(openApi) + assertTrue(issues.isNotEmpty(), "Spec with no paths should produce issues") + val warning = issues.firstOrNull { it is SpecValidator.ValidationIssue.Warning } + assertIs(warning, "Expected a Warning for no paths, got: $issues") + assertTrue( + warning.message.contains("paths", ignoreCase = true), + "Warning should mention 'paths': ${warning.message}", + ) + } +} diff --git a/core/src/test/resources/anyof-spec.yaml b/core/src/test/resources/anyof-spec.yaml new file mode 100644 index 00000000..41c6b764 --- /dev/null +++ b/core/src/test/resources/anyof-spec.yaml @@ -0,0 +1,55 @@ +openapi: '3.0.0' +info: + title: AnyOf Test API + version: '1.0' +paths: + /payments: + get: + operationId: listPayments + summary: List payments + tags: + - payments + responses: + '200': + description: A list of payments + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Payment' +components: + schemas: + Payment: + anyOf: + - $ref: '#/components/schemas/CreditCard' + - $ref: '#/components/schemas/BankTransfer' + discriminator: + propertyName: paymentType + mapping: + card: '#/components/schemas/CreditCard' + bank: '#/components/schemas/BankTransfer' + CreditCard: + type: object + required: + - cardNumber + - paymentType + properties: + cardNumber: + type: string + paymentType: + type: string + BankTransfer: + type: object + required: + - accountNumber + - paymentType + properties: + accountNumber: + type: string + paymentType: + type: string + UnionPayment: + anyOf: + - $ref: '#/components/schemas/CreditCard' + - $ref: '#/components/schemas/BankTransfer' diff --git a/core/src/test/resources/anyof-valid-spec.yaml b/core/src/test/resources/anyof-valid-spec.yaml new file mode 100644 index 00000000..f1bb0ec1 --- /dev/null +++ b/core/src/test/resources/anyof-valid-spec.yaml @@ -0,0 +1,51 @@ +openapi: '3.0.0' +info: + title: AnyOf Valid Test API + version: '1.0' +paths: + /payments: + get: + operationId: listPayments + summary: List payments + tags: + - payments + responses: + '200': + description: A list of payments + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Payment' +components: + schemas: + Payment: + anyOf: + - $ref: '#/components/schemas/CreditCard' + - $ref: '#/components/schemas/BankTransfer' + discriminator: + propertyName: paymentType + mapping: + card: '#/components/schemas/CreditCard' + bank: '#/components/schemas/BankTransfer' + CreditCard: + type: object + required: + - cardNumber + - paymentType + properties: + cardNumber: + type: string + paymentType: + type: string + BankTransfer: + type: object + required: + - accountNumber + - paymentType + properties: + accountNumber: + type: string + paymentType: + type: string diff --git a/core/src/test/resources/invalid-spec.yaml b/core/src/test/resources/invalid-spec.yaml new file mode 100644 index 00000000..34e5d2e5 --- /dev/null +++ b/core/src/test/resources/invalid-spec.yaml @@ -0,0 +1,11 @@ +openapi: "3.0.3" +# Deliberately missing 'info' section -- required by OpenAPI spec +# This should cause SpecValidator to return errors + +paths: + /broken: + get: + summary: An endpoint in a spec with no info + responses: + "200": + description: Should never get here diff --git a/core/src/test/resources/mixed-combinator-spec.yaml b/core/src/test/resources/mixed-combinator-spec.yaml new file mode 100644 index 00000000..fae0a9d9 --- /dev/null +++ b/core/src/test/resources/mixed-combinator-spec.yaml @@ -0,0 +1,28 @@ +openapi: '3.0.0' +info: + title: Mixed Combinator Test API + version: '1.0' +paths: + /test: + get: + operationId: test + responses: + '200': + description: OK +components: + schemas: + MixedCombinator: + oneOf: + - $ref: '#/components/schemas/TypeA' + anyOf: + - $ref: '#/components/schemas/TypeB' + TypeA: + type: object + properties: + a: + type: string + TypeB: + type: object + properties: + b: + type: string diff --git a/core/src/test/resources/petstore-v2.json b/core/src/test/resources/petstore-v2.json new file mode 100644 index 00000000..ef7c8ea2 --- /dev/null +++ b/core/src/test/resources/petstore-v2.json @@ -0,0 +1,82 @@ +{ + "swagger": "2.0", + "info": { + "title": "Petstore V2", + "version": "1.0.0", + "description": "A Swagger 2.0 Petstore for testing auto-conversion" + }, + "host": "petstore.example.com", + "basePath": "/v2", + "schemes": ["https"], + "consumes": ["application/json"], + "produces": ["application/json"], + "paths": { + "/pets": { + "get": { + "operationId": "listPets", + "summary": "List all pets", + "parameters": [ + { + "name": "limit", + "in": "query", + "type": "integer", + "format": "int32", + "required": false + } + ], + "responses": { + "200": { + "description": "A list of pets", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Pet" + } + } + } + } + } + }, + "/pets/{petId}": { + "get": { + "operationId": "getPetById", + "summary": "Get a pet by ID", + "parameters": [ + { + "name": "petId", + "in": "path", + "type": "integer", + "format": "int64", + "required": true + } + ], + "responses": { + "200": { + "description": "A single pet", + "schema": { + "$ref": "#/definitions/Pet" + } + } + } + } + } + }, + "definitions": { + "Pet": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "tag": { + "type": "string" + } + } + } + } +} diff --git a/core/src/test/resources/petstore.yaml b/core/src/test/resources/petstore.yaml new file mode 100644 index 00000000..a74bc89c --- /dev/null +++ b/core/src/test/resources/petstore.yaml @@ -0,0 +1,139 @@ +openapi: "3.0.3" +info: + title: Petstore + version: "1.0.0" + description: A sample Petstore API for testing spec parsing + +tags: + - name: pets + description: Everything about pets + +paths: + /pets: + get: + operationId: listPets + summary: List all pets + tags: + - pets + parameters: + - name: limit + in: query + description: Maximum number of pets to return + required: false + schema: + type: integer + format: int32 + responses: + "200": + description: A list of pets + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + post: + operationId: createPet + summary: Create a new pet + tags: + - pets + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NewPet' + responses: + "201": + description: Pet created + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /pets/{petId}: + get: + operationId: getPetById + summary: Get a pet by ID + tags: + - pets + parameters: + - name: petId + in: path + required: true + description: The ID of the pet to retrieve + schema: + type: integer + format: int64 + responses: + "200": + description: A single pet + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + default: + description: Unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + +components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + status: + $ref: '#/components/schemas/PetStatus' + + PetStatus: + type: string + enum: + - available + - pending + - sold + + NewPet: + type: object + required: + - name + properties: + name: + type: string + tag: + type: string + + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string diff --git a/core/src/test/resources/polymorphic-spec.yaml b/core/src/test/resources/polymorphic-spec.yaml new file mode 100644 index 00000000..9a7f30e1 --- /dev/null +++ b/core/src/test/resources/polymorphic-spec.yaml @@ -0,0 +1,85 @@ +openapi: '3.0.0' +info: + title: Polymorphic Test API + version: '1.0' +paths: + /shapes: + get: + operationId: listShapes + summary: List shapes + tags: + - shapes + responses: + '200': + description: A list of shapes + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Shape' +components: + schemas: + Shape: + oneOf: + - $ref: '#/components/schemas/Circle' + - $ref: '#/components/schemas/Square' + discriminator: + propertyName: shapeType + mapping: + circle: '#/components/schemas/Circle' + square: '#/components/schemas/Square' + Circle: + type: object + required: + - radius + - shapeType + properties: + radius: + type: number + shapeType: + type: string + Square: + type: object + required: + - sideLength + - shapeType + properties: + sideLength: + type: number + shapeType: + type: string + Pet: + oneOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog' + Cat: + type: object + required: + - name + properties: + name: + type: string + indoor: + type: boolean + Dog: + type: object + required: + - name + - breed + properties: + name: + type: string + breed: + type: string + ExtendedDog: + allOf: + - $ref: '#/components/schemas/Dog' + - type: object + required: + - tricks + properties: + tricks: + type: array + items: + type: string diff --git a/core/src/test/resources/refs-spec.yaml b/core/src/test/resources/refs-spec.yaml new file mode 100644 index 00000000..6d53ea8b --- /dev/null +++ b/core/src/test/resources/refs-spec.yaml @@ -0,0 +1,96 @@ +openapi: "3.0.3" +info: + title: Refs Test Spec + version: "1.0.0" + description: Spec exercising various $ref resolution patterns + +paths: + /orders: + get: + operationId: listOrders + summary: List orders + parameters: + - $ref: '#/components/parameters/LimitParam' + responses: + "200": + description: A list of orders + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Order' + + /orders/{orderId}: + get: + operationId: getOrder + summary: Get an order by ID + parameters: + - name: orderId + in: path + required: true + schema: + type: integer + format: int64 + responses: + "200": + description: A single order + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + +components: + parameters: + LimitParam: + name: limit + in: query + required: false + description: Maximum number of items to return + schema: + type: integer + format: int32 + + schemas: + Order: + type: object + required: + - id + - item + properties: + id: + type: integer + format: int64 + item: + $ref: '#/components/schemas/Item' + customer: + $ref: '#/components/schemas/Customer' + + Item: + type: object + required: + - name + properties: + name: + type: string + details: + $ref: '#/components/schemas/ItemDetails' + + ItemDetails: + type: object + properties: + description: + type: string + weight: + type: number + format: double + + Customer: + type: object + required: + - name + properties: + name: + type: string + email: + type: string