Skip to content

Commit 0c0cb14

Browse files
authored
Merge branch 'master' into feat/security-schemes
2 parents 5b03af7 + 9dd51a8 commit 0c0cb14

13 files changed

Lines changed: 1320 additions & 208 deletions

File tree

core/src/main/kotlin/com/avsystem/justworks/core/gen/ModelGenerator.kt

Lines changed: 89 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@ class ModelGenerator(private val modelPackage: String) {
4545

4646
val serializersModuleFile = SerializersModuleGenerator(modelPackage).generate()
4747

48-
schemaFiles + inlineSchemaFiles + enumFiles + listOfNotNull(serializersModuleFile)
48+
val uuidSerializerFile = if (spec.usesUuid()) generateUuidSerializer() else null
49+
50+
schemaFiles + inlineSchemaFiles + enumFiles + listOfNotNull(serializersModuleFile, uuidSerializerFile)
4951
}
5052

5153
data class HierarchyInfo(
@@ -127,7 +129,10 @@ class ModelGenerator(private val modelPackage: String) {
127129
}
128130

129131
schema.isPrimitiveOnly -> {
130-
listOf(generateTypeAlias(schema, STRING))
132+
val targetType = schema.underlyingType
133+
?.let { TypeMapping.toTypeName(it, modelPackage) }
134+
?: STRING
135+
listOf(generateTypeAlias(schema, targetType))
131136
}
132137

133138
else -> {
@@ -316,11 +321,12 @@ class ModelGenerator(private val modelPackage: String) {
316321

317322
constructorBuilder.addParameter(paramBuilder.build())
318323

319-
PropertySpec
324+
val propBuilder = PropertySpec
320325
.builder(kotlinName, type)
321326
.initializer(kotlinName)
322327
.addAnnotation(AnnotationSpec.builder(SERIAL_NAME).addMember("%S", prop.name).build())
323-
.build()
328+
329+
propBuilder.build()
324330
}
325331

326332
val typeSpec = TypeSpec
@@ -339,10 +345,22 @@ class ModelGenerator(private val modelPackage: String) {
339345
typeSpec.addKdoc("%L", schema.description)
340346
}
341347

342-
return FileSpec
343-
.builder(className)
344-
.addType(typeSpec.build())
345-
.build()
348+
val fileBuilder = FileSpec.builder(className).addType(typeSpec.build())
349+
350+
val hasUuid = schema.properties.any { it.type.containsUuid() }
351+
if (hasUuid) {
352+
fileBuilder.addAnnotation(
353+
AnnotationSpec.builder(OPT_IN).addMember("%T::class", EXPERIMENTAL_UUID_API).build(),
354+
)
355+
fileBuilder.addAnnotation(
356+
AnnotationSpec
357+
.builder(USE_SERIALIZERS)
358+
.addMember("%T::class", ClassName(modelPackage, "UuidSerializer"))
359+
.build(),
360+
)
361+
}
362+
363+
return fileBuilder.build()
346364
}
347365

348366
/**
@@ -463,6 +481,69 @@ class ModelGenerator(private val modelPackage: String) {
463481
private val SchemaModel.isPrimitiveOnly: Boolean
464482
get() = properties.isEmpty() && allOf == null && oneOf == null && anyOf == null
465483

484+
private fun TypeRef.containsUuid(): Boolean = when (this) {
485+
is TypeRef.Primitive -> type == PrimitiveType.UUID
486+
is TypeRef.Array -> items.containsUuid()
487+
is TypeRef.Map -> valueType.containsUuid()
488+
is TypeRef.Inline -> properties.any { it.type.containsUuid() }
489+
is TypeRef.Reference, TypeRef.Unknown -> false
490+
}
491+
492+
private fun ApiSpec.usesUuid(): Boolean {
493+
val schemaRefs = schemas.asSequence().flatMap { schema -> schema.properties.map { it.type } }
494+
val endpointRefs = endpoints.asSequence().flatMap { endpoint ->
495+
val responseRefs = endpoint.responses.values
496+
.asSequence()
497+
.mapNotNull { it.schema }
498+
val requestRef = endpoint.requestBody?.schema
499+
val parameterRefs = endpoint.parameters
500+
.asSequence()
501+
.map { it.schema }
502+
responseRefs + listOfNotNull(requestRef) + parameterRefs
503+
}
504+
return schemaRefs.plus(endpointRefs).any { it.containsUuid() }
505+
}
506+
507+
private fun generateUuidSerializer(): FileSpec {
508+
val uuidSerializerClass = ClassName(modelPackage, "UuidSerializer")
509+
510+
val descriptorProp = PropertySpec
511+
.builder("descriptor", SERIAL_DESCRIPTOR)
512+
.addModifiers(KModifier.OVERRIDE)
513+
.initializer("%M(%S, %T.STRING)", PRIMITIVE_SERIAL_DESCRIPTOR_FUN, "Uuid", PRIMITIVE_KIND)
514+
.build()
515+
516+
val serializeFun = FunSpec
517+
.builder("serialize")
518+
.addModifiers(KModifier.OVERRIDE)
519+
.addParameter("encoder", ENCODER)
520+
.addParameter("value", UUID_TYPE)
521+
.addStatement("encoder.encodeString(value.toString())")
522+
.build()
523+
524+
val deserializeFun = FunSpec
525+
.builder("deserialize")
526+
.addModifiers(KModifier.OVERRIDE)
527+
.addParameter("decoder", DECODER)
528+
.returns(UUID_TYPE)
529+
.addStatement("return %T.parse(decoder.decodeString())", UUID_TYPE)
530+
.build()
531+
532+
val objectSpec = TypeSpec
533+
.objectBuilder(uuidSerializerClass)
534+
.addSuperinterface(K_SERIALIZER.parameterizedBy(UUID_TYPE))
535+
.addProperty(descriptorProp)
536+
.addFunction(serializeFun)
537+
.addFunction(deserializeFun)
538+
.build()
539+
540+
return FileSpec
541+
.builder(uuidSerializerClass)
542+
.addAnnotation(AnnotationSpec.builder(OPT_IN).addMember("%T::class", EXPERIMENTAL_UUID_API).build())
543+
.addType(objectSpec)
544+
.build()
545+
}
546+
466547
private fun generateTypeAlias(schema: SchemaModel, primitiveType: TypeName): FileSpec {
467548
val className = ClassName(modelPackage, schema.name)
468549

core/src/main/kotlin/com/avsystem/justworks/core/gen/Names.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ val PATCH_FUN = MemberName("io.ktor.client.request", "patch")
3030
// ============================================================================
3131

3232
val SERIALIZABLE = ClassName("kotlinx.serialization", "Serializable")
33+
val USE_SERIALIZERS = ClassName("kotlinx.serialization", "UseSerializers")
3334
val SERIAL_NAME = ClassName("kotlinx.serialization", "SerialName")
3435
val EXPERIMENTAL_SERIALIZATION_API = ClassName("kotlinx.serialization", "ExperimentalSerializationApi")
3536
val SERIALIZATION_EXCEPTION = ClassName("kotlinx.serialization", "SerializationException")
@@ -41,6 +42,13 @@ val SERIALIZERS_MODULE = ClassName("kotlinx.serialization.modules", "Serializers
4142

4243
val JSON_OBJECT_EXT = MemberName("kotlinx.serialization.json", "jsonObject")
4344

45+
val K_SERIALIZER = ClassName("kotlinx.serialization", "KSerializer")
46+
val SERIAL_DESCRIPTOR = ClassName("kotlinx.serialization.descriptors", "SerialDescriptor")
47+
val PRIMITIVE_SERIAL_DESCRIPTOR_FUN = MemberName("kotlinx.serialization.descriptors", "PrimitiveSerialDescriptor")
48+
val PRIMITIVE_KIND = ClassName("kotlinx.serialization.descriptors", "PrimitiveKind")
49+
val DECODER = ClassName("kotlinx.serialization.encoding", "Decoder")
50+
val ENCODER = ClassName("kotlinx.serialization.encoding", "Encoder")
51+
4452
val ENCODE_TO_STRING_FUN = MemberName("kotlinx.serialization", "encodeToString")
4553
val POLYMORPHIC_FUN = MemberName("kotlinx.serialization.modules", "polymorphic")
4654
val SUBCLASS_FUN = MemberName("kotlinx.serialization.modules", "subclass")
@@ -52,6 +60,13 @@ val SUBCLASS_FUN = MemberName("kotlinx.serialization.modules", "subclass")
5260
val INSTANT = ClassName("kotlin.time", "Instant")
5361
val LOCAL_DATE = ClassName("kotlinx.datetime", "LocalDate")
5462

63+
// ============================================================================
64+
// UUID (kotlin.uuid)
65+
// ============================================================================
66+
67+
val UUID_TYPE = ClassName("kotlin.uuid", "Uuid")
68+
val EXPERIMENTAL_UUID_API = ClassName("kotlin.uuid", "ExperimentalUuidApi")
69+
5570
// ============================================================================
5671
// Error Handling (Arrow + Kotlin stdlib)
5772
// ============================================================================

core/src/main/kotlin/com/avsystem/justworks/core/gen/TypeMapping.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package com.avsystem.justworks.core.gen
22

33
import com.avsystem.justworks.core.model.PrimitiveType
44
import com.avsystem.justworks.core.model.TypeRef
5-
import com.squareup.kotlinpoet.ANY
65
import com.squareup.kotlinpoet.BOOLEAN
76
import com.squareup.kotlinpoet.BYTE_ARRAY
87
import com.squareup.kotlinpoet.ClassName
@@ -32,6 +31,7 @@ object TypeMapping {
3231
PrimitiveType.BYTE_ARRAY -> BYTE_ARRAY
3332
PrimitiveType.DATE_TIME -> INSTANT
3433
PrimitiveType.DATE -> LOCAL_DATE
34+
PrimitiveType.UUID -> UUID_TYPE
3535
}
3636
}
3737

@@ -52,7 +52,7 @@ object TypeMapping {
5252
}
5353

5454
is TypeRef.Unknown -> {
55-
ANY
55+
JSON_ELEMENT
5656
}
5757
}
5858
}

core/src/main/kotlin/com/avsystem/justworks/core/model/ApiSpec.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ data class SchemaModel(
9595
val oneOf: List<TypeRef>?,
9696
val anyOf: List<TypeRef>?,
9797
val discriminator: Discriminator?,
98+
val underlyingType: TypeRef? = null,
9899
) {
99100
val isNested get() = name.contains(".")
100101
}

core/src/main/kotlin/com/avsystem/justworks/core/model/TypeRef.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,4 @@ sealed interface TypeRef {
1818
data object Unknown : TypeRef
1919
}
2020

21-
enum class PrimitiveType { STRING, INT, LONG, DOUBLE, FLOAT, BOOLEAN, BYTE_ARRAY, DATE_TIME, DATE }
21+
enum class PrimitiveType { STRING, INT, LONG, DOUBLE, FLOAT, BOOLEAN, BYTE_ARRAY, DATE_TIME, DATE, UUID }

core/src/main/kotlin/com/avsystem/justworks/core/parser/SpecParser.kt

Lines changed: 64 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.avsystem.justworks.core.parser
22

3+
import arrow.core.compareTo
34
import arrow.core.fold
45
import arrow.core.merge
56
import arrow.core.raise.context.Raise
@@ -124,11 +125,29 @@ object SpecParser {
124125
}
125126
}
126127

128+
// Pick up synthetic schemas added by detectAndUnwrapOneOfWrappers.
129+
// Iterate until stable, since processing a synthetic schema could register more.
130+
tailrec fun collectModels(processed: Set<String>, acc: List<SchemaModel>): List<SchemaModel> {
131+
val currentKeys = componentSchemas.keys - allSchemas.keys - processed
132+
return if (currentKeys.isEmpty()) {
133+
acc
134+
} else {
135+
val newModels = currentKeys
136+
.asSequence()
137+
.mapNotNull { name -> componentSchemas[name]?.let { name to it } }
138+
.filterNot { (_, schema) -> schema.isEnumSchema }
139+
.map { (name, schema) -> extractSchemaModel(name, schema) }
140+
141+
collectModels(processed + currentKeys, acc + newModels)
142+
}
143+
}
144+
145+
val syntheticModels = collectModels(emptySet(), emptyList())
127146
return ApiSpec(
128147
title = info?.title ?: "Untitled",
129148
version = info?.version ?: "0.0.0",
130149
endpoints = endpoints,
131-
schemas = schemaModels,
150+
schemas = schemaModels + syntheticModels,
132151
enums = enumModels,
133152
securitySchemes = securitySchemes,
134153
)
@@ -261,6 +280,14 @@ object SpecParser {
261280
Discriminator(propertyName = propertyName, mapping = disc.mapping.orEmpty())
262281
}
263282

283+
// Resolve underlying type for primitive-only / $ref-wrapper schemas.
284+
// Uses $ref for wrapper schemas, otherwise resolves structurally
285+
// from type/format to bypass componentSchemaIdentity (which would self-reference).
286+
val underlyingType = schema
287+
.takeIf { properties.isEmpty() && allOf.isNullOrEmpty() && oneOf.isNullOrEmpty() && anyOf.isNullOrEmpty() }
288+
?.let { s -> s.`$ref`?.removePrefix(SCHEMA_PREFIX)?.let(TypeRef::Reference) ?: s.resolveByType() }
289+
?.takeUnless { it is TypeRef.Unknown }
290+
264291
return SchemaModel(
265292
name = name,
266293
description = schema.description,
@@ -270,6 +297,7 @@ object SpecParser {
270297
oneOf = oneOf?.let { it.map(TypeRef::Reference).ifEmpty { null } },
271298
anyOf = anyOf?.let { it.map(TypeRef::Reference).ifEmpty { null } },
272299
discriminator = discriminator,
300+
underlyingType = underlyingType,
273301
)
274302
}
275303

@@ -332,12 +360,14 @@ object SpecParser {
332360
)
333361

334362
val schemaName = ensureNotNull(
335-
propertySchema.resolveName() ?: propertyName
336-
.takeIf { propertySchema.isInlineObject }
337-
?.also { name ->
338-
componentSchemas[name] = propertySchema
339-
componentSchemaIdentity[propertySchema] = name
340-
},
363+
propertySchema.resolveName()
364+
?: propertyName
365+
.takeIf { propertySchema.isInlineObject }
366+
?.let { rawName ->
367+
componentSchemas[rawName] = propertySchema
368+
componentSchemaIdentity[propertySchema] = rawName
369+
rawName
370+
},
341371
)
342372

343373
propertyName to schemaName
@@ -353,26 +383,30 @@ object SpecParser {
353383
private fun Schema<*>.toTypeRef(contextName: String? = null): TypeRef = contextName?.let { toInlineTypeRef(it) }
354384
?: (resolveName() ?: allOf?.singleOrNull()?.resolveName())?.let(TypeRef::Reference)
355385
?: TypeRef.Unknown.takeIf { (allOf?.size ?: 0) > 1 }
356-
?: when (type) {
357-
"string" -> STRING_FORMAT_MAP[format] ?: TypeRef.Primitive(PrimitiveType.STRING)
386+
?: resolveByType(contextName)
358387

359-
"integer" -> INTEGER_FORMAT_MAP[format] ?: TypeRef.Primitive(PrimitiveType.INT)
388+
/** Resolves a [TypeRef] based on the schema's structural type/format, ignoring component identity. */
389+
context(_: ComponentSchemaIdentity, _: ComponentSchemas)
390+
private fun Schema<*>.resolveByType(contextName: String? = null): TypeRef = when (type) {
391+
"string" -> STRING_FORMAT_MAP[format] ?: TypeRef.Primitive(PrimitiveType.STRING)
360392

361-
"number" -> NUMBER_FORMAT_MAP[format] ?: TypeRef.Primitive(PrimitiveType.DOUBLE)
393+
"integer" -> INTEGER_FORMAT_MAP[format] ?: TypeRef.Primitive(PrimitiveType.INT)
362394

363-
"boolean" -> TypeRef.Primitive(PrimitiveType.BOOLEAN)
395+
"number" -> NUMBER_FORMAT_MAP[format] ?: TypeRef.Primitive(PrimitiveType.DOUBLE)
364396

365-
"array" -> TypeRef.Array(items?.toTypeRef(contextName?.let { "${it}Item" }) ?: TypeRef.Unknown)
397+
"boolean" -> TypeRef.Primitive(PrimitiveType.BOOLEAN)
366398

367-
"object" -> when (val ap = additionalProperties) {
368-
is Schema<*> -> TypeRef.Map(ap.toTypeRef())
369-
is Boolean -> if (ap) TypeRef.Map(TypeRef.Unknown) else TypeRef.Unknown
370-
else -> title?.let(TypeRef::Reference) ?: TypeRef.Unknown
371-
}
399+
"array" -> TypeRef.Array(items?.toTypeRef(contextName?.let { "${it}Item" }) ?: TypeRef.Unknown)
372400

373-
else -> TypeRef.Unknown
401+
"object" -> when (val ap = additionalProperties) {
402+
is Schema<*> -> TypeRef.Map(ap.toTypeRef())
403+
is Boolean -> if (ap) TypeRef.Map(TypeRef.Unknown) else TypeRef.Unknown
404+
else -> title?.let(TypeRef::Reference) ?: TypeRef.Unknown
374405
}
375406

407+
else -> TypeRef.Unknown
408+
}
409+
376410
context(_: ComponentSchemaIdentity, _: ComponentSchemas)
377411
private fun Schema<*>.toInlineTypeRef(contextName: String): TypeRef? = takeIf { isInlineObject }?.let {
378412
val required = required.orEmpty().toSet()
@@ -429,15 +463,26 @@ object SpecParser {
429463

430464
private val STRING_FORMAT_MAP = mapOf(
431465
"byte" to TypeRef.Primitive(PrimitiveType.BYTE_ARRAY),
466+
"binary" to TypeRef.Primitive(PrimitiveType.BYTE_ARRAY),
432467
"date-time" to TypeRef.Primitive(PrimitiveType.DATE_TIME),
433468
"date" to TypeRef.Primitive(PrimitiveType.DATE),
469+
"uuid" to TypeRef.Primitive(PrimitiveType.UUID),
470+
"uri" to TypeRef.Primitive(PrimitiveType.STRING),
471+
"url" to TypeRef.Primitive(PrimitiveType.STRING),
472+
"email" to TypeRef.Primitive(PrimitiveType.STRING),
473+
"hostname" to TypeRef.Primitive(PrimitiveType.STRING),
474+
"ipv4" to TypeRef.Primitive(PrimitiveType.STRING),
475+
"ipv6" to TypeRef.Primitive(PrimitiveType.STRING),
476+
"password" to TypeRef.Primitive(PrimitiveType.STRING),
434477
)
435478

436479
private val INTEGER_FORMAT_MAP = mapOf(
480+
"int32" to TypeRef.Primitive(PrimitiveType.INT),
437481
"int64" to TypeRef.Primitive(PrimitiveType.LONG),
438482
)
439483

440484
private val NUMBER_FORMAT_MAP = mapOf(
441485
"float" to TypeRef.Primitive(PrimitiveType.FLOAT),
486+
"double" to TypeRef.Primitive(PrimitiveType.DOUBLE),
442487
)
443488
}

0 commit comments

Comments
 (0)