Skip to content

Commit dba7aee

Browse files
authored
Kotlinx polymorphism with custom discriminator support (#21531)
* kotlinx serialization fixes - added new config with kotlinx, discriminator (/w custom name) and kotlinx_serialization - remove discriminator properties from the generator in both base and derived classes - set discriminatorValue in additionalProperties of derived classes - add JsonClassDiscriminator the derived classes in the template - set SerialName to discriminatorValue in the template - change base classes to sealed class instead of interface - make variables in base classes abstract * Generated kotlin-allOff-discriminator-kotlinx-serialization sample * Added test for kotlinx_serialization with discriminator * renamed yaml * Added new sample to github workflow * Added comments to KotlinClientCodegen::postProcessAllModels
1 parent d7a8aae commit dba7aee

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+1952
-4
lines changed

.github/workflows/samples-kotlin-client.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ jobs:
6666
- samples/client/petstore/kotlin-name-parameter-mappings
6767
- samples/client/others/kotlin-jvm-okhttp-parameter-tests
6868
- samples/client/others/kotlin-jvm-okhttp-path-comments
69+
- samples/client/petstore/kotlin-allOff-discriminator-kotlinx-serialization
6970
steps:
7071
- uses: actions/checkout@v4
7172
- uses: actions/setup-java@v4
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
generatorName: kotlin
2+
outputDir: samples/client/petstore/kotlin-allOff-discriminator-kotlinx-serialization
3+
inputSpec: modules/openapi-generator/src/test/resources/3_0/kotlin/polymorphism.yaml
4+
templateDir: modules/openapi-generator/src/main/resources/kotlin-client
5+
additionalProperties:
6+
artifactId: kotlin-allOff-discriminator
7+
serializableModel: "false"
8+
dateLibrary: java8
9+
enumUnknownDefaultCase: true
10+
serializationLibrary: kotlinx_serialization

modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/KotlinClientCodegen.java

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -963,6 +963,44 @@ public ModelsMap postProcessModels(ModelsMap objs) {
963963
return objects;
964964
}
965965

966+
@Override
967+
public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs) {
968+
objs = super.postProcessAllModels(objs);
969+
if (getSerializationLibrary() == SERIALIZATION_LIBRARY_TYPE.kotlinx_serialization) {
970+
// The loop removes unneeded variables so commas are handled correctly in the related templates
971+
for (Map.Entry<String, ModelsMap> modelsMap : objs.entrySet()) {
972+
for (ModelMap mo : modelsMap.getValue().getModels()) {
973+
CodegenModel cm = mo.getModel();
974+
CodegenDiscriminator discriminator = cm.getDiscriminator();
975+
if (discriminator == null) {
976+
continue;
977+
}
978+
// Remove discriminator property from the base class, it is not needed in the generated code
979+
getAllVarProperties(cm).forEach(list -> list.removeIf(var -> var.name == discriminator.getPropertyName()));
980+
981+
for (CodegenDiscriminator.MappedModel mappedModel : discriminator.getMappedModels()) {
982+
// Add the mapping name to additionalProperties.disciminatorValue
983+
// The mapping name is used to define SerializedName, which in result makes derived classes
984+
// found by kotlinx-serialization during deserialization
985+
CodegenProperty additionalProperties = mappedModel.getModel().getAdditionalProperties();
986+
if (additionalProperties == null) {
987+
additionalProperties = new CodegenProperty();
988+
mappedModel.getModel().setAdditionalProperties(additionalProperties);
989+
}
990+
additionalProperties.discriminatorValue = mappedModel.getMappingName();
991+
// Remove the discriminator property from the derived class, it is not needed in the generated code
992+
getAllVarProperties(mappedModel.getModel()).forEach(list -> list.removeIf(prop -> prop.name == discriminator.getPropertyName()));
993+
}
994+
995+
}
996+
}
997+
}
998+
return objs;
999+
}
1000+
private Stream<List<CodegenProperty>> getAllVarProperties(CodegenModel model) {
1001+
return Stream.of(model.vars, model.allVars, model.optionalVars, model.requiredVars, model.readOnlyVars, model.readWriteVars);
1002+
}
1003+
9661004
private boolean usesRetrofit2Library() {
9671005
return getLibrary() != null && getLibrary().contains(JVM_RETROFIT2);
9681006
}

