From 7cab9839a864cb2b8b6dfeb7f7dc891b02865046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 11 Mar 2026 14:02:56 +0100 Subject: [PATCH 01/25] feat: add Gradle build configuration with core module and CI Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 34 ++++ build.gradle.kts | 12 ++ core/build.gradle.kts | 28 +++ gradle.properties | 1 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48966 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 248 +++++++++++++++++++++++ gradlew.bat | 93 +++++++++ settings.gradle.kts | 5 + 9 files changed, 428 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 build.gradle.kts create mode 100644 core/build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..f280e4af --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - uses: gradle/actions/setup-gradle@v4 + + - name: Run tests with coverage + run: ./gradlew core:koverXmlReport + + - name: Upload coverage to Codecov + if: github.event_name == 'pull_request' + uses: codecov/codecov-action@v5 + with: + files: core/build/reports/kover/report.xml + fail_ci_if_error: false diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 00000000..eb2557d8 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + kotlin("jvm") version "2.3.0" apply false +} + +allprojects { + group = "com.avsystem" + version = "0.0.1" + + repositories { + mavenCentral() + } +} diff --git a/core/build.gradle.kts b/core/build.gradle.kts new file mode 100644 index 00000000..ac406838 --- /dev/null +++ b/core/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + kotlin("jvm") + `maven-publish` + id("org.jetbrains.kotlinx.kover") version "0.9.1" +} + +kotlin { + jvmToolchain(21) +} + +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") + testImplementation(kotlin("test")) +} + +tasks.test { + useJUnitPlatform() +} + +publishing { + publications { + create("maven") { + from(components["java"]) + } + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 00000000..7fc6f1ff --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +kotlin.code.style=official diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..d997cfc60f4cff0e7451d19d49a82fa986695d07 GIT binary patch literal 48966 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

