Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
409cc7e
test(05-01): add SecurityScheme model types and failing parser tests
halotukozak Mar 23, 2026
64a3464
feat(05-01): implement SpecParser security scheme extraction
halotukozak Mar 23, 2026
78c62f4
feat(05-02): dynamic auth generation in ApiClientBaseGenerator
halotukozak Mar 23, 2026
a65fd45
feat(05-02): security-aware constructor generation in ClientGenerator
halotukozak Mar 23, 2026
eef4bd9
feat(05-03): wire spec files into JustworksSharedTypesTask for securi…
halotukozak Mar 23, 2026
5b03af7
test(05-03): add functional tests for security schemes in plugin-gene…
halotukozak Mar 23, 2026
b71be5d
feat(10-01): rewrite ApiResponseGenerator with sealed HttpError hiera…
halotukozak Mar 23, 2026
8d476d3
test(10-01): rewrite ApiResponseGeneratorTest for sealed HttpError hi…
halotukozak Mar 23, 2026
d0f0e6f
feat(10-02): rewrite generators for Either-based error returns
halotukozak Mar 23, 2026
6f2027f
test(10-02): update tests for Either-based error returns
halotukozak Mar 23, 2026
442566c
test(10-03): add failing tests for error type resolution
halotukozak Mar 23, 2026
c9b7bdd
feat(10-03): add resolveErrorType() for typed error bodies in HttpResult
halotukozak Mar 23, 2026
cf766ff
Merge feat/security-schemes into feat/typed-error-responses
halotukozak Apr 2, 2026
2af7520
Resolve merge conflicts favoring typed-error-responses approach
halotukozak Apr 2, 2026
8def56c
refactor: replace Arrow Either with sealed HttpResult interface
halotukozak Apr 8, 2026
3f560ba
Merge master into feat/typed-error-responses
halotukozak Apr 8, 2026
2d076c4
docs: update README for sealed HttpResult/HttpError API
halotukozak Apr 8, 2026
933a048
fix: add Redirect and missing HTTP error subtypes, clean up formatting
halotukozak Apr 8, 2026
c25fced
Merge feat/security-schemes into feat/typed-error-responses
halotukozak Apr 8, 2026
8594e62
Merge remote-tracking branch 'origin/feat/security-schemes' into feat…
halotukozak Apr 8, 2026
c06b26e
Merge feat/security-schemes into feat/typed-error-responses
halotukozak Apr 8, 2026
8f407ae
chore: remove unused BSP config and `bodyAsText` import from Names.kt
halotukozak Apr 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 57 additions & 41 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,16 +150,16 @@ build/generated/justworks/
├── shared/kotlin/
│ └── com/avsystem/justworks/
│ ├── ApiClientBase.kt # Abstract base class + auth handling + helper extensions
│ ├── HttpError.kt # HttpErrorType enum + HttpError data class
│ ├── HttpResult.kt # HttpResult<E, T> sealed interface
│ ├── HttpError.kt # HttpError<B> sealed class hierarchy
│ └── HttpSuccess.kt # HttpSuccess<T> data class
└── specName/
└── com/example/
├── model/
│ ├── Pet.kt # @Serializable data class
│ ├── PetStatus.kt # @Serializable enum class
│ ├── Shape.kt # sealed interface (oneOf/anyOf)
│ ├── Circle.kt # variant data class : Shape
│ ├── Shape.kt # sealed interface + nested variants (oneOf/anyOf)
│ ├── UuidSerializer.kt # (if spec uses UUID fields)
│ └── SerializersModule.kt # (if spec has polymorphic types)
└── api/
Expand All @@ -170,15 +170,16 @@ build/generated/justworks/

- **Data classes** -- one per named schema. Properties annotated with `@SerialName`, sorted required-first.
- **Enums** -- constants in `UPPER_SNAKE_CASE` with `@SerialName` for the wire value.
- **Sealed interfaces** -- for `oneOf`/`anyOf` schemas. Variants are separate data classes implementing the interface.
- **Sealed interfaces** -- for `oneOf`/`anyOf` schemas. Discriminated variants are nested inside the sealed interface file.
- **SerializersModule** -- top-level `val generatedSerializersModule` registering all polymorphic hierarchies. Only
generated when needed.