modules/openapi-generator/src/main/resources/kotlin-client/data_class.mustache

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ import kotlinx.serialization.encoding.Encoder
4040
{{/enumUnknownDefaultCase}}
4141
{{#hasEnums}}
4242
{{/hasEnums}}
43+
{{#discriminator}}
44+
import kotlinx.serialization.ExperimentalSerializationApi
45+
import kotlinx.serialization.json.JsonClassDiscriminator
46+
{{/discriminator}}
4347
{{/kotlinx_serialization}}
4448
{{#parcelizeModels}}
4549
import android.os.Parcelable
@@ -78,13 +82,21 @@ import {{packageName}}.infrastructure.ITransformForStorage
7882
{{#vendorExtensions.x-class-extra-annotation}}
7983
{{{vendorExtensions.x-class-extra-annotation}}}
8084
{{/vendorExtensions.x-class-extra-annotation}}
81-
{{#nonPublicApi}}internal {{/nonPublicApi}}{{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}{{#discriminator}}interface{{/discriminator}}{{^discriminator}}{{#hasVars}}data {{/hasVars}}class{{/discriminator}} {{classname}}{{^discriminator}} (
85+
{{#kotlinx_serialization}}{{#discriminator}}
86+
@OptIn(ExperimentalSerializationApi::class)
87+
@JsonClassDiscriminator(discriminator = "{{{discriminator.propertyName}}}")
88+
{{/discriminator}}
89+
{{#additionalProperties.discriminatorValue}}
90+
@SerialName(value = {{#lambda.doublequote}}{{{additionalProperties.discriminatorValue}}}{{/lambda.doublequote}})
91+
{{/additionalProperties.discriminatorValue}}
92+
{{/kotlinx_serialization}}
93+
{{#nonPublicApi}}internal {{/nonPublicApi}}{{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}{{#discriminator}}{{#kotlinx_serialization}}sealed class{{/kotlinx_serialization}}{{^kotlinx_serialization}}interface{{/kotlinx_serialization}}{{/discriminator}}{{^discriminator}}{{#hasVars}}data {{/hasVars}}class{{/discriminator}} {{classname}}{{^discriminator}} (
8294

8395
{{#allVars}}
8496
{{#required}}{{>data_class_req_var}}{{/required}}{{^required}}{{>data_class_opt_var}}{{/required}}{{^-last}},{{/-last}}
8597

8698
{{/allVars}}
87-
){{/discriminator}}{{#parent}}{{^serializableModel}}{{^parcelizeModels}} : {{{parent}}}{{#isMap}}(){{/isMap}}{{#isArray}}(){{/isArray}}{{/parcelizeModels}}{{/serializableModel}}{{/parent}}{{#parent}}{{#serializableModel}}{{^parcelizeModels}} : {{{parent}}}{{#isMap}}(){{/isMap}}{{#isArray}}(){{/isArray}}, Serializable{{/parcelizeModels}}{{/serializableModel}}{{/parent}}{{#parent}}{{^serializableModel}}{{#parcelizeModels}} : {{{parent}}}{{#isMap}}(){{/isMap}}{{#isArray}}(){{/isArray}}, Parcelable{{/parcelizeModels}}{{/serializableModel}}{{/parent}}{{#parent}}{{#serializableModel}}{{#parcelizeModels}} : {{{parent}}}{{#isMap}}(){{/isMap}}{{#isArray}}(){{/isArray}}, Serializable, Parcelable{{/parcelizeModels}}{{/serializableModel}}{{/parent}}{{^parent}}{{#serializableModel}}{{^parcelizeModels}} : Serializable{{/parcelizeModels}}{{/serializableModel}}{{/parent}}{{^parent}}{{^serializableModel}}{{#parcelizeModels}} : Parcelable{{/parcelizeModels}}{{/serializableModel}}{{/parent}}{{^parent}}{{#serializableModel}}{{#parcelizeModels}} : Serializable, Parcelable{{/parcelizeModels}}{{/serializableModel}}{{/parent}}{{#generateRoomModels}}{{#parent}}, {{/parent}}{{^discriminator}}{{^parent}}:{{/parent}} ITransformForStorage<{{classname}}RoomModel>{{/discriminator}}{{/generateRoomModels}}{{#vendorExtensions.x-has-data-class-body}} {
99+
){{/discriminator}}{{#parent}}{{^serializableModel}}{{^parcelizeModels}} : {{{parent}}}{{#isMap}}(){{/isMap}}{{#kotlinx_serialization}}(){{/kotlinx_serialization}}{{#isArray}}(){{/isArray}}{{/parcelizeModels}}{{/serializableModel}}{{/parent}}{{#parent}}{{#serializableModel}}{{^parcelizeModels}} : {{{parent}}}{{#isMap}}(){{/isMap}}{{#isArray}}(){{/isArray}}, Serializable{{/parcelizeModels}}{{/serializableModel}}{{/parent}}{{#parent}}{{^serializableModel}}{{#parcelizeModels}} : {{{parent}}}{{#isMap}}(){{/isMap}}{{#isArray}}(){{/isArray}}, Parcelable{{/parcelizeModels}}{{/serializableModel}}{{/parent}}{{#parent}}{{#serializableModel}}{{#parcelizeModels}} : {{{parent}}}{{#isMap}}(){{/isMap}}{{#isArray}}(){{/isArray}}, Serializable, Parcelable{{/parcelizeModels}}{{/serializableModel}}{{/parent}}{{^parent}}{{#serializableModel}}{{^parcelizeModels}} : Serializable{{/parcelizeModels}}{{/serializableModel}}{{/parent}}{{^parent}}{{^serializableModel}}{{#parcelizeModels}} : Parcelable{{/parcelizeModels}}{{/serializableModel}}{{/parent}}{{^parent}}{{#serializableModel}}{{#parcelizeModels}} : Serializable, Parcelable{{/parcelizeModels}}{{/serializableModel}}{{/parent}}{{#generateRoomModels}}{{#parent}}, {{/parent}}{{^discriminator}}{{^parent}}:{{/parent}} ITransformForStorage<{{classname}}RoomModel>{{/discriminator}}{{/generateRoomModels}}{{#vendorExtensions.x-has-data-class-body}} {
88100
{{/vendorExtensions.x-has-data-class-body}}
89101
{{#generateRoomModels}}
90102
companion object { }

modules/openapi-generator/src/main/resources/kotlin-client/interface_opt_var.mustache

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,4 @@
1515
{{^isEnum}}{{^isArray}}{{^isPrimitiveType}}{{^isModel}}@Contextual {{/isModel}}{{/isPrimitiveType}}{{/isArray}}{{/isEnum}}@SerialName(value = "{{{vendorExtensions.x-base-name-literal}}}")
1616
{{/kotlinx_serialization}}
1717
{{/multiplatform}}
18-
{{#multiplatform}}@SerialName(value = "{{{vendorExtensions.x-base-name-literal}}}") {{/multiplatform}}{{>modelMutable}} {{{name}}}: {{#isArray}}{{#isList}}kotlin.collections.{{#modelMutable}}Mutable{{/modelMutable}}List{{/isList}}{{^isList}}kotlin.Array{{/isList}}<{{^items.isEnum}}{{^items.isPrimitiveType}}{{^items.isModel}}{{#kotlinx_serialization}}@Contextual {{/kotlinx_serialization}}{{/items.isModel}}{{/items.isPrimitiveType}}{{{items.dataType}}}{{/items.isEnum}}{{#items.isEnum}}{{classname}}.{{{nameInPascalCase}}}{{/items.isEnum}}>{{/isArray}}{{^isEnum}}{{^isArray}}{{{dataType}}}{{/isArray}}{{/isEnum}}{{#isEnum}}{{^isArray}}{{classname}}.{{{nameInPascalCase}}}{{/isArray}}{{/isEnum}}?
18+
{{#multiplatform}}@SerialName(value = "{{{vendorExtensions.x-base-name-literal}}}") {{/multiplatform}}{{#kotlinx_serialization}}abstract {{/kotlinx_serialization}}{{>modelMutable}} {{{name}}}: {{#isArray}}{{#isList}}kotlin.collections.{{#modelMutable}}Mutable{{/modelMutable}}List{{/isList}}{{^isList}}kotlin.Array{{/isList}}<{{^items.isEnum}}{{^items.isPrimitiveType}}{{^items.isModel}}{{#kotlinx_serialization}}@Contextual {{/kotlinx_serialization}}{{/items.isModel}}{{/items.isPrimitiveType}}{{{items.dataType}}}{{/items.isEnum}}{{#items.isEnum}}{{classname}}.{{{nameInPascalCase}}}{{/items.isEnum}}>{{/isArray}}{{^isEnum}}{{^isArray}}{{{dataType}}}{{/isArray}}{{/isEnum}}{{#isEnum}}{{^isArray}}{{classname}}.{{{nameInPascalCase}}}{{/isArray}}{{/isEnum}}?

modules/openapi-generator/src/main/resources/kotlin-client/interface_req_var.mustache

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,4 @@
1515
{{^isEnum}}{{^isArray}}{{^isPrimitiveType}}{{^isModel}}@Contextual {{/isModel}}{{/isPrimitiveType}}{{/isArray}}{{/isEnum}}@SerialName(value = "{{{vendorExtensions.x-base-name-literal}}}")
1616
{{/kotlinx_serialization}}
1717
{{/multiplatform}}
18-
{{#multiplatform}}@SerialName(value = "{{{vendorExtensions.x-base-name-literal}}}") @Required {{/multiplatform}}{{>modelMutable}} {{{name}}}: {{#isArray}}{{#isList}}kotlin.collections.{{#modelMutable}}Mutable{{/modelMutable}}List{{/isList}}{{^isList}}kotlin.Array{{/isList}}<{{^items.isEnum}}{{^items.isPrimitiveType}}{{^items.isModel}}{{#kotlinx_serialization}}@Contextual {{/kotlinx_serialization}}{{/items.isModel}}{{/items.isPrimitiveType}}{{{items.dataType}}}{{/items.isEnum}}{{#items.isEnum}}{{classname}}.{{{nameInPascalCase}}}{{/items.isEnum}}>{{/isArray}}{{^isEnum}}{{^isArray}}{{{dataType}}}{{/isArray}}{{/isEnum}}{{#isEnum}}{{^isArray}}{{classname}}.{{{nameInPascalCase}}}{{/isArray}}{{/isEnum}}{{#isNullable}}?{{/isNullable}}
18+
{{#multiplatform}}@SerialName(value = "{{{vendorExtensions.x-base-name-literal}}}") @Required {{/multiplatform}}{{#kotlinx_serialization}}abstract {{/kotlinx_serialization}}{{>modelMutable}} {{{name}}}: {{#isArray}}{{#isList}}kotlin.collections.{{#modelMutable}}Mutable{{/modelMutable}}List{{/isList}}{{^isList}}kotlin.Array{{/isList}}<{{^items.isEnum}}{{^items.isPrimitiveType}}{{^items.isModel}}{{#kotlinx_serialization}}@Contextual {{/kotlinx_serialization}}{{/items.isModel}}{{/items.isPrimitiveType}}{{{items.dataType}}}{{/items.isEnum}}{{#items.isEnum}}{{classname}}.{{{nameInPascalCase}}}{{/items.isEnum}}>{{/isArray}}{{^isEnum}}{{^isArray}}{{{dataType}}}{{/isArray}}{{/isEnum}}{{#isEnum}}{{^isArray}}{{classname}}.{{{nameInPascalCase}}}{{/isArray}}{{/isEnum}}{{#isNullable}}?{{/isNullable}}

modules/openapi-generator/src/test/java/org/openapitools/codegen/kotlin/KotlinClientCodegenModelTest.java

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,46 @@ private void givenSchemaObjectPropertyNameContainsDollarSignWhenGenerateThenDoll
533533
Assert.assertEquals(customKotlinParseListener.getStringReferenceCount(), 0);
534534
}
535535

536+
@Test(description = "generate polymorphic kotlinx_serialization model")
537+
public void polymorphicKotlinxSerialzation() throws IOException {
538+
File output = Files.createTempDirectory("test").toFile();
539+
output.deleteOnExit();
540+
541+
final CodegenConfigurator configurator = new CodegenConfigurator()
542+
.setGeneratorName("kotlin")
543+
.setLibrary("jvm-retrofit2")
544+
.setAdditionalProperties(new HashMap<>() {{
545+
put(CodegenConstants.SERIALIZATION_LIBRARY, "kotlinx_serialization");
546+
put(CodegenConstants.MODEL_PACKAGE, "xyz.abcdef.model");
547+
}})
548+
.setInputSpec("src/test/resources/3_0/kotlin/polymorphism.yaml")
549+
.setOutputDir(output.getAbsolutePath().replace("\\", "/"));
550+
551+
final ClientOptInput clientOptInput = configurator.toClientOptInput();
552+
DefaultGenerator generator = new DefaultGenerator();
553+
List<File> files = generator.opts(clientOptInput).generate();
554+
555+
Assert.assertEquals(files.size(), 36);
556+
557+
final Path animalKt = Paths.get(output + "/src/main/kotlin/xyz/abcdef/model/Animal.kt");
558+
// base doesn't contain discriminator
559+
TestUtils.assertFileNotContains(animalKt, "val discriminator");
560+
// base is sealed class
561+
TestUtils.assertFileContains(animalKt, "sealed class Animal");
562+
// base properties are abstract
563+
TestUtils.assertFileContains(animalKt, "abstract val id");
564+
TestUtils.assertFileContains(animalKt, "abstract val optionalProperty");
565+
// base has extra imports
566+
TestUtils.assertFileContains(animalKt, "import kotlinx.serialization.ExperimentalSerializationApi");
567+
TestUtils.assertFileContains(animalKt, "import kotlinx.serialization.json.JsonClassDiscriminator");
568+
569+
final Path birdKt = Paths.get(output + "/src/main/kotlin/xyz/abcdef/model/Bird.kt");
570+
// derived doesn't contain disciminator
571+
TestUtils.assertFileNotContains(birdKt, "val discriminator");
572+
// derived has serial name set to mapping key
573+
TestUtils.assertFileContains(birdKt, "@SerialName(value = \"BIRD\")");
574+
}
575+
536576
private static class ModelNameTest {
537577
private final String expectedName;
538578
private final String expectedClassName;
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
openapi: 3.0.1
2+
info:
3+
title: Example
4+
description: An example
5+
version: '0.1'
6+
contact:
7+
email: contact@example.org
8+
url: 'https://example.org'
9+
servers:
10+
- url: http://example.org
11+
tags:
12+
- name: bird
13+
paths:
14+
'/v1/bird/{id}':
15+
get:
16+
tags:
17+
- bird
18+
responses:
19+
'200':
20+
description: OK
21+
content:
22+
application/json:
23+
schema:
24+
$ref: '#/components/schemas/bird'
25+
operationId: get-bird
26+
parameters:
27+
- schema:
28+
type: string
29+
format: uuid
30+
name: id
31+
in: path
32+
required: true
33+
components:
34+
schemas:
35+
animal:
36+
title: An animal
37+
type: object
38+
properties:
39+
id:
40+
type: string
41+
format: uuid
42+
optional_property:
43+
type: number
44+
required:
45+
- id
46+
discriminator:
47+
propertyName: discriminator
48+
mapping:
49+
BIRD: '#/components/schemas/bird'
50+
bird:
51+
title: A bird
52+
type: object
53+
allOf:
54+
- $ref: '#/components/schemas/animal'
55+
- properties:
56+
featherType:
57+
type: string
58+
required:
59+
- featherType
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# OpenAPI Generator Ignore
2+
# Generated by openapi-generator https://github.com/openapitools/openapi-generator
3+
4+
# Use this file to prevent files from being overwritten by the generator.
5+
# The patterns follow closely to .gitignore or .dockerignore.
6+
7+
# As an example, the C# client generator defines ApiClient.cs.
8+
# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line:
9+
#ApiClient.cs
10+
11+
# You can match any string of characters against a directory, file or extension with a single asterisk (*):
12+
#foo/*/qux
13+
# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux
14+
15+
# You can recursively match patterns against a directory, file or extension with a double asterisk (**):
16+
#foo/**/qux
17+
# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux
18+
19+
# You can also negate patterns with an exclamation (!).
20+
# For example, you can ignore all files in a docs folder with the file extension .md:
21+
#docs/*.md
22+
# Then explicitly reverse the ignore rule for a single file:
23+
#!docs/README.md
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
README.md
2+
build.gradle
3+
docs/Animal.md
4+
docs/Bird.md
5+
docs/BirdApi.md
6+
gradle/wrapper/gradle-wrapper.jar
7+
gradle/wrapper/gradle-wrapper.properties
8+
gradlew
9+
gradlew.bat
10+
proguard-rules.pro
11+
settings.gradle
12+
src/main/kotlin/org/openapitools/client/apis/BirdApi.kt
13+
src/main/kotlin/org/openapitools/client/infrastructure/ApiAbstractions.kt
14+
src/main/kotlin/org/openapitools/client/infrastructure/ApiClient.kt
15+
src/main/kotlin/org/openapitools/client/infrastructure/ApiResponse.kt
16+
src/main/kotlin/org/openapitools/client/infrastructure/AtomicBooleanAdapter.kt
17+
src/main/kotlin/org/openapitools/client/infrastructure/AtomicIntegerAdapter.kt
18+
src/main/kotlin/org/openapitools/client/infrastructure/AtomicLongAdapter.kt
19+
src/main/kotlin/org/openapitools/client/infrastructure/BigDecimalAdapter.kt
20+
src/main/kotlin/org/openapitools/client/infrastructure/BigIntegerAdapter.kt
21+
src/main/kotlin/org/openapitools/client/infrastructure/Errors.kt
22+
src/main/kotlin/org/openapitools/client/infrastructure/LocalDateAdapter.kt
23+
src/main/kotlin/org/openapitools/client/infrastructure/LocalDateTimeAdapter.kt
24+
src/main/kotlin/org/openapitools/client/infrastructure/OffsetDateTimeAdapter.kt
25+
src/main/kotlin/org/openapitools/client/infrastructure/PartConfig.kt
26+
src/main/kotlin/org/openapitools/client/infrastructure/RequestConfig.kt
27+
src/main/kotlin/org/openapitools/client/infrastructure/RequestMethod.kt
28+
src/main/kotlin/org/openapitools/client/infrastructure/ResponseExtensions.kt
29+
src/main/kotlin/org/openapitools/client/infrastructure/Serializer.kt
30+
src/main/kotlin/org/openapitools/client/infrastructure/StringBuilderAdapter.kt
31+
src/main/kotlin/org/openapitools/client/infrastructure/URIAdapter.kt
32+
src/main/kotlin/org/openapitools/client/infrastructure/URLAdapter.kt
33+
src/main/kotlin/org/openapitools/client/infrastructure/UUIDAdapter.kt
34+
src/main/kotlin/org/openapitools/client/models/Animal.kt
35+
src/main/kotlin/org/openapitools/client/models/Bird.kt

0 commit comments

Comments
 (0)