Skip to content

Commit 7710f91

Browse files
committed
GH-246 Support additionalProperties & examples in @OpenApiContent (Resolve #246)
1 parent 7618ace commit 7710f91

File tree

4 files changed

+195
-110
lines changed

4 files changed

+195
-110
lines changed

examples/javalin-gradle-kotlin/src/main/java/io/javalin/openapi/plugin/test/JavalinTest.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,21 @@ public static void main(String[] args) {
141141
description = "Supports multiple request bodies",
142142
content = {
143143
@OpenApiContent(from = String.class, example = "value"), // simple type
144+
@OpenApiContent(from = String[].class, example = "value"), // array of simple types
145+
@OpenApiContent( // map of simple types
146+
mimeType = "application/map-string-string",
147+
additionalProperties = @OpenApiAdditionalContent(
148+
from = String.class,
149+
exampleObjects = {
150+
@OpenApiExampleProperty(name = "en", value = "hey"),
151+
@OpenApiExampleProperty(name = "pl", value = "hejka tu lenka"),
152+
}
153+
)
154+
),
155+
@OpenApiContent( // map of complex types
156+
mimeType = "application/map-string-object",
157+
additionalProperties = @OpenApiAdditionalContent(from = Foo.class)
158+
),
144159
@OpenApiContent(from = KotlinEntity.class, mimeType = "app/barbie", exampleObjects = {
145160
@OpenApiExampleProperty(name = "name", value = "Margot Robbie")
146161
}), // kotlin

openapi-annotation-processor/src/main/kotlin/io/javalin/openapi/processor/generators/OpenApiGenerator.kt

Lines changed: 128 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,11 @@ package io.javalin.openapi.processor.generators
33
import com.google.gson.JsonArray
44
import com.google.gson.JsonObject
55
import io.javalin.http.HttpStatus
6-
import io.javalin.openapi.ContentType.AUTODETECT
7-
import io.javalin.openapi.HttpMethod
8-
import io.javalin.openapi.NULL_CLASS
9-
import io.javalin.openapi.NULL_STRING
10-
import io.javalin.openapi.OpenApi
11-
import io.javalin.openapi.OpenApiContent
6+
import io.javalin.openapi.*
127
import io.javalin.openapi.OpenApiOperation.AUTO_GENERATE
13-
import io.javalin.openapi.OpenApiParam
14-
import io.javalin.openapi.OpenApiRequestBody
15-
import io.javalin.openapi.OpenApiResponse
16-
import io.javalin.openapi.OpenApis
178
import io.javalin.openapi.experimental.ClassDefinition
189
import io.javalin.openapi.experimental.StructureType.ARRAY
1910
import io.javalin.openapi.experimental.processor.generators.ExampleGenerator
20-
import io.javalin.openapi.experimental.processor.generators.ExampleGenerator.toExampleProperty
2111
import io.javalin.openapi.experimental.processor.shared.addIfNotEmpty
2212
import io.javalin.openapi.experimental.processor.shared.addString
2313
import io.javalin.openapi.experimental.processor.shared.computeIfAbsent
@@ -26,7 +16,6 @@ import io.javalin.openapi.experimental.processor.shared.info
2616
import io.javalin.openapi.experimental.processor.shared.saveResource
2717
import io.javalin.openapi.experimental.processor.shared.toJsonArray
2818
import io.javalin.openapi.experimental.processor.shared.toPrettyString
29-
import io.javalin.openapi.getFormattedPath
3019
import io.javalin.openapi.processor.OpenApiAnnotationProcessor.Companion.context
3120
import io.javalin.openapi.processor.generators.OpenApiGenerator.In.COOKIE
3221
import io.javalin.openapi.processor.generators.OpenApiGenerator.In.FORM_DATA
@@ -381,32 +370,46 @@ internal class OpenApiGenerator {
381370
it.titlecase(Locale.getDefault())
382371
}
383372

384-
private fun JsonObject.addContent(element: Element, contentAnnotations: Array<OpenApiContent>) = context.inContext {
385-
val requestBodyContent = JsonObject()
386-
val requestBodySchemes = TreeMap<String, JsonObject>()
387-
388-
for (contentAnnotation in contentAnnotations) {
389-
val from = contentAnnotation.getTypeMirror { from }
390-
val format = contentAnnotation.format.takeIf { it != NULL_STRING }
391-
val properties = contentAnnotation.properties
392-
var type = contentAnnotation.type.takeIf { it != NULL_STRING }
393-
var mimeType = contentAnnotation.mimeType.takeIf { it != AUTODETECT }
394-
val example = contentAnnotation.example.takeIf { it != NULL_STRING }
395-
val exampleObjects = contentAnnotation.exampleObjects.map { it.toExampleProperty() }
396-
397-
if (mimeType == null) {
398-
when (NULL_CLASS::class.qualifiedName) {
399-
// Use 'type` as `mimeType` if there's no other mime-type declaration in @OpenApiContent annotation
400-
// ~ https://github.com/javalin/javalin-openapi/issues/88
401-
from.getFullName() -> {
402-
mimeType = type
403-
type = null
373+
private fun JsonObject.addContent(element: Element, contentAnnotations: Array<OpenApiContent>) =
374+
context.inContext {
375+
val requestBodyContent = JsonObject()
376+
val requestBodySchemes = TreeMap<String, JsonObject>()
377+
378+
for (contentAnnotation in contentAnnotations) {
379+
val (mimeType, mediaTypeSchema) =
380+
contentAnnotation
381+
.toData()
382+
.toMimeTypeSchema(element, contentAnnotation)
383+
?: continue
384+
385+
requestBodySchemes[mimeType] = mediaTypeSchema
386+
}
387+
388+
requestBodySchemes.forEach { (mimeType, scheme) ->
389+
requestBodyContent.add(mimeType, scheme)
390+
}
391+
392+
if (requestBodyContent.size() > 0) {
393+
add("content", requestBodyContent)
394+
}
395+
}
396+
397+
private fun OpenApiContentData.toMimeTypeSchema(element: Element, source: Annotation): Pair<String, JsonObject>? =
398+
context.inContext {
399+
var contentData = this@toMimeTypeSchema
400+
val from = source.getTypeMirror { contentData.from() }
401+
402+
if (contentData.mimeType == null) {
403+
contentData =
404+
when (NULL_CLASS::class.qualifiedName) {
405+
// Use 'type` as `mimeType` if there's no other mime-type declaration in @OpenApiContent annotation
406+
// ~ https://github.com/javalin/javalin-openapi/issues/88
407+
from.getFullName() -> contentData.copy(mimeType = type, type = null)
408+
else -> contentData.copy(mimeType = detectContentType(from))
404409
}
405-
else -> mimeType = detectContentType(from)
406-
}
407410
}
408411

409-
if (mimeType == null) {
412+
if (contentData.mimeType == null) {
410413
val trees = context.trees
411414

412415
if (trees != null) {
@@ -421,106 +424,122 @@ internal class OpenApiGenerator {
421424
Source:
422425
Annotation in ${compilationUnit.lineMap.getLineNumber(startPosition)} at ${compilationUnit.sourceFile.name} line
423426
Annotation:
424-
$contentAnnotation
427+
$source
425428
""".trimIndent()
426429
)
427430
}
428431

429-
continue
432+
return@inContext null
430433
}
431434

432-
val schema: JsonObject = when {
433-
properties.isEmpty() && from.getFullName() != NULL_CLASS::class.java.name ->
434-
createTypeDescriptionWithReferences(from)
435-
properties.isEmpty() -> {
436-
val schema = JsonObject()
437-
type?.also { schema.addProperty("type", it) }
438-
format?.also { schema.addProperty("format", it) }
439-
schema
440-
}
441-
else -> {
442-
val schema = JsonObject()
443-
val propertiesSchema = JsonObject()
444-
schema.addProperty("type", "object")
435+
val mediaType = JsonObject()
436+
val mediaTypeSchema = contentData.toTypeSchema(source)
445437

446-
for (contentProperty in properties) {
447-
val propertyFormat = contentProperty.format.takeIf { it != NULL_STRING }
438+
if (mediaTypeSchema.size() > 0) {
439+
mediaType.add("schema", mediaTypeSchema)
440+
}
441+
mediaType.addContentExample(contentData)
448442

449-
val contentPropertyFrom = contentAnnotation.getTypeMirror { contentProperty.from }
450-
val propertyScheme = if (contentPropertyFrom.getFullName() != NULL_CLASS::class.java.name) {
451-
createTypeDescriptionWithReferences(contentPropertyFrom)
452-
} else {
453-
JsonObject().apply {
454-
addProperty("type", contentProperty.type)
455-
propertyFormat?.let { addProperty("format", it) }
456-
}
457-
}
443+
return@inContext contentData.mimeType!! to mediaType
444+
}
458445

459-
propertiesSchema.add(contentProperty.name,
460-
if (contentProperty.isArray) {
461-
// wrap into OpenAPI array object
462-
JsonObject().apply {
463-
addProperty("type", "array")
464-
add("items", propertyScheme)
465-
}
466-
} else {
467-
propertyScheme
468-
}
469-
)
470-
}
446+
private fun JsonObject.addContentExample(contentData: OpenApiContentData) {
447+
if (contentData.example != null) {
448+
addProperty("example", contentData.example)
449+
}
471450

472-
schema.add("properties", propertiesSchema)
473-
schema
474-
}
451+
if (contentData.exampleObjects != null) {
452+
val generatorResult = ExampleGenerator.generateFromExamples(contentData.exampleObjects!!)
453+
454+
when {
455+
generatorResult.simpleValue != null -> addProperty("example", generatorResult.simpleValue)
456+
generatorResult.jsonElement != null -> add("example", generatorResult.jsonElement)
475457
}
458+
}
476459

477-
val mediaType = JsonObject()
460+
}
478461

479-
if (schema.size() > 0) {
480-
mediaType.add("schema", schema)
481-
}
462+
private fun OpenApiContentData.toTypeSchema(source: Annotation): JsonObject = context.inContext {
463+
val from = source.getTypeMirror { from() }
482464

483-
if (example != null) {
484-
mediaType.addProperty("example", example)
465+
when {
466+
properties == null && additionalProperties == null && from.getFullName() != NULL_CLASS::class.java.name ->
467+
createTypeDescriptionWithReferences(from)
468+
properties == null && additionalProperties == null -> {
469+
val schema = JsonObject()
470+
type?.also { schema.addProperty("type", it) }
471+
format?.also { schema.addProperty("format", it) }
472+
schema
485473
}
474+
else -> {
475+
val schema = JsonObject()
476+
schema.addProperty("type", "object")
486477

487-
if (exampleObjects.isNotEmpty()) {
488-
val generatorResult = ExampleGenerator.generateFromExamples(exampleObjects)
478+
if (properties != null) {
479+
schema.add("properties", properties!!.toTypeSchema())
480+
}
489481

490-
when {
491-
generatorResult.simpleValue != null -> mediaType.addProperty("example", generatorResult.simpleValue)
492-
generatorResult.jsonElement != null -> mediaType.add("example", generatorResult.jsonElement)
482+
additionalProperties?.let {
483+
val additionalPropertiesData = it.toData()
484+
schema.add("additionalProperties", additionalPropertiesData.toTypeSchema(it))
485+
schema.addContentExample(additionalPropertiesData)
493486
}
487+
schema
494488
}
495-
496-
requestBodySchemes[mimeType] = mediaType
497489
}
490+
}
498491

499-
requestBodySchemes.forEach { (mimeType, scheme) ->
500-
requestBodyContent.add(mimeType, scheme)
501-
}
492+
private fun Collection<OpenApiContentProperty>.toTypeSchema(): JsonObject =
493+
context.inContext {
494+
val propertiesSchema = JsonObject()
495+
496+
for (contentProperty in this@toTypeSchema) {
497+
val propertyFormat = contentProperty.format.takeIf { it != NULL_STRING }
498+
499+
val contentPropertyFrom = contentProperty.getTypeMirror { contentProperty.from }
500+
val propertyScheme = if (contentPropertyFrom.getFullName() != NULL_CLASS::class.java.name) {
501+
createTypeDescriptionWithReferences(contentPropertyFrom)
502+
} else {
503+
JsonObject().apply {
504+
addProperty("type", contentProperty.type)
505+
propertyFormat?.let { addProperty("format", it) }
506+
}
507+
}
502508

503-
if (requestBodyContent.size() > 0) {
504-
add("content", requestBodyContent)
509+
propertiesSchema.add(contentProperty.name,
510+
if (contentProperty.isArray) {
511+
// wrap into OpenAPI array object
512+
JsonObject().apply {
513+
addProperty("type", "array")
514+
add("items", propertyScheme)
515+
}
516+
} else {
517+
propertyScheme
518+
}
519+
)
520+
}
521+
522+
propertiesSchema
505523
}
506-
}
507524

508-
private fun detectContentType(typeMirror: TypeMirror): String = context.inContext {
509-
val model = typeMirror.toClassDefinition()
525+
private fun detectContentType(typeMirror: TypeMirror): String =
526+
context.inContext {
527+
val model = typeMirror.toClassDefinition()
510528

511-
when {
512-
(model.structureType == ARRAY && model.simpleName == "Byte") || model.simpleName == "[B" || model.simpleName == "File" -> "application/octet-stream"
513-
model.structureType == ARRAY -> "application/json"
514-
model.simpleName == "String" -> "text/plain"
515-
else -> "application/json"
529+
when {
530+
(model.structureType == ARRAY && model.simpleName == "Byte") || model.simpleName == "[B" || model.simpleName == "File" -> "application/octet-stream"
531+
model.structureType == ARRAY -> "application/json"
532+
model.simpleName == "String" -> "text/plain"
533+
else -> "application/json"
534+
}
516535
}
517-
}
518536

519-
private fun createTypeDescriptionWithReferences(type: TypeMirror): JsonObject = context.inContext {
520-
val model = type.toClassDefinition()
521-
val (json, references) = context.typeSchemaGenerator.createEmbeddedTypeDescription(model)
522-
componentReferences.putAll(references.associateBy { it.fullName })
523-
json
524-
}
537+
private fun createTypeDescriptionWithReferences(type: TypeMirror): JsonObject =
538+
context.inContext {
539+
val model = type.toClassDefinition()
540+
val (json, references) = context.typeSchemaGenerator.createEmbeddedTypeDescription(model)
541+
componentReferences.putAll(references.associateBy { it.fullName })
542+
json
543+
}
525544

526545
}

openapi-specification/src/main/kotlin/io/javalin/openapi/OpenApiAnnotations.kt

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ package io.javalin.openapi
77

88
import io.javalin.openapi.HttpMethod.GET
99
import io.javalin.openapi.Visibility.PUBLIC
10+
import io.javalin.openapi.experimental.processor.generators.ExampleGenerator
11+
import io.javalin.openapi.experimental.processor.generators.ExampleGenerator.toExampleProperty
1012
import java.lang.annotation.Repeatable
1113
import kotlin.annotation.AnnotationRetention.RUNTIME
1214
import kotlin.annotation.AnnotationTarget.ANNOTATION_CLASS
@@ -132,6 +134,17 @@ annotation class OpenApiCallback(
132134
val responses: Array<OpenApiResponse>
133135
)
134136

137+
data class OpenApiContentData(
138+
val from: (() -> KClass<*>),
139+
val mimeType: String?,
140+
val type: String?,
141+
val format: String?,
142+
val properties: List<OpenApiContentProperty>?,
143+
val additionalProperties: OpenApiAdditionalContent?,
144+
val example: String?,
145+
val exampleObjects: List<ExampleGenerator.ExampleProperty>?,
146+
)
147+
135148
@Target()
136149
@Retention(RUNTIME)
137150
annotation class OpenApiContent(
@@ -140,10 +153,48 @@ annotation class OpenApiContent(
140153
val type: String = NULL_STRING,
141154
val format: String = NULL_STRING,
142155
val properties: Array<OpenApiContentProperty> = [],
156+
val additionalProperties: OpenApiAdditionalContent = OpenApiAdditionalContent(_ignored = true),
157+
val example: String = NULL_STRING,
158+
val exampleObjects: Array<OpenApiExampleProperty> = [],
159+
)
160+
161+
fun OpenApiContent.toData(): OpenApiContentData =
162+
OpenApiContentData(
163+
from = { from },
164+
mimeType = mimeType.takeIf { it != ContentType.AUTODETECT },
165+
type = type.takeIf { it != NULL_STRING },
166+
format = format.takeIf { it != NULL_STRING },
167+
properties = properties.takeIf { it.isNotEmpty() }?.toList(),
168+
additionalProperties = additionalProperties.takeIf { !it._ignored },
169+
example = example.takeIf { it != NULL_STRING },
170+
exampleObjects = exampleObjects.takeIf { it.isNotEmpty() }?.map { it.toExampleProperty() },
171+
)
172+
173+
@Target()
174+
@Retention(RUNTIME)
175+
annotation class OpenApiAdditionalContent(
176+
val from: KClass<*> = NULL_CLASS::class,
177+
val type: String = NULL_STRING,
178+
val format: String = NULL_STRING,
179+
val properties: Array<OpenApiContentProperty> = [],
143180
val example: String = NULL_STRING,
144181
val exampleObjects: Array<OpenApiExampleProperty> = [],
182+
@Suppress("PropertyName")
183+
val _ignored: Boolean = false,
145184
)
146185

186+
fun OpenApiAdditionalContent.toData(): OpenApiContentData =
187+
OpenApiContentData(
188+
from = { from },
189+
mimeType = null,
190+
type = type.takeIf { it != NULL_STRING },
191+
format = format.takeIf { it != NULL_STRING },
192+
properties = properties.takeIf { it.isNotEmpty() }?.toList(),
193+
additionalProperties = null,
194+
example = example.takeIf { it != NULL_STRING },
195+
exampleObjects = exampleObjects.takeIf { it.isNotEmpty() }?.map { it.toExampleProperty() },
196+
)
197+
147198
@Target()
148199
@Retention(RUNTIME)
149200
annotation class OpenApiContentProperty(

0 commit comments

Comments
 (0)