Skip to content

Commit c1c74c0

Browse files
authored
[kotlin-spring][server] Feat: Add Spring Declarative HTTP Interface support for easy client instantiation (#22302)
* add required template files and logic in generator * add possibility to choose between coroutines and reactor style reactive for spring declarative http client * remove Flow as array-type input param for declarative http interface * better name for switches * revert change * fix implementation and add tests * revert unrelated formatting changes * add compile check for generated code to github action config file * fix output folder for config files * commit generated files * commit generated docs files * fix compile test output folder names * fix sample generation * fix compile to jdk 17+ * force test rerun * remove misleading warning * remove extraneous mustache files. Rebuild samples * restoring incorrectly deleted output files
1 parent 7690545 commit c1c74c0

File tree

99 files changed

+6359
-3
lines changed

Some content is hidden

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

99 files changed

+6359
-3
lines changed

.github/workflows/samples-kotlin-server-jdk17.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ on:
88
- 'samples/server/petstore/kotlin-server-modelMutable/**'
99
- 'samples/server/petstore/kotlin-springboot-*/**'
1010
- 'samples/server/petstore/kotlin-server-required-and-nullable-properties/**'
11+
- 'samples/server/petstore/kotlin-spring-declarative*/**'
1112
# comment out due to gradle build failure
1213
# - samples/server/petstore/kotlin-spring-default/**
1314
pull_request:
@@ -17,6 +18,7 @@ on:
1718
- 'samples/server/petstore/kotlin-server-modelMutable/**'
1819
- 'samples/server/petstore/kotlin-springboot-*/**'
1920
- 'samples/server/petstore/kotlin-server-required-and-nullable-properties/**'
21+
- 'samples/server/petstore/kotlin-spring-declarative*/**'
2022
# comment out due to gradle build failure
2123
# - samples/server/petstore/kotlin-spring-default/**
2224

@@ -44,6 +46,10 @@ jobs:
4446
- samples/server/petstore/kotlin-server/ktor
4547
- samples/server/petstore/kotlin-server/ktor2
4648
- samples/server/petstore/kotlin-misk
49+
- samples/server/petstore/kotlin-spring-declarative-interface
50+
- samples/server/petstore/kotlin-spring-declarative-interface-reactive-coroutines
51+
- samples/server/petstore/kotlin-spring-declarative-interface-reactive-reactor-wrapped
52+
- samples/server/petstore/kotlin-spring-declarative-interface-wrapped
4753
# comment out due to gradle build failure
4854
# - samples/server/petstore/kotlin-spring-default/
4955
steps:
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
generatorName: kotlin-spring
2+
outputDir: samples/server/petstore/kotlin-spring-declarative-interface-reactive-coroutines
3+
library: spring-declarative-http-interface
4+
inputSpec: modules/openapi-generator/src/test/resources/3_0/petstore.yaml
5+
templateDir: modules/openapi-generator/src/main/resources/kotlin-spring
6+
additionalProperties:
7+
documentationProvider: springDoc
8+
annotationLibrary: swagger2
9+
useSwaggerUI: "false"
10+
serializableModel: "true"
11+
beanValidations: "true"
12+
interfaceOnly: true
13+
reactive: true
14+
declarativeInterfaceWrapResponses: false
15+
useFlowForArrayReturnType: false
16+
declarativeInterfaceReactiveMode: "coroutines"
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
generatorName: kotlin-spring
2+
outputDir: samples/server/petstore/kotlin-spring-declarative-interface-reactive-reactor-wrapped
3+
library: spring-declarative-http-interface
4+
inputSpec: modules/openapi-generator/src/test/resources/3_0/petstore.yaml
5+
templateDir: modules/openapi-generator/src/main/resources/kotlin-spring
6+
additionalProperties:
7+
documentationProvider: springDoc
8+
annotationLibrary: swagger2
9+
useSwaggerUI: "false"
10+
serializableModel: "true"
11+
beanValidations: "true"
12+
interfaceOnly: true
13+
reactive: true
14+
declarativeInterfaceWrapResponses: true
15+
useFlowForArrayReturnType: false
16+
declarativeInterfaceReactiveMode: "reactor"
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
generatorName: kotlin-spring
2+
outputDir: samples/server/petstore/kotlin-spring-declarative-interface-wrapped
3+
library: spring-declarative-http-interface
4+
inputSpec: modules/openapi-generator/src/test/resources/3_0/petstore.yaml
5+
templateDir: modules/openapi-generator/src/main/resources/kotlin-spring
6+
additionalProperties:
7+
documentationProvider: springDoc
8+
annotationLibrary: swagger2
9+
useSwaggerUI: "false"
10+
serializableModel: "true"
11+
beanValidations: "true"
12+
interfaceOnly: true
13+
reactive: false
14+
declarativeInterfaceWrapResponses: true
15+
useFlowForArrayReturnType: false
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
generatorName: kotlin-spring
2+
outputDir: samples/server/petstore/kotlin-spring-declarative-interface
3+
library: spring-declarative-http-interface
4+
inputSpec: modules/openapi-generator/src/test/resources/3_0/petstore.yaml
5+
templateDir: modules/openapi-generator/src/main/resources/kotlin-spring
6+
additionalProperties:
7+
documentationProvider: springDoc
8+
annotationLibrary: swagger2
9+
useSwaggerUI: "false"
10+
serializableModel: "true"
11+
beanValidations: "true"
12+
interfaceOnly: true
13+
reactive: false
14+
declarativeInterfaceWrapResponses: true
15+
useFlowForArrayReturnType: false

docs/generators/kotlin-spring.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,16 @@ These options may be applied as additional-properties (cli) or configOptions (pl
2727
|basePackage|base package (invokerPackage) for generated code| |org.openapitools|
2828
|beanQualifiers|Whether to add fully-qualifier class names as bean qualifiers in @Component and @RestController annotations. May be used to prevent bean names clash if multiple generated libraries (contexts) added to single project.| |false|
2929
|configPackage|configuration package for generated code| |org.openapitools.configuration|
30+
|declarativeInterfaceReactiveMode|What type of reactive style to use in Spring Http declarative interface|<dl><dt>**coroutines**</dt><dd>Use kotlin-idiomatic 'suspend' functions</dd><dt>**reactor**</dt><dd>Use reactor return wrappers 'Mono' and 'Flux'</dd></dl>|coroutines|
31+
|declarativeInterfaceWrapResponses|Whether (when false) to return actual type (e.g. List&lt;Fruit&gt;) and handle non 2xx responses via exceptions or (when true) return entire ResponseEntity (e.g. ResponseEntity&lt;List&lt;Fruit&gt;&gt;)| |false|
3032
|delegatePattern|Whether to generate the server files using the delegate pattern| |false|
3133
|documentationProvider|Select the OpenAPI documentation provider.|<dl><dt>**none**</dt><dd>Do not publish an OpenAPI specification.</dd><dt>**source**</dt><dd>Publish the original input OpenAPI specification.</dd><dt>**springfox**</dt><dd>Generate an OpenAPI 2 (fka Swagger RESTful API Documentation Specification) specification using SpringFox 2.x. Deprecated (for removal); use springdoc instead.</dd><dt>**springdoc**</dt><dd>Generate an OpenAPI 3 specification using SpringDoc.</dd></dl>|springdoc|
3234
|enumPropertyNaming|Naming convention for enum properties: 'camelCase', 'PascalCase', 'snake_case', 'UPPERCASE', and 'original'| |original|
3335
|exceptionHandler|generate default global exception handlers (not compatible with reactive. enabling reactive will disable exceptionHandler )| |true|
3436
|gradleBuildFile|generate a gradle build file using the Kotlin DSL| |true|
3537
|groupId|Generated artifact package's organization (i.e. maven groupId).| |org.openapitools|
3638
|interfaceOnly|Whether to generate only API interface stubs without the server files.| |false|
37-
|library|library template (sub-template)|<dl><dt>**spring-boot**</dt><dd>Spring-boot Server application.</dd><dt>**spring-cloud**</dt><dd>Spring-Cloud-Feign client with Spring-Boot auto-configured settings.</dd></dl>|spring-boot|
39+
|library|library template (sub-template)|<dl><dt>**spring-boot**</dt><dd>Spring-boot Server application.</dd><dt>**spring-cloud**</dt><dd>Spring-Cloud-Feign client with Spring-Boot auto-configured settings.</dd><dt>**spring-declarative-http-interface**</dt><dd>Spring Declarative Interface client</dd></dl>|spring-boot|
3840
|modelMutable|Create mutable models| |false|
3941
|modelPackage|model package for generated code| |org.openapitools.model|
4042
|packageName|Generated artifact package name.| |org.openapitools|

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

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ public class KotlinSpringServerCodegen extends AbstractKotlinCodegen
7070
public static final String BASE_PACKAGE = "basePackage";
7171
public static final String SPRING_BOOT = "spring-boot";
7272
public static final String SPRING_CLOUD_LIBRARY = "spring-cloud";
73+
public static final String SPRING_DECLARATIVE_HTTP_INTERFACE_LIBRARY = "spring-declarative-http-interface";
7374
public static final String EXCEPTION_HANDLER = "exceptionHandler";
7475
public static final String GRADLE_BUILD_FILE = "gradleBuildFile";
7576
public static final String SERVICE_INTERFACE = "serviceInterface";
@@ -84,13 +85,29 @@ public class KotlinSpringServerCodegen extends AbstractKotlinCodegen
8485
public static final String DELEGATE_PATTERN = "delegatePattern";
8586
public static final String USE_TAGS = "useTags";
8687
public static final String BEAN_QUALIFIERS = "beanQualifiers";
88+
public static final String DECLARATIVE_INTERFACE_WRAP_RESPONSES = "declarativeInterfaceWrapResponses";
89+
public static final String DECLARATIVE_INTERFACE_REACTIVE_MODE = "declarativeInterfaceReactiveMode";
8790

8891
public static final String USE_SPRING_BOOT3 = "useSpringBoot3";
8992
public static final String USE_FLOW_FOR_ARRAY_RETURN_TYPE = "useFlowForArrayReturnType";
9093
public static final String REQUEST_MAPPING_OPTION = "requestMappingMode";
9194
public static final String USE_REQUEST_MAPPING_ON_CONTROLLER = "useRequestMappingOnController";
9295
public static final String USE_REQUEST_MAPPING_ON_INTERFACE = "useRequestMappingOnInterface";
9396

97+
@Getter
98+
public enum DeclarativeInterfaceReactiveMode {
99+
coroutines("Use kotlin-idiomatic 'suspend' functions", "reactiveModeCoroutines"),
100+
reactor("Use reactor return wrappers 'Mono' and 'Flux'", "reactiveModeReactor");
101+
102+
private final String description;
103+
private final String additionalPropertyName;
104+
105+
DeclarativeInterfaceReactiveMode(String description, String additionalPropertyName) {
106+
this.description = description;
107+
this.additionalPropertyName = additionalPropertyName;
108+
}
109+
}
110+
94111
public enum RequestMappingMode {
95112
api_interface("Generate the @RequestMapping annotation on the generated Api Interface."),
96113
controller("Generate the @RequestMapping annotation on the generated Api Controller Implementation."),
@@ -135,6 +152,8 @@ public String getDescription() {
135152
@Setter private boolean delegatePattern = false;
136153
@Setter protected boolean useTags = false;
137154
@Setter private boolean beanQualifiers = false;
155+
@Setter private DeclarativeInterfaceReactiveMode declarativeInterfaceReactiveMode = DeclarativeInterfaceReactiveMode.coroutines;
156+
@Setter private boolean declarativeInterfaceWrapResponses = false;
138157

139158
@Getter @Setter
140159
protected boolean useSpringBoot3 = false;
@@ -220,9 +239,14 @@ public KotlinSpringServerCodegen() {
220239
" (contexts) added to single project.", beanQualifiers);
221240
addSwitch(USE_SPRING_BOOT3, "Generate code and provide dependencies for use with Spring Boot 3.x. (Use jakarta instead of javax in imports). Enabling this option will also enable `useJakartaEe`.", useSpringBoot3);
222241
addSwitch(USE_FLOW_FOR_ARRAY_RETURN_TYPE, "Whether to use Flow for array/collection return types when reactive is enabled. If false, will use List instead.", useFlowForArrayReturnType);
242+
addSwitch(DECLARATIVE_INTERFACE_WRAP_RESPONSES,
243+
"Whether (when false) to return actual type (e.g. List<Fruit>) and handle non 2xx responses via exceptions or (when true) return entire ResponseEntity (e.g. ResponseEntity<List<Fruit>>)",
244+
declarativeInterfaceWrapResponses);
223245
supportedLibraries.put(SPRING_BOOT, "Spring-boot Server application.");
224246
supportedLibraries.put(SPRING_CLOUD_LIBRARY,
225247
"Spring-Cloud-Feign client with Spring-Boot auto-configured settings.");
248+
supportedLibraries.put(SPRING_DECLARATIVE_HTTP_INTERFACE_LIBRARY,
249+
"Spring Declarative Interface client");
226250
setLibrary(SPRING_BOOT);
227251

228252
CliOption cliOpt = new CliOption(CodegenConstants.LIBRARY, CodegenConstants.LIBRARY_DESC);
@@ -238,6 +262,14 @@ public KotlinSpringServerCodegen() {
238262
}
239263
cliOptions.add(requestMappingOpt);
240264

265+
CliOption declarativeInterfaceReactiveModeOpt = new CliOption(DECLARATIVE_INTERFACE_REACTIVE_MODE,
266+
"What type of reactive style to use in Spring Http declarative interface")
267+
.defaultValue(declarativeInterfaceReactiveMode.name());
268+
for (DeclarativeInterfaceReactiveMode mode : DeclarativeInterfaceReactiveMode.values()) {
269+
declarativeInterfaceReactiveModeOpt.addEnum(mode.name(), mode.getDescription());
270+
}
271+
cliOptions.add(declarativeInterfaceReactiveModeOpt);
272+
241273
if (null != defaultDocumentationProvider()) {
242274
CliOption documentationProviderCliOption = new CliOption(DOCUMENTATION_PROVIDER,
243275
"Select the OpenAPI documentation provider.")
@@ -518,6 +550,41 @@ public void processOpts() {
518550
this.setUseFlowForArrayReturnType(convertPropertyToBoolean(USE_FLOW_FOR_ARRAY_RETURN_TYPE));
519551
}
520552
}
553+
if (library.equals(SPRING_DECLARATIVE_HTTP_INTERFACE_LIBRARY)) {
554+
this.setReactive(convertPropertyToBoolean(REACTIVE));
555+
if (additionalProperties.containsKey(USE_FLOW_FOR_ARRAY_RETURN_TYPE)) {
556+
this.setUseFlowForArrayReturnType(convertPropertyToBoolean(USE_FLOW_FOR_ARRAY_RETURN_TYPE));
557+
}
558+
if (this.isUseFlowForArrayReturnType()) {
559+
{
560+
throw new IllegalArgumentException("Additional property '" + USE_FLOW_FOR_ARRAY_RETURN_TYPE + "' must be set to 'false' as it is not supported by Spring declarative HTTP interface");
561+
}
562+
}
563+
if (additionalProperties.containsKey(DECLARATIVE_INTERFACE_REACTIVE_MODE)) {
564+
try {
565+
DeclarativeInterfaceReactiveMode optValue = DeclarativeInterfaceReactiveMode.valueOf(
566+
String.valueOf(additionalProperties.get(DECLARATIVE_INTERFACE_REACTIVE_MODE)));
567+
setDeclarativeInterfaceReactiveMode(optValue);
568+
writePropertyBack(optValue.getAdditionalPropertyName(), true);
569+
additionalProperties.remove(DECLARATIVE_INTERFACE_REACTIVE_MODE);
570+
} catch (IllegalArgumentException e) {
571+
throw new IllegalArgumentException(
572+
"Invalid value for additional property '" + DECLARATIVE_INTERFACE_REACTIVE_MODE + "'. Supported values are " + Arrays.toString(DeclarativeInterfaceReactiveMode.values()) + "."
573+
);
574+
}
575+
}
576+
}
577+
}
578+
if (SPRING_DECLARATIVE_HTTP_INTERFACE_LIBRARY.equals(library)) {
579+
this.setUseSpringBoot3(true);
580+
this.setInterfaceOnly(true);
581+
this.setUseFeignClient(false);
582+
this.setSkipDefaultInterface(true);
583+
584+
writePropertyBack(USE_SPRING_BOOT3, useSpringBoot3);
585+
writePropertyBack(INTERFACE_ONLY, interfaceOnly);
586+
writePropertyBack(USE_FEIGN_CLIENT, useFeignClient);
587+
writePropertyBack(SKIP_DEFAULT_INTERFACE, skipDefaultInterface);
521588
}
522589
writePropertyBack(REACTIVE, reactive);
523590
writePropertyBack(EXCEPTION_HANDLER, exceptionHandler);
@@ -606,7 +673,7 @@ public void processOpts() {
606673
supportingFiles.add(new SupportingFile("README.mustache", "", "README.md"));
607674

608675

609-
if (this.exceptionHandler && !library.equals(SPRING_CLOUD_LIBRARY)) {
676+
if (this.exceptionHandler && !(library.equals(SPRING_CLOUD_LIBRARY) || library.equals(SPRING_DECLARATIVE_HTTP_INTERFACE_LIBRARY))) {
610677
supportingFiles.add(new SupportingFile("exceptions.mustache",
611678
sanitizeDirectory(sourceFolder + File.separator + apiPackage), "Exceptions.kt"));
612679
}
@@ -699,8 +766,29 @@ public void processOpts() {
699766

700767
apiTestTemplateFiles.clear();
701768
}
769+
if (library.equals(SPRING_DECLARATIVE_HTTP_INTERFACE_LIBRARY)) {
770+
LOGGER.info("Setup code generator for Kotlin Spring Declarative Http interface");
771+
772+
supportingFiles.add(new SupportingFile("pom-sb3.mustache", "pom.xml"));
773+
774+
if (this.gradleBuildFile) {
775+
supportingFiles.add(new SupportingFile("buildGradle-sb3-Kts.mustache", "build.gradle.kts"));
776+
supportingFiles.add(new SupportingFile("settingsGradle.mustache", "settings.gradle"));
777+
778+
String gradleWrapperPackage = "gradle.wrapper";
779+
supportingFiles.add(new SupportingFile("gradlew.mustache", "", "gradlew"));
780+
supportingFiles.add(new SupportingFile("gradlew.bat.mustache", "", "gradlew.bat"));
781+
supportingFiles.add(new SupportingFile("gradle-wrapper.properties.mustache",
782+
gradleWrapperPackage.replace(".", File.separator), "gradle-wrapper.properties"));
783+
supportingFiles.add(new SupportingFile("gradle-wrapper.jar",
784+
gradleWrapperPackage.replace(".", File.separator), "gradle-wrapper.jar"));
785+
}
786+
787+
apiTemplateFiles.put("apiInterface.mustache", "Client.kt");
788+
apiTestTemplateFiles.clear();
789+
}
702790

703-
if (!reactive && !library.equals(SPRING_CLOUD_LIBRARY)) {
791+
if (!reactive && !(library.equals(SPRING_CLOUD_LIBRARY) || library.equals(SPRING_DECLARATIVE_HTTP_INTERFACE_LIBRARY))) {
704792
if (DocumentationProvider.SPRINGFOX.equals(getDocumentationProvider())) {
705793
supportingFiles.add(new SupportingFile("springfoxDocumentationConfig.mustache",
706794
(sourceFolder + File.separator + basePackage).replace(".", java.io.File.separator),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# OpenAPI generated API stub
2+
3+
Spring Framework stub
4+
5+
## Overview
6+
This code was generated by the [OpenAPI Generator](https://openapi-generator.tech) project.
7+
By using the [OpenAPI-Spec](https://openapis.org).
8+
This generates Spring 6+ declarative HTTP interfaces that can be used to easily instantiate an http client.
9+
```
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* NOTE: Auto generated by OpenAPI Generator ({{{generatorVersion}}})
3+
* Spring 6 Declarative HTTP Interface
4+
*/
5+
package {{package}}
6+
7+
{{#imports}}import {{import}}
8+
{{/imports}}
9+
{{#useRequestMappingOnInterface}}
10+
import {{#apiPackage}}{{.}}.{{/apiPackage}}{{classname}}.Companion.BASE_PATH
11+
{{/useRequestMappingOnInterface}}
12+
13+
{{#swagger2AnnotationLibrary}}
14+
import io.swagger.v3.oas.annotations.*
15+
import io.swagger.v3.oas.annotations.enums.*
16+
import io.swagger.v3.oas.annotations.media.*
17+
import io.swagger.v3.oas.annotations.responses.*
18+
import io.swagger.v3.oas.annotations.security.*
19+
{{/swagger2AnnotationLibrary}}
20+
{{#swagger1AnnotationLibrary}}
21+
import io.swagger.annotations.Api
22+
import io.swagger.annotations.ApiOperation
23+
import io.swagger.annotations.ApiParam
24+
import io.swagger.annotations.ApiResponse
25+
import io.swagger.annotations.ApiResponses
26+
import io.swagger.annotations.Authorization
27+
import io.swagger.annotations.AuthorizationScope
28+
{{/swagger1AnnotationLibrary}}
29+
30+
import org.springframework.web.service.annotation.*
31+
import org.springframework.web.bind.annotation.*
32+
import org.springframework.http.ResponseEntity
33+
34+
{{#useBeanValidation}}
35+
import org.springframework.validation.annotation.Validated
36+
import {{javaxPackage}}.validation.Valid
37+
import {{javaxPackage}}.validation.constraints.*
38+
{{/useBeanValidation}}
39+
40+
{{#reactiveModeReactor}}
41+
import reactor.core.publisher.Flux
42+
import reactor.core.publisher.Mono
43+
{{/reactiveModeReactor}}
44+
45+
import kotlin.collections.List
46+
import kotlin.collections.Map
47+
48+
{{#useRequestMappingOnInterface}}@HttpExchange(
49+
"{{=<% %>=}}\${api.base-path:$BASE_PATH}<%={{ }}=%>"
50+
){{/useRequestMappingOnInterface}}
51+
{{#useBeanValidation}}
52+
@Validated
53+
{{/useBeanValidation}}
54+
{{#operations}}
55+
interface {{classname}} {
56+
57+
{{#operation}}
58+
{{#httpMethod}}
59+
@HttpExchange(
60+
url = PATH_{{#lambda.uppercase}}{{#lambda.snakecase}}{{{operationId}}}{{/lambda.snakecase}}{{/lambda.uppercase}},
61+
method = "{{httpMethod}}"
62+
)
63+
{{/httpMethod}}{{!
64+
}}{{#reactiveModeCoroutines}}suspend {{/reactiveModeCoroutines}}{{!
65+
}}fun {{operationId}}(
66+
{{#allParams}}
67+
{{>queryParams}}{{>pathParams}}{{>headerParams}}{{>cookieParams}}{{>httpInterfaceBodyParams}}{{>formParams}}{{^-last}},{{/-last}}
68+
{{/allParams}}
69+
): {{>httpInterfaceReturnTypes}}
70+
71+
{{/operation}}
72+
companion object {
73+
//for your own safety never directly reuse these path definitions in tests
74+
{{#useRequestMappingOnInterface}}
75+
const val BASE_PATH: String = "{{=<% %>=}}<%contextPath%><%={{ }}=%>"
76+
{{/useRequestMappingOnInterface}}
77+
{{#operation}}
78+
const val PATH_{{#lambda.uppercase}}{{#lambda.snakecase}}{{{operationId}}}{{/lambda.snakecase}}{{/lambda.uppercase}}: String = "{{{path}}}"
79+
{{/operation}}
80+
}
81+
}
82+
{{/operations}}

0 commit comments

Comments
 (0)