### API Package

One client class per OpenAPI tag (e.g. `pets` tag -> `PetsApi`). Untagged endpoints go to `DefaultApi`.

Each endpoint becomes a `suspend` function with `context(Raise<HttpError>)` that returns `HttpSuccess<T>`.
Each endpoint becomes a `suspend` function that returns `HttpResult<E, T>` -- a sealed interface implemented by
`HttpError<E>` (for failures) and `HttpSuccess<T>` (for successes). No Arrow or other external runtime dependencies are required.

### Gradle Tasks

Expand Down Expand Up @@ -308,13 +309,20 @@ The client implements `Closeable` -- call `client.close()` when done to release

### Making Requests

Every endpoint becomes a `suspend` function on the client that returns `HttpSuccess<T>` on success and throws
`HttpError` on failure:
Every endpoint becomes a `suspend` function on the client that returns `HttpResult<E, T>`:

```kotlin
val result: HttpSuccess<List<Pet>> = client.listPets(limit = 10)
println(result.body) // the deserialized response body
println(result.code) // the HTTP status code
val result: HttpResult<JsonElement, List<Pet>> = client.listPets(limit = 10)

when (result) {
is HttpSuccess -> {
println(result.body) // the deserialized response body
println(result.code) // the HTTP status code
}
is HttpError -> {
println("Error ${result.code}: ${result.body}")
}
}
```

Path, query, and header parameters map to function arguments. Optional parameters default to `null`:
Expand All @@ -325,42 +333,50 @@ val result = client.findPets(status = "available", limit = 20)

### Error Handling

Generated endpoints throw `HttpError` (a `RuntimeException` subclass) for non-2xx responses and network failures.
Use standard `try`/`catch` to handle errors:
`HttpResult<E, T>` is a sealed interface with two branches:

- `HttpSuccess<T>` -- successful response (2xx) with a deserialized body
- `HttpError<E>` -- sealed class hierarchy for all error cases

`HttpError<E>` provides typed subtypes for common HTTP error codes:

| Subtype | HTTP status | Description |
|---------------------------------|-------------|------------------------|
| `HttpError.BadRequest` | 400 | Bad request |
| `HttpError.Unauthorized` | 401 | Unauthorized |
| `HttpError.Forbidden` | 403 | Forbidden |
| `HttpError.NotFound` | 404 | Not found |
| `HttpError.MethodNotAllowed` | 405 | Method not allowed |
| `HttpError.RequestTimeout` | 408 | Request timeout |
| `HttpError.Conflict` | 409 | Conflict |
| `HttpError.Gone` | 410 | Gone |
| `HttpError.PayloadTooLarge` | 413 | Payload too large |
| `HttpError.UnsupportedMediaType`| 415 | Unsupported media type |
| `HttpError.UnprocessableEntity` | 422 | Unprocessable entity |
| `HttpError.TooManyRequests` | 429 | Too many requests |
| `HttpError.InternalServerError` | 500 | Internal server error |
| `HttpError.BadGateway` | 502 | Bad gateway |
| `HttpError.ServiceUnavailable` | 503 | Service unavailable |
| `HttpError.GatewayTimeout` | 504 | Gateway timeout |
| `HttpError.Redirect` | 3xx | Redirect |
| `HttpError.Other` | *any other* | Catchall with code |
| `HttpError.Network` | -- | I/O or timeout |

Each error subtype carries a nullable `body: E?` with the deserialized error response (or `null` if deserialization
failed), plus an `code: Int` property.

