Skip to content

Commit ac7df6e

Browse files
squash
1 parent 5523cc0 commit ac7df6e

File tree

28 files changed

+295
-173
lines changed

28 files changed

+295
-173
lines changed

bin/configs/kotlin-misk-config.yaml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,16 @@ outputDir: samples/server/petstore/kotlin-misk-config
33
inputSpec: modules/openapi-generator/src/test/resources/3_0/petstore.yaml
44
templateDir: modules/openapi-generator/src/main/resources/kotlin-misk
55
validateSpec: false
6+
useBeanValidation: true
67
additionalProperties:
78
hideGenerationTimestamp: "true"
89
moduleClassName: "PetStoreModule"
910
generateStubImplClasses: true
1011
addModelMoshiJsonAnnotation: true
1112
actionPathPrefix: "samplePrefix"
12-
actionAnnotations: "@LogRequestResponse(bodySampling = 1.0, errorBodySampling = 1.0)"
13-
actionImports: "import misk.web.actions.WebAction"
13+
actionAnnotations: "@LogRequestResponse(bodySampling = 1.0, errorBodySampling = 2.0);@Suppress(\"unused\")"
14+
actionImports: "misk.web.actions.WebAction;misk.web.interceptors.LogRequestResponse"
1415
actionParentClass: "WebAction"
16+
actionRequestContentType: "@RequestContentType"
17+
actionRequestContentTypePrefix: "MediaTypes"
18+
testingModule: "misk.web.MiskWebModule"

