OpenAPI 3.1.0 Add webhooks support (#17174)

* Add support for webhooks

* Test webhook generation with Go Gin server

* Generate samples

* Removing \t

* Remove tabs
This commit is contained in:
Beppe Catanese 2023-12-11 04:21:23 +01:00 committed by GitHub
parent 8bb9a10b9f
commit 9eb5882f94
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 363 additions and 14 deletions

View File

@ -30,6 +30,7 @@ import org.openapitools.codegen.meta.GeneratorMetadata;
import org.openapitools.codegen.model.ModelMap;
import org.openapitools.codegen.model.ModelsMap;
import org.openapitools.codegen.model.OperationsMap;
import org.openapitools.codegen.model.WebhooksMap;
import java.io.File;
import java.util.List;
@ -217,6 +218,8 @@ public interface CodegenConfig {
OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<ModelMap> allModels);
WebhooksMap postProcessWebhooksWithModels(WebhooksMap objs, List<ModelMap> allModels);
Map<String, Object> postProcessSupportingFileData(Map<String, Object> objs);
void postProcessModelProperty(CodegenModel model, CodegenProperty property);

View File

@ -28,6 +28,7 @@ public class CodegenConstants {
public static final String SUPPORTING_FILES = "supportingFiles";
public static final String MODEL_TESTS = "modelTests";
public static final String MODEL_DOCS = "modelDocs";
public static final String WEBHOOKS = "webhooks";
public static final String API_TESTS = "apiTests";
public static final String API_DOCS = "apiDocs";
@ -296,6 +297,7 @@ public class CodegenConstants {
public static final String GENERATE_APIS = "generateApis";
public static final String GENERATE_API_DOCS = "generateApiDocs";
public static final String GENERATE_WEBHOOKS = "generateWebhooks";
public static final String GENERATE_API_TESTS = "generateApiTests";
public static final String GENERATE_API_TESTS_DESC = "Specifies that api tests are to be generated.";

View File

@ -60,6 +60,7 @@ import org.openapitools.codegen.meta.features.*;
import org.openapitools.codegen.model.ModelMap;
import org.openapitools.codegen.model.ModelsMap;
import org.openapitools.codegen.model.OperationsMap;
import org.openapitools.codegen.model.WebhooksMap;
import org.openapitools.codegen.serializer.SerializerUtils;
import org.openapitools.codegen.templating.MustacheEngineAdapter;
import org.openapitools.codegen.templating.mustache.*;
@ -977,6 +978,13 @@ public class DefaultCodegen implements CodegenConfig {
return objs;
}
// override with any special post-processing
@Override
@SuppressWarnings("static-method")
public WebhooksMap postProcessWebhooksWithModels(WebhooksMap objs, List<ModelMap> allModels) {
return objs;
}
// override with any special post-processing
@Override
@SuppressWarnings("static-method")

View File

@ -42,11 +42,7 @@ import org.openapitools.codegen.api.TemplateFileType;
import org.openapitools.codegen.ignore.CodegenIgnoreProcessor;
import org.openapitools.codegen.meta.GeneratorMetadata;
import org.openapitools.codegen.meta.Stability;
import org.openapitools.codegen.model.ApiInfoMap;
import org.openapitools.codegen.model.ModelMap;
import org.openapitools.codegen.model.ModelsMap;
import org.openapitools.codegen.model.OperationMap;
import org.openapitools.codegen.model.OperationsMap;
import org.openapitools.codegen.model.*;
import org.openapitools.codegen.serializer.SerializerUtils;
import org.openapitools.codegen.templating.CommonTemplateContentLocator;
import org.openapitools.codegen.templating.GeneratorTemplateContentLocator;
@ -86,6 +82,7 @@ public class DefaultGenerator implements Generator {
private Boolean generateApis = null;
private Boolean generateModels = null;
private Boolean generateSupportingFiles = null;
private Boolean generateWebhooks = null;
private Boolean generateApiTests = null;
private Boolean generateApiDocumentation = null;
private Boolean generateModelTests = null;
@ -210,10 +207,11 @@ public class DefaultGenerator implements Generator {
generateApis = GlobalSettings.getProperty(CodegenConstants.APIS) != null ? Boolean.TRUE : getGeneratorPropertyDefaultSwitch(CodegenConstants.APIS, null);
generateModels = GlobalSettings.getProperty(CodegenConstants.MODELS) != null ? Boolean.TRUE : getGeneratorPropertyDefaultSwitch(CodegenConstants.MODELS, null);
generateSupportingFiles = GlobalSettings.getProperty(CodegenConstants.SUPPORTING_FILES) != null ? Boolean.TRUE : getGeneratorPropertyDefaultSwitch(CodegenConstants.SUPPORTING_FILES, null);
generateWebhooks = GlobalSettings.getProperty(CodegenConstants.WEBHOOKS) != null ? Boolean.TRUE : getGeneratorPropertyDefaultSwitch(CodegenConstants.WEBHOOKS, null);
if (generateApis == null && generateModels == null && generateSupportingFiles == null) {
if (generateApis == null && generateModels == null && generateSupportingFiles == null && generateWebhooks == null) {
// no specifics are set, generate everything
generateApis = generateModels = generateSupportingFiles = true;
generateApis = generateModels = generateSupportingFiles = generateWebhooks = true;
} else {
if (generateApis == null) {
generateApis = false;
@ -224,6 +222,9 @@ public class DefaultGenerator implements Generator {
if (generateSupportingFiles == null) {
generateSupportingFiles = false;
}
if (generateWebhooks == null) {
generateWebhooks = false;
}
}
// model/api tests and documentation options rely on parent generate options (api or model) and no other options.
// They default to true in all scenarios and can only be marked false explicitly
@ -241,6 +242,7 @@ public class DefaultGenerator implements Generator {
config.additionalProperties().put(CodegenConstants.GENERATE_APIS, generateApis);
config.additionalProperties().put(CodegenConstants.GENERATE_MODELS, generateModels);
config.additionalProperties().put(CodegenConstants.GENERATE_WEBHOOKS, generateWebhooks);
if (!generateApiTests && !generateModelTests) {
config.additionalProperties().put(CodegenConstants.EXCLUDE_TESTS, true);
@ -762,6 +764,169 @@ public class DefaultGenerator implements Generator {
}
void generateWebhooks(List<File> files, List<WebhooksMap> allWebhooks, List<ModelMap> allModels) {
if (!generateWebhooks) {
// TODO: Process these anyway and present info via dryRun?
LOGGER.info("Skipping generation of Webhooks.");
return;
}
Map<String, List<CodegenOperation>> webhooks = processWebhooks(this.openAPI.getWebhooks());
Set<String> webhooksToGenerate = null;
String webhookNames = GlobalSettings.getProperty(CodegenConstants.WEBHOOKS);
if (webhookNames != null && !webhookNames.isEmpty()) {
webhooksToGenerate = new HashSet<>(Arrays.asList(webhookNames.split(",")));
}
if (webhooksToGenerate != null && !webhooksToGenerate.isEmpty()) {
Map<String, List<CodegenOperation>> Webhooks = new TreeMap<>();
for (String m : webhooks.keySet()) {
if (webhooksToGenerate.contains(m)) {
Webhooks.put(m, webhooks.get(m));
}
}
webhooks = Webhooks;
}
for (String tag : webhooks.keySet()) {
try {
List<CodegenOperation> wks = webhooks.get(tag);
wks.sort((one, another) -> ObjectUtils.compare(one.operationId, another.operationId));
WebhooksMap operation = processWebhooks(config, tag, wks, allModels);
URL url = URLPathUtils.getServerURL(openAPI, config.serverVariableOverrides());
operation.put("basePath", basePath);
operation.put("basePathWithoutHost", removeTrailingSlash(config.encodePath(url.getPath())));
operation.put("contextPath", contextPath);
operation.put("baseName", tag);
Optional.ofNullable(openAPI.getTags()).orElseGet(Collections::emptyList).stream()
.map(Tag::getName)
.filter(Objects::nonNull)
.filter(tag::equalsIgnoreCase)
.findFirst()
.ifPresent(tagName -> operation.put("operationTagName", config.escapeText(tagName)));
operation.put("operationTagDescription", "");
Optional.ofNullable(openAPI.getTags()).orElseGet(Collections::emptyList).stream()
.filter(t -> tag.equalsIgnoreCase(t.getName()))
.map(Tag::getDescription)
.filter(Objects::nonNull)
.findFirst()
.ifPresent(description -> operation.put("operationTagDescription", config.escapeText(description)));
Optional.ofNullable(config.additionalProperties().get("appVersion")).ifPresent(version -> operation.put("version", version));
operation.put("apiPackage", config.apiPackage());
operation.put("modelPackage", config.modelPackage());
operation.putAll(config.additionalProperties());
operation.put("classname", config.toApiName(tag));
operation.put("classVarName", config.toApiVarName(tag));
operation.put("importPath", config.toApiImport(tag));
operation.put("classFilename", config.toApiFilename(tag));
operation.put("strictSpecBehavior", config.isStrictSpecBehavior());
Optional.ofNullable(openAPI.getInfo()).map(Info::getLicense).ifPresent(license -> operation.put("license", license));
Optional.ofNullable(openAPI.getInfo()).map(Info::getContact).ifPresent(contact -> operation.put("contact", contact));
if (allModels == null || allModels.isEmpty()) {
operation.put("hasModel", false);
} else {
operation.put("hasModel", true);
}
if (!config.vendorExtensions().isEmpty()) {
operation.put("vendorExtensions", config.vendorExtensions());
}
// process top-level x-group-parameters
if (config.vendorExtensions().containsKey("x-group-parameters")) {
boolean isGroupParameters = Boolean.parseBoolean(config.vendorExtensions().get("x-group-parameters").toString());
OperationMap objectMap = operation.getWebhooks();
List<CodegenOperation> operations = objectMap.getOperation();
for (CodegenOperation op : operations) {
if (isGroupParameters && !op.vendorExtensions.containsKey("x-group-parameters")) {
op.vendorExtensions.put("x-group-parameters", Boolean.TRUE);
}
}
}
// Pass sortParamsByRequiredFlag through to the Mustache template...
boolean sortParamsByRequiredFlag = true;
if (this.config.additionalProperties().containsKey(CodegenConstants.SORT_PARAMS_BY_REQUIRED_FLAG)) {
sortParamsByRequiredFlag = Boolean.parseBoolean(this.config.additionalProperties().get(CodegenConstants.SORT_PARAMS_BY_REQUIRED_FLAG).toString());
}
operation.put("sortParamsByRequiredFlag", sortParamsByRequiredFlag);
/* consumes, produces are no longer defined in OAS3.0
processMimeTypes(swagger.getConsumes(), operation, "consumes");
processMimeTypes(swagger.getProduces(), operation, "produces");
*/
allWebhooks.add(operation);
addAuthenticationSwitches(operation);
for (String templateName : config.apiTemplateFiles().keySet()) {
File written = null;
if (config.templateOutputDirs().containsKey(templateName)) {
String outputDir = config.getOutputDir() + File.separator + config.templateOutputDirs().get(templateName);
String filename = config.apiFilename(templateName, tag, outputDir);
// do not overwrite apiController file for spring server
if (apiFilePreCheck(filename, generatorCheck, templateName, templateCheck)){
written = processTemplateToFile(operation, templateName, filename, generateWebhooks, CodegenConstants.WEBHOOKS, outputDir);
} else {
LOGGER.info("Implementation file {} is not overwritten",filename);
}
} else {
String filename = config.apiFilename(templateName, tag);
if(apiFilePreCheck(filename, generatorCheck, templateName, templateCheck)){
written = processTemplateToFile(operation, templateName, filename, generateWebhooks, CodegenConstants.WEBHOOKS);
} else {
LOGGER.info("Implementation file {} is not overwritten",filename);
}
}
if (written != null) {
files.add(written);
if (config.isEnablePostProcessFile() && !dryRun) {
config.postProcessFile(written, "api");
}
}
}
// to generate api test files
for (String templateName : config.apiTestTemplateFiles().keySet()) {
String filename = config.apiTestFilename(templateName, tag);
File apiTestFile = new File(filename);
// do not overwrite test file that already exists
if (apiTestFile.exists()) {
this.templateProcessor.skip(apiTestFile.toPath(), "Test files never overwrite an existing file of the same name.");
} else {
File written = processTemplateToFile(operation, templateName, filename, generateApiTests, CodegenConstants.API_TESTS, config.apiTestFileFolder());
if (written != null) {
files.add(written);
if (config.isEnablePostProcessFile() && !dryRun) {
config.postProcessFile(written, "api-test");
}
}
}
}
// to generate api documentation files
for (String templateName : config.apiDocTemplateFiles().keySet()) {
String filename = config.apiDocFilename(templateName, tag);
File written = processTemplateToFile(operation, templateName, filename, generateApiDocumentation, CodegenConstants.API_DOCS);
if (written != null) {
files.add(written);
if (config.isEnablePostProcessFile() && !dryRun) {
config.postProcessFile(written, "api-doc");
}
}
}
} catch (Exception e) {
throw new RuntimeException("Could not generate api file for '" + tag + "'", e);
}
}
if (GlobalSettings.getProperty("debugOperations") != null) {
LOGGER.info("############ Operation info ############");
Json.prettyPrint(allWebhooks);
}
}
// checking if apiController file is already existed for spring generator
private boolean apiFilePreCheck(String filename, String generator, String templateName, String apiControllerTemplate) {
File apiFile = new File(filename);
@ -917,6 +1082,10 @@ public class DefaultGenerator implements Generator {
}
Map<String, Object> buildSupportFileBundle(List<OperationsMap> allOperations, List<ModelMap> allModels) {
return this.buildSupportFileBundle(allOperations, allModels, null);
}
Map<String, Object> buildSupportFileBundle(List<OperationsMap> allOperations, List<ModelMap> allModels, List<WebhooksMap> allWebhooks) {
Map<String, Object> bundle = new HashMap<>(config.additionalProperties());
bundle.put("apiPackage", config.apiPackage());
@ -936,6 +1105,7 @@ public class DefaultGenerator implements Generator {
}
bundle.put("contextPath", contextPath);
bundle.put("apiInfo", apis);
bundle.put("webhooks", allWebhooks);
bundle.put("models", allModels);
bundle.put("apiFolder", config.apiPackage().replace('.', File.separatorChar));
bundle.put("modelPackage", config.modelPackage());
@ -1066,9 +1236,11 @@ public class DefaultGenerator implements Generator {
// apis
List<OperationsMap> allOperations = new ArrayList<>();
generateApis(files, allOperations, allModels);
// webhooks
List<WebhooksMap> allWebhooks = new ArrayList<>();
generateWebhooks(files, allWebhooks, allModels);
// supporting files
Map<String, Object> bundle = buildSupportFileBundle(allOperations, allModels);
Map<String, Object> bundle = buildSupportFileBundle(allOperations, allModels, allWebhooks);
generateSupportingFiles(files, bundle);
if (dryRun) {
@ -1243,6 +1415,27 @@ public class DefaultGenerator implements Generator {
return ops;
}
public Map<String, List<CodegenOperation>> processWebhooks(Map<String, PathItem> webhooks) {
Map<String, List<CodegenOperation>> ops = new TreeMap<>();
// when input file is not valid and doesn't contain any paths
if (webhooks == null) {
return ops;
}
for (Map.Entry<String, PathItem> webhooksEntry : webhooks.entrySet()) {
String resourceKey = webhooksEntry.getKey();
PathItem path = webhooksEntry.getValue();
processOperation(resourceKey, "get", path.getGet(), ops, path);
processOperation(resourceKey, "head", path.getHead(), ops, path);
processOperation(resourceKey, "put", path.getPut(), ops, path);
processOperation(resourceKey, "post", path.getPost(), ops, path);
processOperation(resourceKey, "delete", path.getDelete(), ops, path);
processOperation(resourceKey, "patch", path.getPatch(), ops, path);
processOperation(resourceKey, "options", path.getOptions(), ops, path);
processOperation(resourceKey, "trace", path.getTrace(), ops, path);
}
return ops;
}
private void processOperation(String resourcePath, String httpMethod, Operation operation, Map<String, List<CodegenOperation>> operations, PathItem path) {
if (operation == null) {
return;
@ -1394,6 +1587,50 @@ public class DefaultGenerator implements Generator {
return operations;
}
private WebhooksMap processWebhooks(CodegenConfig config, String tag, List<CodegenOperation> wks, List<ModelMap> allModels) {
WebhooksMap operations = new WebhooksMap();
OperationMap objs = new OperationMap();
objs.setClassname(config.toApiName(tag));
objs.setPathPrefix(config.toApiVarName(tag));
// check for nickname uniqueness
if (config.getAddSuffixToDuplicateOperationNicknames()) {
Set<String> opIds = new HashSet<>();
int counter = 0;
for (CodegenOperation op : wks) {
String opId = op.nickname;
if (opIds.contains(opId)) {
counter++;
op.nickname += "_" + counter;
}
opIds.add(opId);
}
}
objs.setOperation(wks);
operations.setWebhooks(objs);
operations.put("package", config.apiPackage());
Set<String> allImports = new ConcurrentSkipListSet<>();
for (CodegenOperation op : wks) {
allImports.addAll(op.imports);
}
Map<String, String> mappings = getAllImportsMappings(allImports);
Set<Map<String, String>> imports = toImportsObjects(mappings);
//Some codegen implementations rely on a list interface for the imports
operations.setImports(new ArrayList<>(imports));
// add a flag to indicate whether there's any {{import}}
if (!imports.isEmpty()) {
operations.put("hasImport", true);
}
config.postProcessWebhooksWithModels(operations, allModels);
return operations;
}
/**
* Transforms a set of imports to a map with key config.toModelImport(import) and value the import string.
*

View File

@ -0,0 +1,24 @@
package org.openapitools.codegen.model;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class WebhooksMap extends HashMap<String, Object> {
public OperationMap getWebhooks() {
return (OperationMap) get("operations");
}
public void setWebhooks(OperationMap objs) {
put("operations", objs);
}
@SuppressWarnings("unchecked")
public List<Map<String, String>> getImports() {
return (List<Map<String, String>>) get("imports");
}
public void setImports(List<Map<String, String>> imports) {
put("imports", imports);
}
}

View File

@ -55,13 +55,19 @@ type ApiHandleFunctions struct {
}
func getRoutes(handleFunctions ApiHandleFunctions) []Route {
return []Route{
{{#apiInfo}}{{#apis}}{{#operations}}{{#operation}}
return []Route{ {{#apiInfo}}{{#apis}}{{#operations}}{{#operation}}
{
"{{operationId}}",
http.Method{{httpMethod}},
"{{{basePathWithoutHost}}}{{{path}}}",
handleFunctions.{{classname}}.{{operationId}},
},{{/operation}}{{/operations}}{{/apis}}{{/apiInfo}}
},{{/operation}}{{/operations}}{{/apis}}{{/apiInfo}}{{#webhooks}}{{#operations}}{{#operation}}
// webhook handler (adjust path accordingly)
{
"{{operationId}}",
http.Method{{httpMethod}},
"{{{basePathWithoutHost}}}{{{path}}}",
handleFunctions.{{classname}}.{{operationId}},
},{{/operation}}{{/operations}}{{/webhooks}}
}
}

View File

@ -51,6 +51,7 @@ import org.testng.annotations.Ignore;
import org.testng.annotations.Test;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.*;
import java.util.stream.Collectors;
@ -4808,4 +4809,18 @@ public class DefaultCodegenTest {
Assert.assertTrue(codegen.isXmlMimeType("application/xml"));
Assert.assertTrue(codegen.isXmlMimeType("application/rss+xml"));
}
@Test
public void testWebhooks() throws IOException {
final OpenAPI openAPI = TestUtils.parseFlattenSpec("src/test/resources/3_1/webhooks.yaml");
final DefaultCodegen codegen = new DefaultCodegen();
codegen.setOpenAPI(openAPI);
Operation operation = openAPI.getWebhooks().get("newPet").getPost();
CodegenOperation co = codegen.fromOperation("newPet", "get", operation, null);
Assert.assertEquals(co.path, "/newPet");
Assert.assertEquals(co.operationId, "newPetGet");
}
}

View File

@ -59,4 +59,25 @@ public class GoGinServerCodegenTest {
"require github.com/gin-gonic/gin v1.9.1");
}
@Test
public void webhooks() throws IOException {
File output = Files.createTempDirectory("test").toFile();
output.deleteOnExit();
final CodegenConfigurator configurator = new CodegenConfigurator()
.setGeneratorName("go-gin-server")
.setGitUserId("my-user")
.setGitRepoId("my-repo")
.setPackageName("my-package")
.setInputSpec("src/test/resources/3_1/webhooks.yaml")
.setOutputDir(output.getAbsolutePath().replace("\\", "/"));
DefaultGenerator generator = new DefaultGenerator();
List<File> files = generator.opts(configurator.toClientOptInput()).generate();
files.forEach(File::deleteOnExit);
TestUtils.assertFileContains(Paths.get(output + "/go/routers.go"),
"NewPetPost");
}
}

View File

@ -0,0 +1,34 @@
openapi: 3.1.0
info:
title: Webhook Example
version: 1.0.0
# Since OAS 3.1.0 the paths element isn't necessary. Now a valid OpenAPI Document can describe only paths, webhooks, or even only reusable components
webhooks:
# Each webhook needs a name
newPet:
# This is a Path Item Object, the only difference is that the request is initiated by the API provider
post:
requestBody:
description: Information about a new pet in the system
content:
application/json:
schema:
$ref: "#/components/schemas/Pet"
responses:
"200":
description: Return a 200 status to indicate that the data was received successfully
components:
schemas:
Pet:
required:
- id
- name
properties:
id:
type: integer
format: int64
name:
type: string
tag:
type: string

View File

@ -67,8 +67,7 @@ type ApiHandleFunctions struct {
}
func getRoutes(handleFunctions ApiHandleFunctions) []Route {
return []Route{
return []Route{
{
"AddPet",
http.MethodPost,