```kotlin
try {
val success = client.getPet(petId = 123)
println("Found: ${success.body.name}")
} catch (e: HttpError) {
when (e.type) {
HttpErrorType.Client -> println("Client error ${e.code}: ${e.message}")
HttpErrorType.Server -> println("Server error ${e.code}: ${e.message}")
HttpErrorType.Redirect -> println("Redirect ${e.code}")
HttpErrorType.Network -> println("Connection failed: ${e.message}")
}
when (result) {
is HttpSuccess -> println("Pet: ${result.body.name}")
is HttpError.NotFound -> println("Pet not found")
is HttpError.Unauthorized -> println("Please log in")
is HttpError.Network -> println("Connection failed: ${result.cause}")
is HttpError -> println("HTTP ${result.code}: ${result.body}")
}
```

`HttpError` is a data class with the following fields:

| Field | Type | Description |
|-----------|-----------------|----------------------------------------------|
| `code` | `Int` | HTTP status code (or `0` for network errors) |
| `message` | `String` | Response body text or exception message |
| `type` | `HttpErrorType` | Category of the error |

`HttpErrorType` categorizes errors:

| `HttpErrorType` value | Covered statuses / scenario |
|-----------------------|------------------------------------|
| `Client` | HTTP 4xx client errors |
| `Server` | HTTP 5xx server errors |
| `Redirect` | HTTP 3xx redirect responses |
| `Network` | I/O failures, timeouts, DNS issues |

Network errors (connection timeouts, DNS failures) are caught and reported as
`HttpError(code = 0, ..., type = HttpErrorType.Network)` instead of propagating raw exceptions.
Network errors (connection timeouts, DNS failures) are caught and reported as `HttpError.Network` instead of
propagating exceptions.

## Publishing

Expand Down
6 changes: 2 additions & 4 deletions core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ val HTTP_HEADERS = ClassName("io.ktor.http", "HttpHeaders")

val JSON_FUN = MemberName("io.ktor.serialization.kotlinx.json", "json")
val BODY_FUN = MemberName("io.ktor.client.call", "body")
val BODY_AS_TEXT_FUN = MemberName("io.ktor.client.statement", "bodyAsText")
val SET_BODY_FUN = MemberName("io.ktor.client.request", "setBody")
val CONTENT_TYPE_FUN = MemberName("io.ktor.http", "contentType")
val CONTENT_TYPE_APPLICATION = ClassName("io.ktor.http", "ContentType", "Application")
Expand Down Expand Up @@ -84,11 +83,10 @@ val EXPERIMENTAL_UUID_API = ClassName("kotlin.uuid", "ExperimentalUuidApi")
// Error Handling
// ============================================================================

val RUNTIME_EXCEPTION = ClassName("kotlin", "RuntimeException")

val HTTP_ERROR = ClassName("com.avsystem.justworks", "HttpError")
val HTTP_ERROR_TYPE = ClassName("com.avsystem.justworks", "HttpErrorType")
val HTTP_SUCCESS = ClassName("com.avsystem.justworks", "HttpSuccess")
val HTTP_RESULT = ClassName("com.avsystem.justworks", "HttpResult")
val DESERIALIZE_ERROR_BODY_FUN = MemberName("com.avsystem.justworks", "deserializeErrorBody")