docs/generators/kotlin-misk.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@ These options may be applied as additional-properties (cli) or configOptions (pl
1818

1919
| Option | Description | Values | Default |
2020
| ------ | ----------- | ------ | ------- |
21+
|actionAnnotations|String Annotations for Actions separated by a semicolon(;)| |@LogRequestResponse(bodySampling = 1.0, errorBodySampling = 1.0)|
22+
|actionImports|String Imports for Actions separated by a semicolon(;)| |misk.web.actions.WebAction;misk.web.interceptors.LogRequestResponse|
23+
|actionParentClass|Parent Class for Action| |WebAction|
2124
|actionPathPrefix|Prefix for action path| ||
25+
|actionRequestContentType|Request ContentType for Action| |@RequestContentType|
26+
|actionRequestContentTypePrefix|Request ContentType Prefix for Action| |MediaTypes|
2227
|addModelMoshiJsonAnnotation|Add a Moshi JSON adapter annotation to all model classes| |true|
2328
|additionalModelTypeAnnotations|Additional annotations for model type(class level annotations). List separated by semicolon(;) or new line (Linux or Windows)| |null|
2429
|apiSuffix|suffix for api classes| |Api|
@@ -35,6 +40,7 @@ These options may be applied as additional-properties (cli) or configOptions (pl
3540
|sortModelPropertiesByRequiredFlag|Sort model properties to place required parameters before optional parameters.| |null|
3641
|sortParamsByRequiredFlag|Sort method arguments to place required parameters before optional parameters.| |null|
3742
|sourceFolder|source folder for generated code| |src/main/kotlin|
43+
|testingModule|Testing module class| |misk.testing.MiskTestModule|
3844
|useBeanValidation|Use BeanValidation API annotations to validate data types| |true|
3945

4046
## IMPORT MAPPING

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

Lines changed: 76 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
import org.slf4j.LoggerFactory;
3838

3939
import java.io.File;
40+
import java.util.Arrays;
4041
import java.util.EnumSet;
4142
import java.util.HashMap;
4243
import java.util.List;
@@ -58,6 +59,10 @@ public class KotlinMiskServerCodegen extends AbstractKotlinCodegen implements Be
5859
public static final String ACTION_ANNOTATIONS = "actionAnnotations";
5960
public static final String ACTION_IMPORTS = "actionImports";
6061
public static final String ACTION_PARENT_CLASS = "actionParentClass";
62+
public static final String ACTION_REQUEST_CONTENT_TYPE = "actionRequestContentType";
63+
public static final String ACTION_REQUEST_CONTENT_TYPE_PREFIX = "actionRequestContentTypePrefix";
64+
public static final String TESTING_MODULE = "testingModule";
65+
private static final String TESTING_MODULE_NAME = "testingModuleName";
6166

6267
private boolean useBeanValidation = true;
6368

@@ -72,9 +77,15 @@ public class KotlinMiskServerCodegen extends AbstractKotlinCodegen implements Be
7277

7378
@Setter protected String moduleClassName = "OpenApiModule";
7479
@Setter protected String actionPathPrefix = "";
75-
@Setter protected String actionAnnotations = "";
76-
@Setter protected String actionImports = "import misk.web.actions.WebAction";
80+
@Setter protected List<String> actionAnnotations =
81+
List.of("@LogRequestResponse(bodySampling = 1.0, errorBodySampling = 1.0)");
82+
@Setter protected List<String> actionImports =
83+
List.of("misk.web.actions.WebAction","misk.web.interceptors.LogRequestResponse");
7784
@Setter protected String actionParentClass = "WebAction";
85+
@Setter protected String actionRequestContentType = "@RequestContentType";
86+
@Setter protected String actionRequestContentTypePrefix = "MediaTypes";
87+
@Setter protected String testingModule = "misk.testing.MiskTestModule";
88+
@Setter protected String testingModuleName;
7889

7990
@Override
8091
public CodegenType getTag() {
@@ -128,9 +139,12 @@ public KotlinMiskServerCodegen() {
128139

129140
addOption(MODULE_CLASS_NAME, "Name of the generated module class", moduleClassName);
130141
addOption(ACTION_PATH_PREFIX, "Prefix for action path", actionPathPrefix);
131-
addOption(ACTION_ANNOTATIONS, "Action Annotations", actionAnnotations);
132-
addOption(ACTION_IMPORTS, "Imports for Actions", actionImports);
142+
addOption(ACTION_IMPORTS, "String Imports for Actions separated by a semicolon(;)", String.join(";", actionImports));
143+
addOption(ACTION_ANNOTATIONS, "String Annotations for Actions separated by a semicolon(;)", String.join(";", actionAnnotations));
133144
addOption(ACTION_PARENT_CLASS, "Parent Class for Action", actionParentClass);
145+
addOption(ACTION_REQUEST_CONTENT_TYPE, "Request ContentType for Action", actionRequestContentType);
146+
addOption(ACTION_REQUEST_CONTENT_TYPE_PREFIX, "Request ContentType Prefix for Action", actionRequestContentTypePrefix);
147+
addOption(TESTING_MODULE, "Testing module class", testingModule);
134148

135149
apiTestTemplateFiles.clear();
136150
apiTestTemplateFiles.put("api_test.mustache", ".kt");
@@ -172,6 +186,23 @@ public KotlinMiskServerCodegen() {
172186
public void processOpts() {
173187
super.processOpts();
174188

189+
if (additionalProperties.containsKey(ACTION_ANNOTATIONS)) {
190+
convertPropertyToTypeAndWriteBack(ACTION_ANNOTATIONS,
191+
it -> Arrays.asList(it.split(";")), this::setActionAnnotations);
192+
}
193+
writePropertyBack(ACTION_ANNOTATIONS, actionAnnotations);
194+
195+
if (additionalProperties.containsKey(ACTION_IMPORTS)) {
196+
convertPropertyToTypeAndWriteBack(ACTION_IMPORTS,
197+
it -> Arrays.asList(it.split(";")), this::setActionImports);
198+
}
199+
writePropertyBack(ACTION_IMPORTS, actionImports);
200+
201+
if (additionalProperties.containsKey(ACTION_PARENT_CLASS)) {
202+
setActionParentClass((String) additionalProperties.get(ACTION_PARENT_CLASS));
203+
}
204+
writePropertyBack(ACTION_PARENT_CLASS, actionParentClass);
205+
175206
if (additionalProperties.containsKey(MODULE_CLASS_NAME)) {
176207
setModuleClassName((String) additionalProperties.get(MODULE_CLASS_NAME));
177208
}
@@ -182,20 +213,22 @@ public void processOpts() {
182213
}
183214
writePropertyBack(ACTION_PATH_PREFIX, actionPathPrefix);
184215

185-
if (additionalProperties.containsKey(ACTION_ANNOTATIONS)) {
186-
setActionAnnotations((String) additionalProperties.get(ACTION_ANNOTATIONS));
216+
if (additionalProperties.containsKey(ACTION_REQUEST_CONTENT_TYPE)) {
217+
setActionRequestContentType((String) additionalProperties.get(ACTION_REQUEST_CONTENT_TYPE));
187218
}
188-
writePropertyBack(ACTION_ANNOTATIONS, actionAnnotations);
219+
writePropertyBack(ACTION_REQUEST_CONTENT_TYPE, actionRequestContentType);
189220

190-
if (additionalProperties.containsKey(ACTION_IMPORTS)) {
191-
setActionImports((String) additionalProperties.get(ACTION_IMPORTS));
221+
if (additionalProperties.containsKey(ACTION_REQUEST_CONTENT_TYPE_PREFIX)) {
222+
setActionRequestContentTypePrefix((String) additionalProperties.get(ACTION_REQUEST_CONTENT_TYPE_PREFIX));
192223
}
193-
writePropertyBack(ACTION_IMPORTS, actionImports);
224+
writePropertyBack(ACTION_REQUEST_CONTENT_TYPE_PREFIX, actionRequestContentTypePrefix);
194225

195-
if (additionalProperties.containsKey(ACTION_PARENT_CLASS)) {
196-
setActionParentClass((String) additionalProperties.get(ACTION_PARENT_CLASS));
226+
if (additionalProperties.containsKey(TESTING_MODULE)) {
227+
setTestingModule((String) additionalProperties.get(TESTING_MODULE));
197228
}
198-
writePropertyBack(ACTION_PARENT_CLASS, actionParentClass);
229+
writePropertyBack(TESTING_MODULE, testingModule);
230+
231+
writePropertyBack(TESTING_MODULE_NAME, getTestingModuleName());
199232

200233
if (additionalProperties.containsKey(USE_BEANVALIDATION)) {
201234
this.setUseBeanValidation(convertPropertyToBoolean(USE_BEANVALIDATION));
@@ -255,39 +288,43 @@ public boolean getUseBeanValidation() {
255288
}
256289

257290
private String mapMediaType(String mediaType) {
258-
return MEDIA_MAPPING.getOrDefault(mediaType, "MediaTypes.APPLICATION_OCTETSTREAM /* @todo(unknown) -> " + mediaType + " */ ");
291+
return MEDIA_MAPPING.getOrDefault(mediaType, "APPLICATION_OCTETSTREAM /* @todo(unknown) -> " + mediaType + " */ ");
292+
}
293+
294+
public String getTestingModuleName() {
295+
return testingModule.substring(testingModule.lastIndexOf(".")+1);
259296
}
260297

261298
private final static Map<String, String> MEDIA_MAPPING = getMappings();
262299

263300
private static Map<String, String> getMappings() {
264301
// add new values in order
265302
Map<String, String> result = new HashMap<>();
266-
result.put("*/*", "MediaTypes.ALL");
267-
268-
result.put("application/grpc", "MediaTypes.APPLICATION_GRPC");
269-
result.put("application/javascript", "MediaTypes.APPLICATION_JAVASCRIPT");
270-
result.put("application/json", "MediaTypes.APPLICATION_JSON");
271-
result.put("application/jwt", "MediaTypes.APPLICATION_JWT");
272-
result.put("application/octetstream", "MediaTypes.APPLICATION_OCTETSTREAM");
273-
result.put("application/pdf", "MediaTypes.APPLICATION_OCTETSTREAM");
274-
result.put("application/x-protobuf", "MediaTypes.APPLICATION_PROTOBUF");
275-
result.put("application/x-www-form-urlencoded", "MediaTypes.APPLICATION_FORM_URLENCODED");
276-
result.put("application/xml", "MediaTypes.APPLICATION_XML");
277-
result.put("application/zip", "MediaTypes.APPLICATION_ZIP");
278-
279-
result.put("image/gif", "MediaTypes.IMAGE_GIF");
280-
result.put("image/x-icon", "MediaTypes.IMAGE_ICO");
281-
result.put("image/jpeg", "MediaTypes.IMAGE_JPEG");
282-
result.put("image/png", "MediaTypes.IMAGE_PNG");
283-
result.put("image/svg+xml", "MediaTypes.IMAGE_SVG");
284-
result.put("image/tiff", "MediaTypes.IMAGE_TIFF");
285-
286-
result.put("multipart/form-data", "MediaTypes.FORM_DATA");
287-
288-
result.put("text/css", "MediaTypes.TEXT_CSS");
289-
result.put("text/html", "MediaTypes.TEXT_HTML");
290-
result.put("text/plain", "MediaTypes.TEXT_PLAIN_UTF8");
303+
result.put("*/*", "ALL");
304+
305+
result.put("application/grpc", "APPLICATION_GRPC");
306+
result.put("application/javascript", "APPLICATION_JAVASCRIPT");
307+
result.put("application/json", "APPLICATION_JSON");
308+
result.put("application/jwt", "APPLICATION_JWT");
309+
result.put("application/octetstream", "APPLICATION_OCTETSTREAM");
310+
result.put("application/pdf", "APPLICATION_OCTETSTREAM");
311+
result.put("application/x-protobuf", "APPLICATION_PROTOBUF");
312+
result.put("application/x-www-form-urlencoded", "APPLICATION_FORM_URLENCODED");
313+
result.put("application/xml", "APPLICATION_XML");
314+
result.put("application/zip", "APPLICATION_ZIP");
315+
316+
result.put("image/gif", "IMAGE_GIF");
317+
result.put("image/x-icon", "IMAGE_ICO");
318+
result.put("image/jpeg", "IMAGE_JPEG");
319+
result.put("image/png", "IMAGE_PNG");
320+
result.put("image/svg+xml", "IMAGE_SVG");
321+
result.put("image/tiff", "IMAGE_TIFF");
322+
323+
result.put("multipart/form-data", "FORM_DATA");
324+
325+
result.put("text/css", "TEXT_CSS");
326+
result.put("text/html", "TEXT_HTML");
327+
result.put("text/plain", "TEXT_PLAIN_UTF8");
291328

292329
return result;
293330
}

modules/openapi-generator/src/main/resources/kotlin-misk/apiAction.mustache

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import {{javaxPackage}}.validation.constraints.NotNull
1313
import {{javaxPackage}}.validation.constraints.Pattern
1414
import {{javaxPackage}}.validation.constraints.Size
1515
{{/useBeanValidation}}
16+
{{#actionImports}}
17+
import {{{.}}}
18+
{{/actionImports}}
1619
import misk.web.Delete
1720
import misk.web.Description
1821
import misk.web.Get
@@ -26,9 +29,9 @@ import misk.web.RequestBody
2629
import misk.web.RequestContentType
2730
import misk.web.RequestHeader
2831
import misk.web.ResponseContentType
29-
import misk.web.mediatype.MediaTypes{{#actionImports}}
30-
{{.}}{{/actionImports}}
31-
{{#imports}}import {{import}}
32+
import misk.web.mediatype.MediaTypes
33+
{{#imports}}
34+
import {{import}}
3235
{{/imports}}
3336

3437
/**
@@ -40,11 +43,13 @@ class {{classname}}Action @Inject constructor(
4043
) : {{actionParentClass}} {
4144
{{#operation}}
4245

43-
@{{httpMethod}}("{{actionPathPrefix}}{{path}}")
46+
@{{httpMethod}}("{{{actionPathPrefix}}}{{path}}")
4447
@Description("{{{summary}}}"){{#hasConsumes}}
45-
@RequestContentType({{#consumes}}{{{mediaType}}}{{^-last}}, {{/-last}}{{/consumes}}){{/hasConsumes}}{{#hasProduces}}
46-
@ResponseContentType({{#produces}}{{{mediaType}}}{{^-last}}, {{/-last}}{{/produces}}){{/hasProduces}}{{#actionAnnotations}}
47-
{{.}}{{/actionAnnotations}}
48+
{{{actionRequestContentType}}}({{#consumes}}{{{actionRequestContentTypePrefix}}}.{{{mediaType}}}{{^-last}}, {{/-last}}{{/consumes}}){{/hasConsumes}}{{#hasProduces}}
49+
@ResponseContentType({{#produces}}MediaTypes.{{{mediaType}}}{{^-last}}, {{/-last}}{{/produces}}){{/hasProduces}}
50+
{{#actionAnnotations}}
51+
{{{.}}}
52+
{{/actionAnnotations}}
4853
fun {{operationId}}({{#allParams}}
4954
{{>queryParams}}{{>pathParams}}{{>headerParams}}{{>cookieParams}}{{>bodyParams}}{{>formParams}}{{^-last}}, {{/-last}}{{/allParams}}){{#returnType}}: {{{returnType}}}{{/returnType}} {
5055
TODO()

modules/openapi-generator/src/main/resources/kotlin-misk/apiImpl.mustache

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ import misk.web.PathParam
1818
import misk.web.QueryParam
1919
import misk.web.RequestBody
2020
import misk.web.RequestHeader
21-
{{#imports}}import {{import}}
21+
{{#imports}}
22+
import {{import}}
2223
{{/imports}}
2324

2425
/**

modules/openapi-generator/src/main/resources/kotlin-misk/apiInterface.mustache

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ import misk.web.PathParam
1616
import misk.web.QueryParam
1717
import misk.web.RequestBody
1818
import misk.web.RequestHeader
19-
{{#imports}}import {{import}}
19+
{{#imports}}
20+
import {{import}}
2021
{{/imports}}
2122

2223
{{#operations}}
Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,40 @@
11
package {{package}}
22

3+
import {{{testingModule}}}
34
import {{javaxPackage}}.inject.Inject
45
import misk.testing.MiskTest
6+
import misk.testing.MiskTestModule
57
import org.junit.jupiter.api.Test
6-
78
import misk.web.HttpCall
89
import misk.web.PathParam
910
import misk.web.QueryParam
1011
import misk.web.RequestBody
1112
import misk.web.RequestHeader
12-
13-
{{#imports}}import {{import}}
13+
{{#imports}}
14+
import {{import}}
1415
{{/imports}}
1516

1617
@MiskTest(startService = true)
1718
internal class {{classname}}Test {
1819
20+
@Suppress("unused")
21+
@MiskTestModule
22+
private val module = {{{testingModuleName}}}()
23+
1924
@Inject private lateinit var {{#lambda.camelcase}}{{classname}}{{/lambda.camelcase}}: {{classname}}Action
25+
{{#operations}}
26+
{{#operation}}
2027

21-
{{#operations}}
22-
{{#operation}}
2328
/**
2429
* To test {{classname}}Action.{{operationId}}
2530
*/
2631
@Test
2732
fun `should handle {{operationId}}`() {
28-
{{#allParams}}
33+
{{#allParams}}
2934
val {{{paramName}}} = TODO()
30-
{{/allParams}}
35+
{{/allParams}}
3136
val response{{#returnType}}: {{{returnType}}}{{/returnType}} = {{#lambda.camelcase}}{{classname}}{{/lambda.camelcase}}.{{operationId}}({{#allParams}}{{{paramName}}}{{^-last}}, {{/-last}}{{/allParams}})
3237
}
33-
34-
{{/operation}}
35-
{{/operations}}
38+
{{/operation}}
39+
{{/operations}}
3640
}

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,12 @@ protected void verifyOptions() {
3939
verify(codegen).setUseBeanValidation(Boolean.valueOf(KotlinMiskServerCodegenOptionsProvider.USE_BEAN_VALIDATION));
4040
verify(codegen).setModuleClassName(KotlinMiskServerCodegenOptionsProvider.MODULE_CLASS_NAME);
4141
verify(codegen).setActionPathPrefix(KotlinMiskServerCodegenOptionsProvider.ACTION_PATH_PREFIX);
42+
verify(codegen).setActionImports(List.of("a.x","b.y"));
43+
verify(codegen).setActionAnnotations(List.of("@c()","@d()"));
44+
verify(codegen).setActionParentClass(KotlinMiskServerCodegenOptionsProvider.ACTION_PARENT_CLASS);
45+
verify(codegen).setActionRequestContentType(KotlinMiskServerCodegenOptionsProvider.ACTION_REQUEST_CONTENT_TYPE);
46+
verify(codegen).setActionRequestContentTypePrefix(KotlinMiskServerCodegenOptionsProvider.ACTION_REQUEST_CONTENT_TYPE_PREFIX);
4247
verify(codegen).setGenerateStubImplClasses(Boolean.valueOf(KotlinMiskServerCodegenOptionsProvider.GENERATE_STUB_IMPL_CLASSES));
4348
verify(codegen).setAddModelMoshiJsonAnnotation(Boolean.valueOf(KotlinMiskServerCodegenOptionsProvider.ADD_MODEL_MOSHI_JSON_ANNOTATION));
4449
}
45-
}
50+
}

modules/openapi-generator/src/test/java/org/openapitools/codegen/options/KotlinMiskServerCodegenOptionsProvider.java

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,13 @@ public class KotlinMiskServerCodegenOptionsProvider implements OptionsProvider {
2626
public static final String GENERATE_STUB_IMPL_CLASSES = "false";
2727
public static final String ADD_MODEL_MOSHI_JSON_ANNOTATION = "true";
2828
public static final String MODULE_CLASS_NAME = "OpenApiModule";
29-
public static final String ACTION_PATH_PREFIX = "samplePrefix";
29+
public static final String ACTION_PATH_PREFIX = "samplePrefix<";
30+
public static final String ACTION_IMPORTS = "a.x;b.y";
31+
public static final String ACTION_ANNOTATIONS = "@c();@d()";
32+
public static final String ACTION_PARENT_CLASS = "class<";
33+
public static final String ACTION_REQUEST_CONTENT_TYPE = "contentType<";
34+
public static final String ACTION_REQUEST_CONTENT_TYPE_PREFIX = "contentTypePrefix<";
35+
public static final String TESTING_MODULE = "testingModule";
3036

3137
@Override
3238
public String getLanguage() {
@@ -55,13 +61,19 @@ public Map<String, String> createOptions() {
5561
.put(KotlinMiskServerCodegen.MODULE_CLASS_NAME, MODULE_CLASS_NAME)
5662
.put(BeanValidationFeatures.USE_BEANVALIDATION, USE_BEAN_VALIDATION)
5763
.put(KotlinMiskServerCodegen.ACTION_PATH_PREFIX, ACTION_PATH_PREFIX)
64+
.put(KotlinMiskServerCodegen.ACTION_IMPORTS, ACTION_IMPORTS)
65+
.put(KotlinMiskServerCodegen.ACTION_ANNOTATIONS, ACTION_ANNOTATIONS)
66+
.put(KotlinMiskServerCodegen.ACTION_PARENT_CLASS, ACTION_PARENT_CLASS)
67+
.put(KotlinMiskServerCodegen.ACTION_REQUEST_CONTENT_TYPE, ACTION_REQUEST_CONTENT_TYPE)
68+
.put(KotlinMiskServerCodegen.ACTION_REQUEST_CONTENT_TYPE_PREFIX, ACTION_REQUEST_CONTENT_TYPE_PREFIX)
5869
.put(KotlinMiskServerCodegen.ADD_MODEL_MOSHI_JSON_ANNOTATION, ADD_MODEL_MOSHI_JSON_ANNOTATION)
5970
.put(KotlinMiskServerCodegen.GENERATE_STUB_IMPL_CLASSES, GENERATE_STUB_IMPL_CLASSES)
71+
.put(KotlinMiskServerCodegen.TESTING_MODULE, TESTING_MODULE)
6072
.build();
6173
}
6274

6375
@Override
6476
public boolean isServer() {
6577
return true;
6678
}
67-
}
79+
}

0 commit comments

Comments
 (0)