`Lem|V$A>eR0uN8Y&sdjtu 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

1bvi{DS7E@q1{$_8->K{_OWv2 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 literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..37f853b1 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..0262dcbd --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/b631911858264c0b6e4d6603d677ff5218766cee/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..c4bdd3ab --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 00000000..14ce48e2 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} +rootProject.name = "justworks" +include("core") From 98bc12d3bd63482835f9b4a6f480629367e9c7cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 11 Mar 2026 14:05:29 +0100 Subject: [PATCH 02/25] feat: add OpenAPI data model, parser, and validator Intermediate model (ApiSpec, TypeRef, SchemaModel) for parsed OpenAPI specs. SpecParser handles schemas, endpoints, oneOf/anyOf/allOf, discriminators, inline schemas, and wrapper pattern detection. SpecValidator checks for unsupported features. Co-Authored-By: Claude Opus 4.6 --- .../avsystem/justworks/core/model/ApiSpec.kt | 62 ++ .../avsystem/justworks/core/model/TypeRef.kt | 19 + .../justworks/core/parser/ParseResult.kt | 14 + .../justworks/core/parser/SpecParser.kt | 591 ++++++++++++++++++ .../justworks/core/parser/SpecValidator.kt | 39 ++ .../core/parser/SpecParserPolymorphicTest.kt | 73 +++ .../justworks/core/parser/SpecParserTest.kt | 403 ++++++++++++ .../core/parser/SpecValidatorTest.kt | 48 ++ core/src/test/resources/anyof-spec.yaml | 55 ++ core/src/test/resources/anyof-valid-spec.yaml | 51 ++ core/src/test/resources/invalid-spec.yaml | 11 + .../test/resources/mixed-combinator-spec.yaml | 28 + core/src/test/resources/petstore-v2.json | 82 +++ core/src/test/resources/petstore.yaml | 139 ++++ core/src/test/resources/polymorphic-spec.yaml | 85 +++ core/src/test/resources/refs-spec.yaml | 96 +++ 16 files changed, 1796 insertions(+) create mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt create mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/model/TypeRef.kt create mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/parser/ParseResult.kt create mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt create mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecValidator.kt create mode 100644 core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserPolymorphicTest.kt create mode 100644 core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt create mode 100644 core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecValidatorTest.kt create mode 100644 core/src/test/resources/anyof-spec.yaml create mode 100644 core/src/test/resources/anyof-valid-spec.yaml create mode 100644 core/src/test/resources/invalid-spec.yaml create mode 100644 core/src/test/resources/mixed-combinator-spec.yaml create mode 100644 core/src/test/resources/petstore-v2.json create mode 100644 core/src/test/resources/petstore.yaml create mode 100644 core/src/test/resources/polymorphic-spec.yaml create mode 100644 core/src/test/resources/refs-spec.yaml 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..344c32e7 --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt @@ -0,0 +1,62 @@ +package com.avsystem.justworks.core.model + +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 } + +data class Parameter( + val name: String, + val location: ParameterLocation, + val required: Boolean, + val schema: TypeRef, + val description: String?, +) + +enum class ParameterLocation { PATH, QUERY, HEADER } + +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 isEnum: Boolean, + 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 } + +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..468d2572 --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/model/TypeRef.kt @@ -0,0 +1,19 @@ +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 +} + +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/ParseResult.kt b/core/src/main/kotlin/com/avsystem/justworks/core/parser/ParseResult.kt new file mode 100644 index 00000000..2391410c --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/ParseResult.kt @@ -0,0 +1,14 @@ +package com.avsystem.justworks.core.parser + +import com.avsystem.justworks.core.model.ApiSpec + +sealed interface ParseResult { + data class Success( + val spec: ApiSpec, + val warnings: List = emptyList(), + ) : ParseResult + + data class Failure( + val errors: List, + ) : ParseResult +} 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..a7e4ce51 --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -0,0 +1,591 @@ +package com.avsystem.justworks.core.parser + +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 + +class SpecParser { + // Identity map from resolved Schema objects to their component schema names. + // Populated during transformToModel to detect inlined refs after resolveFully. + private var componentSchemaIdentity: IdentityHashMap, String> = IdentityHashMap() + + // Component schemas keyed by name, for resolving $ref in allOf sub-schemas. + private var componentSchemas: Map> = emptyMap() + + fun parse(specFile: File): ParseResult { + val parseOptions = + ParseOptions().apply { + isResolve = true + isResolveFully = true + isResolveCombinators = false + } + + val swaggerResult = OpenAPIParser().readLocation(specFile.absolutePath, null, parseOptions) + + val messages = swaggerResult.messages.orEmpty() + val openApi = + swaggerResult.openAPI + ?: return ParseResult.Failure( + messages.ifEmpty { + listOf("Failed to parse spec: ${specFile.name}") + }, + ) + + val validationErrors = SpecValidator.validate(openApi) + if (validationErrors.isNotEmpty()) { + return ParseResult.Failure(validationErrors) + } + + return try { + val apiSpec = transformToModel(openApi) + ParseResult.Success(apiSpec, warnings = messages) + } catch (e: IllegalArgumentException) { + ParseResult.Failure(listOf(e.message ?: "Schema validation failed")) + } + } + + private fun transformToModel(openApi: OpenAPI): ApiSpec { + val allSchemas = openApi.components?.schemas.orEmpty() + + // Build identity map from resolved Schema objects to their component names. + // After resolveFully=true, $ref pointers are replaced with the actual Schema + // object from components, so we can detect resolved refs by object identity. + componentSchemas = allSchemas + componentSchemaIdentity = + IdentityHashMap, String>().apply { + allSchemas.forEach { (name, schema) -> put(schema, name) } + } + + val enumModels = mutableListOf() + val schemaModels = mutableListOf() + + allSchemas.forEach { (name, schema) -> + if (isEnumSchema(schema)) { + enumModels.add(extractEnumModel(name, schema)) + } else { + schemaModels.add(extractSchemaModel(name, schema)) + } + } + + // Extract any inline schemas that were created during wrapper unwrapping + // These are now in componentSchemas but weren't in the original allSchemas + val newInlineSchemas = componentSchemas.filter { (name, _) -> name !in allSchemas.keys } + newInlineSchemas.forEach { (name, schema) -> + if (isEnumSchema(schema)) { + enumModels.add(extractEnumModel(name, schema)) + } else { + schemaModels.add(extractSchemaModel(name, schema)) + } + } + + val endpoints = extractEndpoints(openApi.paths.orEmpty()) + + return ApiSpec( + title = openApi.info?.title ?: "Untitled", + version = openApi.info?.version ?: "0.0.0", + endpoints = endpoints, + schemas = schemaModels, + enums = enumModels, + ) + } + + private fun extractEndpoints(paths: Map): List = paths.flatMap { (path, pathItem) -> + pathItem.readOperationsMap().map { (method, operation) -> + // Merge path-level and operation-level parameters + // Operation-level takes precedence (unique key = name + location) + val pathParams = pathItem.parameters.orEmpty() + val opParams = operation.parameters.orEmpty() + val mergedParams = + (pathParams + opParams) + .associateBy { "${it.name}:${it.`in`}" } + .values + .map { toParameter(it) } + + val requestBody = + operation.requestBody?.let { body -> + val contentEntry = body.content?.entries?.firstOrNull() + contentEntry?.let { (contentType, mediaType) -> + mediaType.schema?.let { schema -> + val schemaTypeRef = if (isInlineObjectSchema(schema)) { + // Inline request body schema + val contextName = generateOperationId(method.name, path).replaceFirstChar { it.uppercase() } + "Request" + createInlineTypeRef(schema, contextName) + } else { + schemaToTypeRef(schema) + } + RequestBody( + required = body.required ?: false, + contentType = contentType, + schema = schemaTypeRef, + ) + } + } + } + + val responses = + operation.responses + ?.map { (code, resp) -> + val schema = + resp.content + ?.get("application/json") + ?.schema + val schemaTypeRef = schema?.let { s -> + if (isInlineObjectSchema(s)) { + // Inline response body schema + val contextName = generateOperationId(method.name, path).replaceFirstChar { it.uppercase() } + "Response" + createInlineTypeRef(s, contextName) + } else { + schemaToTypeRef(s) + } + } + code to + Response( + statusCode = code, + description = resp.description, + schema = schemaTypeRef, + ) + }?.toMap() + .orEmpty() + + Endpoint( + path = path, + method = HttpMethod.valueOf(method.name), + operationId = + operation.operationId + ?: generateOperationId(method.name, path), + summary = operation.summary, + tags = operation.tags.orEmpty(), + parameters = mergedParams, + requestBody = requestBody, + responses = responses, + ) + } + } + + private fun toParameter(param: io.swagger.v3.oas.models.parameters.Parameter): Parameter { + val location = + when (param.`in`?.lowercase()) { + "path" -> ParameterLocation.PATH + "query" -> ParameterLocation.QUERY + "header" -> ParameterLocation.HEADER + else -> ParameterLocation.QUERY + } + return Parameter( + name = param.name ?: "", + location = location, + required = param.required ?: false, + schema = + param.schema?.let { schemaToTypeRef(it) } + ?: TypeRef.Primitive(PrimitiveType.STRING), + description = param.description, + ) + } + + private fun schemaToTypeRef(schema: Schema<*>): TypeRef { + schema.`$ref`?.let { ref -> + val schemaName = ref.removePrefix("#/components/schemas/") + return TypeRef.Reference(schemaName) + } + + // After resolveFully, $ref is null but the schema object may be the same + // instance as a component schema. Detect by identity to preserve references. + componentSchemaIdentity[schema]?.let { schemaName -> + return TypeRef.Reference(schemaName) + } + + // Check for allOf with single reference (common pattern for property schemas with defaults) + schema.allOf?.takeIf { it.size == 1 }?.firstOrNull()?.let { allOfSchema -> + allOfSchema.`$ref`?.let { ref -> + return TypeRef.Reference(ref.removePrefix("#/components/schemas/")) + } + componentSchemaIdentity[allOfSchema]?.let { schemaName -> + return TypeRef.Reference(schemaName) + } + } + + return when (schema.type) { + "string" -> { + when (schema.format) { + "byte" -> TypeRef.Primitive(PrimitiveType.BYTE_ARRAY) + "date-time" -> TypeRef.Primitive(PrimitiveType.DATE_TIME) + "date" -> TypeRef.Primitive(PrimitiveType.DATE) + else -> TypeRef.Primitive(PrimitiveType.STRING) + } + } + + "integer" -> { + when (schema.format) { + "int64" -> TypeRef.Primitive(PrimitiveType.LONG) + else -> TypeRef.Primitive(PrimitiveType.INT) + } + } + + "number" -> { + when (schema.format) { + "float" -> TypeRef.Primitive(PrimitiveType.FLOAT) + else -> TypeRef.Primitive(PrimitiveType.DOUBLE) + } + } + + "boolean" -> { + TypeRef.Primitive(PrimitiveType.BOOLEAN) + } + + "array" -> { + val items = + schema.items + ?: return TypeRef.Primitive(PrimitiveType.STRING) + TypeRef.Array(schemaToTypeRef(items)) + } + + "object" -> { + val additionalProperties = schema.additionalProperties + if (additionalProperties is Schema<*>) { + TypeRef.Map(schemaToTypeRef(additionalProperties)) + } else { + TypeRef.Reference(schema.title ?: "Unknown") + } + } + + else -> { + TypeRef.Primitive(PrimitiveType.STRING) + } + } + } + + private fun isEnumSchema(schema: Schema<*>): Boolean = !schema.enum.isNullOrEmpty() + + private fun extractEnumModel( + name: String, + schema: Schema<*>, + ): EnumModel { + val backingType = + when (schema.type) { + "integer" -> EnumBackingType.INTEGER + else -> EnumBackingType.STRING + } + return EnumModel( + name = name, + description = schema.description, + type = backingType, + values = schema.enum.map { it.toString() }, + ) + } + + /** + * 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. + */ + private fun detectAndUnwrapOneOfWrappers(schema: Schema<*>): Pair, Discriminator>? { + // Must have oneOf and no explicit discriminator + if (schema.oneOf.isNullOrEmpty() || schema.discriminator != null) { + return null + } + + val unwrappedRefs = mutableListOf() + val discriminatorMapping = mutableMapOf() + + for (variant in schema.oneOf.orEmpty()) { + // Check if variant is a wrapper object (not a $ref, not in identity map) + if (variant.`$ref` != null || componentSchemaIdentity[variant] != null) { + // This variant is a direct reference, not a wrapper object + return null + } + + // Check if variant is object type with exactly one property + if (variant.type != "object" || variant.properties.isNullOrEmpty()) { + return null + } + + val properties = variant.properties.orEmpty() + if (properties.size != 1) { + return null + } + + // Extract the single property + val (propertyName, propertySchema) = properties.entries.first() + + // The property value must be a reference or inline object + val unwrappedRef = if (propertySchema.`$ref` != null) { + // Property points to a $ref - extract schema name + val schemaName = propertySchema.`$ref`.removePrefix("#/components/schemas/") + discriminatorMapping[propertyName] = propertySchema.`$ref` + TypeRef.Reference(schemaName) + } else if (componentSchemaIdentity[propertySchema] != null) { + // Property is a resolved reference (after resolveFully) + val schemaName = componentSchemaIdentity[propertySchema]!! + discriminatorMapping[propertyName] = "#/components/schemas/$schemaName" + TypeRef.Reference(schemaName) + } else if (propertySchema.type == "object" && !propertySchema.properties.isNullOrEmpty()) { + // Property is an inline object - create a component schema for it + val inlineSchemaName = propertyName.toPascalCase() + val inlineSchema = propertySchema + + // Register this inline schema as a component for future lookups + componentSchemas = componentSchemas + (inlineSchemaName to inlineSchema) + componentSchemaIdentity[inlineSchema] = inlineSchemaName + + discriminatorMapping[propertyName] = "#/components/schemas/$inlineSchemaName" + TypeRef.Reference(inlineSchemaName) + } else { + // Property value is neither a ref nor an inline object + return null + } + + unwrappedRefs.add(unwrappedRef) + } + + // All variants matched the wrapper pattern - create synthetic discriminator + val syntheticDiscriminator = Discriminator( + propertyName = "type", + mapping = discriminatorMapping, + ) + + return unwrappedRefs to syntheticDiscriminator + } + + private fun extractSchemaModel( + name: String, + schema: Schema<*>, + ): SchemaModel { + val allOf = + schema.allOf?.mapNotNull { subSchema -> + subSchema.`$ref`?.let { ref -> + TypeRef.Reference(ref.removePrefix("#/components/schemas/")) + } ?: componentSchemaIdentity[subSchema]?.let { TypeRef.Reference(it) } + } + + // Check for oneOf wrapper pattern before standard extraction + val (oneOf, discriminatorFromWrapper) = detectAndUnwrapOneOfWrappers(schema) + ?: run { + // Standard oneOf extraction (no wrapper pattern) + val standardOneOf = schema.oneOf?.mapNotNull { subSchema -> + subSchema.`$ref`?.let { ref -> + TypeRef.Reference(ref.removePrefix("#/components/schemas/")) + } ?: componentSchemaIdentity[subSchema]?.let { TypeRef.Reference(it) } + } + standardOneOf to null + } + + val anyOf = + schema.anyOf?.mapNotNull { subSchema -> + subSchema.`$ref`?.let { ref -> + TypeRef.Reference(ref.removePrefix("#/components/schemas/")) + } ?: componentSchemaIdentity[subSchema]?.let { TypeRef.Reference(it) } + } + + // Validate no mixed anyOf+oneOf (check first, before discriminator check) + if (oneOf != null && oneOf.isNotEmpty() && anyOf != null && anyOf.isNotEmpty()) { + throw IllegalArgumentException("Schema '$name' has both oneOf and anyOf. Use one combinator only.") + } + + // For allOf schemas, merge properties from all sub-schemas + val (properties, requiredProps) = + if (!schema.allOf.isNullOrEmpty()) { + extractAllOfProperties(schema) + } else { + val requiredProps = schema.required.orEmpty().toSet() + val props = + schema.properties.orEmpty().map { (propName, propSchema) -> + val propType = if (isInlineObjectSchema(propSchema)) { + // Inline property schema - use Parent.PropertyName pattern + val contextName = "$name.${propName.toPascalCase()}" + createInlineTypeRef(propSchema, contextName) + } else { + schemaToTypeRef(propSchema) + } + PropertyModel( + name = propName, + type = propType, + description = propSchema.description, + nullable = propName !in requiredProps, + defaultValue = propSchema.default, + ) + } + props to requiredProps + } + + val discriminator = + discriminatorFromWrapper ?: schema.discriminator?.let { disc -> + disc.propertyName?.let { + Discriminator(propertyName = it, mapping = disc.mapping.orEmpty()) + } + } + + return SchemaModel( + name = name, + description = schema.description, + properties = properties, + requiredProperties = requiredProps, + isEnum = false, + allOf = allOf?.ifEmpty { null }, + oneOf = oneOf?.ifEmpty { null }, + anyOf = anyOf?.ifEmpty { null }, + discriminator = discriminator, + ) + } + + /** + * Extracts and merges properties from all allOf sub-schemas. + * For $ref sub-schemas (resolved by identity), looks up properties from the resolved schema. + * For inline sub-schemas, extracts properties directly. + * Also includes any top-level properties defined alongside allOf. + * Deduplicates by property name (later definition wins). + */ + private fun extractAllOfProperties(schema: Schema<*>): Pair, Set> { + val mergedRequired = mutableSetOf() + val mergedProperties = mutableMapOf() + + // Collect properties from each allOf sub-schema. + // For $ref sub-schemas (or identity-matched resolved refs), look up the + // referenced component schema to get its properties and required fields. + for (subSchema in schema.allOf.orEmpty()) { + val resolvedSchema = resolveSubSchema(subSchema) + val subRequired = resolvedSchema.required.orEmpty().toSet() + mergedRequired.addAll(subRequired) + + resolvedSchema.properties.orEmpty().forEach { (propName, propSchema) -> + mergedProperties[propName] = + PropertyModel( + name = propName, + type = schemaToTypeRef(propSchema), + description = propSchema.description, + nullable = propName !in mergedRequired, + defaultValue = propSchema.default, + ) + } + } + + // Add top-level properties (inline alongside allOf) + val topRequired = schema.required.orEmpty().toSet() + mergedRequired.addAll(topRequired) + + schema.properties.orEmpty().forEach { (propName, propSchema) -> + mergedProperties[propName] = + PropertyModel( + name = propName, + type = schemaToTypeRef(propSchema), + description = propSchema.description, + nullable = propName !in mergedRequired, + defaultValue = propSchema.default, + ) + } + + // Recompute nullable based on final merged required set + val finalProperties = + mergedProperties.values.map { prop -> + prop.copy(nullable = prop.name !in mergedRequired) + } + + return finalProperties to mergedRequired + } + + /** + * Resolves a sub-schema that may be a `$ref` or an identity-matched ref to the + * actual component schema with properties. Returns the sub-schema itself if it is + * an inline schema (not a reference). + */ + private fun resolveSubSchema(subSchema: Schema<*>): Schema<*> { + // Check for explicit $ref string + subSchema.`$ref`?.let { ref -> + val name = ref.removePrefix("#/components/schemas/") + componentSchemas[name]?.let { return it } + } + // Check for identity-matched resolved ref + componentSchemaIdentity[subSchema]?.let { name -> + componentSchemas[name]?.let { return it } + } + return subSchema + } + + /** + * Checks if a schema is an inline object schema (not a $ref, not in componentSchemaIdentity, + * is type object with properties). + */ + private fun isInlineObjectSchema(schema: Schema<*>): Boolean = schema.`$ref` == null && + componentSchemaIdentity[schema] == null && + schema.type == "object" && + !schema.properties.isNullOrEmpty() + + /** + * Creates a TypeRef.Inline for an inline object schema. + */ + private fun createInlineTypeRef( + schema: Schema<*>, + contextName: String + ): TypeRef { + val requiredProps = schema.required.orEmpty().toSet() + val properties = schema.properties.orEmpty().map { (propName, propSchema) -> + val propType = if (isInlineObjectSchema(propSchema)) { + // Nested inline property schema + val nestedContextName = "$contextName.${propName.toPascalCase()}" + createInlineTypeRef(propSchema, nestedContextName) + } else { + schemaToTypeRef(propSchema) + } + PropertyModel( + name = propName, + type = propType, + description = propSchema.description, + nullable = propName !in requiredProps, + defaultValue = propSchema.default, + ) + } + return TypeRef.Inline( + properties = properties, + requiredProperties = requiredProps, + contextHint = contextName, + ) + } + + private fun generateOperationId( + method: String, + 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.lowercase() + segments + } + + private fun String.toPascalCase(): String = split("-", "_", ".").joinToString("") { part -> + part.replaceFirstChar { + it.uppercase() + } + } +} 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..86362d34 --- /dev/null +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecValidator.kt @@ -0,0 +1,39 @@ +package com.avsystem.justworks.core.parser + +import io.swagger.v3.oas.models.OpenAPI + +object SpecValidator { + + fun validate(openApi: OpenAPI): List { + val errors = mutableListOf() + + // Missing info is an error -- required by OpenAPI spec + if (openApi.info == null) { + errors.add("[JUSTWORKS] Spec is missing required 'info' section") + } + + // Missing paths is a warning logged but not an error + if (openApi.paths.isNullOrEmpty()) { + errors.add("[JUSTWORKS] Warning: Spec has no paths defined") + } + + // Detect unsupported constructs for v1 + openApi.paths?.values?.forEach { pathItem -> + pathItem.readOperationsMap()?.values?.forEach { operation -> + if (!operation.callbacks.isNullOrEmpty()) { + errors.add("[JUSTWORKS] Warning: Callbacks are not supported in v1 and will be ignored") + return@forEach + } + } + } + + openApi.components?.links?.let { links -> + if (links.isNotEmpty()) { + errors.add("[JUSTWORKS] Warning: Links are not supported in v1 and will be ignored") + } + } + + // Only return actual errors (non-warnings) as blockers + return errors.filter { !it.contains("Warning:") } + } +} 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..f5886668 --- /dev/null +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserPolymorphicTest.kt @@ -0,0 +1,73 @@ +package com.avsystem.justworks.core.parser + +import com.avsystem.justworks.core.model.TypeRef +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.test.fail + +class SpecParserPolymorphicTest { + private val parser = SpecParser() + + private fun loadResource(name: String): File { + val url = + javaClass.getResource("/$name") + ?: fail("Test resource not found: $name") + return File(url.toURI()) + } + + @Test + fun `allOf schema has merged properties from referenced schema`() { + val result = parser.parse(loadResource("polymorphic-spec.yaml")) + val spec = assertIs(result).spec + + 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 result = parser.parse(loadResource("polymorphic-spec.yaml")) + val spec = assertIs(result).spec + + 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 result = parser.parse(loadResource("polymorphic-spec.yaml")) + val spec = assertIs(result).spec + + 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..5fb88c93 --- /dev/null +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt @@ -0,0 +1,403 @@ +package com.avsystem.justworks.core.parser + +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 java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.test.fail + +class SpecParserTest { + private val parser = SpecParser() + + private fun loadResource(name: String): File { + val url = + javaClass.getResource("/$name") + ?: fail("Test resource not found: $name") + return File(url.toURI()) + } + + // -- SPEC-01: OpenAPI 3.0 parsing -- + + @Test + fun `parse petstore yaml produces Success with endpoints`() { + val result = parser.parse(loadResource("petstore.yaml")) + val spec = assertIs(result).spec + + assertEquals(3, spec.endpoints.size, "Expected 3 endpoints") + } + + @Test + fun `parse petstore yaml produces schemas`() { + val result = parser.parse(loadResource("petstore.yaml")) + val spec = assertIs(result).spec + + val schemaNames = spec.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 result = parser.parse(loadResource("petstore.yaml")) + val spec = assertIs(result).spec + + val petStatus = spec.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 result = parser.parse(loadResource("petstore.yaml")) + val spec = assertIs(result).spec + val pet = + spec.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 result = parser.parse(loadResource("petstore.yaml")) + val spec = assertIs(result).spec + val listPets = + spec.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 result = parser.parse(loadResource("petstore.yaml")) + val spec = assertIs(result).spec + val getPet = + spec.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 result = parser.parse(loadResource("petstore.yaml")) + val spec = assertIs(result).spec + val createPet = + spec.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 result = parser.parse(loadResource("petstore.yaml")) + val spec = assertIs(result).spec + val listPets = spec.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 result = parser.parse(loadResource("petstore.yaml")) + val spec = assertIs(result).spec + val listPets = spec.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 result = parser.parse(loadResource("refs-spec.yaml")) + val spec = assertIs(result).spec + + // 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 result = parser.parse(loadResource("refs-spec.yaml")) + val spec = assertIs(result).spec + + // 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 result = parser.parse(loadResource("refs-spec.yaml")) + val spec = assertIs(result).spec + + 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 = parser.parse(loadResource("invalid-spec.yaml")) + assertIs(result) + } + + @Test + fun `parse invalid spec has descriptive error messages`() { + val result = parser.parse(loadResource("invalid-spec.yaml")) + val failure = assertIs(result) + + assertTrue(failure.errors.isNotEmpty(), "Failure should have error messages") + // Errors should be human-readable, not empty or codes-only + failure.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 = parser.parse(loadResource("petstore-v2.json")) + assertIs(result) + } + + @Test + fun `swagger 2 spec produces endpoints and schemas`() { + val result = parser.parse(loadResource("petstore-v2.json")) + val spec = assertIs(result).spec + + 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 result = parser.parse(loadResource("anyof-spec.yaml")) + val spec = assertIs(result).spec + + 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 result = parser.parse(loadResource("anyof-valid-spec.yaml")) + val spec = assertIs(result).spec + + 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 throws IllegalArgumentException`() { + val result = parser.parse(loadResource("mixed-combinator-spec.yaml")) + val failure = assertIs(result) + + val errorMessages = failure.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 result = parser.parse(spec.toTempFile()) + val apiSpec = assertIs(result).spec + + 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, null -> {} + } + } +} 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..a95cda6e --- /dev/null +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecValidatorTest.kt @@ -0,0 +1,48 @@ +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.assertTrue + +class SpecValidatorTest { + @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") + } + + @Test + fun `OpenAPI with null info produces errors`() { + val openApi = + OpenAPI().apply { + info = null + paths = + Paths().apply { + addPathItem("/test", PathItem()) + } + } + + val errors = SpecValidator.validate(openApi) + assertTrue(errors.isNotEmpty(), "Missing info should produce errors") + assertTrue( + errors.any { it.contains("info", ignoreCase = true) }, + "Error should mention 'info': $errors", + ) + } +} 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 From fb05a753a5cf43fb578da747e9e8fd1cb4325a4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 11 Mar 2026 14:45:58 +0100 Subject: [PATCH 03/25] chore: update Gradle properties and adjust ktlint rules ktlint format --- .../justworks/core/parser/SpecParser.kt | 26 ++++++------------- .../justworks/core/parser/SpecValidator.kt | 1 - .../justworks/core/parser/SpecParserTest.kt | 5 +--- 3 files changed, 9 insertions(+), 23 deletions(-) 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 index a7e4ce51..bdab1bb5 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -126,7 +126,8 @@ class SpecParser { mediaType.schema?.let { schema -> val schemaTypeRef = if (isInlineObjectSchema(schema)) { // Inline request body schema - val contextName = generateOperationId(method.name, path).replaceFirstChar { it.uppercase() } + "Request" + val contextName = generateOperationId(method.name, path) + .replaceFirstChar { it.uppercase() } + "Request" createInlineTypeRef(schema, contextName) } else { schemaToTypeRef(schema) @@ -150,7 +151,8 @@ class SpecParser { val schemaTypeRef = schema?.let { s -> if (isInlineObjectSchema(s)) { // Inline response body schema - val contextName = generateOperationId(method.name, path).replaceFirstChar { it.uppercase() } + "Response" + val contextName = generateOperationId(method.name, path) + .replaceFirstChar { it.uppercase() } + "Response" createInlineTypeRef(s, contextName) } else { schemaToTypeRef(s) @@ -273,10 +275,7 @@ class SpecParser { private fun isEnumSchema(schema: Schema<*>): Boolean = !schema.enum.isNullOrEmpty() - private fun extractEnumModel( - name: String, - schema: Schema<*>, - ): EnumModel { + private fun extractEnumModel(name: String, schema: Schema<*>): EnumModel { val backingType = when (schema.type) { "integer" -> EnumBackingType.INTEGER @@ -371,10 +370,7 @@ class SpecParser { return unwrappedRefs to syntheticDiscriminator } - private fun extractSchemaModel( - name: String, - schema: Schema<*>, - ): SchemaModel { + private fun extractSchemaModel(name: String, schema: Schema<*>): SchemaModel { val allOf = schema.allOf?.mapNotNull { subSchema -> subSchema.`$ref`?.let { ref -> @@ -537,10 +533,7 @@ class SpecParser { /** * Creates a TypeRef.Inline for an inline object schema. */ - private fun createInlineTypeRef( - schema: Schema<*>, - contextName: String - ): TypeRef { + private fun createInlineTypeRef(schema: Schema<*>, contextName: String): TypeRef { val requiredProps = schema.required.orEmpty().toSet() val properties = schema.properties.orEmpty().map { (propName, propSchema) -> val propType = if (isInlineObjectSchema(propSchema)) { @@ -565,10 +558,7 @@ class SpecParser { ) } - private fun generateOperationId( - method: String, - path: String, - ): String { + private fun generateOperationId(method: String, path: String): String { val segments = path .split("/") 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 index 86362d34..d208b85a 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecValidator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecValidator.kt @@ -3,7 +3,6 @@ package com.avsystem.justworks.core.parser import io.swagger.v3.oas.models.OpenAPI object SpecValidator { - fun validate(openApi: OpenAPI): List { val errors = mutableListOf() 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 index 5fb88c93..0b8f0015 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt @@ -373,10 +373,7 @@ class SpecParserTest { return tempFile } - private fun collectRefs( - typeRef: TypeRef?, - refs: MutableSet, - ) { + private fun collectRefs(typeRef: TypeRef?, refs: MutableSet) { when (typeRef) { is TypeRef.Reference -> { refs.add(typeRef.schemaName) From 2b64c3ba9ca3930bdd54c7f8040f67e20f94ea4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 11 Mar 2026 17:25:40 +0100 Subject: [PATCH 04/25] feat: enhance OpenAPI parser with Arrow library and refactor validation - Integrated Arrow core for improved null safety and functional error handling in `SpecParser` and `SpecValidator`. - Refactored validation logic to use Arrow's contextual `Raise` API, distinguishing errors and warnings as independent result types. - Added support for Kotlin compiler `-Xcontext-parameters` flag for cleaner contextual DSL usage. - Adjusted Gradle dependencies to include `arrow-core` and updated Kotlin compile configuration. --- core/build.gradle.kts | 7 + .../avsystem/justworks/core/model/ApiSpec.kt | 38 +- .../avsystem/justworks/core/model/TypeRef.kt | 3 + .../justworks/core/parser/ParseResult.kt | 9 +- .../justworks/core/parser/SpecParser.kt | 504 +++++++----------- .../justworks/core/parser/SpecValidator.kt | 41 +- 6 files changed, 265 insertions(+), 337 deletions(-) diff --git a/core/build.gradle.kts b/core/build.gradle.kts index ac406838..34ca8707 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` @@ -12,6 +14,7 @@ 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 +29,7 @@ publishing { } } } +val compileKotlin: KotlinCompile by tasks +compileKotlin.compilerOptions { + freeCompilerArgs.set(listOf("-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 index 344c32e7..ed35ae26 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt @@ -29,11 +29,27 @@ data class Parameter( val description: String?, ) -enum class ParameterLocation { PATH, QUERY, HEADER } +enum class ParameterLocation { + PATH, + QUERY, + HEADER; -data class RequestBody(val required: Boolean, val contentType: String, val schema: TypeRef) + companion object { + fun parse(name: String): ParameterLocation? = entries.find { it.name.equals(name, true) } + } +} -data class Response(val statusCode: String, val description: String?, val schema: TypeRef?) +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, @@ -55,8 +71,20 @@ data class PropertyModel( val defaultValue: Any? = null, ) -data class EnumModel(val name: String, val description: String?, val type: EnumBackingType, val values: List) +data class EnumModel( + val name: String, + val description: String?, + val type: EnumBackingType, + val values: List, +) + +enum class EnumBackingType { + STRING, + INTEGER; -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 index 468d2572..775aa612 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/model/TypeRef.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/model/TypeRef.kt @@ -1,5 +1,8 @@ package com.avsystem.justworks.core.model +import arrow.core.raise.nullable +import io.swagger.v3.oas.models.media.Schema + sealed interface TypeRef { data class Primitive(val type: PrimitiveType) : TypeRef diff --git a/core/src/main/kotlin/com/avsystem/justworks/core/parser/ParseResult.kt b/core/src/main/kotlin/com/avsystem/justworks/core/parser/ParseResult.kt index 2391410c..64148fe8 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/ParseResult.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/ParseResult.kt @@ -3,12 +3,7 @@ package com.avsystem.justworks.core.parser import com.avsystem.justworks.core.model.ApiSpec sealed interface ParseResult { - data class Success( - val spec: ApiSpec, - val warnings: List = emptyList(), - ) : ParseResult + data class Success(val spec: ApiSpec, val warnings: List = emptyList()) : ParseResult - data class Failure( - val errors: List, - ) : ParseResult + data class Failure(val errors: List) : ParseResult } 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 index bdab1bb5..ed6cd31d 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -1,5 +1,7 @@ package com.avsystem.justworks.core.parser +import arrow.core.raise.nullable +import arrow.core.raise.recover import com.avsystem.justworks.core.model.ApiSpec import com.avsystem.justworks.core.model.Discriminator import com.avsystem.justworks.core.model.Endpoint @@ -21,14 +23,17 @@ 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 kotlin.collections.emptyList +import kotlin.collections.emptyMap +import io.swagger.v3.oas.models.parameters.Parameter as SwaggerParameter -class SpecParser { +object SpecParser { // Identity map from resolved Schema objects to their component schema names. // Populated during transformToModel to detect inlined refs after resolveFully. - private var componentSchemaIdentity: IdentityHashMap, String> = IdentityHashMap() + typealias ComponentSchemaIdentity = IdentityHashMap, String> // Component schemas keyed by name, for resolving $ref in allOf sub-schemas. - private var componentSchemas: Map> = emptyMap() + typealias ComponentSchemas = MutableMap> fun parse(specFile: File): ParseResult { val parseOptions = @@ -43,127 +48,108 @@ class SpecParser { val messages = swaggerResult.messages.orEmpty() val openApi = swaggerResult.openAPI - ?: return ParseResult.Failure( - messages.ifEmpty { - listOf("Failed to parse spec: ${specFile.name}") - }, - ) + ?: return ParseResult.Failure(messages.ifEmpty { listOf("Failed to parse spec: ${specFile.name}") }) - val validationErrors = SpecValidator.validate(openApi) - if (validationErrors.isNotEmpty()) { - return ParseResult.Failure(validationErrors) - } + recover( + { SpecValidator.validate(openApi) }, + { validationIssues -> + val (errors, warnings) = validationIssues.partition { it is SpecValidator.ValidationIssue.Error } + warnings.forEach { println("[justworks] Warning: $it") } + + return@parse ParseResult.Failure(errors.map { it.message }) + }, + ) return try { - val apiSpec = transformToModel(openApi) + val apiSpec = openApi.toApiSpec() ParseResult.Success(apiSpec, warnings = messages) } catch (e: IllegalArgumentException) { ParseResult.Failure(listOf(e.message ?: "Schema validation failed")) } } - private fun transformToModel(openApi: OpenAPI): ApiSpec { - val allSchemas = openApi.components?.schemas.orEmpty() - - // Build identity map from resolved Schema objects to their component names. - // After resolveFully=true, $ref pointers are replaced with the actual Schema - // object from components, so we can detect resolved refs by object identity. - componentSchemas = allSchemas - componentSchemaIdentity = - IdentityHashMap, String>().apply { - allSchemas.forEach { (name, schema) -> put(schema, name) } - } - - val enumModels = mutableListOf() - val schemaModels = mutableListOf() + fun OpenAPI.toApiSpec(): ApiSpec { + val allSchemas = this.components?.schemas.orEmpty() - allSchemas.forEach { (name, schema) -> - if (isEnumSchema(schema)) { - enumModels.add(extractEnumModel(name, schema)) - } else { - schemaModels.add(extractSchemaModel(name, schema)) - } + val componentSchemaIdentity = ComponentSchemaIdentity(allSchemas.size).apply { + // Build identity map from resolved Schema objects to their component names. + // After resolveFully=true, $ref pointers are replaced with the actual Schema + // object from components, so we can detect resolved refs by object identity. + allSchemas.forEach { (name, schema) -> this[schema] = name } } - - // Extract any inline schemas that were created during wrapper unwrapping - // These are now in componentSchemas but weren't in the original allSchemas - val newInlineSchemas = componentSchemas.filter { (name, _) -> name !in allSchemas.keys } - newInlineSchemas.forEach { (name, schema) -> - if (isEnumSchema(schema)) { - enumModels.add(extractEnumModel(name, schema)) - } else { - schemaModels.add(extractSchemaModel(name, schema)) - } + context(componentSchemaIdentity, allSchemas.toMutableMap()) { + val (enumModels, schemaModels) = + allSchemas.asSequence().partition { (_, schema) -> schema.isEnumSchema } + + // Extract any inline schemas that were created during wrapper unwrapping + // These are now in componentSchemas but weren't in the original allSchemas + val newInlineSchemas = allSchemas.filterKeys { it !in allSchemas.keys } + + val (inlineEnumModels, inlineSchemaModels) = + newInlineSchemas.asSequence().partition { (_, schema) -> schema.isEnumSchema } + + return ApiSpec( + title = this.info?.title ?: "Untitled", + version = this.info?.version ?: "0.0.0", + endpoints = extractEndpoints(this.paths.orEmpty()), + schemas = schemaModels + .plus(inlineSchemaModels) + .map { (name, schema) -> extractSchemaModel(name, schema) }, + enums = enumModels + .plus(inlineEnumModels) + .map { (name, schema) -> extractEnumModel(name, schema) }, + ) } - - val endpoints = extractEndpoints(openApi.paths.orEmpty()) - - return ApiSpec( - title = openApi.info?.title ?: "Untitled", - version = openApi.info?.version ?: "0.0.0", - endpoints = endpoints, - schemas = schemaModels, - enums = enumModels, - ) } + context(componentSchemaIdentity: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) private fun extractEndpoints(paths: Map): List = paths.flatMap { (path, pathItem) -> pathItem.readOperationsMap().map { (method, operation) -> // Merge path-level and operation-level parameters // Operation-level takes precedence (unique key = name + location) - val pathParams = pathItem.parameters.orEmpty() - val opParams = operation.parameters.orEmpty() - val mergedParams = - (pathParams + opParams) - .associateBy { "${it.name}:${it.`in`}" } - .values - .map { toParameter(it) } - - val requestBody = - operation.requestBody?.let { body -> - val contentEntry = body.content?.entries?.firstOrNull() - contentEntry?.let { (contentType, mediaType) -> - mediaType.schema?.let { schema -> - val schemaTypeRef = if (isInlineObjectSchema(schema)) { - // Inline request body schema - val contextName = generateOperationId(method.name, path) - .replaceFirstChar { it.uppercase() } + "Request" - createInlineTypeRef(schema, contextName) - } else { - schemaToTypeRef(schema) - } - RequestBody( - required = body.required ?: false, - contentType = contentType, - schema = schemaTypeRef, - ) - } - } - } + val mergedParams = (pathItem.parameters.orEmpty() + operation.parameters.orEmpty()) + .associateBy { "${it.name}:${it.`in`}" } + .values + .map { it.toParameter() } + + val requestBody = nullable { + val body = operation.requestBody.bind() + + val (contentType, mediaType) = body.content + ?.entries + ?.firstOrNull() + .bind() + val schema = mediaType.schema.bind() + + val schemaTypeRef = if (schema.isInlineObject) { + // Inline request body schema + createInlineTypeRef(schema, generateOperationId(method.name, path).upperCase() + "Request") + } else { + schema.toTypeRef() + } + RequestBody( + required = body.required ?: false, + contentType = contentType, + schema = schemaTypeRef, + ) + } val responses = operation.responses ?.map { (code, resp) -> - val schema = - resp.content - ?.get("application/json") - ?.schema + val schema = resp.content?.get("application/json")?.schema val schemaTypeRef = schema?.let { s -> - if (isInlineObjectSchema(s)) { + if (s.isInlineObject) { // Inline response body schema - val contextName = generateOperationId(method.name, path) - .replaceFirstChar { it.uppercase() } + "Response" - createInlineTypeRef(s, contextName) + createInlineTypeRef( + s, + generateOperationId(method.name, path).upperCase() + "Response", + ) } else { - schemaToTypeRef(s) + s.toTypeRef() } } - code to - Response( - statusCode = code, - description = resp.description, - schema = schemaTypeRef, - ) + code to Response(statusCode = code, description = resp.description, schema = schemaTypeRef) }?.toMap() .orEmpty() @@ -182,112 +168,51 @@ class SpecParser { } } - private fun toParameter(param: io.swagger.v3.oas.models.parameters.Parameter): Parameter { - val location = - when (param.`in`?.lowercase()) { - "path" -> ParameterLocation.PATH - "query" -> ParameterLocation.QUERY - "header" -> ParameterLocation.HEADER - else -> ParameterLocation.QUERY - } - return Parameter( - name = param.name ?: "", - location = location, - required = param.required ?: false, - schema = - param.schema?.let { schemaToTypeRef(it) } - ?: TypeRef.Primitive(PrimitiveType.STRING), - description = param.description, - ) - } + context(componentSchemaIdentity: ComponentSchemaIdentity, componentSchemas: 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, + ) - private fun schemaToTypeRef(schema: Schema<*>): TypeRef { - schema.`$ref`?.let { ref -> - val schemaName = ref.removePrefix("#/components/schemas/") - return TypeRef.Reference(schemaName) - } + context(componentSchemaIdentity: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) + private fun Schema<*>.toTypeRef(): TypeRef = + (this.name ?: allOf.takeIf { it.size == 1 }?.firstOrNull()?.name)?.let(TypeRef::Reference) ?: when (this.type) { + "string" if this.format == "byte" -> TypeRef.Primitive(PrimitiveType.BYTE_ARRAY) - // After resolveFully, $ref is null but the schema object may be the same - // instance as a component schema. Detect by identity to preserve references. - componentSchemaIdentity[schema]?.let { schemaName -> - return TypeRef.Reference(schemaName) - } + "string" if this.format == "date-time" -> TypeRef.Primitive(PrimitiveType.DATE_TIME) - // Check for allOf with single reference (common pattern for property schemas with defaults) - schema.allOf?.takeIf { it.size == 1 }?.firstOrNull()?.let { allOfSchema -> - allOfSchema.`$ref`?.let { ref -> - return TypeRef.Reference(ref.removePrefix("#/components/schemas/")) - } - componentSchemaIdentity[allOfSchema]?.let { schemaName -> - return TypeRef.Reference(schemaName) - } - } + "string" if this.format == "date" -> TypeRef.Primitive(PrimitiveType.DATE) - return when (schema.type) { - "string" -> { - when (schema.format) { - "byte" -> TypeRef.Primitive(PrimitiveType.BYTE_ARRAY) - "date-time" -> TypeRef.Primitive(PrimitiveType.DATE_TIME) - "date" -> TypeRef.Primitive(PrimitiveType.DATE) - else -> TypeRef.Primitive(PrimitiveType.STRING) - } - } + "string" -> TypeRef.Primitive(PrimitiveType.STRING) - "integer" -> { - when (schema.format) { - "int64" -> TypeRef.Primitive(PrimitiveType.LONG) - else -> TypeRef.Primitive(PrimitiveType.INT) - } - } + "integer" if this.format == "int64" -> TypeRef.Primitive(PrimitiveType.LONG) - "number" -> { - when (schema.format) { - "float" -> TypeRef.Primitive(PrimitiveType.FLOAT) - else -> TypeRef.Primitive(PrimitiveType.DOUBLE) - } - } + "integer" -> TypeRef.Primitive(PrimitiveType.INT) - "boolean" -> { - TypeRef.Primitive(PrimitiveType.BOOLEAN) - } + "number" if this.format == "float" -> TypeRef.Primitive(PrimitiveType.FLOAT) - "array" -> { - val items = - schema.items - ?: return TypeRef.Primitive(PrimitiveType.STRING) - TypeRef.Array(schemaToTypeRef(items)) - } + "number" -> TypeRef.Primitive(PrimitiveType.DOUBLE) - "object" -> { - val additionalProperties = schema.additionalProperties - if (additionalProperties is Schema<*>) { - TypeRef.Map(schemaToTypeRef(additionalProperties)) - } else { - TypeRef.Reference(schema.title ?: "Unknown") - } - } + "boolean" -> TypeRef.Primitive(PrimitiveType.BOOLEAN) - else -> { - TypeRef.Primitive(PrimitiveType.STRING) - } - } - } + "array" -> this.items?.toTypeRef()?.let(TypeRef::Array) ?: TypeRef.Primitive(PrimitiveType.STRING) - private fun isEnumSchema(schema: Schema<*>): Boolean = !schema.enum.isNullOrEmpty() + "object" -> (this.additionalProperties as? Schema<*>)?.toTypeRef() ?: TypeRef.Reference( + this.title ?: "Unknown", + ) - private fun extractEnumModel(name: String, schema: Schema<*>): EnumModel { - val backingType = - when (schema.type) { - "integer" -> EnumBackingType.INTEGER - else -> EnumBackingType.STRING - } - return EnumModel( - name = name, - description = schema.description, - type = backingType, - values = schema.enum.map { it.toString() }, - ) - } + else -> TypeRef.Primitive(PrimitiveType.STRING) + } + + 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() }, + ) /** * Detects and unwraps the oneOf wrapper pattern where each variant is a single-property @@ -302,103 +227,67 @@ class SpecParser { * * Returns: Pair of (unwrapped oneOf refs, synthetic discriminator) or null if pattern not matched. */ - private fun detectAndUnwrapOneOfWrappers(schema: Schema<*>): Pair, Discriminator>? { - // Must have oneOf and no explicit discriminator - if (schema.oneOf.isNullOrEmpty() || schema.discriminator != null) { - return null - } - val unwrappedRefs = mutableListOf() - val discriminatorMapping = mutableMapOf() + context(componentSchemaIdentity: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) + private fun detectAndUnwrapOneOfWrappers(schema: Schema<*>): Pair, Discriminator>? = nullable { + // Must have oneOf and no explicit discriminator + ensure(schema.oneOf.isNullOrEmpty() || schema.discriminator != null) - for (variant in schema.oneOf.orEmpty()) { - // Check if variant is a wrapper object (not a $ref, not in identity map) - if (variant.`$ref` != null || componentSchemaIdentity[variant] != null) { + val (discriminatorMapping, unwrappedRefs) = schema.oneOf + .orEmpty() + .fold((emptyMap() to emptyList())) { (mappings, refs), variant -> + // Check if variant is a wrapper object (not a $ref, not in identity map) // This variant is a direct reference, not a wrapper object - return null - } + ensure(variant.`$ref` != null || componentSchemaIdentity[variant] != null) - // Check if variant is object type with exactly one property - if (variant.type != "object" || variant.properties.isNullOrEmpty()) { - return null - } + // Check if variant is object type with exactly one property + ensure(variant.type != "object" || variant.properties.isNullOrEmpty()) - val properties = variant.properties.orEmpty() - if (properties.size != 1) { - return null - } + val properties = variant.properties.orEmpty() + ensure(properties.size != 1) - // Extract the single property - val (propertyName, propertySchema) = properties.entries.first() - - // The property value must be a reference or inline object - val unwrappedRef = if (propertySchema.`$ref` != null) { - // Property points to a $ref - extract schema name - val schemaName = propertySchema.`$ref`.removePrefix("#/components/schemas/") - discriminatorMapping[propertyName] = propertySchema.`$ref` - TypeRef.Reference(schemaName) - } else if (componentSchemaIdentity[propertySchema] != null) { - // Property is a resolved reference (after resolveFully) - val schemaName = componentSchemaIdentity[propertySchema]!! - discriminatorMapping[propertyName] = "#/components/schemas/$schemaName" - TypeRef.Reference(schemaName) - } else if (propertySchema.type == "object" && !propertySchema.properties.isNullOrEmpty()) { - // Property is an inline object - create a component schema for it - val inlineSchemaName = propertyName.toPascalCase() - val inlineSchema = propertySchema - - // Register this inline schema as a component for future lookups - componentSchemas = componentSchemas + (inlineSchemaName to inlineSchema) - componentSchemaIdentity[inlineSchema] = inlineSchemaName - - discriminatorMapping[propertyName] = "#/components/schemas/$inlineSchemaName" - TypeRef.Reference(inlineSchemaName) - } else { - // Property value is neither a ref nor an inline object - return null - } + // Extract the single property + val (propertyName, propertySchema) = properties.entries.first() - unwrappedRefs.add(unwrappedRef) - } + // The property value must be a reference or inline object + val res = + propertySchema.resolveName()?.let { schemaName -> + "$SCHEMA_PREFIX$schemaName" to schemaName + } ?: propertySchema + .takeIf { it.type == "object" && !it.properties.isNullOrEmpty() } + ?.let { propertySchema -> + // Property is an inline object - create a component schema for it + val inlineSchemaName = propertyName.toPascalCase() + + // Register this inline schema as a component for future lookups + componentSchemas += (inlineSchemaName to propertySchema) + componentSchemaIdentity[propertySchema] = inlineSchemaName + + "$SCHEMA_PREFIX$inlineSchemaName" to inlineSchemaName + } + + res?.let { (mapping, ref) -> mappings + (propertyName to mapping) to refs + ref } ?: (mappings to refs) + } // All variants matched the wrapper pattern - create synthetic discriminator - val syntheticDiscriminator = Discriminator( - propertyName = "type", - mapping = discriminatorMapping, - ) + val syntheticDiscriminator = Discriminator(propertyName = "type", mapping = discriminatorMapping) return unwrappedRefs to syntheticDiscriminator } + context(componentSchemaIdentity: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) private fun extractSchemaModel(name: String, schema: Schema<*>): SchemaModel { - val allOf = - schema.allOf?.mapNotNull { subSchema -> - subSchema.`$ref`?.let { ref -> - TypeRef.Reference(ref.removePrefix("#/components/schemas/")) - } ?: componentSchemaIdentity[subSchema]?.let { TypeRef.Reference(it) } - } + val allOf = schema.allOf?.mapNotNull { it.resolveName() } // Check for oneOf wrapper pattern before standard extraction val (oneOf, discriminatorFromWrapper) = detectAndUnwrapOneOfWrappers(schema) - ?: run { - // Standard oneOf extraction (no wrapper pattern) - val standardOneOf = schema.oneOf?.mapNotNull { subSchema -> - subSchema.`$ref`?.let { ref -> - TypeRef.Reference(ref.removePrefix("#/components/schemas/")) - } ?: componentSchemaIdentity[subSchema]?.let { TypeRef.Reference(it) } - } - standardOneOf to null - } + ?: // Standard oneOf extraction (no wrapper pattern) + (schema.oneOf?.mapNotNull { it.resolveName() } to null) - val anyOf = - schema.anyOf?.mapNotNull { subSchema -> - subSchema.`$ref`?.let { ref -> - TypeRef.Reference(ref.removePrefix("#/components/schemas/")) - } ?: componentSchemaIdentity[subSchema]?.let { TypeRef.Reference(it) } - } + val anyOf = schema.anyOf?.mapNotNull { it.resolveName() } // Validate no mixed anyOf+oneOf (check first, before discriminator check) - if (oneOf != null && oneOf.isNotEmpty() && anyOf != null && anyOf.isNotEmpty()) { + if (!oneOf.isNullOrEmpty() && !anyOf.isNullOrEmpty()) { throw IllegalArgumentException("Schema '$name' has both oneOf and anyOf. Use one combinator only.") } @@ -410,12 +299,11 @@ class SpecParser { val requiredProps = schema.required.orEmpty().toSet() val props = schema.properties.orEmpty().map { (propName, propSchema) -> - val propType = if (isInlineObjectSchema(propSchema)) { + val propType = if (propSchema.isInlineObject) { // Inline property schema - use Parent.PropertyName pattern - val contextName = "$name.${propName.toPascalCase()}" - createInlineTypeRef(propSchema, contextName) + createInlineTypeRef(propSchema, "$name.${propName.toPascalCase()}") } else { - schemaToTypeRef(propSchema) + propSchema.toTypeRef() } PropertyModel( name = propName, @@ -428,12 +316,11 @@ class SpecParser { props to requiredProps } - val discriminator = - discriminatorFromWrapper ?: schema.discriminator?.let { disc -> - disc.propertyName?.let { - Discriminator(propertyName = it, mapping = disc.mapping.orEmpty()) - } - } + 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, @@ -441,9 +328,9 @@ class SpecParser { properties = properties, requiredProperties = requiredProps, isEnum = false, - allOf = allOf?.ifEmpty { null }, - oneOf = oneOf?.ifEmpty { null }, - anyOf = anyOf?.ifEmpty { null }, + allOf = allOf?.map(TypeRef::Reference)?.ifEmpty { null }, + oneOf = oneOf?.map(TypeRef::Reference)?.ifEmpty { null }, + anyOf = anyOf?.map(TypeRef::Reference)?.ifEmpty { null }, discriminator = discriminator, ) } @@ -455,6 +342,8 @@ class SpecParser { * Also includes any top-level properties defined alongside allOf. * Deduplicates by property name (later definition wins). */ + + context(componentSchemaIdentity: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) private fun extractAllOfProperties(schema: Schema<*>): Pair, Set> { val mergedRequired = mutableSetOf() val mergedProperties = mutableMapOf() @@ -471,7 +360,7 @@ class SpecParser { mergedProperties[propName] = PropertyModel( name = propName, - type = schemaToTypeRef(propSchema), + type = propSchema.toTypeRef(), description = propSchema.description, nullable = propName !in mergedRequired, defaultValue = propSchema.default, @@ -487,7 +376,7 @@ class SpecParser { mergedProperties[propName] = PropertyModel( name = propName, - type = schemaToTypeRef(propSchema), + type = propSchema.toTypeRef(), description = propSchema.description, nullable = propName !in mergedRequired, defaultValue = propSchema.default, @@ -508,40 +397,32 @@ class SpecParser { * actual component schema with properties. Returns the sub-schema itself if it is * an inline schema (not a reference). */ - private fun resolveSubSchema(subSchema: Schema<*>): Schema<*> { - // Check for explicit $ref string - subSchema.`$ref`?.let { ref -> - val name = ref.removePrefix("#/components/schemas/") - componentSchemas[name]?.let { return it } - } - // Check for identity-matched resolved ref - componentSchemaIdentity[subSchema]?.let { name -> - componentSchemas[name]?.let { return it } - } - return subSchema - } + context(componentSchemaIdentity: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) + private fun resolveSubSchema(subSchema: Schema<*>): Schema<*> = + // Check for explicit $ref string // Check for identity-matched resolved ref + subSchema.name?.let { componentSchemas[it] } ?: subSchema /** * Checks if a schema is an inline object schema (not a $ref, not in componentSchemaIdentity, * is type object with properties). */ - private fun isInlineObjectSchema(schema: Schema<*>): Boolean = schema.`$ref` == null && - componentSchemaIdentity[schema] == null && - schema.type == "object" && - !schema.properties.isNullOrEmpty() + context(componentSchemaIdentity: ComponentSchemaIdentity) + private val Schema<*>.isInlineObject + get(): Boolean = `$ref` == null && + this !in componentSchemaIdentity && type == "object" && properties.isNullOrEmpty() /** * Creates a TypeRef.Inline for an inline object schema. */ + context(componentSchemaIdentity: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) private fun createInlineTypeRef(schema: Schema<*>, contextName: String): TypeRef { val requiredProps = schema.required.orEmpty().toSet() val properties = schema.properties.orEmpty().map { (propName, propSchema) -> - val propType = if (isInlineObjectSchema(propSchema)) { + val propType = if (propSchema.isInlineObject) { // Nested inline property schema - val nestedContextName = "$contextName.${propName.toPascalCase()}" - createInlineTypeRef(propSchema, nestedContextName) + createInlineTypeRef(propSchema, "$contextName.${propName.toPascalCase()}") } else { - schemaToTypeRef(propSchema) + propSchema.toTypeRef() } PropertyModel( name = propName, @@ -573,9 +454,16 @@ class SpecParser { return method.lowercase() + segments } - private fun String.toPascalCase(): String = split("-", "_", ".").joinToString("") { part -> - part.replaceFirstChar { - it.uppercase() - } - } + private val Schema<*>.isEnumSchema get(): Boolean = !this.enum.isNullOrEmpty() + + private fun String.toPascalCase(): String = split("-", "_", ".").joinToString("") { it.upperCase() } + + context(componentSchemaIdentity: ComponentSchemaIdentity) + private fun Schema<*>.resolveName(): String? = this.`$ref`?.stripSchemaPrefix() ?: componentSchemaIdentity[this] + + private fun String.stripSchemaPrefix() = removePrefix(SCHEMA_PREFIX) + + private fun String.upperCase() = replaceFirstChar { it.uppercase() } } + +private const val SCHEMA_PREFIX = "#/components/schemas/" 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 index d208b85a..c1c59ec7 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecValidator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecValidator.kt @@ -1,38 +1,45 @@ package com.avsystem.justworks.core.parser +import arrow.core.NonEmptyList +import arrow.core.raise.ExperimentalRaiseAccumulateApi +import arrow.core.raise.context.Raise +import arrow.core.raise.context.accumulate +import arrow.core.raise.context.ensure +import arrow.core.raise.context.ensureNotNull import io.swagger.v3.oas.models.OpenAPI object SpecValidator { - fun validate(openApi: OpenAPI): List { - val errors = mutableListOf() + sealed class ValidationIssue { + abstract val message: String - // Missing info is an error -- required by OpenAPI spec - if (openApi.info == null) { - errors.add("[JUSTWORKS] Spec is missing required 'info' section") - } + class Error(override val message: String) : ValidationIssue() + + class Warning(override val message: String) : ValidationIssue() + } - // Missing paths is a warning logged but not an error - if (openApi.paths.isNullOrEmpty()) { - errors.add("[JUSTWORKS] Warning: Spec has no paths defined") + @OptIn(ExperimentalRaiseAccumulateApi::class) + context(_: Raise>) + fun validate(openApi: OpenAPI): Unit = accumulate { + ensureNotNull(openApi.info) { + ValidationIssue.Error("Spec is missing required 'info' section") } + ensure(!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 -> - if (!operation.callbacks.isNullOrEmpty()) { - errors.add("[JUSTWORKS] Warning: Callbacks are not supported in v1 and will be ignored") - return@forEach + ensure(operation.callbacks.isNullOrEmpty()) { + ValidationIssue.Warning("Callbacks are not supported in v1 and will be ignored") } } } openApi.components?.links?.let { links -> - if (links.isNotEmpty()) { - errors.add("[JUSTWORKS] Warning: Links are not supported in v1 and will be ignored") + ensure(links.isEmpty()) { + ValidationIssue.Warning("Links are not supported in v1 and will be ignored") } } - - // Only return actual errors (non-warnings) as blockers - return errors.filter { !it.contains("Warning:") } } } From 1c5dce38513f6644004ea34363b8d8ccebbab1b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 11 Mar 2026 18:50:04 +0100 Subject: [PATCH 05/25] refactor: improve SpecParser readability and streamline schema handling - Reorganized schema-handling logic to reduce duplication and enhance maintainability. - Simplified inline type reference creation and property extraction. - Improved handling of `oneOf`, `anyOf`, and `allOf` schemas with reusable utilities. - Replaced ad-hoc checks with clear helper functions for resolving primitives and composite types. - Optimized property and required field merging logic for `allOf` schemas, reducing redundancies. --- .../justworks/core/parser/SpecParser.kt | 314 ++++++++---------- 1 file changed, 136 insertions(+), 178 deletions(-) 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 index ed6cd31d..a8bed37c 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -23,8 +23,12 @@ 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 kotlin.collections.component1 +import kotlin.collections.component2 import kotlin.collections.emptyList import kotlin.collections.emptyMap +import kotlin.collections.mapValues +import kotlin.collections.orEmpty import io.swagger.v3.oas.models.parameters.Parameter as SwaggerParameter object SpecParser { @@ -72,44 +76,30 @@ object SpecParser { val allSchemas = this.components?.schemas.orEmpty() val componentSchemaIdentity = ComponentSchemaIdentity(allSchemas.size).apply { - // Build identity map from resolved Schema objects to their component names. - // After resolveFully=true, $ref pointers are replaced with the actual Schema - // object from components, so we can detect resolved refs by object identity. allSchemas.forEach { (name, schema) -> this[schema] = name } } + context(componentSchemaIdentity, allSchemas.toMutableMap()) { val (enumModels, schemaModels) = allSchemas.asSequence().partition { (_, schema) -> schema.isEnumSchema } - // Extract any inline schemas that were created during wrapper unwrapping - // These are now in componentSchemas but weren't in the original allSchemas - val newInlineSchemas = allSchemas.filterKeys { it !in allSchemas.keys } - - val (inlineEnumModels, inlineSchemaModels) = - newInlineSchemas.asSequence().partition { (_, schema) -> schema.isEnumSchema } - return ApiSpec( title = this.info?.title ?: "Untitled", version = this.info?.version ?: "0.0.0", endpoints = extractEndpoints(this.paths.orEmpty()), - schemas = schemaModels - .plus(inlineSchemaModels) - .map { (name, schema) -> extractSchemaModel(name, schema) }, - enums = enumModels - .plus(inlineEnumModels) - .map { (name, schema) -> extractEnumModel(name, schema) }, + schemas = schemaModels.map { (name, schema) -> extractSchemaModel(name, schema) }, + enums = enumModels.map { (name, schema) -> extractEnumModel(name, schema) }, ) } } - context(componentSchemaIdentity: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) + context(_: ComponentSchemaIdentity, _: ComponentSchemas) private fun extractEndpoints(paths: Map): List = paths.flatMap { (path, pathItem) -> pathItem.readOperationsMap().map { (method, operation) -> // Merge path-level and operation-level parameters // Operation-level takes precedence (unique key = name + location) val mergedParams = (pathItem.parameters.orEmpty() + operation.parameters.orEmpty()) - .associateBy { "${it.name}:${it.`in`}" } - .values + .distinctBy { "${it.name}:${it.`in`}" } .map { it.toParameter() } val requestBody = nullable { @@ -122,43 +112,26 @@ object SpecParser { val schema = mediaType.schema.bind() - val schemaTypeRef = if (schema.isInlineObject) { - // Inline request body schema - createInlineTypeRef(schema, generateOperationId(method.name, path).upperCase() + "Request") - } else { - schema.toTypeRef() - } RequestBody( required = body.required ?: false, contentType = contentType, - schema = schemaTypeRef, + schema = schema.toTypeRef("${generateOperationId(method.name, path).upperCase()}Request"), ) } - val responses = - operation.responses - ?.map { (code, resp) -> - val schema = resp.content?.get("application/json")?.schema - val schemaTypeRef = schema?.let { s -> - if (s.isInlineObject) { - // Inline response body schema - createInlineTypeRef( - s, - generateOperationId(method.name, path).upperCase() + "Response", - ) - } else { - s.toTypeRef() - } - } - code to Response(statusCode = code, description = resp.description, schema = schemaTypeRef) - }?.toMap() - .orEmpty() + val responses = operation.responses + ?.mapValues { (code, resp) -> + val schema = resp.content?.get("application/json")?.schema + Response( + statusCode = code, + description = resp.description, + schema = schema?.toTypeRef("${generateOperationId(method.name, path).upperCase()}Response"), + ) + }.orEmpty() Endpoint( path = path, method = HttpMethod.valueOf(method.name), - operationId = - operation.operationId - ?: generateOperationId(method.name, path), + operationId = operation.operationId ?: generateOperationId(method.name, path), summary = operation.summary, tags = operation.tags.orEmpty(), parameters = mergedParams, @@ -177,35 +150,33 @@ object SpecParser { description = description, ) - context(componentSchemaIdentity: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) - private fun Schema<*>.toTypeRef(): TypeRef = - (this.name ?: allOf.takeIf { it.size == 1 }?.firstOrNull()?.name)?.let(TypeRef::Reference) ?: when (this.type) { - "string" if this.format == "byte" -> TypeRef.Primitive(PrimitiveType.BYTE_ARRAY) - - "string" if this.format == "date-time" -> TypeRef.Primitive(PrimitiveType.DATE_TIME) - - "string" if this.format == "date" -> TypeRef.Primitive(PrimitiveType.DATE) + context(_: ComponentSchemaIdentity, _: ComponentSchemas) + private fun Schema<*>.toTypeRef(contextName: String? = null): TypeRef = contextName?.let { toInlineTypeRef(it) } + ?: (this.name ?: allOf.takeIf { it.size == 1 }?.firstOrNull()?.name)?.let(TypeRef::Reference) + ?: toPrimitiveOrCompositeTypeRef() - "string" -> TypeRef.Primitive(PrimitiveType.STRING) + context(_: ComponentSchemaIdentity, _: ComponentSchemas) + private fun Schema<*>.toPrimitiveOrCompositeTypeRef(): TypeRef = when (this.type) { + "string" -> resolvePrimitive(STRING_FORMAT_MAP, PrimitiveType.STRING) - "integer" if this.format == "int64" -> TypeRef.Primitive(PrimitiveType.LONG) + "integer" -> resolvePrimitive(INTEGER_FORMAT_MAP, PrimitiveType.INT) - "integer" -> TypeRef.Primitive(PrimitiveType.INT) + "number" -> resolvePrimitive(NUMBER_FORMAT_MAP, PrimitiveType.DOUBLE) - "number" if this.format == "float" -> TypeRef.Primitive(PrimitiveType.FLOAT) + "boolean" -> TypeRef.Primitive(PrimitiveType.BOOLEAN) - "number" -> TypeRef.Primitive(PrimitiveType.DOUBLE) + "array" -> this.items?.toTypeRef()?.let(TypeRef::Array) ?: TypeRef.Primitive(PrimitiveType.STRING) - "boolean" -> TypeRef.Primitive(PrimitiveType.BOOLEAN) + "object" -> (this.additionalProperties as? Schema<*>)?.toTypeRef() + ?: TypeRef.Reference(this.title ?: "Unknown") - "array" -> this.items?.toTypeRef()?.let(TypeRef::Array) ?: TypeRef.Primitive(PrimitiveType.STRING) - - "object" -> (this.additionalProperties as? Schema<*>)?.toTypeRef() ?: TypeRef.Reference( - this.title ?: "Unknown", - ) + else -> TypeRef.Primitive(PrimitiveType.STRING) + } - else -> TypeRef.Primitive(PrimitiveType.STRING) - } + private fun Schema<*>.resolvePrimitive( + formatMap: Map, + default: PrimitiveType, + ): TypeRef.Primitive = TypeRef.Primitive(this.format?.let { formatMap[it] } ?: default) private fun extractEnumModel(name: String, schema: Schema<*>): EnumModel = EnumModel( name = name, @@ -231,40 +202,35 @@ object SpecParser { context(componentSchemaIdentity: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) private fun detectAndUnwrapOneOfWrappers(schema: Schema<*>): Pair, Discriminator>? = nullable { // Must have oneOf and no explicit discriminator - ensure(schema.oneOf.isNullOrEmpty() || schema.discriminator != null) + ensure(!schema.oneOf.isNullOrEmpty() && schema.discriminator == null) val (discriminatorMapping, unwrappedRefs) = schema.oneOf .orEmpty() .fold((emptyMap() to emptyList())) { (mappings, refs), variant -> - // Check if variant is a wrapper object (not a $ref, not in identity map) - // This variant is a direct reference, not a wrapper object - ensure(variant.`$ref` != null || componentSchemaIdentity[variant] != null) + // Skip direct references — only process wrapper objects + ensure(variant.`$ref` == null && componentSchemaIdentity[variant] == null) - // Check if variant is object type with exactly one property - ensure(variant.type != "object" || variant.properties.isNullOrEmpty()) + // Must be an inline object with exactly one property + ensure(variant.isInlineObject) val properties = variant.properties.orEmpty() - ensure(properties.size != 1) + ensure(properties.size == 1) // Extract the single property val (propertyName, propertySchema) = properties.entries.first() // The property value must be a reference or inline object - val res = - propertySchema.resolveName()?.let { schemaName -> - "$SCHEMA_PREFIX$schemaName" to schemaName - } ?: propertySchema - .takeIf { it.type == "object" && !it.properties.isNullOrEmpty() } - ?.let { propertySchema -> - // Property is an inline object - create a component schema for it - val inlineSchemaName = propertyName.toPascalCase() - - // Register this inline schema as a component for future lookups - componentSchemas += (inlineSchemaName to propertySchema) - componentSchemaIdentity[propertySchema] = inlineSchemaName - - "$SCHEMA_PREFIX$inlineSchemaName" to inlineSchemaName - } + val res = propertySchema.resolveName()?.let { schemaName -> "$SCHEMA_PREFIX$schemaName" to schemaName } + ?: propertySchema.takeIf { it.isInlineObject }?.let { propertySchema -> + // Property is an inline object - create a component schema for it + val inlineSchemaName = propertyName.toPascalCase() + + // Register this inline schema as a component for future lookups + componentSchemas += (inlineSchemaName to propertySchema) + componentSchemaIdentity[propertySchema] = inlineSchemaName + + "$SCHEMA_PREFIX$inlineSchemaName" to inlineSchemaName + } res?.let { (mapping, ref) -> mappings + (propertyName to mapping) to refs + ref } ?: (mappings to refs) } @@ -277,18 +243,16 @@ object SpecParser { context(componentSchemaIdentity: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) private fun extractSchemaModel(name: String, schema: Schema<*>): SchemaModel { - val allOf = schema.allOf?.mapNotNull { it.resolveName() } + val allOf = schema.allOf.resolveRefs() // Check for oneOf wrapper pattern before standard extraction val (oneOf, discriminatorFromWrapper) = detectAndUnwrapOneOfWrappers(schema) - ?: // Standard oneOf extraction (no wrapper pattern) - (schema.oneOf?.mapNotNull { it.resolveName() } to null) + ?: (schema.oneOf.resolveRefs() to null) - val anyOf = schema.anyOf?.mapNotNull { it.resolveName() } + val anyOf = schema.anyOf.resolveRefs() - // Validate no mixed anyOf+oneOf (check first, before discriminator check) - if (!oneOf.isNullOrEmpty() && !anyOf.isNullOrEmpty()) { - throw IllegalArgumentException("Schema '$name' has both oneOf and anyOf. Use one combinator only.") + require(oneOf.isNullOrEmpty() || anyOf.isNullOrEmpty()) { + "Schema '$name' has both oneOf and anyOf. Use one combinator only." } // For allOf schemas, merge properties from all sub-schemas @@ -296,23 +260,11 @@ object SpecParser { if (!schema.allOf.isNullOrEmpty()) { extractAllOfProperties(schema) } else { - val requiredProps = schema.required.orEmpty().toSet() - val props = - schema.properties.orEmpty().map { (propName, propSchema) -> - val propType = if (propSchema.isInlineObject) { - // Inline property schema - use Parent.PropertyName pattern - createInlineTypeRef(propSchema, "$name.${propName.toPascalCase()}") - } else { - propSchema.toTypeRef() - } - PropertyModel( - name = propName, - type = propType, - description = propSchema.description, - nullable = propName !in requiredProps, - defaultValue = propSchema.default, - ) - } + val requiredProps = schema.requiredSet + val props = schema + .propertyModels(requiredProps) { propName -> "$name.${propName.toPascalCase()}" } + .values + .toList() props to requiredProps } @@ -328,9 +280,9 @@ object SpecParser { properties = properties, requiredProperties = requiredProps, isEnum = false, - allOf = allOf?.map(TypeRef::Reference)?.ifEmpty { null }, - oneOf = oneOf?.map(TypeRef::Reference)?.ifEmpty { null }, - anyOf = anyOf?.map(TypeRef::Reference)?.ifEmpty { null }, + allOf = allOf?.toTypeRefs(), + oneOf = oneOf?.toTypeRefs(), + anyOf = anyOf?.toTypeRefs(), discriminator = discriminator, ) } @@ -345,62 +297,52 @@ object SpecParser { context(componentSchemaIdentity: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) private fun extractAllOfProperties(schema: Schema<*>): Pair, Set> { - val mergedRequired = mutableSetOf() - val mergedProperties = mutableMapOf() + val topRequired = schema.requiredSet // Collect properties from each allOf sub-schema. // For $ref sub-schemas (or identity-matched resolved refs), look up the // referenced component schema to get its properties and required fields. - for (subSchema in schema.allOf.orEmpty()) { - val resolvedSchema = resolveSubSchema(subSchema) - val subRequired = resolvedSchema.required.orEmpty().toSet() - mergedRequired.addAll(subRequired) - - resolvedSchema.properties.orEmpty().forEach { (propName, propSchema) -> - mergedProperties[propName] = - PropertyModel( - name = propName, - type = propSchema.toTypeRef(), - description = propSchema.description, - nullable = propName !in mergedRequired, - defaultValue = propSchema.default, - ) + 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) } - } - // Add top-level properties (inline alongside allOf) - val topRequired = schema.required.orEmpty().toSet() - mergedRequired.addAll(topRequired) - - schema.properties.orEmpty().forEach { (propName, propSchema) -> - mergedProperties[propName] = - PropertyModel( - name = propName, - type = propSchema.toTypeRef(), - description = propSchema.description, - nullable = propName !in mergedRequired, - defaultValue = propSchema.default, - ) - } + val topLevelProperties = schema.propertyModels(required) // Recompute nullable based on final merged required set val finalProperties = - mergedProperties.values.map { prop -> - prop.copy(nullable = prop.name !in mergedRequired) - } + properties.plus(topLevelProperties).values.map { prop -> prop.copy(nullable = prop.name !in required) } - return finalProperties to mergedRequired + return finalProperties to required } + context(_: ComponentSchemaIdentity, _: ComponentSchemas) + fun createPropertyModel( + propName: String, + propSchema: Schema<*>, + required: Set, + contextName: String? = null, + ) = PropertyModel( + name = propName, + type = propSchema.toTypeRef(contextName), + description = propSchema.description, + nullable = propName !in required, + defaultValue = propSchema.default, + ) + /** * Resolves a sub-schema that may be a `$ref` or an identity-matched ref to the * actual component schema with properties. Returns the sub-schema itself if it is * an inline schema (not a reference). */ - context(componentSchemaIdentity: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) - private fun resolveSubSchema(subSchema: Schema<*>): Schema<*> = + context(_: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) + private fun Schema<*>.resolveSubSchema(): Schema<*> = // Check for explicit $ref string // Check for identity-matched resolved ref - subSchema.name?.let { componentSchemas[it] } ?: subSchema + this.resolveName()?.let { componentSchemas[it] } ?: this /** * Checks if a schema is an inline object schema (not a $ref, not in componentSchemaIdentity, @@ -409,34 +351,22 @@ object SpecParser { context(componentSchemaIdentity: ComponentSchemaIdentity) private val Schema<*>.isInlineObject get(): Boolean = `$ref` == null && - this !in componentSchemaIdentity && type == "object" && properties.isNullOrEmpty() + this !in componentSchemaIdentity && type == "object" && !properties.isNullOrEmpty() /** * Creates a TypeRef.Inline for an inline object schema. */ - context(componentSchemaIdentity: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) - private fun createInlineTypeRef(schema: Schema<*>, contextName: String): TypeRef { - val requiredProps = schema.required.orEmpty().toSet() - val properties = schema.properties.orEmpty().map { (propName, propSchema) -> - val propType = if (propSchema.isInlineObject) { - // Nested inline property schema - createInlineTypeRef(propSchema, "$contextName.${propName.toPascalCase()}") - } else { - propSchema.toTypeRef() - } - PropertyModel( - name = propName, - type = propType, - description = propSchema.description, - nullable = propName !in requiredProps, - defaultValue = propSchema.default, - ) - } - return TypeRef.Inline( - properties = properties, + context(_: ComponentSchemaIdentity, _: ComponentSchemas) + private fun Schema<*>.toInlineTypeRef(contextName: String): TypeRef? = if (isInlineObject) { + val requiredProps = requiredSet + val properties = propertyModels(requiredProps) { propName -> "$contextName.${propName.toPascalCase()}" } + TypeRef.Inline( + properties = properties.values.toList(), requiredProperties = requiredProps, contextHint = contextName, ) + } else { + null } private fun generateOperationId(method: String, path: String): String { @@ -459,11 +389,39 @@ object SpecParser { private fun String.toPascalCase(): String = split("-", "_", ".").joinToString("") { it.upperCase() } context(componentSchemaIdentity: ComponentSchemaIdentity) - private fun Schema<*>.resolveName(): String? = this.`$ref`?.stripSchemaPrefix() ?: componentSchemaIdentity[this] + private fun Schema<*>.resolveName(): String? = + this.`$ref`?.removePrefix(SCHEMA_PREFIX) ?: componentSchemaIdentity[this] + + context(_: ComponentSchemaIdentity) + private fun List>?.resolveRefs(): List? = this?.mapNotNull { it.resolveName() } - private fun String.stripSchemaPrefix() = removePrefix(SCHEMA_PREFIX) + private fun List.toTypeRefs(): List? = map(TypeRef::Reference).ifEmpty { null } + + private val Schema<*>.requiredSet: Set get() = required.orEmpty().toSet() private fun String.upperCase() = replaceFirstChar { it.uppercase() } + + context(_: ComponentSchemaIdentity, _: ComponentSchemas) + private fun Schema<*>.propertyModels(required: Set, createContext: (String) -> String? = { null }) = + properties + .orEmpty() + .mapValues { (propName, propSchema) -> + createPropertyModel(propName, propSchema, required, createContext(propName)) + } } private const val SCHEMA_PREFIX = "#/components/schemas/" + +private val STRING_FORMAT_MAP = mapOf( + "byte" to PrimitiveType.BYTE_ARRAY, + "date-time" to PrimitiveType.DATE_TIME, + "date" to PrimitiveType.DATE, +) + +private val INTEGER_FORMAT_MAP = mapOf( + "int64" to PrimitiveType.LONG, +) + +private val NUMBER_FORMAT_MAP = mapOf( + "float" to PrimitiveType.FLOAT, +) From e8cb61f4faa827513eb72064058ed240dbbd0d53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 11 Mar 2026 20:06:59 +0100 Subject: [PATCH 06/25] refactor: replace `ParseResult` with functional error handling using Arrow - Removed the `ParseResult` sealed interface and fully transitioned to Arrow's `Either` for handling parsing outcomes. - Refactored tests and internal methods to use `either` for improved safety and simplicity in error management. - Enhanced validation and schema parsing to seamlessly integrate with functional error handling. --- .../avsystem/justworks/core/model/TypeRef.kt | 5 +- .../justworks/core/parser/ParseResult.kt | 9 - .../justworks/core/parser/SpecParser.kt | 179 +++++++++--------- .../justworks/core/parser/SpecValidator.kt | 50 +++-- .../core/parser/SpecParserPolymorphicTest.kt | 15 +- .../justworks/core/parser/SpecParserTest.kt | 87 ++++----- 6 files changed, 164 insertions(+), 181 deletions(-) delete mode 100644 core/src/main/kotlin/com/avsystem/justworks/core/parser/ParseResult.kt 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 index 775aa612..82387255 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/model/TypeRef.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/model/TypeRef.kt @@ -1,8 +1,5 @@ package com.avsystem.justworks.core.model -import arrow.core.raise.nullable -import io.swagger.v3.oas.models.media.Schema - sealed interface TypeRef { data class Primitive(val type: PrimitiveType) : TypeRef @@ -17,6 +14,8 @@ sealed interface TypeRef { 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/ParseResult.kt b/core/src/main/kotlin/com/avsystem/justworks/core/parser/ParseResult.kt deleted file mode 100644 index 64148fe8..00000000 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/ParseResult.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.avsystem.justworks.core.parser - -import com.avsystem.justworks.core.model.ApiSpec - -sealed interface ParseResult { - data class Success(val spec: ApiSpec, val warnings: List = emptyList()) : ParseResult - - data class Failure(val errors: List) : ParseResult -} 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 index a8bed37c..95b367d2 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -1,5 +1,11 @@ package com.avsystem.justworks.core.parser +import arrow.core.mapValuesNotNull +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 arrow.core.raise.recover import com.avsystem.justworks.core.model.ApiSpec @@ -26,7 +32,6 @@ import java.util.IdentityHashMap import kotlin.collections.component1 import kotlin.collections.component2 import kotlin.collections.emptyList -import kotlin.collections.emptyMap import kotlin.collections.mapValues import kotlin.collections.orEmpty import io.swagger.v3.oas.models.parameters.Parameter as SwaggerParameter @@ -39,7 +44,13 @@ object SpecParser { // Component schemas keyed by name, for resolving $ref in allOf sub-schemas. typealias ComponentSchemas = MutableMap> - fun parse(specFile: File): ParseResult { + 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 + } + + fun parse(specFile: File): ParseResult = either { val parseOptions = ParseOptions().apply { isResolve = true @@ -49,29 +60,27 @@ object SpecParser { val swaggerResult = OpenAPIParser().readLocation(specFile.absolutePath, null, parseOptions) - val messages = swaggerResult.messages.orEmpty() - val openApi = - swaggerResult.openAPI - ?: return ParseResult.Failure(messages.ifEmpty { listOf("Failed to parse spec: ${specFile.name}") }) + val openApi = swaggerResult.openAPI + val swaggerMessages = swaggerResult.messages.orEmpty() + + ensureNotNull(openApi) { + ParseResult.Failure(swaggerMessages.ifEmpty { listOf("Failed to parse spec: ${specFile.name}") }) + } - recover( - { SpecValidator.validate(openApi) }, - { validationIssues -> - val (errors, warnings) = validationIssues.partition { it is SpecValidator.ValidationIssue.Error } - warnings.forEach { println("[justworks] Warning: $it") } + val validationIssues = SpecValidator.validate(openApi) - return@parse ParseResult.Failure(errors.map { it.message }) - }, - ) + val (errors, warnings) = validationIssues.partition { it is SpecValidator.ValidationIssue.Error } - return try { - val apiSpec = openApi.toApiSpec() - ParseResult.Success(apiSpec, warnings = messages) - } catch (e: IllegalArgumentException) { - ParseResult.Failure(listOf(e.message ?: "Schema validation failed")) + val allWarnings = warnings.map { it.message } + swaggerMessages + + ensure(errors.isEmpty()) { + ParseResult.Failure(errors.map { it.message }, allWarnings) } - } + return ParseResult.Success(openApi.toApiSpec(), warnings = allWarnings) + }.merge() + + context(_: Raise) fun OpenAPI.toApiSpec(): ApiSpec { val allSchemas = this.components?.schemas.orEmpty() @@ -96,6 +105,8 @@ object SpecParser { context(_: ComponentSchemaIdentity, _: ComponentSchemas) private fun extractEndpoints(paths: Map): List = paths.flatMap { (path, pathItem) -> pathItem.readOperationsMap().map { (method, operation) -> + val operationId = operation.operationId ?: generateOperationId(method.name, path) + // Merge path-level and operation-level parameters // Operation-level takes precedence (unique key = name + location) val mergedParams = (pathItem.parameters.orEmpty() + operation.parameters.orEmpty()) @@ -115,23 +126,24 @@ object SpecParser { RequestBody( required = body.required ?: false, contentType = contentType, - schema = schema.toTypeRef("${generateOperationId(method.name, path).upperCase()}Request"), + schema = schema.toTypeRef("${operationId.upperCase()}Request"), ) } val responses = operation.responses - ?.mapValues { (code, resp) -> + .orEmpty() + .mapValues { (code, resp) -> val schema = resp.content?.get("application/json")?.schema Response( statusCode = code, description = resp.description, - schema = schema?.toTypeRef("${generateOperationId(method.name, path).upperCase()}Response"), + schema = schema?.toTypeRef("${operationId.upperCase()}Response"), ) - }.orEmpty() + } Endpoint( path = path, method = HttpMethod.valueOf(method.name), - operationId = operation.operationId ?: generateOperationId(method.name, path), + operationId = operationId, summary = operation.summary, tags = operation.tags.orEmpty(), parameters = mergedParams, @@ -141,7 +153,7 @@ object SpecParser { } } - context(componentSchemaIdentity: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) + context(_: ComponentSchemaIdentity, _: ComponentSchemas) private fun SwaggerParameter.toParameter(): Parameter = Parameter( name = name ?: "", location = ParameterLocation.parse(`in`) ?: ParameterLocation.QUERY, @@ -152,31 +164,24 @@ object SpecParser { context(_: ComponentSchemaIdentity, _: ComponentSchemas) private fun Schema<*>.toTypeRef(contextName: String? = null): TypeRef = contextName?.let { toInlineTypeRef(it) } - ?: (this.name ?: allOf.takeIf { it.size == 1 }?.firstOrNull()?.name)?.let(TypeRef::Reference) - ?: toPrimitiveOrCompositeTypeRef() - - context(_: ComponentSchemaIdentity, _: ComponentSchemas) - private fun Schema<*>.toPrimitiveOrCompositeTypeRef(): TypeRef = when (this.type) { - "string" -> resolvePrimitive(STRING_FORMAT_MAP, PrimitiveType.STRING) + ?: (this.name ?: allOf.singleOrNull()?.name)?.let(TypeRef::Reference) + ?: when (this@toTypeRef.type) { + "string" -> STRING_FORMAT_MAP[this@toTypeRef.format] ?: TypeRef.Primitive(PrimitiveType.STRING) - "integer" -> resolvePrimitive(INTEGER_FORMAT_MAP, PrimitiveType.INT) + "integer" -> INTEGER_FORMAT_MAP[this@toTypeRef.format] ?: TypeRef.Primitive(PrimitiveType.INT) - "number" -> resolvePrimitive(NUMBER_FORMAT_MAP, PrimitiveType.DOUBLE) + "number" -> NUMBER_FORMAT_MAP[this@toTypeRef.format] ?: TypeRef.Primitive(PrimitiveType.DOUBLE) - "boolean" -> TypeRef.Primitive(PrimitiveType.BOOLEAN) + "boolean" -> TypeRef.Primitive(PrimitiveType.BOOLEAN) - "array" -> this.items?.toTypeRef()?.let(TypeRef::Array) ?: TypeRef.Primitive(PrimitiveType.STRING) + "array" -> this@toTypeRef.items?.toTypeRef()?.let(TypeRef::Array) ?: TypeRef.Primitive(PrimitiveType.STRING) - "object" -> (this.additionalProperties as? Schema<*>)?.toTypeRef() - ?: TypeRef.Reference(this.title ?: "Unknown") + "object" -> (this@toTypeRef.additionalProperties as? Schema<*>)?.toTypeRef() + ?: this@toTypeRef.title?.let(TypeRef::Reference) + ?: TypeRef.Unknown - else -> TypeRef.Primitive(PrimitiveType.STRING) - } - - private fun Schema<*>.resolvePrimitive( - formatMap: Map, - default: PrimitiveType, - ): TypeRef.Primitive = TypeRef.Primitive(this.format?.let { formatMap[it] } ?: default) + else -> TypeRef.Primitive(PrimitiveType.STRING) + } private fun extractEnumModel(name: String, schema: Schema<*>): EnumModel = EnumModel( name = name, @@ -204,44 +209,35 @@ object SpecParser { // Must have oneOf and no explicit discriminator ensure(!schema.oneOf.isNullOrEmpty() && schema.discriminator == null) - val (discriminatorMapping, unwrappedRefs) = schema.oneOf + val unwrapped = schema.oneOf .orEmpty() - .fold((emptyMap() to emptyList())) { (mappings, refs), variant -> - // Skip direct references — only process wrapper objects - ensure(variant.`$ref` == null && componentSchemaIdentity[variant] == null) - - // Must be an inline object with exactly one property - ensure(variant.isInlineObject) - - val properties = variant.properties.orEmpty() - ensure(properties.size == 1) - - // Extract the single property - val (propertyName, propertySchema) = properties.entries.first() - - // The property value must be a reference or inline object - val res = propertySchema.resolveName()?.let { schemaName -> "$SCHEMA_PREFIX$schemaName" to schemaName } - ?: propertySchema.takeIf { it.isInlineObject }?.let { propertySchema -> - // Property is an inline object - create a component schema for it - val inlineSchemaName = propertyName.toPascalCase() - - // Register this inline schema as a component for future lookups - componentSchemas += (inlineSchemaName to propertySchema) - componentSchemaIdentity[propertySchema] = inlineSchemaName + .asSequence() + .filter { it.isInlineObject } + .mapNotNull { + it.properties + ?.entries + ?.singleOrNull() + ?.toPair() + }.toMap() + .mapValuesNotNull { (propertyName, propertySchema) -> propertySchema.resolveOrRegisterName(propertyName) } - "$SCHEMA_PREFIX$inlineSchemaName" to inlineSchemaName - } + ensure(unwrapped.isNotEmpty()) - res?.let { (mapping, ref) -> mappings + (propertyName to mapping) to refs + ref } ?: (mappings to refs) - } + val mapping = unwrapped.mapValues { (_, schemaName) -> "$SCHEMA_PREFIX$schemaName" } + val refs = unwrapped.values.toList() - // All variants matched the wrapper pattern - create synthetic discriminator - val syntheticDiscriminator = Discriminator(propertyName = "type", mapping = discriminatorMapping) - - return unwrappedRefs to syntheticDiscriminator + refs to Discriminator(propertyName = "type", mapping = mapping) } + /** Resolves the schema name, or registers an inline object as a new component. */ context(componentSchemaIdentity: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) + private fun Schema<*>.resolveOrRegisterName(fallbackName: String): String? = + resolveName() ?: fallbackName.takeIf { isInlineObject }?.also { name -> + componentSchemas[name] = this + componentSchemaIdentity[this] = name + } + + context(_: Raise, _: ComponentSchemaIdentity, _: ComponentSchemas) private fun extractSchemaModel(name: String, schema: Schema<*>): SchemaModel { val allOf = schema.allOf.resolveRefs() @@ -251,8 +247,8 @@ object SpecParser { val anyOf = schema.anyOf.resolveRefs() - require(oneOf.isNullOrEmpty() || anyOf.isNullOrEmpty()) { - "Schema '$name' has both oneOf and anyOf. Use one combinator only." + ensure(oneOf.isNullOrEmpty() || anyOf.isNullOrEmpty()) { + ParseResult.Failure(listOf("Schema '$name' has both oneOf and anyOf. Use one combinator only.")) } // For allOf schemas, merge properties from all sub-schemas @@ -306,7 +302,7 @@ object SpecParser { .orEmpty() .fold(topRequired to emptyMap()) { (accRequired, accProperties), subSchema -> val resolvedSchema = subSchema.resolveSubSchema() - val mergedRequired = accRequired + resolvedSchema.required.orEmpty().toSet() + val mergedRequired = accRequired + resolvedSchema.requiredSet mergedRequired to accProperties + resolvedSchema.propertyModels(mergedRequired) } @@ -321,7 +317,7 @@ object SpecParser { } context(_: ComponentSchemaIdentity, _: ComponentSchemas) - fun createPropertyModel( + private fun createPropertyModel( propName: String, propSchema: Schema<*>, required: Set, @@ -357,16 +353,13 @@ object SpecParser { * Creates a TypeRef.Inline for an inline object schema. */ context(_: ComponentSchemaIdentity, _: ComponentSchemas) - private fun Schema<*>.toInlineTypeRef(contextName: String): TypeRef? = if (isInlineObject) { + private fun Schema<*>.toInlineTypeRef(contextName: String): TypeRef? = takeIf { isInlineObject }?.let { val requiredProps = requiredSet - val properties = propertyModels(requiredProps) { propName -> "$contextName.${propName.toPascalCase()}" } TypeRef.Inline( - properties = properties.values.toList(), + properties = propertyModels(requiredProps) { "$contextName.${it.toPascalCase()}" }.values.toList(), requiredProperties = requiredProps, contextHint = contextName, ) - } else { - null } private fun generateOperationId(method: String, path: String): String { @@ -412,16 +405,16 @@ object SpecParser { private const val SCHEMA_PREFIX = "#/components/schemas/" -private val STRING_FORMAT_MAP = mapOf( - "byte" to PrimitiveType.BYTE_ARRAY, - "date-time" to PrimitiveType.DATE_TIME, - "date" to PrimitiveType.DATE, +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 PrimitiveType.LONG, +private val INTEGER_FORMAT_MAP = mapOf( + "int64" to TypeRef.Primitive(PrimitiveType.LONG), ) -private val NUMBER_FORMAT_MAP = mapOf( - "float" to PrimitiveType.FLOAT, +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 index c1c59ec7..afb8daa4 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecValidator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecValidator.kt @@ -3,9 +3,14 @@ package com.avsystem.justworks.core.parser import arrow.core.NonEmptyList import arrow.core.raise.ExperimentalRaiseAccumulateApi import arrow.core.raise.context.Raise +import arrow.core.raise.context.RaiseAccumulate import arrow.core.raise.context.accumulate import arrow.core.raise.context.ensure import arrow.core.raise.context.ensureNotNull +import arrow.core.raise.context.ensureNotNullOrAccumulate +import arrow.core.raise.context.ensureOrAccumulate +import arrow.core.raise.fold +import arrow.core.raise.recover import io.swagger.v3.oas.models.OpenAPI object SpecValidator { @@ -18,28 +23,33 @@ object SpecValidator { } @OptIn(ExperimentalRaiseAccumulateApi::class) - context(_: Raise>) - fun validate(openApi: OpenAPI): Unit = accumulate { - ensureNotNull(openApi.info) { - ValidationIssue.Error("Spec is missing required 'info' section") - } + fun validate(openApi: OpenAPI) = fold( + { + accumulate { + ensureNotNullOrAccumulate(openApi.info) { + ValidationIssue.Error("Spec is missing required 'info' section") + } - ensure(!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 -> - ensure(operation.callbacks.isNullOrEmpty()) { - ValidationIssue.Warning("Callbacks are not supported in v1 and will be ignored") + 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 -> - ensure(links.isEmpty()) { - ValidationIssue.Warning("Links 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 index f5886668..8e8d6b5a 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserPolymorphicTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserPolymorphicTest.kt @@ -1,5 +1,6 @@ package com.avsystem.justworks.core.parser +import arrow.core.raise.either import com.avsystem.justworks.core.model.TypeRef import java.io.File import kotlin.test.Test @@ -10,8 +11,6 @@ import kotlin.test.assertTrue import kotlin.test.fail class SpecParserPolymorphicTest { - private val parser = SpecParser() - private fun loadResource(name: String): File { val url = javaClass.getResource("/$name") @@ -19,10 +18,12 @@ class SpecParserPolymorphicTest { return File(url.toURI()) } + private fun parseSpec(file: File) = + either { SpecParser.parse(file) }.getOrElse { fail("Expected success but got errors: $it") } + @Test fun `allOf schema has merged properties from referenced schema`() { - val result = parser.parse(loadResource("polymorphic-spec.yaml")) - val spec = assertIs(result).spec + val spec = parseSpec(loadResource("polymorphic-spec.yaml")) val extendedDog = spec.schemas.find { it.name == "ExtendedDog" } @@ -42,8 +43,7 @@ class SpecParserPolymorphicTest { @Test fun `oneOf schema preserves oneOf refs`() { - val result = parser.parse(loadResource("polymorphic-spec.yaml")) - val spec = assertIs(result).spec + val spec = parseSpec(loadResource("polymorphic-spec.yaml")) val shape = spec.schemas.find { it.name == "Shape" } @@ -57,8 +57,7 @@ class SpecParserPolymorphicTest { @Test fun `discriminator is preserved in parsed model`() { - val result = parser.parse(loadResource("polymorphic-spec.yaml")) - val spec = assertIs(result).spec + val spec = parseSpec(loadResource("polymorphic-spec.yaml")) val shape = spec.schemas.find { it.name == "Shape" } 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 index 0b8f0015..5f5ed475 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt @@ -1,5 +1,8 @@ package com.avsystem.justworks.core.parser +import arrow.core.Either +import arrow.core.raise.either +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 @@ -14,8 +17,6 @@ import kotlin.test.assertTrue import kotlin.test.fail class SpecParserTest { - private val parser = SpecParser() - private fun loadResource(name: String): File { val url = javaClass.getResource("/$name") @@ -23,20 +24,26 @@ class SpecParserTest { return File(url.toURI()) } + private fun parseSpec(file: File): ApiSpec = + either { SpecParser.parse(file) }.getOrElse { fail("Expected success but got errors: $it") } + + private fun parseSpecErrors(file: File): List { + val result = either { SpecParser.parse(file) } + assertIs>(result, "Expected failure") + return result.value as List + } + // -- SPEC-01: OpenAPI 3.0 parsing -- @Test fun `parse petstore yaml produces Success with endpoints`() { - val result = parser.parse(loadResource("petstore.yaml")) - val spec = assertIs(result).spec - + val spec = parseSpec(loadResource("petstore.yaml")) assertEquals(3, spec.endpoints.size, "Expected 3 endpoints") } @Test fun `parse petstore yaml produces schemas`() { - val result = parser.parse(loadResource("petstore.yaml")) - val spec = assertIs(result).spec + val spec = parseSpec(loadResource("petstore.yaml")) val schemaNames = spec.schemas.map { it.name }.toSet() assertTrue("Pet" in schemaNames, "Pet schema missing") @@ -46,8 +53,7 @@ class SpecParserTest { @Test fun `parse petstore yaml produces enums`() { - val result = parser.parse(loadResource("petstore.yaml")) - val spec = assertIs(result).spec + val spec = parseSpec(loadResource("petstore.yaml")) val petStatus = spec.enums.find { it.name == "PetStatus" } assertNotNull(petStatus, "PetStatus enum missing") @@ -57,8 +63,7 @@ class SpecParserTest { @Test fun `parsed Pet schema has correct properties`() { - val result = parser.parse(loadResource("petstore.yaml")) - val spec = assertIs(result).spec + val spec = parseSpec(loadResource("petstore.yaml")) val pet = spec.schemas.find { it.name == "Pet" } ?: fail("Pet schema not found") @@ -90,8 +95,7 @@ class SpecParserTest { @Test fun `parsed GET pets endpoint has query parameter limit with INT type`() { - val result = parser.parse(loadResource("petstore.yaml")) - val spec = assertIs(result).spec + val spec = parseSpec(loadResource("petstore.yaml")) val listPets = spec.endpoints.find { it.operationId == "listPets" } ?: fail("listPets endpoint not found") @@ -109,8 +113,7 @@ class SpecParserTest { @Test fun `parsed GET pets petId has path parameter`() { - val result = parser.parse(loadResource("petstore.yaml")) - val spec = assertIs(result).spec + val spec = parseSpec(loadResource("petstore.yaml")) val getPet = spec.endpoints.find { it.operationId == "getPetById" } ?: fail("getPetById endpoint not found") @@ -126,8 +129,7 @@ class SpecParserTest { @Test fun `parsed POST pets has requestBody referencing NewPet`() { - val result = parser.parse(loadResource("petstore.yaml")) - val spec = assertIs(result).spec + val spec = parseSpec(loadResource("petstore.yaml")) val createPet = spec.endpoints.find { it.operationId == "createPet" } ?: fail("createPet endpoint not found") @@ -144,8 +146,7 @@ class SpecParserTest { @Test fun `parsed endpoints have tags`() { - val result = parser.parse(loadResource("petstore.yaml")) - val spec = assertIs(result).spec + val spec = parseSpec(loadResource("petstore.yaml")) val listPets = spec.endpoints.find { it.operationId == "listPets" }!! assertTrue(listPets.tags.contains("pets"), "listPets should have 'pets' tag") @@ -153,8 +154,7 @@ class SpecParserTest { @Test fun `parsed GET pets response is array of Pet`() { - val result = parser.parse(loadResource("petstore.yaml")) - val spec = assertIs(result).spec + val spec = parseSpec(loadResource("petstore.yaml")) val listPets = spec.endpoints.find { it.operationId == "listPets" }!! val okResponse = @@ -170,8 +170,7 @@ class SpecParserTest { @Test fun `parse refs spec resolves all references`() { - val result = parser.parse(loadResource("refs-spec.yaml")) - val spec = assertIs(result).spec + 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() @@ -201,8 +200,7 @@ class SpecParserTest { @Test fun `refs spec nested references are resolved in model`() { - val result = parser.parse(loadResource("refs-spec.yaml")) - val spec = assertIs(result).spec + val spec = parseSpec(loadResource("refs-spec.yaml")) // Order -> Item -> ItemDetails (chain of refs) val order = @@ -220,8 +218,7 @@ class SpecParserTest { @Test fun `refs spec parameter ref is resolved`() { - val result = parser.parse(loadResource("refs-spec.yaml")) - val spec = assertIs(result).spec + val spec = parseSpec(loadResource("refs-spec.yaml")) val listOrders = spec.endpoints.find { it.operationId == "listOrders" } @@ -238,18 +235,17 @@ class SpecParserTest { @Test fun `parse invalid spec returns Failure`() { - val result = parser.parse(loadResource("invalid-spec.yaml")) - assertIs(result) + val result = either { SpecParser.parse(loadResource("invalid-spec.yaml")) } + assertIs>(result) } @Test fun `parse invalid spec has descriptive error messages`() { - val result = parser.parse(loadResource("invalid-spec.yaml")) - val failure = assertIs(result) + val errors = parseSpecErrors(loadResource("invalid-spec.yaml")) - assertTrue(failure.errors.isNotEmpty(), "Failure should have error messages") + assertTrue(errors.isNotEmpty(), "Failure should have error messages") // Errors should be human-readable, not empty or codes-only - failure.errors.forEach { error -> + errors.forEach { error -> assertTrue(error.length > 5, "Error message too short to be useful: '$error'") } } @@ -258,14 +254,13 @@ class SpecParserTest { @Test fun `parse swagger 2 json returns Success`() { - val result = parser.parse(loadResource("petstore-v2.json")) - assertIs(result) + val result = either { SpecParser.parse(loadResource("petstore-v2.json")) } + assertIs>(result) } @Test fun `swagger 2 spec produces endpoints and schemas`() { - val result = parser.parse(loadResource("petstore-v2.json")) - val spec = assertIs(result).spec + val spec = parseSpec(loadResource("petstore-v2.json")) assertTrue(spec.endpoints.isNotEmpty(), "v2 spec should produce endpoints") assertTrue( @@ -285,8 +280,7 @@ class SpecParserTest { @Test fun `anyOf without discriminator parses successfully`() { - val result = parser.parse(loadResource("anyof-spec.yaml")) - val spec = assertIs(result).spec + val spec = parseSpec(loadResource("anyof-spec.yaml")) val unionPayment = spec.schemas.find { it.name == "UnionPayment" } assertNotNull(unionPayment, "UnionPayment schema should exist") @@ -297,8 +291,7 @@ class SpecParserTest { @Test fun `anyOf with discriminator parses successfully`() { - val result = parser.parse(loadResource("anyof-valid-spec.yaml")) - val spec = assertIs(result).spec + val spec = parseSpec(loadResource("anyof-valid-spec.yaml")) val payment = spec.schemas.find { it.name == "Payment" } assertNotNull(payment, "Payment schema should exist") @@ -309,11 +302,10 @@ class SpecParserTest { } @Test - fun `mixed anyOf and oneOf throws IllegalArgumentException`() { - val result = parser.parse(loadResource("mixed-combinator-spec.yaml")) - val failure = assertIs(result) + fun `mixed anyOf and oneOf raises error`() { + val errors = parseSpecErrors(loadResource("mixed-combinator-spec.yaml")) - val errorMessages = failure.errors.joinToString("\n") + val errorMessages = errors.joinToString("\n") assertTrue( "both oneOf and anyOf" in errorMessages, "Expected error about mixed combinators, got: $errorMessages", @@ -350,8 +342,7 @@ class SpecParserTest { - name """.trimIndent() - val result = parser.parse(spec.toTempFile()) - val apiSpec = assertIs(result).spec + val apiSpec = parseSpec(spec.toTempFile()) val task = apiSpec.schemas.find { it.name == "Task" } assertNotNull(task) @@ -394,7 +385,7 @@ class SpecParserTest { } } - is TypeRef.Primitive, null -> {} + is TypeRef.Primitive, TypeRef.Unknown, null -> {} } } } From 106f69ac3709e76edc3c343f91dccbed863a90d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 11 Mar 2026 20:14:18 +0100 Subject: [PATCH 07/25] chore: update ktlint configuration and clean up SpecParser - Set ktlint version to `1.8.0` as a temporary fix for known issue #912. - Removed unused imports and redundant helper functions from `SpecParser`. - Replaced custom string case conversion with `replaceFirstChar` for clarity and consistency. --- core/build.gradle.kts | 6 ++ .../justworks/core/parser/SpecParser.kt | 61 +++++++++---------- .../core/parser/SpecParserPolymorphicTest.kt | 1 - 3 files changed, 34 insertions(+), 34 deletions(-) diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 34ca8707..b38e7f97 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -10,6 +10,12 @@ 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") 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 index 95b367d2..7a36ef5c 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -7,7 +7,6 @@ import arrow.core.raise.context.ensure import arrow.core.raise.context.ensureNotNull import arrow.core.raise.either import arrow.core.raise.nullable -import arrow.core.raise.recover import com.avsystem.justworks.core.model.ApiSpec import com.avsystem.justworks.core.model.Discriminator import com.avsystem.justworks.core.model.Endpoint @@ -126,7 +125,7 @@ object SpecParser { RequestBody( required = body.required ?: false, contentType = contentType, - schema = schema.toTypeRef("${operationId.upperCase()}Request"), + schema = schema.toTypeRef("${operationId.replaceFirstChar { it.uppercase() }}Request"), ) } val responses = operation.responses @@ -136,7 +135,7 @@ object SpecParser { Response( statusCode = code, description = resp.description, - schema = schema?.toTypeRef("${operationId.upperCase()}Response"), + schema = schema?.toTypeRef("${operationId.replaceFirstChar { it.uppercase() }}Response"), ) } @@ -165,19 +164,19 @@ object SpecParser { context(_: ComponentSchemaIdentity, _: ComponentSchemas) private fun Schema<*>.toTypeRef(contextName: String? = null): TypeRef = contextName?.let { toInlineTypeRef(it) } ?: (this.name ?: allOf.singleOrNull()?.name)?.let(TypeRef::Reference) - ?: when (this@toTypeRef.type) { - "string" -> STRING_FORMAT_MAP[this@toTypeRef.format] ?: TypeRef.Primitive(PrimitiveType.STRING) + ?: when (this.type) { + "string" -> STRING_FORMAT_MAP[this.format] ?: TypeRef.Primitive(PrimitiveType.STRING) - "integer" -> INTEGER_FORMAT_MAP[this@toTypeRef.format] ?: TypeRef.Primitive(PrimitiveType.INT) + "integer" -> INTEGER_FORMAT_MAP[this.format] ?: TypeRef.Primitive(PrimitiveType.INT) - "number" -> NUMBER_FORMAT_MAP[this@toTypeRef.format] ?: TypeRef.Primitive(PrimitiveType.DOUBLE) + "number" -> NUMBER_FORMAT_MAP[this.format] ?: TypeRef.Primitive(PrimitiveType.DOUBLE) "boolean" -> TypeRef.Primitive(PrimitiveType.BOOLEAN) - "array" -> this@toTypeRef.items?.toTypeRef()?.let(TypeRef::Array) ?: TypeRef.Primitive(PrimitiveType.STRING) + "array" -> this.items?.toTypeRef()?.let(TypeRef::Array) ?: TypeRef.Primitive(PrimitiveType.STRING) - "object" -> (this@toTypeRef.additionalProperties as? Schema<*>)?.toTypeRef() - ?: this@toTypeRef.title?.let(TypeRef::Reference) + "object" -> (this.additionalProperties as? Schema<*>)?.toTypeRef() + ?: this.title?.let(TypeRef::Reference) ?: TypeRef.Unknown else -> TypeRef.Primitive(PrimitiveType.STRING) @@ -219,7 +218,12 @@ object SpecParser { ?.singleOrNull() ?.toPair() }.toMap() - .mapValuesNotNull { (propertyName, propertySchema) -> propertySchema.resolveOrRegisterName(propertyName) } + .mapValuesNotNull { (propertyName, propertySchema) -> + propertySchema.resolveName() ?: propertyName.takeIf { propertySchema.isInlineObject }?.also { name -> + componentSchemas[name] = propertySchema + componentSchemaIdentity[propertySchema] = name + } + } ensure(unwrapped.isNotEmpty()) @@ -229,14 +233,6 @@ object SpecParser { refs to Discriminator(propertyName = "type", mapping = mapping) } - /** Resolves the schema name, or registers an inline object as a new component. */ - context(componentSchemaIdentity: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) - private fun Schema<*>.resolveOrRegisterName(fallbackName: String): String? = - resolveName() ?: fallbackName.takeIf { isInlineObject }?.also { name -> - componentSchemas[name] = this - componentSchemaIdentity[this] = name - } - context(_: Raise, _: ComponentSchemaIdentity, _: ComponentSchemas) private fun extractSchemaModel(name: String, schema: Schema<*>): SchemaModel { val allOf = schema.allOf.resolveRefs() @@ -353,14 +349,14 @@ object SpecParser { * Creates a TypeRef.Inline for an inline object schema. */ context(_: ComponentSchemaIdentity, _: ComponentSchemas) - private fun Schema<*>.toInlineTypeRef(contextName: String): TypeRef? = takeIf { isInlineObject }?.let { - val requiredProps = requiredSet - TypeRef.Inline( - properties = propertyModels(requiredProps) { "$contextName.${it.toPascalCase()}" }.values.toList(), - requiredProperties = requiredProps, - contextHint = contextName, - ) - } + private fun Schema<*>.toInlineTypeRef(contextName: String): TypeRef? = + takeIf { isInlineObject }?.requiredSet?.let { required -> + TypeRef.Inline( + properties = propertyModels(required) { "$contextName.${it.toPascalCase()}" }.values.toList(), + requiredProperties = required, + contextHint = contextName, + ) + } private fun generateOperationId(method: String, path: String): String { val segments = @@ -379,7 +375,8 @@ object SpecParser { private val Schema<*>.isEnumSchema get(): Boolean = !this.enum.isNullOrEmpty() - private fun String.toPascalCase(): String = split("-", "_", ".").joinToString("") { it.upperCase() } + private fun String.toPascalCase(): String = + split("-", "_", ".").joinToString("") { part -> part.replaceFirstChar { it.uppercase() } } context(componentSchemaIdentity: ComponentSchemaIdentity) private fun Schema<*>.resolveName(): String? = @@ -392,8 +389,6 @@ object SpecParser { private val Schema<*>.requiredSet: Set get() = required.orEmpty().toSet() - private fun String.upperCase() = replaceFirstChar { it.uppercase() } - context(_: ComponentSchemaIdentity, _: ComponentSchemas) private fun Schema<*>.propertyModels(required: Set, createContext: (String) -> String? = { null }) = properties @@ -405,16 +400,16 @@ object SpecParser { private const val SCHEMA_PREFIX = "#/components/schemas/" -private val STRING_FORMAT_MAP = mapOf( +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( +private val INTEGER_FORMAT_MAP = mapOf( "int64" to TypeRef.Primitive(PrimitiveType.LONG), ) -private val NUMBER_FORMAT_MAP = mapOf( +private val NUMBER_FORMAT_MAP = mapOf( "float" to TypeRef.Primitive(PrimitiveType.FLOAT), ) 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 index 8e8d6b5a..01e6aa2d 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserPolymorphicTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserPolymorphicTest.kt @@ -5,7 +5,6 @@ import com.avsystem.justworks.core.model.TypeRef import java.io.File import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertTrue import kotlin.test.fail From 992900158c3c9062a99796bc2e0a7e7340983010 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 11 Mar 2026 20:30:47 +0100 Subject: [PATCH 08/25] fix: align tests with Arrow-based ParseResult API and fix null-safe allOf - Remove Arrow either/Either usage from test helpers, use ParseResult pattern match - Fix SpecValidator test to use ValidationIssue.message - Fix null-safe allOf access in toTypeRef - Use resolveName() for identity-based schema resolution in toTypeRef Co-Authored-By: Claude Opus 4.6 --- .../justworks/core/parser/SpecParser.kt | 2 +- .../core/parser/SpecParserPolymorphicTest.kt | 9 +++++--- .../justworks/core/parser/SpecParserTest.kt | 22 ++++++++++--------- .../core/parser/SpecValidatorTest.kt | 8 +++---- 4 files changed, 23 insertions(+), 18 deletions(-) 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 index 7a36ef5c..9b43025f 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -163,7 +163,7 @@ object SpecParser { context(_: ComponentSchemaIdentity, _: ComponentSchemas) private fun Schema<*>.toTypeRef(contextName: String? = null): TypeRef = contextName?.let { toInlineTypeRef(it) } - ?: (this.name ?: allOf.singleOrNull()?.name)?.let(TypeRef::Reference) + ?: (this.resolveName() ?: allOf?.singleOrNull()?.resolveName())?.let(TypeRef::Reference) ?: when (this.type) { "string" -> STRING_FORMAT_MAP[this.format] ?: TypeRef.Primitive(PrimitiveType.STRING) 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 index 01e6aa2d..393b7ee6 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserPolymorphicTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserPolymorphicTest.kt @@ -1,6 +1,6 @@ package com.avsystem.justworks.core.parser -import arrow.core.raise.either +import com.avsystem.justworks.core.model.ApiSpec import com.avsystem.justworks.core.model.TypeRef import java.io.File import kotlin.test.Test @@ -17,8 +17,11 @@ class SpecParserPolymorphicTest { return File(url.toURI()) } - private fun parseSpec(file: File) = - either { SpecParser.parse(file) }.getOrElse { fail("Expected success but got errors: $it") } + private fun parseSpec(file: File): ApiSpec = + when (val result = SpecParser.parse(file)) { + is SpecParser.ParseResult.Success -> result.apiSpec + is SpecParser.ParseResult.Failure -> fail("Expected success but got errors: ${result.errors}") + } @Test fun `allOf schema has merged properties from referenced schema`() { 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 index 5f5ed475..92ef3b37 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt @@ -1,7 +1,5 @@ package com.avsystem.justworks.core.parser -import arrow.core.Either -import arrow.core.raise.either import com.avsystem.justworks.core.model.ApiSpec import com.avsystem.justworks.core.model.EnumBackingType import com.avsystem.justworks.core.model.HttpMethod @@ -10,6 +8,7 @@ import com.avsystem.justworks.core.model.PrimitiveType import com.avsystem.justworks.core.model.TypeRef import java.io.File import kotlin.test.Test +import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNotNull @@ -25,12 +24,15 @@ class SpecParserTest { } private fun parseSpec(file: File): ApiSpec = - either { SpecParser.parse(file) }.getOrElse { fail("Expected success but got errors: $it") } + when (val result = SpecParser.parse(file)) { + is SpecParser.ParseResult.Success -> result.apiSpec + is SpecParser.ParseResult.Failure -> fail("Expected success but got errors: ${result.errors}") + } private fun parseSpecErrors(file: File): List { - val result = either { SpecParser.parse(file) } - assertIs>(result, "Expected failure") - return result.value as List + val result = SpecParser.parse(file) + check(result is SpecParser.ParseResult.Failure) { "Expected failure" } + return result.errors } // -- SPEC-01: OpenAPI 3.0 parsing -- @@ -235,8 +237,8 @@ class SpecParserTest { @Test fun `parse invalid spec returns Failure`() { - val result = either { SpecParser.parse(loadResource("invalid-spec.yaml")) } - assertIs>(result) + val result = SpecParser.parse(loadResource("invalid-spec.yaml")) + assertIs(result) } @Test @@ -254,8 +256,8 @@ class SpecParserTest { @Test fun `parse swagger 2 json returns Success`() { - val result = either { SpecParser.parse(loadResource("petstore-v2.json")) } - assertIs>(result) + val result = SpecParser.parse(loadResource("petstore-v2.json")) + assertIs(result) } @Test 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 index a95cda6e..fa8bd05b 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecValidatorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecValidatorTest.kt @@ -38,11 +38,11 @@ class SpecValidatorTest { } } - val errors = SpecValidator.validate(openApi) - assertTrue(errors.isNotEmpty(), "Missing info should produce errors") + val issues = SpecValidator.validate(openApi) + assertTrue(issues.isNotEmpty(), "Missing info should produce issues") assertTrue( - errors.any { it.contains("info", ignoreCase = true) }, - "Error should mention 'info': $errors", + issues.any { it.message.contains("info", ignoreCase = true) }, + "Error should mention 'info': $issues", ) } } From 2ba35ba0f2c79af874392699dc2531676e25ac90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 11 Mar 2026 20:40:56 +0100 Subject: [PATCH 09/25] refactor: improve null-safe schema handling and consolidate property extraction - Updated handling of `required` fields using `orEmpty --- .../justworks/core/parser/SpecParser.kt | 89 ++++++++----------- .../core/parser/SpecParserPolymorphicTest.kt | 9 +- .../justworks/core/parser/SpecParserTest.kt | 9 +- 3 files changed, 45 insertions(+), 62 deletions(-) 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 index 9b43025f..dab71214 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -235,13 +235,13 @@ object SpecParser { context(_: Raise, _: ComponentSchemaIdentity, _: ComponentSchemas) private fun extractSchemaModel(name: String, schema: Schema<*>): SchemaModel { - val allOf = schema.allOf.resolveRefs() + val allOf = schema.allOf?.mapNotNull { it.resolveName() } // Check for oneOf wrapper pattern before standard extraction val (oneOf, discriminatorFromWrapper) = detectAndUnwrapOneOfWrappers(schema) - ?: (schema.oneOf.resolveRefs() to null) + ?: (schema.oneOf?.mapNotNull { it.resolveName() } to null) - val anyOf = schema.anyOf.resolveRefs() + 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.")) @@ -252,7 +252,7 @@ object SpecParser { if (!schema.allOf.isNullOrEmpty()) { extractAllOfProperties(schema) } else { - val requiredProps = schema.requiredSet + val requiredProps = schema.required.orEmpty().toSet() val props = schema .propertyModels(requiredProps) { propName -> "$name.${propName.toPascalCase()}" } .values @@ -272,9 +272,9 @@ object SpecParser { properties = properties, requiredProperties = requiredProps, isEnum = false, - allOf = allOf?.toTypeRefs(), - oneOf = oneOf?.toTypeRefs(), - anyOf = anyOf?.toTypeRefs(), + 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, ) } @@ -289,7 +289,7 @@ object SpecParser { context(componentSchemaIdentity: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) private fun extractAllOfProperties(schema: Schema<*>): Pair, Set> { - val topRequired = schema.requiredSet + val topRequired = schema.required.orEmpty().toSet() // Collect properties from each allOf sub-schema. // For $ref sub-schemas (or identity-matched resolved refs), look up the @@ -298,7 +298,7 @@ object SpecParser { .orEmpty() .fold(topRequired to emptyMap()) { (accRequired, accProperties), subSchema -> val resolvedSchema = subSchema.resolveSubSchema() - val mergedRequired = accRequired + resolvedSchema.requiredSet + val mergedRequired = accRequired + resolvedSchema.required.orEmpty().toSet() mergedRequired to accProperties + resolvedSchema.propertyModels(mergedRequired) } @@ -312,20 +312,6 @@ object SpecParser { return finalProperties to required } - context(_: ComponentSchemaIdentity, _: ComponentSchemas) - private fun createPropertyModel( - propName: String, - propSchema: Schema<*>, - required: Set, - contextName: String? = null, - ) = PropertyModel( - name = propName, - type = propSchema.toTypeRef(contextName), - description = propSchema.description, - nullable = propName !in required, - defaultValue = propSchema.default, - ) - /** * Resolves a sub-schema that may be a `$ref` or an identity-matched ref to the * actual component schema with properties. Returns the sub-schema itself if it is @@ -349,14 +335,14 @@ object SpecParser { * Creates a TypeRef.Inline for an inline object schema. */ context(_: ComponentSchemaIdentity, _: ComponentSchemas) - private fun Schema<*>.toInlineTypeRef(contextName: String): TypeRef? = - takeIf { isInlineObject }?.requiredSet?.let { required -> - TypeRef.Inline( - properties = propertyModels(required) { "$contextName.${it.toPascalCase()}" }.values.toList(), - requiredProperties = required, - contextHint = contextName, - ) - } + 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, + ) + } private fun generateOperationId(method: String, path: String): String { val segments = @@ -382,34 +368,33 @@ object SpecParser { private fun Schema<*>.resolveName(): String? = this.`$ref`?.removePrefix(SCHEMA_PREFIX) ?: componentSchemaIdentity[this] - context(_: ComponentSchemaIdentity) - private fun List>?.resolveRefs(): List? = this?.mapNotNull { it.resolveName() } - - private fun List.toTypeRefs(): List? = map(TypeRef::Reference).ifEmpty { null } - - private val Schema<*>.requiredSet: Set get() = required.orEmpty().toSet() - context(_: ComponentSchemaIdentity, _: ComponentSchemas) private fun Schema<*>.propertyModels(required: Set, createContext: (String) -> String? = { null }) = properties .orEmpty() .mapValues { (propName, propSchema) -> - createPropertyModel(propName, propSchema, required, createContext(propName)) + PropertyModel( + name = propName, + type = propSchema.toTypeRef(createContext(propName)), + description = propSchema.description, + nullable = propName !in required, + defaultValue = propSchema.default, + ) } -} -private const val SCHEMA_PREFIX = "#/components/schemas/" + 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 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 INTEGER_FORMAT_MAP = mapOf( + "int64" to TypeRef.Primitive(PrimitiveType.LONG), + ) -private val NUMBER_FORMAT_MAP = mapOf( - "float" to TypeRef.Primitive(PrimitiveType.FLOAT), -) + private val NUMBER_FORMAT_MAP = mapOf( + "float" to TypeRef.Primitive(PrimitiveType.FLOAT), + ) +} 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 index 393b7ee6..e88ec956 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserPolymorphicTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserPolymorphicTest.kt @@ -17,11 +17,10 @@ class SpecParserPolymorphicTest { return File(url.toURI()) } - private fun parseSpec(file: File): ApiSpec = - when (val result = SpecParser.parse(file)) { - is SpecParser.ParseResult.Success -> result.apiSpec - is SpecParser.ParseResult.Failure -> fail("Expected success but got errors: ${result.errors}") - } + private fun parseSpec(file: File): ApiSpec = when (val result = SpecParser.parse(file)) { + is SpecParser.ParseResult.Success -> result.apiSpec + is SpecParser.ParseResult.Failure -> fail("Expected success but got errors: ${result.errors}") + } @Test fun `allOf schema has merged properties from referenced schema`() { 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 index 92ef3b37..9fc7e7ae 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt @@ -23,11 +23,10 @@ class SpecParserTest { return File(url.toURI()) } - private fun parseSpec(file: File): ApiSpec = - when (val result = SpecParser.parse(file)) { - is SpecParser.ParseResult.Success -> result.apiSpec - is SpecParser.ParseResult.Failure -> fail("Expected success but got errors: ${result.errors}") - } + private fun parseSpec(file: File): ApiSpec = when (val result = SpecParser.parse(file)) { + is SpecParser.ParseResult.Success -> result.apiSpec + is SpecParser.ParseResult.Failure -> fail("Expected success but got errors: ${result.errors}") + } private fun parseSpecErrors(file: File): List { val result = SpecParser.parse(file) From 67b18ddc6a3dbcff51e44b76b53c10a9ad534cae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 11 Mar 2026 20:42:30 +0100 Subject: [PATCH 10/25] refactor: reorder SpecParser definitions by responsibility Group methods into logical sections: public API, transformation, endpoint/schema extraction, allOf merging, oneOf detection, type resolution, schema extensions, naming helpers, constants. Co-Authored-By: Claude Opus 4.6 --- .../justworks/core/parser/SpecParser.kt | 275 ++++++++---------- 1 file changed, 117 insertions(+), 158 deletions(-) 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 index dab71214..7479ebfa 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -28,37 +28,25 @@ 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 kotlin.collections.component1 -import kotlin.collections.component2 -import kotlin.collections.emptyList -import kotlin.collections.mapValues -import kotlin.collections.orEmpty import io.swagger.v3.oas.models.parameters.Parameter as SwaggerParameter object SpecParser { - // Identity map from resolved Schema objects to their component schema names. - // Populated during transformToModel to detect inlined refs after resolveFully. - typealias ComponentSchemaIdentity = IdentityHashMap, String> - // Component schemas keyed by name, for resolving $ref in allOf sub-schemas. - typealias ComponentSchemas = MutableMap> + // --- Public API --- 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 } fun parse(specFile: File): ParseResult = either { - val parseOptions = - ParseOptions().apply { - isResolve = true - isResolveFully = true - isResolveCombinators = false - } + 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() @@ -67,9 +55,7 @@ object SpecParser { } 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()) { @@ -79,9 +65,16 @@ object SpecParser { return ParseResult.Success(openApi.toApiSpec(), warnings = allWarnings) }.merge() + // --- Internal type aliases --- + + private typealias ComponentSchemaIdentity = IdentityHashMap, String> + private typealias ComponentSchemas = MutableMap> + + // --- Top-level transformation --- + context(_: Raise) - fun OpenAPI.toApiSpec(): ApiSpec { - val allSchemas = this.components?.schemas.orEmpty() + private fun OpenAPI.toApiSpec(): ApiSpec { + val allSchemas = components?.schemas.orEmpty() val componentSchemaIdentity = ComponentSchemaIdentity(allSchemas.size).apply { allSchemas.forEach { (name, schema) -> this[schema] = name } @@ -92,50 +85,45 @@ object SpecParser { allSchemas.asSequence().partition { (_, schema) -> schema.isEnumSchema } return ApiSpec( - title = this.info?.title ?: "Untitled", - version = this.info?.version ?: "0.0.0", - endpoints = extractEndpoints(this.paths.orEmpty()), + title = info?.title ?: "Untitled", + version = info?.version ?: "0.0.0", + endpoints = extractEndpoints(paths.orEmpty()), schemas = schemaModels.map { (name, schema) -> extractSchemaModel(name, schema) }, enums = enumModels.map { (name, schema) -> extractEnumModel(name, schema) }, ) } } + // --- Endpoint extraction --- + context(_: ComponentSchemaIdentity, _: ComponentSchemas) private fun extractEndpoints(paths: Map): List = paths.flatMap { (path, pathItem) -> pathItem.readOperationsMap().map { (method, operation) -> val operationId = operation.operationId ?: generateOperationId(method.name, path) - // Merge path-level and operation-level parameters - // Operation-level takes precedence (unique key = name + location) val mergedParams = (pathItem.parameters.orEmpty() + operation.parameters.orEmpty()) .distinctBy { "${it.name}:${it.`in`}" } .map { it.toParameter() } val requestBody = nullable { val body = operation.requestBody.bind() - - val (contentType, mediaType) = body.content - ?.entries - ?.firstOrNull() - .bind() - + val (contentType, mediaType) = body.content?.entries?.firstOrNull().bind() val schema = mediaType.schema.bind() - RequestBody( required = body.required ?: false, contentType = contentType, schema = schema.toTypeRef("${operationId.replaceFirstChar { it.uppercase() }}Request"), ) } + val responses = operation.responses .orEmpty() .mapValues { (code, resp) -> - val schema = resp.content?.get("application/json")?.schema Response( statusCode = code, description = resp.description, - schema = schema?.toTypeRef("${operationId.replaceFirstChar { it.uppercase() }}Response"), + schema = resp.content?.get("application/json")?.schema + ?.toTypeRef("${operationId.replaceFirstChar { it.uppercase() }}Response"), ) } @@ -161,83 +149,12 @@ object SpecParser { description = description, ) - context(_: ComponentSchemaIdentity, _: ComponentSchemas) - private fun Schema<*>.toTypeRef(contextName: String? = null): TypeRef = contextName?.let { toInlineTypeRef(it) } - ?: (this.resolveName() ?: allOf?.singleOrNull()?.resolveName())?.let(TypeRef::Reference) - ?: when (this.type) { - "string" -> STRING_FORMAT_MAP[this.format] ?: TypeRef.Primitive(PrimitiveType.STRING) - - "integer" -> INTEGER_FORMAT_MAP[this.format] ?: TypeRef.Primitive(PrimitiveType.INT) - - "number" -> NUMBER_FORMAT_MAP[this.format] ?: TypeRef.Primitive(PrimitiveType.DOUBLE) - - "boolean" -> TypeRef.Primitive(PrimitiveType.BOOLEAN) - - "array" -> this.items?.toTypeRef()?.let(TypeRef::Array) ?: TypeRef.Primitive(PrimitiveType.STRING) - - "object" -> (this.additionalProperties as? Schema<*>)?.toTypeRef() - ?: this.title?.let(TypeRef::Reference) - ?: TypeRef.Unknown - - else -> TypeRef.Primitive(PrimitiveType.STRING) - } - - 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() }, - ) - - /** - * 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 { - // Must have oneOf and no explicit discriminator - ensure(!schema.oneOf.isNullOrEmpty() && schema.discriminator == null) - - val unwrapped = schema.oneOf - .orEmpty() - .asSequence() - .filter { it.isInlineObject } - .mapNotNull { - it.properties - ?.entries - ?.singleOrNull() - ?.toPair() - }.toMap() - .mapValuesNotNull { (propertyName, propertySchema) -> - propertySchema.resolveName() ?: propertyName.takeIf { propertySchema.isInlineObject }?.also { name -> - componentSchemas[name] = propertySchema - componentSchemaIdentity[propertySchema] = name - } - } - - ensure(unwrapped.isNotEmpty()) - - val mapping = unwrapped.mapValues { (_, schemaName) -> "$SCHEMA_PREFIX$schemaName" } - val refs = unwrapped.values.toList() - - refs to Discriminator(propertyName = "type", mapping = mapping) - } + // --- Schema extraction --- context(_: Raise, _: ComponentSchemaIdentity, _: ComponentSchemas) private fun extractSchemaModel(name: String, schema: Schema<*>): SchemaModel { val allOf = schema.allOf?.mapNotNull { it.resolveName() } - // Check for oneOf wrapper pattern before standard extraction val (oneOf, discriminatorFromWrapper) = detectAndUnwrapOneOfWrappers(schema) ?: (schema.oneOf?.mapNotNull { it.resolveName() } to null) @@ -247,7 +164,6 @@ object SpecParser { ParseResult.Failure(listOf("Schema '$name' has both oneOf and anyOf. Use one combinator only.")) } - // For allOf schemas, merge properties from all sub-schemas val (properties, requiredProps) = if (!schema.allOf.isNullOrEmpty()) { extractAllOfProperties(schema) @@ -279,61 +195,94 @@ object SpecParser { ) } - /** - * Extracts and merges properties from all allOf sub-schemas. - * For $ref sub-schemas (resolved by identity), looks up properties from the resolved schema. - * For inline sub-schemas, extracts properties directly. - * Also includes any top-level properties defined alongside allOf. - * Deduplicates by property name (later definition wins). - */ + 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(schema: Schema<*>): Pair, Set> { val topRequired = schema.required.orEmpty().toSet() - // Collect properties from each allOf sub-schema. - // For $ref sub-schemas (or identity-matched resolved refs), look up the - // referenced component schema to get its properties and required fields. 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) } val topLevelProperties = schema.propertyModels(required) - - // Recompute nullable based on final merged required set val finalProperties = properties.plus(topLevelProperties).values.map { prop -> prop.copy(nullable = prop.name !in required) } return finalProperties to required } - /** - * Resolves a sub-schema that may be a `$ref` or an identity-matched ref to the - * actual component schema with properties. Returns the sub-schema itself if it is - * an inline schema (not a reference). - */ context(_: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) private fun Schema<*>.resolveSubSchema(): Schema<*> = - // Check for explicit $ref string // Check for identity-matched resolved ref - this.resolveName()?.let { componentSchemas[it] } ?: this + resolveName()?.let { componentSchemas[it] } ?: this - /** - * Checks if a schema is an inline object schema (not a $ref, not in componentSchemaIdentity, - * is type object with properties). - */ - context(componentSchemaIdentity: ComponentSchemaIdentity) - private val Schema<*>.isInlineObject - get(): Boolean = `$ref` == null && - this !in componentSchemaIdentity && type == "object" && !properties.isNullOrEmpty() + // --- oneOf wrapper pattern detection --- /** - * Creates a TypeRef.Inline for an inline object schema. + * 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 unwrapped = schema.oneOf + .orEmpty() + .asSequence() + .filter { it.isInlineObject } + .mapNotNull { it.properties?.entries?.singleOrNull()?.toPair() } + .toMap() + .mapValuesNotNull { (propertyName, propertySchema) -> + propertySchema.resolveName() ?: propertyName.takeIf { propertySchema.isInlineObject }?.also { name -> + componentSchemas[name] = propertySchema + componentSchemaIdentity[propertySchema] = name + } + } + + ensure(unwrapped.isNotEmpty()) + + val mapping = unwrapped.mapValues { (_, schemaName) -> "$SCHEMA_PREFIX$schemaName" } + unwrapped.values.toList() to Discriminator(propertyName = "type", mapping = mapping) + } + + // --- Type resolution --- + + context(_: ComponentSchemaIdentity, _: ComponentSchemas) + private fun Schema<*>.toTypeRef(contextName: String? = null): TypeRef = contextName?.let { toInlineTypeRef(it) } + ?: (resolveName() ?: allOf?.singleOrNull()?.resolveName())?.let(TypeRef::Reference) + ?: 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" -> items?.toTypeRef()?.let(TypeRef::Array) ?: TypeRef.Primitive(PrimitiveType.STRING) + "object" -> (additionalProperties as? Schema<*>)?.toTypeRef() + ?: title?.let(TypeRef::Reference) + ?: TypeRef.Unknown + + else -> TypeRef.Primitive(PrimitiveType.STRING) + } + context(_: ComponentSchemaIdentity, _: ComponentSchemas) private fun Schema<*>.toInlineTypeRef(contextName: String): TypeRef? = takeIf { isInlineObject }?.let { val required = required.orEmpty().toSet() @@ -344,29 +293,18 @@ object SpecParser { ) } - private fun generateOperationId(method: String, 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.lowercase() + segments - } - - private val Schema<*>.isEnumSchema get(): Boolean = !this.enum.isNullOrEmpty() - - private fun String.toPascalCase(): String = - split("-", "_", ".").joinToString("") { part -> part.replaceFirstChar { it.uppercase() } } + // --- Schema<*> extensions --- context(componentSchemaIdentity: ComponentSchemaIdentity) private fun Schema<*>.resolveName(): String? = - this.`$ref`?.removePrefix(SCHEMA_PREFIX) ?: componentSchemaIdentity[this] + `$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 }) = @@ -382,6 +320,27 @@ object SpecParser { ) } + // --- Naming helpers --- + + private fun generateOperationId(method: String, 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.lowercase() + segments + } + + private fun String.toPascalCase(): String = + split("-", "_", ".").joinToString("") { part -> part.replaceFirstChar { it.uppercase() } } + + // --- Constants --- + private const val SCHEMA_PREFIX = "#/components/schemas/" private val STRING_FORMAT_MAP = mapOf( From 27d5d529a86de4505b012bd27d455d56ea379d7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 11 Mar 2026 20:44:18 +0100 Subject: [PATCH 11/25] refactor: remove redundant comments and improve SpecParser formatting Streamlined the code by removing section headers and redundant comments. Reformatted property extraction and type resolution logic for better readability. --- .../justworks/core/parser/SpecParser.kt | 56 +++++++++---------- 1 file changed, 25 insertions(+), 31 deletions(-) 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 index 7479ebfa..3880c0b5 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -30,15 +30,13 @@ import java.io.File import java.util.IdentityHashMap import io.swagger.v3.oas.models.parameters.Parameter as SwaggerParameter -object SpecParser { - - // --- Public API --- +sealed interface ParseResult { + data class Success(val apiSpec: ApiSpec, val warnings: List = emptyList()) : ParseResult - 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 - } + data class Failure(val errors: List, val warnings: List = emptyList()) : ParseResult +} +object SpecParser { fun parse(specFile: File): ParseResult = either { val parseOptions = ParseOptions().apply { isResolve = true @@ -65,13 +63,9 @@ object SpecParser { return ParseResult.Success(openApi.toApiSpec(), warnings = allWarnings) }.merge() - // --- Internal type aliases --- - private typealias ComponentSchemaIdentity = IdentityHashMap, String> private typealias ComponentSchemas = MutableMap> - // --- Top-level transformation --- - context(_: Raise) private fun OpenAPI.toApiSpec(): ApiSpec { val allSchemas = components?.schemas.orEmpty() @@ -94,8 +88,6 @@ object SpecParser { } } - // --- Endpoint extraction --- - context(_: ComponentSchemaIdentity, _: ComponentSchemas) private fun extractEndpoints(paths: Map): List = paths.flatMap { (path, pathItem) -> pathItem.readOperationsMap().map { (method, operation) -> @@ -107,7 +99,10 @@ object SpecParser { val requestBody = nullable { val body = operation.requestBody.bind() - val (contentType, mediaType) = body.content?.entries?.firstOrNull().bind() + val (contentType, mediaType) = body.content + ?.entries + ?.firstOrNull() + .bind() val schema = mediaType.schema.bind() RequestBody( required = body.required ?: false, @@ -122,7 +117,9 @@ object SpecParser { Response( statusCode = code, description = resp.description, - schema = resp.content?.get("application/json")?.schema + schema = resp.content + ?.get("application/json") + ?.schema ?.toTypeRef("${operationId.replaceFirstChar { it.uppercase() }}Response"), ) } @@ -224,10 +221,7 @@ object SpecParser { } context(_: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) - private fun Schema<*>.resolveSubSchema(): Schema<*> = - resolveName()?.let { componentSchemas[it] } ?: this - - // --- oneOf wrapper pattern detection --- + private fun Schema<*>.resolveSubSchema(): Schema<*> = resolveName()?.let { componentSchemas[it] } ?: this /** * Detects and unwraps the oneOf wrapper pattern where each variant is a single-property @@ -250,8 +244,12 @@ object SpecParser { .orEmpty() .asSequence() .filter { it.isInlineObject } - .mapNotNull { it.properties?.entries?.singleOrNull()?.toPair() } - .toMap() + .mapNotNull { + it.properties + ?.entries + ?.singleOrNull() + ?.toPair() + }.toMap() .mapValuesNotNull { (propertyName, propertySchema) -> propertySchema.resolveName() ?: propertyName.takeIf { propertySchema.isInlineObject }?.also { name -> componentSchemas[name] = propertySchema @@ -265,17 +263,20 @@ object SpecParser { unwrapped.values.toList() to Discriminator(propertyName = "type", mapping = mapping) } - // --- Type resolution --- - context(_: ComponentSchemaIdentity, _: ComponentSchemas) private fun Schema<*>.toTypeRef(contextName: String? = null): TypeRef = contextName?.let { toInlineTypeRef(it) } ?: (resolveName() ?: allOf?.singleOrNull()?.resolveName())?.let(TypeRef::Reference) ?: 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" -> items?.toTypeRef()?.let(TypeRef::Array) ?: TypeRef.Primitive(PrimitiveType.STRING) + "object" -> (additionalProperties as? Schema<*>)?.toTypeRef() ?: title?.let(TypeRef::Reference) ?: TypeRef.Unknown @@ -293,11 +294,8 @@ object SpecParser { ) } - // --- Schema<*> extensions --- - context(componentSchemaIdentity: ComponentSchemaIdentity) - private fun Schema<*>.resolveName(): String? = - `$ref`?.removePrefix(SCHEMA_PREFIX) ?: componentSchemaIdentity[this] + private fun Schema<*>.resolveName(): String? = `$ref`?.removePrefix(SCHEMA_PREFIX) ?: componentSchemaIdentity[this] context(componentSchemaIdentity: ComponentSchemaIdentity) private val Schema<*>.isInlineObject @@ -320,8 +318,6 @@ object SpecParser { ) } - // --- Naming helpers --- - private fun generateOperationId(method: String, path: String): String { val segments = path .split("/") @@ -339,8 +335,6 @@ object SpecParser { private fun String.toPascalCase(): String = split("-", "_", ".").joinToString("") { part -> part.replaceFirstChar { it.uppercase() } } - // --- Constants --- - private const val SCHEMA_PREFIX = "#/components/schemas/" private val STRING_FORMAT_MAP = mapOf( From 821834f795dd15b9e6dbe3dff433afc5e4e673db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Wed, 11 Mar 2026 20:45:39 +0100 Subject: [PATCH 12/25] refactor: simplify test cases by using direct ParseResult references - Removed redundant `SpecParser.` prefix in test assertions and parsing logic. - Updated tests for consistency and clarity in handling ParseResult types. --- .../justworks/core/parser/SpecParserPolymorphicTest.kt | 4 ++-- .../avsystem/justworks/core/parser/SpecParserTest.kt | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) 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 index e88ec956..541bef30 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserPolymorphicTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserPolymorphicTest.kt @@ -18,8 +18,8 @@ class SpecParserPolymorphicTest { } private fun parseSpec(file: File): ApiSpec = when (val result = SpecParser.parse(file)) { - is SpecParser.ParseResult.Success -> result.apiSpec - is SpecParser.ParseResult.Failure -> fail("Expected success but got errors: ${result.errors}") + is ParseResult.Success -> result.apiSpec + is ParseResult.Failure -> fail("Expected success but got errors: ${result.errors}") } @Test 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 index 9fc7e7ae..1efea9ce 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt @@ -24,13 +24,13 @@ class SpecParserTest { } private fun parseSpec(file: File): ApiSpec = when (val result = SpecParser.parse(file)) { - is SpecParser.ParseResult.Success -> result.apiSpec - is SpecParser.ParseResult.Failure -> fail("Expected success but got errors: ${result.errors}") + is ParseResult.Success -> result.apiSpec + is ParseResult.Failure -> fail("Expected success but got errors: ${result.errors}") } private fun parseSpecErrors(file: File): List { val result = SpecParser.parse(file) - check(result is SpecParser.ParseResult.Failure) { "Expected failure" } + check(result is ParseResult.Failure) { "Expected failure" } return result.errors } @@ -237,7 +237,7 @@ class SpecParserTest { @Test fun `parse invalid spec returns Failure`() { val result = SpecParser.parse(loadResource("invalid-spec.yaml")) - assertIs(result) + assertIs(result) } @Test @@ -256,7 +256,7 @@ class SpecParserTest { @Test fun `parse swagger 2 json returns Success`() { val result = SpecParser.parse(loadResource("petstore-v2.json")) - assertIs(result) + assertIs(result) } @Test From 9282e80dd9170a54317d557532845855f30f3f63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Thu, 12 Mar 2026 16:22:58 +0100 Subject: [PATCH 13/25] refactor(01-01): review and clean up parser and model code - Add KDoc to ParseResult sealed interface explaining Success/Failure pattern and .merge() usage - Add KDoc to SpecParser.parse() explaining params, return type, and Arrow either pattern - Add KDoc to SpecValidator.validate() explaining accumulate pattern and return semantics - Add explicit return type List to SpecValidator.validate() - Remove unused imports from SpecValidator.kt (NonEmptyList, Raise, RaiseAccumulate, ensure, ensureNotNull, recover) - Add KDoc to ApiSpec data class explaining its role as intermediate model --- .../avsystem/justworks/core/model/ApiSpec.kt | 7 +++++ .../justworks/core/parser/SpecParser.kt | 28 +++++++++++++++++++ .../justworks/core/parser/SpecValidator.kt | 20 ++++++++----- 3 files changed, 48 insertions(+), 7 deletions(-) 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 index ed35ae26..246a50cb 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt @@ -1,5 +1,12 @@ 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, 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 index 3880c0b5..379a66f4 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -30,6 +30,20 @@ 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 @@ -37,6 +51,20 @@ sealed interface 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 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 index afb8daa4..8ccc15df 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecValidator.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecValidator.kt @@ -1,16 +1,10 @@ package com.avsystem.justworks.core.parser -import arrow.core.NonEmptyList import arrow.core.raise.ExperimentalRaiseAccumulateApi -import arrow.core.raise.context.Raise -import arrow.core.raise.context.RaiseAccumulate import arrow.core.raise.context.accumulate -import arrow.core.raise.context.ensure -import arrow.core.raise.context.ensureNotNull import arrow.core.raise.context.ensureNotNullOrAccumulate import arrow.core.raise.context.ensureOrAccumulate import arrow.core.raise.fold -import arrow.core.raise.recover import io.swagger.v3.oas.models.OpenAPI object SpecValidator { @@ -22,8 +16,20 @@ object SpecValidator { 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) = fold( + fun validate(openApi: OpenAPI): List = fold( { accumulate { ensureNotNullOrAccumulate(openApi.info) { From b79f705818246257bc64dc51a10a7b4ffdd37668 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Thu, 12 Mar 2026 16:23:49 +0100 Subject: [PATCH 14/25] refactor(01-01): verify and clean up parser test conventions - Add // -- Category -- grouping headers to SpecValidatorTest - Add missing test case: OpenAPI with no paths produces warning - Add assertIs import to support type-checking in the new test --- .../core/parser/SpecValidatorTest.kt | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) 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 index fa8bd05b..67de584d 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecValidatorTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecValidatorTest.kt @@ -5,9 +5,12 @@ 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 = @@ -27,6 +30,8 @@ class SpecValidatorTest { 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 = @@ -45,4 +50,28 @@ class SpecValidatorTest { "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}", + ) + } } From 863dae815efe2f19d4e075253b6b33613685492a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Thu, 12 Mar 2026 17:02:15 +0100 Subject: [PATCH 15/25] refactor: remove unused `isEnum` field and simplify schema handling in `SpecParser` - Removed redundant `isEnum` property from `ApiSpec`. - Refined `allOf` property merging by adding parent context in `extractAllOfProperties`. - Standardized handling of `JSON_CONTENT_TYPE` and improved `oneOf` schema validation. - Fixed property ordering in operations and updated array/item resolution logic. - Adjusted compiler options configuration in Gradle for clarity and correctness. --- core/build.gradle.kts | 2 +- .../avsystem/justworks/core/model/ApiSpec.kt | 1 - .../justworks/core/parser/SpecParser.kt | 45 +++++++++---------- .../justworks/core/parser/SpecParserTest.kt | 1 - 4 files changed, 23 insertions(+), 26 deletions(-) diff --git a/core/build.gradle.kts b/core/build.gradle.kts index b38e7f97..36ec1ba9 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -37,5 +37,5 @@ publishing { } val compileKotlin: KotlinCompile by tasks compileKotlin.compilerOptions { - freeCompilerArgs.set(listOf("-Xcontext-parameters")) + 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 index 246a50cb..cc741d2a 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt @@ -63,7 +63,6 @@ data class SchemaModel( val description: String?, val properties: List, val requiredProperties: Set, - val isEnum: Boolean, val allOf: List?, val oneOf: List?, val anyOf: List?, 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 index 379a66f4..23acaf43 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -88,7 +88,7 @@ object SpecParser { ParseResult.Failure(errors.map { it.message }, allWarnings) } - return ParseResult.Success(openApi.toApiSpec(), warnings = allWarnings) + ParseResult.Success(openApi.toApiSpec(), warnings = allWarnings) }.merge() private typealias ComponentSchemaIdentity = IdentityHashMap, String> @@ -121,20 +121,17 @@ object SpecParser { pathItem.readOperationsMap().map { (method, operation) -> val operationId = operation.operationId ?: generateOperationId(method.name, path) - val mergedParams = (pathItem.parameters.orEmpty() + operation.parameters.orEmpty()) + 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 (contentType, mediaType) = body.content - ?.entries - ?.firstOrNull() - .bind() - val schema = mediaType.schema.bind() + val content = body.content.bind() + val schema = content[JSON_CONTENT_TYPE]?.schema.bind() RequestBody( required = body.required ?: false, - contentType = contentType, + contentType = JSON_CONTENT_TYPE, schema = schema.toTypeRef("${operationId.replaceFirstChar { it.uppercase() }}Request"), ) } @@ -146,7 +143,7 @@ object SpecParser { statusCode = code, description = resp.description, schema = resp.content - ?.get("application/json") + ?.get(JSON_CONTENT_TYPE) ?.schema ?.toTypeRef("${operationId.replaceFirstChar { it.uppercase() }}Response"), ) @@ -191,7 +188,7 @@ object SpecParser { val (properties, requiredProps) = if (!schema.allOf.isNullOrEmpty()) { - extractAllOfProperties(schema) + extractAllOfProperties(name, schema) } else { val requiredProps = schema.required.orEmpty().toSet() val props = schema @@ -212,7 +209,6 @@ object SpecParser { description = schema.description, properties = properties, requiredProperties = requiredProps, - isEnum = false, 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 } }, @@ -230,18 +226,19 @@ object SpecParser { // --- allOf property merging --- context(componentSchemaIdentity: ComponentSchemaIdentity, componentSchemas: ComponentSchemas) - private fun extractAllOfProperties(schema: Schema<*>): Pair, Set> { + 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) + mergedRequired to accProperties + resolvedSchema.propertyModels(mergedRequired, contextCreator) } - val topLevelProperties = schema.propertyModels(required) + val topLevelProperties = schema.propertyModels(required, contextCreator) val finalProperties = properties.plus(topLevelProperties).values.map { prop -> prop.copy(nullable = prop.name !in required) } @@ -268,10 +265,10 @@ object SpecParser { private fun detectAndUnwrapOneOfWrappers(schema: Schema<*>): Pair, Discriminator>? = nullable { ensure(!schema.oneOf.isNullOrEmpty() && schema.discriminator == null) - val unwrapped = schema.oneOf - .orEmpty() - .asSequence() - .filter { it.isInlineObject } + val variants = schema.oneOf.orEmpty() + ensure(variants.all { it.isInlineObject }) + + val unwrapped = variants .mapNotNull { it.properties ?.entries @@ -285,7 +282,7 @@ object SpecParser { } } - ensure(unwrapped.isNotEmpty()) + ensure(unwrapped.size == variants.size) val mapping = unwrapped.mapValues { (_, schemaName) -> "$SCHEMA_PREFIX$schemaName" } unwrapped.values.toList() to Discriminator(propertyName = "type", mapping = mapping) @@ -303,11 +300,12 @@ object SpecParser { "boolean" -> TypeRef.Primitive(PrimitiveType.BOOLEAN) - "array" -> items?.toTypeRef()?.let(TypeRef::Array) ?: TypeRef.Primitive(PrimitiveType.STRING) + "array" -> items?.toTypeRef(contextName?.let { "${it}Item" })?.let(TypeRef::Array) + ?: TypeRef.Primitive(PrimitiveType.STRING) - "object" -> (additionalProperties as? Schema<*>)?.toTypeRef() - ?: title?.let(TypeRef::Reference) - ?: TypeRef.Unknown + "object" -> TypeRef.Map( + (additionalProperties as? Schema<*>)?.toTypeRef() ?: TypeRef.Primitive(PrimitiveType.STRING), + ) else -> TypeRef.Primitive(PrimitiveType.STRING) } @@ -363,6 +361,7 @@ object SpecParser { 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( 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 index 1efea9ce..faf07de9 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt @@ -8,7 +8,6 @@ import com.avsystem.justworks.core.model.PrimitiveType import com.avsystem.justworks.core.model.TypeRef import java.io.File import kotlin.test.Test -import kotlin.test.assertContains import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNotNull From 0a9b36c0ee54eb2dababbdb99738f2e712a3efb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Thu, 12 Mar 2026 17:32:47 +0100 Subject: [PATCH 16/25] refactor: improve property extraction and type resolution in SpecParser - Replace `mapValuesNotNull` with stricter `associate` for clearer logic. - Enhance handling of `additionalProperties` in `toTypeRef`, supporting schema references and boolean validation. - Add fallback for unknown type handling in `toTypeRef`. --- .../justworks/core/parser/SpecParser.kt | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) 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 index 23acaf43..edd6b025 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -1,6 +1,5 @@ package com.avsystem.justworks.core.parser -import arrow.core.mapValuesNotNull import arrow.core.merge import arrow.core.raise.context.Raise import arrow.core.raise.context.ensure @@ -269,17 +268,22 @@ object SpecParser { ensure(variants.all { it.isInlineObject }) val unwrapped = variants - .mapNotNull { - it.properties - ?.entries - ?.singleOrNull() - ?.toPair() - }.toMap() - .mapValuesNotNull { (propertyName, propertySchema) -> - propertySchema.resolveName() ?: propertyName.takeIf { propertySchema.isInlineObject }?.also { name -> - componentSchemas[name] = propertySchema - componentSchemaIdentity[propertySchema] = name - } + .associate { + ensure(it.isInlineObject) + 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) @@ -291,6 +295,7 @@ object SpecParser { 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 > 1 } ?: when (type) { "string" -> STRING_FORMAT_MAP[format] ?: TypeRef.Primitive(PrimitiveType.STRING) @@ -303,9 +308,11 @@ object SpecParser { "array" -> items?.toTypeRef(contextName?.let { "${it}Item" })?.let(TypeRef::Array) ?: TypeRef.Primitive(PrimitiveType.STRING) - "object" -> TypeRef.Map( - (additionalProperties as? Schema<*>)?.toTypeRef() ?: TypeRef.Primitive(PrimitiveType.STRING), - ) + "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.Primitive(PrimitiveType.STRING) } From 5d80666ad36a65f5c300cb886735c2bbf9888b46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Fri, 13 Mar 2026 11:50:30 +0100 Subject: [PATCH 17/25] refactor: add HttpMethod parsing and enhance SpecParser for endpoint extraction - Introduced `HttpMethod.parse` with case-insensitive parsing. - Updated `extractEndpoints` to leverage `HttpMethod.parse` for improved safety. - Refactored schema extraction logic to prevent redundancy and ensure unique definitions. --- .../avsystem/justworks/core/model/ApiSpec.kt | 12 +- .../justworks/core/parser/SpecParser.kt | 120 ++++++++++-------- 2 files changed, 81 insertions(+), 51 deletions(-) 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 index cc741d2a..99600251 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt @@ -26,7 +26,17 @@ data class Endpoint( val responses: Map, ) -enum class HttpMethod { GET, POST, PUT, DELETE, PATCH } +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, 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 index edd6b025..3d17e162 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -101,65 +101,85 @@ object SpecParser { allSchemas.forEach { (name, schema) -> this[schema] = name } } - context(componentSchemaIdentity, allSchemas.toMutableMap()) { + val componentSchemas: ComponentSchemas = allSchemas.toMutableMap() + + context(componentSchemaIdentity, componentSchemas) { + val endpoints = extractEndpoints(paths.orEmpty()) + val (enumModels, schemaModels) = - allSchemas.asSequence().partition { (_, schema) -> schema.isEnumSchema } + componentSchemas + .asSequence() + .plus(allSchemas.asSequence()) + .distinctBy { it.key } + .fold(emptyList() to emptyList()) { (accEnum, accModels), (name, schema) -> + if (schema.isEnumSchema) { + accEnum to accModels + extractSchemaModel(name, schema) + } else { + accEnum + extractEnumModel(name, schema) to accModels + } + } return ApiSpec( title = info?.title ?: "Untitled", version = info?.version ?: "0.0.0", - endpoints = extractEndpoints(paths.orEmpty()), - schemas = schemaModels.map { (name, schema) -> extractSchemaModel(name, schema) }, - enums = enumModels.map { (name, schema) -> extractEnumModel(name, schema) }, + endpoints = endpoints, + schemas = schemaModels, + enums = enumModels, ) } } context(_: ComponentSchemaIdentity, _: ComponentSchemas) - private fun extractEndpoints(paths: Map): List = paths.flatMap { (path, pathItem) -> - pathItem.readOperationsMap().map { (method, operation) -> - val operationId = operation.operationId ?: generateOperationId(method.name, 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"), + 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, ) } - - Endpoint( - path = path, - method = HttpMethod.valueOf(method.name), - 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( @@ -295,7 +315,7 @@ object SpecParser { 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 > 1 } + ?: TypeRef.Unknown.takeIf { (allOf?.size ?: 0) > 1 } ?: when (type) { "string" -> STRING_FORMAT_MAP[format] ?: TypeRef.Primitive(PrimitiveType.STRING) @@ -351,7 +371,7 @@ object SpecParser { ) } - private fun generateOperationId(method: String, path: String): String { + private fun generateOperationId(method: HttpMethod, path: String): String { val segments = path .split("/") .filter { it.isNotEmpty() } @@ -362,7 +382,7 @@ object SpecParser { segment.toPascalCase() } } - return method.lowercase() + segments + return method.name.lowercase() + segments } private fun String.toPascalCase(): String = From 3b6774eb9c0d5ddb6a8356093209ea7f2b04aa33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Fri, 13 Mar 2026 12:05:25 +0100 Subject: [PATCH 18/25] refactor: simplify test helpers and remove redundant code - Extract shared test logic into `SpecParserTestBase`. - Remove unused `loadResource` and `parseSpec` methods. - Clean up legacy validation logic in SpecParser tests. - Comment placeholder for future cookie parameter addition in `ParameterLocation`. --- .../com/avsystem/justworks/core/model/ApiSpec.kt | 1 + .../avsystem/justworks/core/parser/SpecParser.kt | 1 - .../core/parser/SpecParserPolymorphicTest.kt | 15 +-------------- .../justworks/core/parser/SpecParserTest.kt | 15 +-------------- 4 files changed, 3 insertions(+), 29 deletions(-) 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 index 99600251..35cd2e0c 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt @@ -46,6 +46,7 @@ data class Parameter( val description: String?, ) +// todo: add cookie enum class ParameterLocation { PATH, QUERY, 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 index 3d17e162..44a35346 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -289,7 +289,6 @@ object SpecParser { val unwrapped = variants .associate { - ensure(it.isInlineObject) val (propertyName, propertySchema) = ensureNotNull( it.properties?.entries?.singleOrNull(), ) 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 index 541bef30..2188c137 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserPolymorphicTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserPolymorphicTest.kt @@ -1,26 +1,13 @@ package com.avsystem.justworks.core.parser -import com.avsystem.justworks.core.model.ApiSpec import com.avsystem.justworks.core.model.TypeRef -import java.io.File import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue import kotlin.test.fail -class SpecParserPolymorphicTest { - private fun loadResource(name: String): File { - val url = - javaClass.getResource("/$name") - ?: fail("Test resource not found: $name") - return File(url.toURI()) - } - - private 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}") - } +class SpecParserPolymorphicTest : SpecParserTestBase() { @Test fun `allOf schema has merged properties from referenced schema`() { 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 index faf07de9..686af076 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt @@ -1,6 +1,5 @@ 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 @@ -14,19 +13,7 @@ import kotlin.test.assertNotNull import kotlin.test.assertTrue import kotlin.test.fail -class SpecParserTest { - private fun loadResource(name: String): File { - val url = - javaClass.getResource("/$name") - ?: fail("Test resource not found: $name") - return File(url.toURI()) - } - - private 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}") - } - +class SpecParserTest : SpecParserTestBase() { private fun parseSpecErrors(file: File): List { val result = SpecParser.parse(file) check(result is ParseResult.Failure) { "Expected failure" } From 4b3063e8aba3c38abff7aca63adac61fefba5039 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Fri, 13 Mar 2026 12:08:41 +0100 Subject: [PATCH 19/25] refactor: fix enum and schema model extraction order in SpecParser - Corrected the order of `extractEnumModel` and `extractSchemaModel` to ensure proper model generation. --- .../kotlin/com/avsystem/justworks/core/parser/SpecParser.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 44a35346..3dcb8abc 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -113,9 +113,9 @@ object SpecParser { .distinctBy { it.key } .fold(emptyList() to emptyList()) { (accEnum, accModels), (name, schema) -> if (schema.isEnumSchema) { - accEnum to accModels + extractSchemaModel(name, schema) - } else { accEnum + extractEnumModel(name, schema) to accModels + } else { + accEnum to accModels + extractSchemaModel(name, schema) } } From dfc9ddae4977417e996299ce5c5260dbae1323b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Fri, 13 Mar 2026 12:18:22 +0100 Subject: [PATCH 20/25] test: remove redundant blank line in SpecParserPolymorphicTest --- .../avsystem/justworks/core/parser/SpecParserPolymorphicTest.kt | 1 - 1 file changed, 1 deletion(-) 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 index 2188c137..908a836d 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserPolymorphicTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserPolymorphicTest.kt @@ -8,7 +8,6 @@ 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")) From 7a2bd0d497b54b1d0de37564b5b47a15d5c30fe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Fri, 13 Mar 2026 12:25:04 +0100 Subject: [PATCH 21/25] fix: add missing SpecParserTestBase extracted in previous refactor Co-Authored-By: Claude Opus 4.6 --- .../core/parser/SpecParserTestBase.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTestBase.kt 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}") + } +} From 6cfb44dd41bbb5a4f3afb805474bf562c927cdb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Fri, 13 Mar 2026 13:51:26 +0100 Subject: [PATCH 22/25] refactor: replace asSequence with toMap for schema processing in SpecParser - Simplified schema folding logic by using `toMap` for more concise and efficient transformation. --- .../kotlin/com/avsystem/justworks/core/parser/SpecParser.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index 3dcb8abc..6e00f313 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -1,5 +1,6 @@ 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 @@ -108,9 +109,8 @@ object SpecParser { val (enumModels, schemaModels) = componentSchemas - .asSequence() - .plus(allSchemas.asSequence()) - .distinctBy { it.key } + .toMap() + .plus(allSchemas.toMap()) .fold(emptyList() to emptyList()) { (accEnum, accModels), (name, schema) -> if (schema.isEnumSchema) { accEnum + extractEnumModel(name, schema) to accModels From d3a101c688ebc13a367979191674e575730ac215 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Mon, 16 Mar 2026 11:52:17 +0100 Subject: [PATCH 23/25] Refactor `SpecParserTest` to reuse a single `petstore` object initialized in `@BeforeTest`. --- .../justworks/core/parser/SpecParserTest.kt | 42 ++++++++++--------- 1 file changed, 22 insertions(+), 20 deletions(-) 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 index 686af076..fc9871a3 100644 --- a/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt +++ b/core/src/test/kotlin/com/avsystem/justworks/core/parser/SpecParserTest.kt @@ -1,11 +1,14 @@ 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 @@ -13,7 +16,17 @@ 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" } @@ -24,15 +37,12 @@ class SpecParserTest : SpecParserTestBase() { @Test fun `parse petstore yaml produces Success with endpoints`() { - val spec = parseSpec(loadResource("petstore.yaml")) - assertEquals(3, spec.endpoints.size, "Expected 3 endpoints") + assertEquals(3, petstore.endpoints.size, "Expected 3 endpoints") } @Test fun `parse petstore yaml produces schemas`() { - val spec = parseSpec(loadResource("petstore.yaml")) - - val schemaNames = spec.schemas.map { it.name }.toSet() + 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") @@ -40,9 +50,7 @@ class SpecParserTest : SpecParserTestBase() { @Test fun `parse petstore yaml produces enums`() { - val spec = parseSpec(loadResource("petstore.yaml")) - - val petStatus = spec.enums.find { it.name == "PetStatus" } + 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) @@ -50,9 +58,8 @@ class SpecParserTest : SpecParserTestBase() { @Test fun `parsed Pet schema has correct properties`() { - val spec = parseSpec(loadResource("petstore.yaml")) val pet = - spec.schemas.find { it.name == "Pet" } + petstore.schemas.find { it.name == "Pet" } ?: fail("Pet schema not found") val propMap = pet.properties.associateBy { it.name } @@ -82,9 +89,8 @@ class SpecParserTest : SpecParserTestBase() { @Test fun `parsed GET pets endpoint has query parameter limit with INT type`() { - val spec = parseSpec(loadResource("petstore.yaml")) val listPets = - spec.endpoints.find { it.operationId == "listPets" } + petstore.endpoints.find { it.operationId == "listPets" } ?: fail("listPets endpoint not found") assertEquals(HttpMethod.GET, listPets.method) @@ -100,9 +106,8 @@ class SpecParserTest : SpecParserTestBase() { @Test fun `parsed GET pets petId has path parameter`() { - val spec = parseSpec(loadResource("petstore.yaml")) val getPet = - spec.endpoints.find { it.operationId == "getPetById" } + petstore.endpoints.find { it.operationId == "getPetById" } ?: fail("getPetById endpoint not found") assertEquals(HttpMethod.GET, getPet.method) @@ -116,9 +121,8 @@ class SpecParserTest : SpecParserTestBase() { @Test fun `parsed POST pets has requestBody referencing NewPet`() { - val spec = parseSpec(loadResource("petstore.yaml")) val createPet = - spec.endpoints.find { it.operationId == "createPet" } + petstore.endpoints.find { it.operationId == "createPet" } ?: fail("createPet endpoint not found") assertEquals(HttpMethod.POST, createPet.method) @@ -133,16 +137,14 @@ class SpecParserTest : SpecParserTestBase() { @Test fun `parsed endpoints have tags`() { - val spec = parseSpec(loadResource("petstore.yaml")) - val listPets = spec.endpoints.find { it.operationId == "listPets" }!! + 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 spec = parseSpec(loadResource("petstore.yaml")) - val listPets = spec.endpoints.find { it.operationId == "listPets" }!! + val listPets = petstore.endpoints.find { it.operationId == "listPets" }!! val okResponse = listPets.responses["200"] From b7a9790cedcfa7ed5253df6c751550ed8791e7de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Mon, 16 Mar 2026 12:01:53 +0100 Subject: [PATCH 24/25] Refactor `SpecParser` to simplify schema folding logic and improve type inference for arrays and unknown types. --- .../justworks/core/parser/SpecParser.kt | 27 ++++++++----------- 1 file changed, 11 insertions(+), 16 deletions(-) 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 index 6e00f313..57e3a34b 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -27,7 +27,7 @@ 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 java.util.* import io.swagger.v3.oas.models.parameters.Parameter as SwaggerParameter /** @@ -107,17 +107,13 @@ object SpecParser { context(componentSchemaIdentity, componentSchemas) { val endpoints = extractEndpoints(paths.orEmpty()) - val (enumModels, schemaModels) = - componentSchemas - .toMap() - .plus(allSchemas.toMap()) - .fold(emptyList() to emptyList()) { (accEnum, accModels), (name, schema) -> - if (schema.isEnumSchema) { - accEnum + extractEnumModel(name, schema) to accModels - } else { - accEnum to accModels + extractSchemaModel(name, schema) - } - } + 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", @@ -196,7 +192,7 @@ object SpecParser { private fun extractSchemaModel(name: String, schema: Schema<*>): SchemaModel { val allOf = schema.allOf?.mapNotNull { it.resolveName() } - val (oneOf, discriminatorFromWrapper) = detectAndUnwrapOneOfWrappers(schema) + val (oneOf, discriminatorFromWrapper) = detectAndUnwrapOneOfWrappers(schema) // may register new schemas ?: (schema.oneOf?.mapNotNull { it.resolveName() } to null) val anyOf = schema.anyOf?.mapNotNull { it.resolveName() } @@ -324,8 +320,7 @@ object SpecParser { "boolean" -> TypeRef.Primitive(PrimitiveType.BOOLEAN) - "array" -> items?.toTypeRef(contextName?.let { "${it}Item" })?.let(TypeRef::Array) - ?: TypeRef.Primitive(PrimitiveType.STRING) + "array" -> TypeRef.Array(items?.toTypeRef(contextName?.let { "${it}Item" }) ?: TypeRef.Unknown) "object" -> when (val ap = additionalProperties) { is Schema<*> -> TypeRef.Map(ap.toTypeRef()) @@ -333,7 +328,7 @@ object SpecParser { else -> title?.let(TypeRef::Reference) ?: TypeRef.Unknown } - else -> TypeRef.Primitive(PrimitiveType.STRING) + else -> TypeRef.Unknown } context(_: ComponentSchemaIdentity, _: ComponentSchemas) From 0933c7340682bc1f3cda72c6aabdd8ba289a03ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Kozak?= Date: Mon, 16 Mar 2026 12:04:13 +0100 Subject: [PATCH 25/25] fmt --- .../kotlin/com/avsystem/justworks/core/parser/SpecParser.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 index 57e3a34b..a9239d57 100644 --- a/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt +++ b/core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt @@ -27,7 +27,7 @@ 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.* +import java.util.IdentityHashMap import io.swagger.v3.oas.models.parameters.Parameter as SwaggerParameter /** @@ -107,7 +107,9 @@ object SpecParser { context(componentSchemaIdentity, componentSchemas) { val endpoints = extractEndpoints(paths.orEmpty()) - val (enumModels, schemaModels) = allSchemas.fold(emptyList() to emptyList()) { (accEnum, accModels), (name, schema) -> + val (enumModels, schemaModels) = allSchemas.fold( + emptyList() to emptyList(), + ) { (accEnum, accModels), (name, schema) -> if (schema.isEnumSchema) { accEnum + extractEnumModel(name, schema) to accModels } else {