// ============================================================================
// Kotlin stdlib
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,19 +45,26 @@ internal object BodyGenerator {
endpoint: Endpoint,
params: Map<ParameterLocation, List<Parameter>>,
returnBodyType: TypeName,
): CodeBlock = CodeBlock
.builder()
.beginControlFlow("return $SAFE_CALL")
.apply {
val urlString = buildUrlString(endpoint, params)
when (endpoint.requestBody?.contentType) {
ContentType.MULTIPART_FORM_DATA -> buildMultipartBody(endpoint, params, urlString)
ContentType.FORM_URL_ENCODED -> buildFormUrlEncodedBody(endpoint, params, urlString)
ContentType.JSON_CONTENT_TYPE, null -> buildJsonBody(endpoint, params, urlString)
}
}.unindent()
.add("}.%M()\n", if (returnBodyType == UNIT) TO_EMPTY_RESULT_FUN else TO_RESULT_FUN)
.build()
): CodeBlock {
val resultFun = if (returnBodyType == UNIT) TO_EMPTY_RESULT_FUN else TO_RESULT_FUN
val code = CodeBlock.builder()

code.beginControlFlow("return $SAFE_CALL")

val urlString = buildUrlString(endpoint, params)
when (endpoint.requestBody?.contentType) {
ContentType.MULTIPART_FORM_DATA -> code.buildMultipartBody(endpoint, params, urlString)
ContentType.FORM_URL_ENCODED -> code.buildFormUrlEncodedBody(endpoint, params, urlString)
ContentType.JSON_CONTENT_TYPE, null -> code.buildJsonBody(endpoint, params, urlString)
}

// Close the HTTP call block and chain .toResult() / .toEmptyResult()
code.unindent()
code.add("}.%M()\n", resultFun)
code.endControlFlow() // safeCall

return code.build()
}

private fun CodeBlock.Builder.buildJsonBody(
endpoint: Endpoint,
Expand All @@ -80,7 +87,7 @@ internal object BodyGenerator {
addStatement("%M(%L)", SET_BODY_FUN, BODY)
}

endControlFlow() // client.METHOD
// Don't endControlFlow here — the outer buildFunctionBody closes with .toResult()
}

private fun CodeBlock.Builder.buildMultipartBody(
Expand Down Expand Up @@ -173,7 +180,7 @@ internal object BodyGenerator {
beginControlFlow(")")
addCommonRequestParts(params)
addHttpMethodIfNeeded(endpoint.method)
endControlFlow()
// Don't endControlFlow here — the outer buildFunctionBody closes with .toResult()
}

private fun CodeBlock.Builder.addCommonRequestParts(params: Map<ParameterLocation, List<Parameter>>) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import com.avsystem.justworks.core.gen.CLIENT
import com.avsystem.justworks.core.gen.CREATE_HTTP_CLIENT
import com.avsystem.justworks.core.gen.GENERATED_SERIALIZERS_MODULE
import com.avsystem.justworks.core.gen.HTTP_CLIENT
import com.avsystem.justworks.core.gen.HTTP_RESULT
import com.avsystem.justworks.core.gen.HTTP_SUCCESS
import com.avsystem.justworks.core.gen.Hierarchy
import com.avsystem.justworks.core.gen.JSON_ELEMENT
import com.avsystem.justworks.core.gen.NameRegistry
import com.avsystem.justworks.core.gen.client.BodyGenerator.buildFunctionBody
import com.avsystem.justworks.core.gen.client.ParametersGenerator.buildBodyParams
Expand Down Expand Up @@ -112,7 +114,8 @@ internal object ClientGenerator {
private fun generateEndpointFunction(endpoint: Endpoint): FunSpec {
val functionName = methodRegistry.register(endpoint.operationId.toCamelCase())
val returnBodyType = resolveReturnType(endpoint)
val returnType = HTTP_SUCCESS.parameterizedBy(returnBodyType)
val errorType = resolveErrorType(endpoint)
val returnType = HTTP_RESULT.parameterizedBy(errorType, returnBodyType)

val funBuilder = FunSpec
.builder(functionName)
Expand Down Expand Up @@ -163,6 +166,22 @@ internal object ClientGenerator {
return funBuilder.build()
}

context(_: Hierarchy)
private fun resolveErrorType(endpoint: Endpoint): TypeName {
val errorSchemas = endpoint.responses.entries
.asSequence()
.filter { !it.key.startsWith("2") }
.mapNotNull { it.value.schema }
.map { it.toTypeName() }
.distinct()
.toList()

return when {
errorSchemas.size == 1 -> errorSchemas.single()
else -> JSON_ELEMENT
}
}

context(_: Hierarchy)
private fun resolveReturnType(endpoint: Endpoint): TypeName = endpoint.responses.entries
.asSequence()
Expand Down
Loading