diff --git a/.github/workflows/samples-rust-server.yaml b/.github/workflows/samples-rust-server.yaml index 7530b82530d6..aa24e23dfc28 100644 --- a/.github/workflows/samples-rust-server.yaml +++ b/.github/workflows/samples-rust-server.yaml @@ -5,10 +5,12 @@ on: paths: - "samples/server/petstore/rust-server/**" - "samples/server/petstore/rust-axum/**" + - "samples/server/petstore/rust-salvo/**" pull_request: paths: - "samples/server/petstore/rust-server/**" - "samples/server/petstore/rust-axum/**" + - "samples/server/petstore/rust-salvo/**" jobs: build: @@ -22,6 +24,7 @@ jobs: - samples/server/petstore/rust-server/ - samples/server/petstore/rust-server-deprecated/ - samples/server/petstore/rust-axum/ + - samples/server/petstore/rust-salvo/ steps: - uses: actions/checkout@v5 - uses: actions-rs/toolchain@v1 diff --git a/bin/configs/manual/rust-salvo-petstore.yaml b/bin/configs/manual/rust-salvo-petstore.yaml new file mode 100644 index 000000000000..30fb04bbe3fe --- /dev/null +++ b/bin/configs/manual/rust-salvo-petstore.yaml @@ -0,0 +1,11 @@ +generatorName: rust-salvo +outputDir: samples/server/petstore/rust-salvo/output/petstore +inputSpec: modules/openapi-generator/src/test/resources/3_0/petstore.yaml +templateDir: modules/openapi-generator/src/main/resources/rust-salvo +generateAliasAsModel: true +additionalProperties: + hideGenerationTimestamp: "true" + packageName: petstore_salvo + homePageUrl: https://github.com/openapitools/openapi-generator +globalProperties: + skipFormModel: false diff --git a/docs/generators.md b/docs/generators.md index d9d5bafcaf3c..ed22c4b53e16 100644 --- a/docs/generators.md +++ b/docs/generators.md @@ -147,6 +147,7 @@ The following generators are available: * [ruby-on-rails](generators/ruby-on-rails.md) * [ruby-sinatra](generators/ruby-sinatra.md) * [rust-axum (beta)](generators/rust-axum.md) +* [rust-salvo (beta)](generators/rust-salvo.md) * [rust-server](generators/rust-server.md) * [rust-server-deprecated](generators/rust-server-deprecated.md) * [scala-akka-http-server (beta)](generators/scala-akka-http-server.md) diff --git a/docs/generators/rust-salvo.md b/docs/generators/rust-salvo.md new file mode 100644 index 000000000000..712527544e35 --- /dev/null +++ b/docs/generators/rust-salvo.md @@ -0,0 +1,236 @@ +--- +title: Documentation for the rust-salvo Generator +--- + +## METADATA + +| Property | Value | Notes | +| -------- | ----- | ----- | +| generator name | rust-salvo | pass this to the generate command after -g | +| generator stability | BETA | | +| generator type | SERVER | | +| generator language | Rust | | +| generator default templating engine | mustache | | +| helpTxt | Generates a Rust server library using Salvo web framework. | | + +## CONFIG OPTIONS +These options may be applied as additional-properties (cli) or configOptions (plugins). Refer to [configuration docs](https://openapi-generator.tech/docs/configuration) for more details. + +| Option | Description | Values | Default | +| ------ | ----------- | ------ | ------- | +|enableAuthMiddleware|Enable authentication middleware| |false| +|enableCorsMiddleware|Enable CORS middleware| |false| +|enableRequestValidation|Enable request validation middleware| |true| +|enableResponseValidation|Enable response validation middleware| |false| +|packageName|Rust crate name (convention: snake_case).| |salvo_openapi| +|packageVersion|Rust crate version.| |null| + +## IMPORT MAPPING + +| Type/Alias | Imports | +| ---------- | ------- | + + +## INSTANTIATION TYPES + +| Type/Alias | Instantiated By | +| ---------- | --------------- | +|array|Vec| +|map|std::collections::HashMap| + + +## LANGUAGE PRIMITIVES + + + +## RESERVED WORDS + + + +## FEATURE SET + + +### Client Modification Feature +| Name | Supported | Defined By | +| ---- | --------- | ---------- | +|BasePath|✗|ToolingExtension +|Authorizations|✗|ToolingExtension +|UserAgent|✗|ToolingExtension +|MockServer|✗|ToolingExtension + +### Data Type Feature +| Name | Supported | Defined By | +| ---- | --------- | ---------- | +|Custom|✗|OAS2,OAS3 +|Int32|✓|OAS2,OAS3 +|Int64|✓|OAS2,OAS3 +|Float|✓|OAS2,OAS3 +|Double|✓|OAS2,OAS3 +|Decimal|✓|ToolingExtension +|String|✓|OAS2,OAS3 +|Byte|✓|OAS2,OAS3 +|Binary|✓|OAS2,OAS3 +|Boolean|✓|OAS2,OAS3 +|Date|✓|OAS2,OAS3 +|DateTime|✓|OAS2,OAS3 +|Password|✓|OAS2,OAS3 +|File|✓|OAS2 +|Uuid|✗| +|Array|✓|OAS2,OAS3 +|Null|✗|OAS3 +|AnyType|✗|OAS2,OAS3 +|Object|✓|OAS2,OAS3 +|Maps|✓|ToolingExtension +|CollectionFormat|✓|OAS2 +|CollectionFormatMulti|✓|OAS2 +|Enum|✓|OAS2,OAS3 +|ArrayOfEnum|✓|ToolingExtension +|ArrayOfModel|✓|ToolingExtension +|ArrayOfCollectionOfPrimitives|✓|ToolingExtension +|ArrayOfCollectionOfModel|✓|ToolingExtension +|ArrayOfCollectionOfEnum|✓|ToolingExtension +|MapOfEnum|✓|ToolingExtension +|MapOfModel|✓|ToolingExtension +|MapOfCollectionOfPrimitives|✓|ToolingExtension +|MapOfCollectionOfModel|✓|ToolingExtension +|MapOfCollectionOfEnum|✓|ToolingExtension + +### Documentation Feature +| Name | Supported | Defined By | +| ---- | --------- | ---------- | +|Readme|✗|ToolingExtension +|Model|✓|ToolingExtension +|Api|✓|ToolingExtension + +### Global Feature +| Name | Supported | Defined By | +| ---- | --------- | ---------- | +|Host|✓|OAS2,OAS3 +|BasePath|✓|OAS2,OAS3 +|Info|✗|OAS2,OAS3 +|Schemes|✗|OAS2,OAS3 +|PartialSchemes|✓|OAS2,OAS3 +|Consumes|✓|OAS2 +|Produces|✓|OAS2 +|ExternalDocumentation|✗|OAS2,OAS3 +|Examples|✗|OAS2,OAS3 +|XMLStructureDefinitions|✗|OAS2,OAS3 +|MultiServer|✗|OAS3 +|ParameterizedServer|✗|OAS3 +|ParameterStyling|✗|OAS3 +|Callbacks|✗|OAS3 +|LinkObjects|✗|OAS3 + +### Parameter Feature +| Name | Supported | Defined By | +| ---- | --------- | ---------- | +|Path|✓|OAS2,OAS3 +|Query|✓|OAS2,OAS3 +|Header|✓|OAS2,OAS3 +|Body|✓|OAS2 +|FormUnencoded|✓|OAS2 +|FormMultipart|✓|OAS2 +|Cookie|✓|OAS3 + +### Schema Support Feature +| Name | Supported | Defined By | +| ---- | --------- | ---------- | +|Simple|✓|OAS2,OAS3 +|Composite|✓|OAS2,OAS3 +|Polymorphism|✗|OAS2,OAS3 +|Union|✗|OAS3 +|allOf|✓|OAS2,OAS3 +|anyOf|✓|OAS3 +|oneOf|✓|OAS3 +|not|✗|OAS3 + +### Security Feature +| Name | Supported | Defined By | +| ---- | --------- | ---------- | +|BasicAuth|✓|OAS2,OAS3 +|ApiKey|✓|OAS2,OAS3 +|OpenIDConnect|✗|OAS3 +|BearerToken|✓|OAS3 +|OAuth2_Implicit|✗|OAS2,OAS3 +|OAuth2_Password|✗|OAS2,OAS3 +|OAuth2_ClientCredentials|✗|OAS2,OAS3 +|OAuth2_AuthorizationCode|✗|OAS2,OAS3 +|SignatureAuth|✗|OAS3 +|AWSV4Signature|✗|ToolingExtension + +### Wire Format Feature +| Name | Supported | Defined By | +| ---- | --------- | ---------- | +|JSON|✓|OAS2,OAS3 +|XML|✗|OAS2,OAS3 +|PROTOBUF|✗|ToolingExtension +|Custom|✓|OAS2,OAS3 diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RustSalvoServerCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RustSalvoServerCodegen.java new file mode 100644 index 000000000000..73d40ebbb337 --- /dev/null +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/RustSalvoServerCodegen.java @@ -0,0 +1,551 @@ +/* + * Copyright 2018 OpenAPI-Generator Contributors (https://openapi-generator.tech) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.openapitools.codegen.languages; + +import com.samskivert.mustache.Mustache; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.parameters.Parameter; +import io.swagger.v3.oas.models.parameters.RequestBody; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.servers.Server; +import io.swagger.v3.oas.models.tags.Tag; +import org.apache.commons.io.FilenameUtils; +import org.apache.commons.lang3.StringUtils; +import org.openapitools.codegen.*; +import org.openapitools.codegen.meta.GeneratorMetadata; +import org.openapitools.codegen.meta.Stability; +import org.openapitools.codegen.meta.features.GlobalFeature; +import org.openapitools.codegen.meta.features.SchemaSupportFeature; +import org.openapitools.codegen.meta.features.SecurityFeature; +import org.openapitools.codegen.meta.features.WireFormatFeature; +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.utils.ModelUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.math.BigInteger; +import java.nio.file.Path; +import java.util.*; +import java.util.stream.Collectors; + +import static org.openapitools.codegen.utils.StringUtils.camelize; +import static org.openapitools.codegen.utils.StringUtils.underscore; + +public class RustSalvoServerCodegen extends AbstractRustCodegen implements CodegenConfig { + public static final String PROJECT_NAME = "salvo-openapi-server"; + + private String packageName; + private String packageVersion; + private Boolean enableRequestValidation = true; + private Boolean enableResponseValidation = false; + private Boolean enableAuthMiddleware = false; + private Boolean enableCorsMiddleware = false; + + private String externCrateName; + + // Types specific to Salvo + private static final String uuidType = "uuid::Uuid"; + private static final String bytesType = "Vec"; + private static final String dateType = "chrono::naive::NaiveDate"; + private static final String dateTimeType = "chrono::DateTime"; + private static final String stringType = "String"; + private static final String objectType = "serde_json::Value"; + private static final String mapType = "std::collections::HashMap"; + private static final String vecType = "Vec"; + + // MIME types + private static final String jsonMimeType = "application/json"; + private static final String formUrlEncodedMimeType = "application/x-www-form-urlencoded"; + private static final String multipartMimeType = "multipart/form-data"; + private static final String plainTextMimeType = "text/plain"; + + // Salvo-specific routing and handler mapping + private final Map> routeMap = new HashMap<>(); + private boolean hasAuthHandlers = false; + + private final Logger LOGGER = LoggerFactory.getLogger(RustSalvoServerCodegen.class); + + public RustSalvoServerCodegen() { + super(); + + modifyFeatureSet(features -> features + .wireFormatFeatures(EnumSet.of( + WireFormatFeature.JSON, + WireFormatFeature.Custom + )) + .securityFeatures(EnumSet.of( + SecurityFeature.ApiKey, + SecurityFeature.BasicAuth, + SecurityFeature.BearerToken + )) + .schemaSupportFeatures(EnumSet.of( + SchemaSupportFeature.Simple, + SchemaSupportFeature.Composite, + SchemaSupportFeature.oneOf, + SchemaSupportFeature.anyOf, + SchemaSupportFeature.allOf + )) + .excludeGlobalFeatures( + GlobalFeature.Info, + GlobalFeature.ExternalDocumentation, + GlobalFeature.Examples, + GlobalFeature.XMLStructureDefinitions, + GlobalFeature.MultiServer, + GlobalFeature.ParameterizedServer, + GlobalFeature.ParameterStyling, + GlobalFeature.Callbacks, + GlobalFeature.LinkObjects + ) + ); + + generatorMetadata = GeneratorMetadata.newBuilder(generatorMetadata) + .stability(Stability.BETA) + .build(); + + hideGenerationTimestamp = Boolean.FALSE; + outputFolder = Path.of("generated-code", "rust-salvo").toString(); + embeddedTemplateDir = templateDir = "rust-salvo"; + + importMapping = new HashMap<>(); + modelTemplateFiles.clear(); + apiTemplateFiles.put("handlers.mustache", ".rs"); + + // Setup type mappings for Salvo + setupTypeMappings(); + + // Setup CLI options + setupCliOptions(); + + // Setup supporting files + setupSupportingFiles(); + } + + private void setupTypeMappings() { + defaultIncludes = new HashSet<>(Set.of("map", "array")); + + languageSpecificPrimitives = new HashSet<>(Set.of( + "bool", "char", "i8", "i16", "i32", "i64", + "u8", "u16", "u32", "u64", "isize", "usize", + "f32", "f64", "str", stringType + )); + + instantiationTypes = new HashMap<>(Map.of( + "array", vecType, + "map", mapType + )); + + typeMapping = new HashMap<>(Map.ofEntries( + new AbstractMap.SimpleEntry<>("number", "f64"), + new AbstractMap.SimpleEntry<>("integer", "i32"), + new AbstractMap.SimpleEntry<>("long", "i64"), + new AbstractMap.SimpleEntry<>("float", "f32"), + new AbstractMap.SimpleEntry<>("double", "f64"), + new AbstractMap.SimpleEntry<>("string", stringType), + new AbstractMap.SimpleEntry<>("UUID", uuidType), + new AbstractMap.SimpleEntry<>("URI", stringType), + new AbstractMap.SimpleEntry<>("byte", "u8"), + new AbstractMap.SimpleEntry<>("ByteArray", bytesType), + new AbstractMap.SimpleEntry<>("binary", bytesType), + new AbstractMap.SimpleEntry<>("boolean", "bool"), + new AbstractMap.SimpleEntry<>("date", dateType), + new AbstractMap.SimpleEntry<>("DateTime", dateTimeType), + new AbstractMap.SimpleEntry<>("password", stringType), + new AbstractMap.SimpleEntry<>("File", bytesType), + new AbstractMap.SimpleEntry<>("file", bytesType), + new AbstractMap.SimpleEntry<>("array", vecType), + new AbstractMap.SimpleEntry<>("map", mapType), + new AbstractMap.SimpleEntry<>("object", objectType), + new AbstractMap.SimpleEntry<>("AnyType", objectType) + )); + } + + private void setupCliOptions() { + cliOptions = new ArrayList<>(List.of( + new CliOption(CodegenConstants.PACKAGE_NAME, + "Rust crate name (convention: snake_case).") + .defaultValue("salvo_openapi"), + new CliOption(CodegenConstants.PACKAGE_VERSION, + "Rust crate version."), + new CliOption("enableRequestValidation", + "Enable request validation middleware") + .defaultValue(Boolean.TRUE.toString()), + new CliOption("enableResponseValidation", + "Enable response validation middleware") + .defaultValue(Boolean.FALSE.toString()), + new CliOption("enableAuthMiddleware", + "Enable authentication middleware") + .defaultValue(Boolean.FALSE.toString()), + new CliOption("enableCorsMiddleware", + "Enable CORS middleware") + .defaultValue(Boolean.FALSE.toString()) + )); + } + + private void setupSupportingFiles() { + supportingFiles.add(new SupportingFile("Cargo.mustache", "", "Cargo.toml")); + supportingFiles.add(new SupportingFile("gitignore", "", ".gitignore")); + supportingFiles.add(new SupportingFile("lib.mustache", "src", "lib.rs")); + supportingFiles.add(new SupportingFile("main.mustache", "src", "main.rs")); + supportingFiles.add(new SupportingFile("models.mustache", "src", "models.rs")); + supportingFiles.add(new SupportingFile("handlers-mod.mustache", "src/handlers", "mod.rs")); + supportingFiles.add(new SupportingFile("middleware.mustache", "src", "middleware.rs")); + supportingFiles.add(new SupportingFile("routes.mustache", "src", "routes.rs")); + supportingFiles.add(new SupportingFile("README.mustache", "", "README.md").doNotOverwrite()); + } + + @Override + public CodegenType getTag() { + return CodegenType.SERVER; + } + + @Override + public String getName() { + return "rust-salvo"; + } + + @Override + public String getHelp() { + return "Generates a Rust server library using Salvo web framework."; + } + + @Override + public void processOpts() { + super.processOpts(); + + setPackageName((String) additionalProperties.getOrDefault(CodegenConstants.PACKAGE_NAME, "salvo_openapi")); + + if (additionalProperties.containsKey(CodegenConstants.PACKAGE_VERSION)) { + setPackageVersion((String) additionalProperties.get(CodegenConstants.PACKAGE_VERSION)); + } + + // Process Salvo-specific options; ensure defaults land in additionalProperties + // so templates can branch on them and tests can inspect them. + if (additionalProperties.containsKey("enableRequestValidation")) { + enableRequestValidation = convertPropertyToBooleanAndWriteBack("enableRequestValidation"); + } else { + additionalProperties.put("enableRequestValidation", enableRequestValidation); + } + + if (additionalProperties.containsKey("enableResponseValidation")) { + enableResponseValidation = convertPropertyToBooleanAndWriteBack("enableResponseValidation"); + } else { + additionalProperties.put("enableResponseValidation", enableResponseValidation); + } + + if (additionalProperties.containsKey("enableAuthMiddleware")) { + enableAuthMiddleware = convertPropertyToBooleanAndWriteBack("enableAuthMiddleware"); + } else { + additionalProperties.put("enableAuthMiddleware", enableAuthMiddleware); + } + + if (additionalProperties.containsKey("enableCorsMiddleware")) { + enableCorsMiddleware = convertPropertyToBooleanAndWriteBack("enableCorsMiddleware"); + } else { + additionalProperties.put("enableCorsMiddleware", enableCorsMiddleware); + } + + additionalProperties.put(CodegenConstants.PACKAGE_NAME, packageName); + additionalProperties.put("externCrateName", externCrateName); + } + + private void setPackageName(String packageName) { + this.packageName = packageName; + this.externCrateName = packageName.replace('-', '_'); + } + + private void setPackageVersion(String packageVersion) { + this.packageVersion = packageVersion; + } + + @Override + public String apiPackage() { + return "src" + File.separator + "handlers"; + } + + @Override + public void preprocessOpenAPI(OpenAPI openAPI) { + Info info = openAPI.getInfo(); + + if (packageVersion == null || packageVersion.isEmpty()) { + List versionComponents = new ArrayList<>(Arrays.asList(info.getVersion().split("[.]"))); + if (versionComponents.isEmpty()) { + versionComponents.add("1"); + } + while (versionComponents.size() < 3) { + versionComponents.add("0"); + } + setPackageVersion(String.join(".", versionComponents)); + } + + additionalProperties.put(CodegenConstants.PACKAGE_VERSION, packageVersion); + } + + @Override + public String toApiName(String name) { + return name.isEmpty() ? + "default" : + sanitizeIdentifier(name, CasingType.SNAKE_CASE, "api", "API", true); + } + + @Override + public String toApiFilename(String name) { + return toApiName(name); + } + + @Override + public String apiFileFolder() { + return Path.of(outputFolder, apiPackage().replace('.', File.separatorChar)).toString(); + } + + @Override + public String toOperationId(String operationId) { + return sanitizeIdentifier(operationId, CasingType.SNAKE_CASE, "handler", "handler", true); + } + + @Override + public CodegenOperation fromOperation(String path, String httpMethod, Operation operation, List servers) { + CodegenOperation op = super.fromOperation(path, httpMethod, operation, servers); + + // Salvo-specific operation processing + String handlerName = underscore(op.operationId); + String method = httpMethod.toLowerCase(Locale.ROOT); + op.vendorExtensions.put("x-handler-name", handlerName); + op.vendorExtensions.put("x-route-path", convertPathToSalvoFormat(path)); + op.vendorExtensions.put("x-http-method", method); + + // Group operations by route for Salvo router setup. AuthSchemes is + // populated later in postProcessOperationsWithModels because + // CodegenOperation.authMethods is set by DefaultGenerator AFTER + // fromOperation returns. + String salvoPath = convertPathToSalvoFormat(path); + routeMap.computeIfAbsent(salvoPath, k -> new ArrayList<>()) + .add(new SalvoOperation(method, handlerName, op.vendorExtensions, Collections.emptyList())); + + return op; + } + + private String convertPathToSalvoFormat(String path) { + // Salvo 0.76+ uses {name} for path parameters, matching OpenAPI. + return path; + } + + @Override + public OperationsMap postProcessOperationsWithModels(OperationsMap operationsMap, List allModels) { + OperationMap operations = operationsMap.getOperations(); + operations.put("classnamePascalCase", camelize(operations.getClassname())); + + for (CodegenOperation op : operations.getOperation()) { + List opAuthSchemes = new ArrayList<>(); + if (op.authMethods != null && !op.authMethods.isEmpty()) { + LinkedHashSet seen = new LinkedHashSet<>(); + for (CodegenSecurity sec : op.authMethods) { + SalvoAuthScheme s = SalvoAuthScheme.fromCodegen(sec); + if (s != null && seen.add(s.kind)) { + opAuthSchemes.add(s); + } + } + } + + boolean opHasAuth = !opAuthSchemes.isEmpty(); + if (opHasAuth) { + hasAuthHandlers = true; + op.vendorExtensions.put("x-has-auth", true); + op.vendorExtensions.put("x-salvo-auth-schemes", opAuthSchemes); + op.vendorExtensions.put("x-salvo-auth-multi", opAuthSchemes.size() > 1); + } + + // Mirror the schemes onto the matching SalvoOperation so routes.mustache + // can attach per-route hoops without having to flatten vendorExtensions. + String handlerName = (String) op.vendorExtensions.get("x-handler-name"); + String method = (String) op.vendorExtensions.get("x-http-method"); + if (handlerName != null && method != null) { + for (ArrayList group : routeMap.values()) { + for (SalvoOperation so : group) { + if (method.equals(so.method) && handlerName.equals(so.handlerName)) { + so.authSchemes = opAuthSchemes; + so.hasAuth = opHasAuth; + so.multiAuth = opAuthSchemes.size() > 1; + } + } + } + } + } + + if (hasAuthHandlers) { + operations.put("hasAuthHandlers", true); + operations.getOperation().forEach(op -> op.vendorExtensions.put("hasAuthHandlers", true)); + } + + return operationsMap; + } + + @Override + public Map postProcessSupportingFileData(Map bundle) { + generateYAMLSpecFile(bundle); + + // Add Salvo-specific routing information + List routeGroups = routeMap.entrySet().stream() + .map(entry -> new SalvoRouteGroup(entry.getKey(), entry.getValue())) + .sorted(Comparator.comparing(group -> group.path)) + .collect(Collectors.toList()); + + bundle.put("salvoRoutes", routeGroups); + bundle.put("hasAuthHandlers", hasAuthHandlers); + bundle.put("enableRequestValidation", enableRequestValidation); + bundle.put("enableResponseValidation", enableResponseValidation); + bundle.put("enableAuthMiddleware", enableAuthMiddleware); + bundle.put("enableCorsMiddleware", enableCorsMiddleware); + + return super.postProcessSupportingFileData(bundle); + } + + + + @Override + public void postProcessFile(File file, String fileType) { + super.postProcessFile(file, fileType); + if (file == null) { + return; + } + + String fileName = file.toString(); + String cmd = System.getenv("RUST_POST_PROCESS_FILE"); + if (StringUtils.isEmpty(cmd)) { + cmd = "rustfmt"; + } + + if ("rs".equals(FilenameUtils.getExtension(fileName))) { + executePostProcessor(new String[]{cmd, "--edition", "2021", fileName}); + } + } + + // Helper classes for Salvo-specific data structures + static class SalvoOperation { + public String method; + public String handlerName; + public Map vendorExtensions; + public List authSchemes; + public boolean hasAuth; + public boolean multiAuth; + + SalvoOperation(String method, String handlerName, Map vendorExtensions, + List authSchemes) { + this.method = method; + this.handlerName = handlerName; + this.vendorExtensions = vendorExtensions; + this.authSchemes = authSchemes == null ? Collections.emptyList() : authSchemes; + this.hasAuth = !this.authSchemes.isEmpty(); + this.multiAuth = this.authSchemes.size() > 1; + } + } + + static class SalvoRouteGroup { + public String path; + public ArrayList operations; + + SalvoRouteGroup(String path, ArrayList operations) { + this.path = path; + this.operations = operations; + } + } + + // Auth scheme descriptor exposed to templates. `kind` is used to pick the + // factory function in routes.mustache; `schemaName` shows up in the + // `#[salvo::oapi::endpoint(security(...))]` annotation along with any + // declared OAuth2/OIDC scopes. + static class SalvoAuthScheme { + public String kind; // "apiKey" | "basic" | "bearer" | "oauth2" + public String factory; // factory fn in middleware.rs, null for oauth2 + public String schemaName; // name used in OpenAPI security() annotation + public List scopes; + + SalvoAuthScheme(String kind, String factory, String schemaName, List scopes) { + this.kind = kind; + this.factory = factory; + this.schemaName = schemaName; + this.scopes = scopes; + } + + static SalvoAuthScheme fromCodegen(CodegenSecurity sec) { + List scopeList = extractScopes(sec); + if (Boolean.TRUE.equals(sec.isApiKey)) { + return new SalvoAuthScheme("apiKey", "api_key_auth", "ApiKeyAuth", scopeList); + } + if (Boolean.TRUE.equals(sec.isBasic) && Boolean.TRUE.equals(sec.isBasicBearer)) { + return new SalvoAuthScheme("bearer", "bearer_auth", "BearerAuth", scopeList); + } + if (Boolean.TRUE.equals(sec.isBasic) && Boolean.TRUE.equals(sec.isBasicBasic)) { + return new SalvoAuthScheme("basic", "basic_auth_hoop", "BasicAuth", scopeList); + } + if (Boolean.TRUE.equals(sec.isBasic)) { + return new SalvoAuthScheme("basic", "basic_auth_hoop", "BasicAuth", scopeList); + } + // OAuth2 / OpenIdConnect: leave runtime enforcement to the user, + // but surface the scheme + scopes so the OpenAPI annotation stays + // faithful to the source spec. + if (Boolean.TRUE.equals(sec.isOAuth)) { + return new SalvoAuthScheme("oauth2", null, "OAuth2", scopeList); + } + return null; + } + + @SuppressWarnings("unchecked") + private static List extractScopes(CodegenSecurity sec) { + List out = new ArrayList<>(); + if (sec.scopes == null) { + return out; + } + for (Map entry : sec.scopes) { + Object name = entry.get("scope"); + if (name != null) { + out.add(new SalvoAuthScope(name.toString())); + } + } + return out; + } + } + + // Single OAuth2/OIDC scope name. Held in its own class so mustache can + // render the comma-separated list naturally with `{{#-last}}` semantics. + static class SalvoAuthScope { + public String scope; + + SalvoAuthScope(String scope) { + this.scope = scope; + } + } + + @Override + public void postProcessModelProperty(CodegenModel model, CodegenProperty property) { + super.postProcessModelProperty(model, property); + // Surface a serde rename whenever the Rust field name (already snake_cased + // and keyword-escaped by AbstractRustCodegen.toVarName) differs from the + // OpenAPI wire name, so JSON contracts stay intact (e.g. `petId` on the + // wire, `pet_id` in Rust). + if (property.baseName != null && property.name != null + && !property.name.equals(property.baseName)) { + property.vendorExtensions.put("x-salvo-serde-rename", true); + } + } +} diff --git a/modules/openapi-generator/src/main/resources/META-INF/services/org.openapitools.codegen.CodegenConfig b/modules/openapi-generator/src/main/resources/META-INF/services/org.openapitools.codegen.CodegenConfig index 04da8d20435b..57a8381c0c22 100644 --- a/modules/openapi-generator/src/main/resources/META-INF/services/org.openapitools.codegen.CodegenConfig +++ b/modules/openapi-generator/src/main/resources/META-INF/services/org.openapitools.codegen.CodegenConfig @@ -125,6 +125,7 @@ org.openapitools.codegen.languages.RubyOnRailsServerCodegen org.openapitools.codegen.languages.RubySinatraServerCodegen org.openapitools.codegen.languages.RustAxumServerCodegen org.openapitools.codegen.languages.RustClientCodegen +org.openapitools.codegen.languages.RustSalvoServerCodegen org.openapitools.codegen.languages.RustServerCodegen org.openapitools.codegen.languages.RustServerCodegenDeprecated org.openapitools.codegen.languages.ScalatraServerCodegen diff --git a/modules/openapi-generator/src/main/resources/rust-salvo/Cargo.mustache b/modules/openapi-generator/src/main/resources/rust-salvo/Cargo.mustache new file mode 100644 index 000000000000..5662d1e81ead --- /dev/null +++ b/modules/openapi-generator/src/main/resources/rust-salvo/Cargo.mustache @@ -0,0 +1,58 @@ +[package] +name = "{{{packageName}}}" +version = "{{{packageVersion}}}" +{{#infoEmail}} +authors = ["{{{.}}}"] +{{/infoEmail}} +{{^infoEmail}} +authors = ["OpenAPI Generator team and contributors"] +{{/infoEmail}} +{{#appDescription}} +description = "{{{.}}}" +{{/appDescription}} +{{#licenseInfo}} +license = "{{.}}" +{{/licenseInfo}} +{{^licenseInfo}} +license = "Unlicense" +{{/licenseInfo}} +edition = "2021" +{{#publishRustRegistry}} +publish = ["{{.}}"] +{{/publishRustRegistry}} +{{#repositoryUrl}} +repository = "{{.}}" +{{/repositoryUrl}} +{{#documentationUrl}} +documentation = "{{.}}" +{{/documentationUrl}} +{{#homePageUrl}} +homepage = "{{.}}" +{{/homePageUrl}} + +[features] +default = ["server"] +server = [] + +[dependencies] +salvo = { version = "0.93", features = [ + "oapi", + "cors", + "jwt-auth", + "serve-static", +] } +async-trait = "0.1" +base64 = "0.22" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +anyhow = "1" +chrono = { version = "0.4", features = ["serde"] } +{{#hasUUIDs}} +uuid = { version = "1", features = ["serde", "v4"] } +{{/hasUUIDs}} +{{#enableRequestValidation}} +validator = { version = "0.20", features = ["derive"] } +{{/enableRequestValidation}} diff --git a/modules/openapi-generator/src/main/resources/rust-salvo/README.mustache b/modules/openapi-generator/src/main/resources/rust-salvo/README.mustache new file mode 100644 index 000000000000..1345dcf66366 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/rust-salvo/README.mustache @@ -0,0 +1,122 @@ +# {{#appName}}{{.}}{{/appName}}{{^appName}}{{packageName}}{{/appName}} + +{{#appDescription}} +{{.}} +{{/appDescription}} + +This server was generated by the [OpenAPI Generator](https://openapi-generator.tech) project using the **rust-salvo** generator. + +- API version: {{#appVersion}}{{.}}{{/appVersion}}{{^appVersion}}1.0.0{{/appVersion}} +- Package version: {{packageVersion}} +{{^hideGenerationTimestamp}} +- Build date: {{generatedDate}} +{{/hideGenerationTimestamp}} +- Generator version: {{generatorVersion}} + +{{#externalDocumentationDescription}} +For more information, please visit [{{{externalDocumentationDescription}}}]({{{externalDocumentationURL}}}) +{{/externalDocumentationDescription}} + +## Stack + +| Crate | Purpose | +| ----- | ------- | +| [salvo](https://salvo.rs/) (≥ 0.93) | Web framework + `salvo::oapi` for the OpenAPI-aware endpoint macro | +| [serde](https://serde.rs/) | (De)serialization for models | +| [tokio](https://tokio.rs/) | Async runtime | +| [tracing](https://crates.io/crates/tracing) | Structured logging | +{{#hasUUIDs}} +| [uuid](https://crates.io/crates/uuid) | UUID type for model fields | +{{/hasUUIDs}} +{{#enableRequestValidation}} +| [validator](https://crates.io/crates/validator) | Request-body validation via `#[derive(Validate)]` | +{{/enableRequestValidation}} + +## What the generator produces + +The crate is laid out so the framework-facing surface lives in `src/` and the business logic is something you fill in: + +``` +src/ +├── lib.rs # crate entry: re-exports + create_service / create_router +├── main.rs # binary entry: binds the router on 0.0.0.0:7878 +├── models.rs # data models (derive Debug, Clone, Serialize, Deserialize, ToSchema{{#enableRequestValidation}}, Validate{{/enableRequestValidation}}) +├── routes.rs # Router::with_path("/...") chains mapping methods to handlers +├── handlers/ # one module per OpenAPI tag +│ ├── mod.rs +│ └── .rs # one handler stub per operation +{{#enableAuthMiddleware}} +└── middleware.rs # ApiKey / Basic / Bearer middleware structs (based on the spec's securitySchemes) +{{/enableAuthMiddleware}} +``` + +Handlers use the typed extractors from `salvo::oapi::extract` (`JsonBody`, `PathParam`, `QueryParam`, `HeaderParam`, `FormBody`) so that the `#[salvo::oapi::endpoint]` macro picks up the OpenAPI metadata automatically. Models derive `salvo::oapi::ToSchema` for the same reason — they can flow through the extractors and show up in the generated OpenAPI doc. + +## Filling in the implementation + +Each generated handler is a stub that returns `StatusCode::NOT_IMPLEMENTED`. Replace the body with your business logic, for example: + +```rust +#[salvo::oapi::endpoint(operation_id = "get_pet_by_id", tags("pet"))] +pub async fn get_pet_by_id( + pet_id: PathParam, + res: &mut Response, +) -> Result<(), StatusError> { + let pet = Pet::new("doggie".to_string(), vec!["url".to_string()]); + res.render(Json(pet)); + Ok(()) +} +``` + +## Building and running + +```bash +cargo build # build +cargo run # run the server on http://0.0.0.0:7878 +RUST_LOG=debug cargo run # with verbose logging +``` + +`tracing-subscriber` honours `RUST_LOG`, so set it to whatever level you need (`error`, `warn`, `info`, `debug`, `trace`). + +## API endpoints + +{{#apiDocumentationUrl}} +API documentation is available at: {{.}} +{{/apiDocumentationUrl}} + +{{#apiInfo}} +{{#apis}} +### {{classname}} + +{{#operations}} +{{#operation}} +- **{{httpMethod}}** `{{path}}` — {{summary}}{{^summary}}{{operationId}}{{/summary}} +{{/operation}} +{{/operations}} + +{{/apis}} +{{/apiInfo}} + +## Extending + +- **Authentication** — `{{#enableAuthMiddleware}}auth is enabled{{/enableAuthMiddleware}}{{^enableAuthMiddleware}}re-generate with `--additional-properties=enableAuthMiddleware=true` (or set it in `bin/configs/...yaml`) to scaffold `src/middleware.rs` with `ApiKeyAuth`, `BasicAuth`, and `BearerAuth` handlers wired to the spec's `securitySchemes`{{/enableAuthMiddleware}}. +- **CORS** — `{{#enableCorsMiddleware}}CORS is enabled{{/enableCorsMiddleware}}{{^enableCorsMiddleware}}re-generate with `--additional-properties=enableCorsMiddleware=true` to attach `salvo::cors::Cors::new()` as a hoop on the service{{/enableCorsMiddleware}}. +- **Validation** — `{{#enableRequestValidation}}request validation is enabled, models derive `Validate`{{/enableRequestValidation}}{{^enableRequestValidation}}re-generate with `--additional-properties=enableRequestValidation=true` to derive `Validate` on models and pull in the `validator` crate{{/enableRequestValidation}}. +- **Database** — drop one of [sqlx](https://crates.io/crates/sqlx), [Diesel](https://crates.io/crates/diesel), or [SeaORM](https://crates.io/crates/sea-orm) into `Cargo.toml` and inject a connection pool through `Depot` (Salvo's per-request typed storage). + +## Testing + +```bash +cargo test +``` + +Salvo's `TestClient` (re-exported as `salvo::test::TestClient`) is the idiomatic way to drive handlers from integration tests. + +## License + +{{#licenseInfo}} +This project is licensed under {{.}}. +{{/licenseInfo}} +{{^licenseInfo}} +This project is unlicensed. +{{/licenseInfo}} diff --git a/modules/openapi-generator/src/main/resources/rust-salvo/gitignore b/modules/openapi-generator/src/main/resources/rust-salvo/gitignore new file mode 100644 index 000000000000..a9ff9faa89e5 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/rust-salvo/gitignore @@ -0,0 +1,30 @@ +# Generated by Cargo +/target/ +Cargo.lock + +# Editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Log files +*.log + +# Environment variables +.env +.env.local + +# Backup files +*.bak +*.backup diff --git a/modules/openapi-generator/src/main/resources/rust-salvo/handlers-mod.mustache b/modules/openapi-generator/src/main/resources/rust-salvo/handlers-mod.mustache new file mode 100644 index 000000000000..b3a909dae427 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/rust-salvo/handlers-mod.mustache @@ -0,0 +1,18 @@ +{{>partial_header}} +//! API handlers module +//! +//! This module contains HTTP request handlers for the API endpoints. +//! Each handler function processes incoming requests and returns responses. + +{{#apiInfo}} +{{#apis}} +pub mod {{classFilename}}; +{{/apis}} +{{/apiInfo}} + +// Re-exports for easier access +{{#apiInfo}} +{{#apis}} +pub use {{classFilename}}::*; +{{/apis}} +{{/apiInfo}} diff --git a/modules/openapi-generator/src/main/resources/rust-salvo/handlers.mustache b/modules/openapi-generator/src/main/resources/rust-salvo/handlers.mustache new file mode 100644 index 000000000000..640508728c40 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/rust-salvo/handlers.mustache @@ -0,0 +1,66 @@ +{{>partial_header}} +//! {{#appName}}{{.}}{{/appName}}{{^appName}}{{packageName}}{{/appName}} handlers +//! +//! API version: {{#appVersion}}{{.}}{{/appVersion}}{{^appVersion}}1.0.0{{/appVersion}} +//! + +use salvo::prelude::*; +use salvo::oapi::extract::{JsonBody, PathParam, QueryParam, HeaderParam, FormBody}; +use serde::{Deserialize, Serialize}; +{{#hasUUIDs}} +use uuid::Uuid; +{{/hasUUIDs}} +{{#enableRequestValidation}} +use validator::Validate; +{{/enableRequestValidation}} + +use crate::models::{self, *}; + +{{#operations}} +{{#operation}} +{{#notes}} +/// {{.}} +{{/notes}} +{{^notes}} +/// {{summary}}{{^summary}}{{operationId}}{{/summary}} +{{/notes}} +#[salvo::oapi::endpoint( + operation_id = "{{operationId}}", + tags("{{classname}}"){{#vendorExtensions.x-has-auth}}, + security({{#vendorExtensions.x-salvo-auth-schemes}}("{{schemaName}}" = [{{#scopes}}"{{scope}}"{{^-last}}, {{/-last}}{{/scopes}}]){{^-last}}, {{/-last}}{{/vendorExtensions.x-salvo-auth-schemes}}){{/vendorExtensions.x-has-auth}} +)] +pub async fn {{operationId}}( +{{#hasParams}} +{{#allParams}} +{{#isQueryParam}} + {{paramName}}: QueryParam<{{{dataType}}}, {{#required}}true{{/required}}{{^required}}false{{/required}}>, +{{/isQueryParam}} +{{#isPathParam}} + {{paramName}}: PathParam<{{{dataType}}}>, +{{/isPathParam}} +{{#isHeaderParam}} + {{paramName}}: HeaderParam<{{{dataType}}}, {{#required}}true{{/required}}{{^required}}false{{/required}}>, +{{/isHeaderParam}} +{{#isBodyParam}} + {{paramName}}: JsonBody<{{{dataType}}}>, +{{/isBodyParam}} +{{#isFormParam}} + {{paramName}}: FormBody<{{{dataType}}}>, +{{/isFormParam}} +{{/allParams}} +{{/hasParams}} + res: &mut Response, +) -> Result<(), StatusError> { + {{#notes}} + // {{.}} + {{/notes}} + {{^notes}} + // TODO: Implement {{operationId}} + {{/notes}} + + res.status_code(StatusCode::NOT_IMPLEMENTED); + Ok(()) +} + +{{/operation}} +{{/operations}} diff --git a/modules/openapi-generator/src/main/resources/rust-salvo/lib.mustache b/modules/openapi-generator/src/main/resources/rust-salvo/lib.mustache new file mode 100644 index 000000000000..85de2e3348e0 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/rust-salvo/lib.mustache @@ -0,0 +1,55 @@ +#![allow( + missing_docs, + trivial_casts, + unused_variables, + unused_mut, + unused_extern_crates, + non_camel_case_types, + unused_imports, + unused_attributes +)] +#![allow( + clippy::derive_partial_eq_without_eq, + clippy::disallowed_names, + clippy::too_many_arguments +)] + +pub const BASE_PATH: &str = "{{{basePathWithoutHost}}}"; +{{#appVersion}} +pub const API_VERSION: &str = "{{{.}}}"; +{{/appVersion}} + +pub mod handlers; +pub mod models; +pub mod routes; +{{#enableAuthMiddleware}} +pub mod middleware; +{{/enableAuthMiddleware}} + +pub use handlers::*; +pub use models::*; +pub use routes::*; + +use salvo::prelude::*; + +{{#enableAuthMiddleware}} +use middleware::*; +{{/enableAuthMiddleware}} + +/// Create and configure the Salvo service. +/// +/// Authentication middleware is attached per route (see `routes.rs`) so that +/// public operations are not blocked by a global auth hoop. +pub fn create_service() -> Service { + let router = create_router(); + + Service::new(router) + {{#enableCorsMiddleware}} + .hoop(salvo::cors::Cors::new()) + {{/enableCorsMiddleware}} +} + +/// Create the main router with all API routes +pub fn create_router() -> Router { + routes::create_router() +} diff --git a/modules/openapi-generator/src/main/resources/rust-salvo/main.mustache b/modules/openapi-generator/src/main/resources/rust-salvo/main.mustache new file mode 100644 index 000000000000..8e53e81ccb53 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/rust-salvo/main.mustache @@ -0,0 +1,18 @@ +use {{externCrateName}}::create_service; +use salvo::prelude::*; +use tracing_subscriber; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize tracing + tracing_subscriber::fmt::init(); + + let service = create_service(); + let acceptor = TcpListener::new("0.0.0.0:7878").bind().await; + + tracing::info!("{{#appName}}{{.}}{{/appName}}{{^appName}}Salvo OpenAPI Server{{/appName}} listening on http://0.0.0.0:7878"); + + Server::new(acceptor).serve(service).await; + + Ok(()) +} diff --git a/modules/openapi-generator/src/main/resources/rust-salvo/middleware.mustache b/modules/openapi-generator/src/main/resources/rust-salvo/middleware.mustache new file mode 100644 index 000000000000..0a9a24852c29 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/rust-salvo/middleware.mustache @@ -0,0 +1,203 @@ +{{>partial_header}} +//! Authentication and middleware for {{#appName}}{{.}}{{/appName}}{{^appName}}{{packageName}}{{/appName}} +//! + +use salvo::prelude::*; +use serde::{Deserialize, Serialize}; +{{#hasAuthMethods}} +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; + +/// Case-insensitively strip an `Authorization` scheme prefix +/// (e.g. `Bearer ` or `Basic `) and return the trimmed credential. +fn strip_scheme_prefix<'a>(header: &'a str, scheme: &str) -> Option<&'a str> { + if header.len() <= scheme.len() { + return None; + } + let (prefix, rest) = header.split_at(scheme.len()); + if !prefix.eq_ignore_ascii_case(scheme) { + return None; + } + let rest = rest.strip_prefix(' ')?; + Some(rest.trim_start()) +} +{{/hasAuthMethods}} + +{{#hasAuthMethods}} +{{#authMethods}} +{{#isApiKey}} +/// API Key authentication middleware +#[derive(Debug, Clone)] +pub struct ApiKeyAuth { + pub key_name: String, + pub api_key: String, +} + +impl ApiKeyAuth { + pub fn new(key_name: impl Into, api_key: impl Into) -> Self { + Self { + key_name: key_name.into(), + api_key: api_key.into(), + } + } +} + +#[async_trait::async_trait] +impl Handler for ApiKeyAuth { + async fn handle(&self, req: &mut Request, depot: &mut Depot, res: &mut Response, ctrl: &mut FlowCtrl) { + {{#isKeyInHeader}} + let provided_key = req.headers().get(&self.key_name) + .and_then(|v| v.to_str().ok()); + {{/isKeyInHeader}} + {{#isKeyInQuery}} + let provided_key = req.query::(&self.key_name); + {{/isKeyInQuery}} + + if let Some(key) = provided_key { + if key == self.api_key { + ctrl.call_next(req, depot, res).await; + return; + } + } + + res.status_code(StatusCode::UNAUTHORIZED); + res.render("Unauthorized: Invalid API Key"); + ctrl.skip_rest(); + } +} + +/// Factory used as a per-route `.hoop(api_key_auth())`. +/// Replace the placeholder with credentials sourced from your config. +pub fn api_key_auth() -> ApiKeyAuth { + ApiKeyAuth::new("{{keyParamName}}", "your-api-key") +} +{{/isApiKey}} + +{{#isBasic}} +{{#isBasicBasic}} +/// Basic authentication middleware +#[derive(Debug, Clone)] +pub struct BasicAuth { + pub username: String, + pub password: String, +} + +impl BasicAuth { + pub fn new(username: impl Into, password: impl Into) -> Self { + Self { + username: username.into(), + password: password.into(), + } + } +} + +#[async_trait::async_trait] +impl Handler for BasicAuth { + async fn handle(&self, req: &mut Request, depot: &mut Depot, res: &mut Response, ctrl: &mut FlowCtrl) { + if let Some(auth_header) = req.headers().get("authorization") + .and_then(|v| v.to_str().ok()) { + + if let Some(encoded) = strip_scheme_prefix(auth_header, "Basic") { + if let Ok(decoded) = BASE64_STANDARD.decode(encoded) { + if let Ok(credentials) = String::from_utf8(decoded) { + let parts: Vec<&str> = credentials.splitn(2, ':').collect(); + if parts.len() == 2 && parts[0] == self.username && parts[1] == self.password { + ctrl.call_next(req, depot, res).await; + return; + } + } + } + } + } + + res.status_code(StatusCode::UNAUTHORIZED); + let _ = res.add_header("WWW-Authenticate", "Basic realm=\"API\"", true); + res.render("Unauthorized: Invalid credentials"); + ctrl.skip_rest(); + } +} + +/// Factory used as a per-route `.hoop(basic_auth_hoop())`. +pub fn basic_auth_hoop() -> BasicAuth { + BasicAuth::new("admin", "password") +} +{{/isBasicBasic}} + +{{#isBasicBearer}} +/// Bearer token authentication middleware +#[derive(Debug, Clone)] +pub struct BearerAuth { + pub token: String, +} + +impl BearerAuth { + pub fn new(token: impl Into) -> Self { + Self { + token: token.into(), + } + } +} + +#[async_trait::async_trait] +impl Handler for BearerAuth { + async fn handle(&self, req: &mut Request, depot: &mut Depot, res: &mut Response, ctrl: &mut FlowCtrl) { + if let Some(auth_header) = req.headers().get("authorization") + .and_then(|v| v.to_str().ok()) { + + if let Some(token) = strip_scheme_prefix(auth_header, "Bearer") { + if token == self.token { + ctrl.call_next(req, depot, res).await; + return; + } + } + } + + res.status_code(StatusCode::UNAUTHORIZED); + res.render("Unauthorized: Invalid token"); + ctrl.skip_rest(); + } +} + +/// Factory used as a per-route `.hoop(bearer_auth())`. +pub fn bearer_auth() -> BearerAuth { + BearerAuth::new("your-bearer-token") +} +{{/isBasicBearer}} +{{/isBasic}} +{{/authMethods}} + +/// Backwards-compatible entry point used by older generated code paths. +/// +/// Returns a no-op handler so attaching it as a hoop is harmless; per-route +/// authentication is wired up via the per-scheme factories above. +pub fn auth_middleware() -> impl Handler { + NoneHandler +} + +#[derive(Debug, Clone)] +pub struct NoneHandler; + +#[async_trait::async_trait] +impl Handler for NoneHandler { + async fn handle(&self, req: &mut Request, depot: &mut Depot, res: &mut Response, ctrl: &mut FlowCtrl) { + ctrl.call_next(req, depot, res).await; + } +} +{{/hasAuthMethods}} + +{{^hasAuthMethods}} +/// No authentication methods defined +pub fn auth_middleware() -> impl Handler { + // No authentication required + NoneHandler +} + +#[derive(Debug, Clone)] +pub struct NoneHandler; + +#[async_trait::async_trait] +impl Handler for NoneHandler { + async fn handle(&self, req: &mut Request, depot: &mut Depot, res: &mut Response, ctrl: &mut FlowCtrl) { + ctrl.call_next(req, depot, res).await; + } +} +{{/hasAuthMethods}} diff --git a/modules/openapi-generator/src/main/resources/rust-salvo/models.mustache b/modules/openapi-generator/src/main/resources/rust-salvo/models.mustache new file mode 100644 index 000000000000..67306bc6a3a8 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/rust-salvo/models.mustache @@ -0,0 +1,75 @@ +{{>partial_header}} +//! Data models for {{#appName}}{{.}}{{/appName}}{{^appName}}{{packageName}}{{/appName}} +//! +//! API version: {{#appVersion}}{{.}}{{/appVersion}}{{^appVersion}}1.0.0{{/appVersion}} +//! + +#![allow(unused_qualifications)] + +use salvo::oapi::ToSchema; +use serde::{Deserialize, Serialize}; +{{#hasUUIDs}} +use uuid::Uuid; +{{/hasUUIDs}} +{{#enableRequestValidation}} +use validator::Validate; +{{/enableRequestValidation}} + +// Self-reference so `models::Foo` (the form emitted by the codegen for +// cross-model references) resolves inside this file. +use crate::models; + +{{#models}} +{{#model}} +{{#description}} +/// {{.}} +{{/description}} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema{{#enableRequestValidation}}, Validate{{/enableRequestValidation}})] +{{#vendorExtensions.x-is-string}} +#[serde(transparent)] +{{/vendorExtensions.x-is-string}} +pub struct {{classname}} { +{{#vars}} +{{#description}} + /// {{.}} +{{/description}} + {{#enableRequestValidation}} + {{#hasValidation}} + #[validate({{#hasMinLength}}length(min = {{minLength}}){{/hasMinLength}}{{#hasMaxLength}}{{#hasMinLength}}, {{/hasMinLength}}length(max = {{maxLength}}){{/hasMaxLength}}{{#hasMinimum}}{{#hasMinLength}}{{hasMaxLength}}, {{/hasMinLength}}{{#hasMaxLength}}, {{/hasMaxLength}}range(min = {{minimum}}){{/hasMinimum}}{{#hasMaximum}}{{#hasMinimum}}, {{/hasMinimum}}{{^hasMinimum}}{{#hasMinLength}}{{hasMaxLength}}, {{/hasMinLength}}{{#hasMaxLength}}, {{/hasMaxLength}}{{/hasMinimum}}range(max = {{maximum}}){{/hasMaximum}})] + {{/hasValidation}} + {{/enableRequestValidation}} + {{#required}} + {{#vendorExtensions.x-salvo-serde-rename}} + #[serde(rename = "{{baseName}}")] + {{/vendorExtensions.x-salvo-serde-rename}} + pub {{{name}}}: {{{dataType}}}, + {{/required}} + {{^required}} + {{#vendorExtensions.x-salvo-serde-rename}} + #[serde(rename = "{{baseName}}", skip_serializing_if = "Option::is_none")] + {{/vendorExtensions.x-salvo-serde-rename}} + {{^vendorExtensions.x-salvo-serde-rename}} + #[serde(skip_serializing_if = "Option::is_none")] + {{/vendorExtensions.x-salvo-serde-rename}} + pub {{{name}}}: Option<{{{dataType}}}>, + {{/required}} +{{/vars}} +} + +impl {{classname}} { + pub fn new({{#requiredVars}}{{{name}}}: {{{dataType}}}{{^-last}}, {{/-last}}{{/requiredVars}}) -> Self { + Self { +{{#vars}} +{{#required}} + {{{name}}}, +{{/required}} +{{^required}} + {{{name}}}: None, +{{/required}} +{{/vars}} + } + } +} + +{{/model}} +{{/models}} diff --git a/modules/openapi-generator/src/main/resources/rust-salvo/partial_header.mustache b/modules/openapi-generator/src/main/resources/rust-salvo/partial_header.mustache new file mode 100644 index 000000000000..ddd262106435 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/rust-salvo/partial_header.mustache @@ -0,0 +1,18 @@ +/* + * {{#appName}}{{.}}{{/appName}}{{^appName}}{{packageName}}{{/appName}} + * +{{#appDescription}} + * {{.}} + * +{{/appDescription}} +{{#version}} + * The version of the OpenAPI document: {{.}} +{{/version}} +{{#contact}} +{{#contactEmail}} + * Contact: {{.}} +{{/contactEmail}} +{{/contact}} + * + * Generated by: https://openapi-generator.tech + */ diff --git a/modules/openapi-generator/src/main/resources/rust-salvo/routes.mustache b/modules/openapi-generator/src/main/resources/rust-salvo/routes.mustache new file mode 100644 index 000000000000..e14a4da458f2 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/rust-salvo/routes.mustache @@ -0,0 +1,31 @@ +{{>partial_header}} +//! API routing module +//! +//! This module defines the routing configuration for all API endpoints. + +use salvo::prelude::*; +use crate::handlers::*; +{{#enableAuthMiddleware}} +{{#hasAuthHandlers}} +use crate::middleware::*; +{{/hasAuthHandlers}} +{{/enableAuthMiddleware}} + +/// Create the main router with all API routes +pub fn create_router() -> Router { + let mut router = Router::new(); + + {{#salvoRoutes}} + // Routes for {{path}} + router = router.push( + Router::with_path("{{path}}"){{#operations}} + .push( + Router::new() + .{{method}}({{handlerName}}){{#enableAuthMiddleware}}{{#hasAuth}}{{#authSchemes}}{{#factory}} + .hoop({{factory}}()){{/factory}}{{/authSchemes}}{{/hasAuth}}{{/enableAuthMiddleware}} + ){{/operations}} + ); + {{/salvoRoutes}} + + router +} diff --git a/modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustSalvoServerCodegenTest.java b/modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustSalvoServerCodegenTest.java new file mode 100644 index 000000000000..79405bfb22f7 --- /dev/null +++ b/modules/openapi-generator/src/test/java/org/openapitools/codegen/rust/RustSalvoServerCodegenTest.java @@ -0,0 +1,239 @@ +package org.openapitools.codegen.rust; + +import org.openapitools.codegen.DefaultGenerator; +import org.openapitools.codegen.TestUtils; +import org.openapitools.codegen.config.CodegenConfigurator; +import org.openapitools.codegen.languages.RustSalvoServerCodegen; +import org.testng.Assert; +import org.testng.annotations.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.openapitools.codegen.TestUtils.linearize; + +public class RustSalvoServerCodegenTest { + + @Test + public void testInitialConfigValues() throws Exception { + final RustSalvoServerCodegen codegen = new RustSalvoServerCodegen(); + codegen.processOpts(); + + // Test default values + Assert.assertEquals(codegen.getName(), "rust-salvo"); + Assert.assertEquals(codegen.additionalProperties().get("packageName"), "salvo_openapi"); + Assert.assertTrue(codegen.additionalProperties().containsKey("enableRequestValidation")); + Assert.assertTrue(codegen.additionalProperties().containsKey("enableAuthMiddleware")); + Assert.assertTrue(codegen.additionalProperties().containsKey("enableCorsMiddleware")); + } + + @Test + public void testBasicGeneration() throws IOException { + Path target = Files.createTempDirectory("salvo-test"); + System.out.println("📁 生成目录: " + target.toAbsolutePath()); + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("rust-salvo") + .setInputSpec("src/test/resources/3_0/rust/rust-salvo-basic-test.yaml") + .setSkipOverwrite(false) + .setOutputDir(target.toAbsolutePath().toString().replace("\\", "/")); + + List files = new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + System.out.println("✅ 生成了 " + files.size() + " 个文件:"); + files.stream().limit(10).forEach(f -> System.out.println(" 📄 " + f.getName())); + System.out.println("🗂️ 完整目录结构:"); + printDirectoryTree(target.toFile(), " "); + // 注释掉删除文件的代码,这样我们可以检查生成的文件 + // files.forEach(File::deleteOnExit); + + // Verify core files are generated + TestUtils.assertFileExists(Path.of(target.toString(), "Cargo.toml")); + TestUtils.assertFileExists(Path.of(target.toString(), "src/lib.rs")); + TestUtils.assertFileExists(Path.of(target.toString(), "src/main.rs")); + TestUtils.assertFileExists(Path.of(target.toString(), "src/models.rs")); + TestUtils.assertFileExists(Path.of(target.toString(), "src/routes.rs")); + TestUtils.assertFileExists(Path.of(target.toString(), "src/handlers/mod.rs")); + } + + @Test + public void testHandlerGeneration() throws IOException { + Path target = Files.createTempDirectory("salvo-handlers-test"); + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("rust-salvo") + .setInputSpec("src/test/resources/3_0/rust/rust-salvo-basic-test.yaml") + .setSkipOverwrite(false) + .setOutputDir(target.toAbsolutePath().toString().replace("\\", "/")); + + List files = new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + files.forEach(File::deleteOnExit); + + // Verify handler files are generated + Path handlersModPath = Path.of(target.toString(), "src/handlers/mod.rs"); + TestUtils.assertFileExists(handlersModPath); + + // Check that the handlers module exports are present. + // The basic test spec is tagged "pets", so the per-tag module should be `pets`. + TestUtils.assertFileContains(handlersModPath, "pub mod pets;"); + TestUtils.assertFileContains(handlersModPath, "pub use pets::*;"); + } + + @Test + public void testCargoTomlGeneration() throws IOException { + Path target = Files.createTempDirectory("salvo-cargo-test"); + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("rust-salvo") + .setInputSpec("src/test/resources/3_0/rust/rust-salvo-basic-test.yaml") + .setSkipOverwrite(false) + .setOutputDir(target.toAbsolutePath().toString().replace("\\", "/")); + + List files = new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + files.forEach(File::deleteOnExit); + + // Verify Cargo.toml contains required dependencies + Path cargoPath = Path.of(target.toString(), "Cargo.toml"); + TestUtils.assertFileExists(cargoPath); + TestUtils.assertFileContains(cargoPath, "salvo = { version = \"0.93\""); + TestUtils.assertFileContains(cargoPath, "serde = { version = \"1\""); + TestUtils.assertFileContains(cargoPath, "tokio = { version = \"1\""); + TestUtils.assertFileContains(cargoPath, "serde_json = \"1\""); + } + + @Test + public void testMiddlewareGeneration() throws IOException { + Path target = Files.createTempDirectory("salvo-middleware-test"); + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("rust-salvo") + .setInputSpec("src/test/resources/3_0/rust/rust-salvo-auth-test.yaml") + .setSkipOverwrite(false) + .addAdditionalProperty("enableAuthMiddleware", "true") + .setOutputDir(target.toAbsolutePath().toString().replace("\\", "/")); + + List files = new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + files.forEach(File::deleteOnExit); + + // The auth-test spec declares ApiKey, Basic, and Bearer schemes; all three + // structs plus their per-route factory functions should appear. + Path middlewarePath = Path.of(target.toString(), "src/middleware.rs"); + TestUtils.assertFileExists(middlewarePath); + TestUtils.assertFileContains(middlewarePath, "pub struct ApiKeyAuth"); + TestUtils.assertFileContains(middlewarePath, "pub struct BasicAuth"); + TestUtils.assertFileContains(middlewarePath, "pub struct BearerAuth"); + TestUtils.assertFileContains(middlewarePath, "pub fn auth_middleware()"); + TestUtils.assertFileContains(middlewarePath, "pub fn api_key_auth()"); + TestUtils.assertFileContains(middlewarePath, "pub fn basic_auth_hoop()"); + TestUtils.assertFileContains(middlewarePath, "pub fn bearer_auth()"); + // Case-insensitive Authorization scheme prefix matching. + TestUtils.assertFileContains(middlewarePath, "eq_ignore_ascii_case"); + + // Routes file should wire auth per-route (no global hoop in lib.rs). + Path routesPath = Path.of(target.toString(), "src/routes.rs"); + TestUtils.assertFileContains(routesPath, ".hoop(api_key_auth())"); + TestUtils.assertFileContains(routesPath, ".hoop(bearer_auth())"); + TestUtils.assertFileContains(routesPath, ".hoop(basic_auth_hoop())"); + + Path libPath = Path.of(target.toString(), "src/lib.rs"); + TestUtils.assertFileNotContains(libPath, ".hoop(auth_middleware())"); + } + + @Test + public void testOAuth2ScopesPropagateToEndpointAnnotation() throws IOException { + Path target = Files.createTempDirectory("salvo-oauth-scopes-test"); + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("rust-salvo") + .setInputSpec("src/test/resources/3_0/petstore.yaml") + .setSkipOverwrite(false) + .setOutputDir(target.toAbsolutePath().toString().replace("\\", "/")); + + List files = new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + files.forEach(File::deleteOnExit); + + // Petstore's `petstore_auth` OAuth2 scheme grants `write:pets` and + // `read:pets`; the generated endpoint annotation must carry those + // exact scopes rather than an empty list. + Path petHandlersPath = Path.of(target.toString(), "src/handlers/pet.rs"); + TestUtils.assertFileExists(petHandlersPath); + TestUtils.assertFileContains(petHandlersPath, + "security((\"OAuth2\" = [\"write:pets\", \"read:pets\"]))"); + } + + @Test + public void testSerdeRenameOnCamelCaseFields() throws IOException { + Path target = Files.createTempDirectory("salvo-rename-test"); + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("rust-salvo") + .setInputSpec("src/test/resources/3_0/petstore.yaml") + .setSkipOverwrite(false) + .setOutputDir(target.toAbsolutePath().toString().replace("\\", "/")); + + List files = new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + files.forEach(File::deleteOnExit); + + // Petstore has `petId`, `shipDate`, `photoUrls` — Rust snake_cases them + // to `pet_id` / `ship_date` / `photo_urls`; a serde rename must preserve + // the OpenAPI wire name. + Path modelsPath = Path.of(target.toString(), "src/models.rs"); + TestUtils.assertFileExists(modelsPath); + TestUtils.assertFileContains(modelsPath, "#[serde(rename = \"petId\""); + TestUtils.assertFileContains(modelsPath, "#[serde(rename = \"shipDate\""); + TestUtils.assertFileContains(modelsPath, "#[serde(rename = \"photoUrls\""); + // Identical names (e.g. `id`, `name`) should NOT get a redundant rename. + TestUtils.assertFileNotContains(modelsPath, "#[serde(rename = \"id\""); + } + + @Test + public void testValidationSupport() throws IOException { + Path target = Files.createTempDirectory("salvo-validation-test"); + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("rust-salvo") + .setInputSpec("src/test/resources/3_0/rust/rust-salvo-validation-test.yaml") + .setSkipOverwrite(false) + .addAdditionalProperty("enableRequestValidation", "true") + .setOutputDir(target.toAbsolutePath().toString().replace("\\", "/")); + + List files = new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + files.forEach(File::deleteOnExit); + + // Verify validation dependencies are included when enabled + Path cargoPath = Path.of(target.toString(), "Cargo.toml"); + TestUtils.assertFileExists(cargoPath); + TestUtils.assertFileContains(cargoPath, "validator = { version = \"0.20\""); + } + + @Test + public void testRouteGeneration() throws IOException { + Path target = Files.createTempDirectory("salvo-routes-test"); + final CodegenConfigurator configurator = new CodegenConfigurator() + .setGeneratorName("rust-salvo") + .setInputSpec("src/test/resources/3_0/rust/rust-salvo-basic-test.yaml") + .setSkipOverwrite(false) + .setOutputDir(target.toAbsolutePath().toString().replace("\\", "/")); + + List files = new DefaultGenerator().opts(configurator.toClientOptInput()).generate(); + files.forEach(File::deleteOnExit); + + // Verify routes file contains expected Salvo routing code + Path routesPath = Path.of(target.toString(), "src/routes.rs"); + TestUtils.assertFileExists(routesPath); + TestUtils.assertFileContains(routesPath, "use salvo::prelude::*;"); + TestUtils.assertFileContains(routesPath, "pub fn create_router()"); + TestUtils.assertFileContains(routesPath, "Router::new()"); + } + + private static void printDirectoryTree(File dir, String indent) { + if (dir.isDirectory()) { + System.out.println(indent + "📁 " + dir.getName() + "/"); + File[] files = dir.listFiles(); + if (files != null) { + for (File file : files) { + if (file.isDirectory()) { + printDirectoryTree(file, indent + " "); + } else { + System.out.println(indent + " 📄 " + file.getName()); + } + } + } + } + } +} diff --git a/modules/openapi-generator/src/test/resources/3_0/rust/rust-salvo-auth-test.yaml b/modules/openapi-generator/src/test/resources/3_0/rust/rust-salvo-auth-test.yaml new file mode 100644 index 000000000000..83d792b86701 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/rust/rust-salvo-auth-test.yaml @@ -0,0 +1,106 @@ +openapi: 3.0.1 +info: + title: Salvo Auth Test API + version: 1.0.0 + description: Test API for Salvo server with authentication +paths: + /protected: + get: + operationId: getProtected + summary: Get protected resource + security: + - ApiKeyAuth: [] + responses: + '200': + description: Protected resource + content: + application/json: + schema: + type: object + properties: + message: + type: string + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /admin: + post: + operationId: adminAction + summary: Admin only action + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + action: + type: string + responses: + '200': + description: Action completed + '401': + description: Unauthorized + '403': + description: Forbidden + /report: + get: + operationId: getReport + summary: Get a report (Basic-auth protected) + security: + - BasicAuth: [] + responses: + '200': + description: Report payload + content: + application/json: + schema: + type: object + properties: + title: + type: string + '401': + description: Unauthorized + /search: + get: + operationId: searchAny + summary: Endpoint that accepts any of the configured auth schemes (OR) + security: + - ApiKeyAuth: [] + - BearerAuth: [] + - BasicAuth: [] + responses: + '200': + description: Results + '401': + description: Unauthorized +components: + schemas: + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + BasicAuth: + type: http + scheme: basic diff --git a/modules/openapi-generator/src/test/resources/3_0/rust/rust-salvo-basic-test.yaml b/modules/openapi-generator/src/test/resources/3_0/rust/rust-salvo-basic-test.yaml new file mode 100644 index 000000000000..7337ea20b00e --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/rust/rust-salvo-basic-test.yaml @@ -0,0 +1,122 @@ +openapi: 3.0.1 +info: + title: Basic Salvo Test API + version: 1.0.0 + description: Test API for Salvo server code generation +paths: + /pets: + get: + operationId: listPets + summary: List all pets + tags: + - pets + parameters: + - name: limit + in: query + description: How many items to return at one time (max 100) + required: false + schema: + type: integer + format: int32 + maximum: 100 + responses: + '200': + description: A paged array of pets + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Pet" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + post: + operationId: createPet + summary: Create a pet + tags: + - pets + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NewPet" + responses: + '201': + description: Pet created + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /pets/{petId}: + get: + operationId: showPetById + summary: Info for a specific pet + tags: + - pets + parameters: + - name: petId + in: path + required: true + description: The id of the pet to retrieve + schema: + type: integer + format: int64 + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + default: + description: unexpected error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" +components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + NewPet: + type: object + required: + - name + properties: + name: + type: string + tag: + type: string + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string diff --git a/modules/openapi-generator/src/test/resources/3_0/rust/rust-salvo-validation-test.yaml b/modules/openapi-generator/src/test/resources/3_0/rust/rust-salvo-validation-test.yaml new file mode 100644 index 000000000000..289f913ae406 --- /dev/null +++ b/modules/openapi-generator/src/test/resources/3_0/rust/rust-salvo-validation-test.yaml @@ -0,0 +1,102 @@ +openapi: 3.0.1 +info: + title: Salvo Validation Test API + version: 1.0.0 + description: Test API for Salvo server with request validation +paths: + /users: + post: + operationId: createUser + summary: Create a user with validation + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/User" + responses: + '201': + description: User created + content: + application/json: + schema: + $ref: "#/components/schemas/User" + '400': + description: Validation error + content: + application/json: + schema: + $ref: "#/components/schemas/ValidationError" + /users/{userId}/email: + put: + operationId: updateUserEmail + summary: Update user email with validation + parameters: + - name: userId + in: path + required: true + schema: + type: integer + format: int64 + minimum: 1 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/EmailUpdate" + responses: + '200': + description: Email updated + '400': + description: Validation error +components: + schemas: + User: + type: object + required: + - name + - email + - age + properties: + id: + type: integer + format: int64 + name: + type: string + minLength: 2 + maxLength: 100 + email: + type: string + format: email + pattern: '^[\w\-\.]+@([\w-]+\.)+[\w-]{2,}$' + age: + type: integer + minimum: 0 + maximum: 150 + bio: + type: string + maxLength: 500 + EmailUpdate: + type: object + required: + - email + properties: + email: + type: string + format: email + pattern: '^[\w\-\.]+@([\w-]+\.)+[\w-]{2,}$' + ValidationError: + type: object + required: + - error + - message + properties: + error: + type: string + message: + type: string + details: + type: array + items: + type: string diff --git a/samples/server/petstore/rust-salvo/Cargo.toml b/samples/server/petstore/rust-salvo/Cargo.toml new file mode 100644 index 000000000000..c086d1f01388 --- /dev/null +++ b/samples/server/petstore/rust-salvo/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +members = ["output/*"] +resolver = "2" diff --git a/samples/server/petstore/rust-salvo/output/petstore/.gitignore b/samples/server/petstore/rust-salvo/output/petstore/.gitignore new file mode 100644 index 000000000000..a9ff9faa89e5 --- /dev/null +++ b/samples/server/petstore/rust-salvo/output/petstore/.gitignore @@ -0,0 +1,30 @@ +# Generated by Cargo +/target/ +Cargo.lock + +# Editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Log files +*.log + +# Environment variables +.env +.env.local + +# Backup files +*.bak +*.backup diff --git a/samples/server/petstore/rust-salvo/output/petstore/.openapi-generator-ignore b/samples/server/petstore/rust-salvo/output/petstore/.openapi-generator-ignore new file mode 100644 index 000000000000..7484ee590a38 --- /dev/null +++ b/samples/server/petstore/rust-salvo/output/petstore/.openapi-generator-ignore @@ -0,0 +1,23 @@ +# OpenAPI Generator Ignore +# Generated by openapi-generator https://github.com/openapitools/openapi-generator + +# Use this file to prevent files from being overwritten by the generator. +# The patterns follow closely to .gitignore or .dockerignore. + +# As an example, the C# client generator defines ApiClient.cs. +# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: +#ApiClient.cs + +# You can match any string of characters against a directory, file or extension with a single asterisk (*): +#foo/*/qux +# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux + +# You can recursively match patterns against a directory, file or extension with a double asterisk (**): +#foo/**/qux +# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux + +# You can also negate patterns with an exclamation (!). +# For example, you can ignore all files in a docs folder with the file extension .md: +#docs/*.md +# Then explicitly reverse the ignore rule for a single file: +#!docs/README.md diff --git a/samples/server/petstore/rust-salvo/output/petstore/.openapi-generator/FILES b/samples/server/petstore/rust-salvo/output/petstore/.openapi-generator/FILES new file mode 100644 index 000000000000..b9043f03a5f7 --- /dev/null +++ b/samples/server/petstore/rust-salvo/output/petstore/.openapi-generator/FILES @@ -0,0 +1,13 @@ +.gitignore +.openapi-generator-ignore +Cargo.toml +README.md +src/handlers/mod.rs +src/handlers/pet.rs +src/handlers/store.rs +src/handlers/user.rs +src/lib.rs +src/main.rs +src/middleware.rs +src/models.rs +src/routes.rs diff --git a/samples/server/petstore/rust-salvo/output/petstore/.openapi-generator/VERSION b/samples/server/petstore/rust-salvo/output/petstore/.openapi-generator/VERSION new file mode 100644 index 000000000000..ca7bf6e46889 --- /dev/null +++ b/samples/server/petstore/rust-salvo/output/petstore/.openapi-generator/VERSION @@ -0,0 +1 @@ +7.23.0-SNAPSHOT diff --git a/samples/server/petstore/rust-salvo/output/petstore/Cargo.toml b/samples/server/petstore/rust-salvo/output/petstore/Cargo.toml new file mode 100644 index 000000000000..3884c5a81f42 --- /dev/null +++ b/samples/server/petstore/rust-salvo/output/petstore/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "petstore_salvo" +version = "1.0.0" +authors = ["OpenAPI Generator team and contributors"] +description = "This is a sample server Petstore server. For this sample, you can use the api key `special-key` to test the authorization filters." +license = "Apache-2.0" +edition = "2021" +homepage = "https://github.com/openapitools/openapi-generator" + +[features] +default = ["server"] +server = [] + +[dependencies] +salvo = { version = "0.93", features = [ + "oapi", + "cors", + "jwt-auth", + "serve-static", +] } +async-trait = "0.1" +base64 = "0.22" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +anyhow = "1" +chrono = { version = "0.4", features = ["serde"] } +validator = { version = "0.20", features = ["derive"] } diff --git a/samples/server/petstore/rust-salvo/output/petstore/README.md b/samples/server/petstore/rust-salvo/output/petstore/README.md new file mode 100644 index 000000000000..2a3a80c3bc79 --- /dev/null +++ b/samples/server/petstore/rust-salvo/output/petstore/README.md @@ -0,0 +1,115 @@ +# OpenAPI Petstore + +This is a sample server Petstore server. For this sample, you can use the api key `special-key` to test the authorization filters. + +This server was generated by the [OpenAPI Generator](https://openapi-generator.tech) project using the **rust-salvo** generator. + +- API version: 1.0.0 +- Package version: 1.0.0 +- Generator version: 7.23.0-SNAPSHOT + + +## Stack + +| Crate | Purpose | +| ----- | ------- | +| [salvo](https://salvo.rs/) (≥ 0.93) | Web framework + `salvo::oapi` for the OpenAPI-aware endpoint macro | +| [serde](https://serde.rs/) | (De)serialization for models | +| [tokio](https://tokio.rs/) | Async runtime | +| [tracing](https://crates.io/crates/tracing) | Structured logging | +| [validator](https://crates.io/crates/validator) | Request-body validation via `#[derive(Validate)]` | + +## What the generator produces + +The crate is laid out so the framework-facing surface lives in `src/` and the business logic is something you fill in: + +``` +src/ +├── lib.rs # crate entry: re-exports + create_service / create_router +├── main.rs # binary entry: binds the router on 0.0.0.0:7878 +├── models.rs # data models (derive Debug, Clone, Serialize, Deserialize, ToSchema, Validate) +├── routes.rs # Router::with_path("/...") chains mapping methods to handlers +├── handlers/ # one module per OpenAPI tag +│ ├── mod.rs +│ └── .rs # one handler stub per operation +``` + +Handlers use the typed extractors from `salvo::oapi::extract` (`JsonBody`, `PathParam`, `QueryParam`, `HeaderParam`, `FormBody`) so that the `#[salvo::oapi::endpoint]` macro picks up the OpenAPI metadata automatically. Models derive `salvo::oapi::ToSchema` for the same reason — they can flow through the extractors and show up in the generated OpenAPI doc. + +## Filling in the implementation + +Each generated handler is a stub that returns `StatusCode::NOT_IMPLEMENTED`. Replace the body with your business logic, for example: + +```rust +#[salvo::oapi::endpoint(operation_id = "get_pet_by_id", tags("pet"))] +pub async fn get_pet_by_id( + pet_id: PathParam, + res: &mut Response, +) -> Result<(), StatusError> { + let pet = Pet::new("doggie".to_string(), vec!["url".to_string()]); + res.render(Json(pet)); + Ok(()) +} +``` + +## Building and running + +```bash +cargo build # build +cargo run # run the server on http://0.0.0.0:7878 +RUST_LOG=debug cargo run # with verbose logging +``` + +`tracing-subscriber` honours `RUST_LOG`, so set it to whatever level you need (`error`, `warn`, `info`, `debug`, `trace`). + +## API endpoints + + +### pet + +- **POST** `/pet` — Add a new pet to the store +- **DELETE** `/pet/{petId}` — Deletes a pet +- **GET** `/pet/findByStatus` — Finds Pets by status +- **GET** `/pet/findByTags` — Finds Pets by tags +- **GET** `/pet/{petId}` — Find pet by ID +- **PUT** `/pet` — Update an existing pet +- **POST** `/pet/{petId}` — Updates a pet in the store with form data +- **POST** `/pet/{petId}/uploadImage` — uploads an image + +### store + +- **DELETE** `/store/order/{orderId}` — Delete purchase order by ID +- **GET** `/store/inventory` — Returns pet inventories by status +- **GET** `/store/order/{orderId}` — Find purchase order by ID +- **POST** `/store/order` — Place an order for a pet + +### user + +- **POST** `/user` — Create user +- **POST** `/user/createWithArray` — Creates list of users with given input array +- **POST** `/user/createWithList` — Creates list of users with given input array +- **DELETE** `/user/{username}` — Delete user +- **GET** `/user/{username}` — Get user by user name +- **GET** `/user/login` — Logs user into the system +- **GET** `/user/logout` — Logs out current logged in user session +- **PUT** `/user/{username}` — Updated user + + +## Extending + +- **Authentication** — `re-generate with `--additional-properties=enableAuthMiddleware=true` (or set it in `bin/configs/...yaml`) to scaffold `src/middleware.rs` with `ApiKeyAuth`, `BasicAuth`, and `BearerAuth` handlers wired to the spec's `securitySchemes`. +- **CORS** — `re-generate with `--additional-properties=enableCorsMiddleware=true` to attach `salvo::cors::Cors::new()` as a hoop on the service. +- **Validation** — `request validation is enabled, models derive `Validate`. +- **Database** — drop one of [sqlx](https://crates.io/crates/sqlx), [Diesel](https://crates.io/crates/diesel), or [SeaORM](https://crates.io/crates/sea-orm) into `Cargo.toml` and inject a connection pool through `Depot` (Salvo's per-request typed storage). + +## Testing + +```bash +cargo test +``` + +Salvo's `TestClient` (re-exported as `salvo::test::TestClient`) is the idiomatic way to drive handlers from integration tests. + +## License + +This project is licensed under Apache-2.0. diff --git a/samples/server/petstore/rust-salvo/output/petstore/src/handlers/mod.rs b/samples/server/petstore/rust-salvo/output/petstore/src/handlers/mod.rs new file mode 100644 index 000000000000..c164a7cd5dbc --- /dev/null +++ b/samples/server/petstore/rust-salvo/output/petstore/src/handlers/mod.rs @@ -0,0 +1,23 @@ +/* + * OpenAPI Petstore + * + * This is a sample server Petstore server. For this sample, you can use the api key `special-key` to test the authorization filters. + * + * The version of the OpenAPI document: 1.0.0 + * + * Generated by: https://openapi-generator.tech + */ + +//! API handlers module +//! +//! This module contains HTTP request handlers for the API endpoints. +//! Each handler function processes incoming requests and returns responses. + +pub mod pet; +pub mod store; +pub mod user; + +// Re-exports for easier access +pub use pet::*; +pub use store::*; +pub use user::*; diff --git a/samples/server/petstore/rust-salvo/output/petstore/src/handlers/pet.rs b/samples/server/petstore/rust-salvo/output/petstore/src/handlers/pet.rs new file mode 100644 index 000000000000..6daf30095b29 --- /dev/null +++ b/samples/server/petstore/rust-salvo/output/petstore/src/handlers/pet.rs @@ -0,0 +1,155 @@ +/* + * OpenAPI Petstore + * + * This is a sample server Petstore server. For this sample, you can use the api key `special-key` to test the authorization filters. + * + * The version of the OpenAPI document: 1.0.0 + * + * Generated by: https://openapi-generator.tech + */ + +//! OpenAPI Petstore handlers +//! +//! API version: 1.0.0 +//! + +use salvo::prelude::*; +use salvo::oapi::extract::{JsonBody, PathParam, QueryParam, HeaderParam, FormBody}; +use serde::{Deserialize, Serialize}; +use validator::Validate; + +use crate::models::{self, *}; + +/// +#[salvo::oapi::endpoint( + operation_id = "add_pet", + tags("pet"), + security(("OAuth2" = ["write:pets", "read:pets"])) +)] +pub async fn add_pet( + pet: JsonBody, + res: &mut Response, +) -> Result<(), StatusError> { + // + + res.status_code(StatusCode::NOT_IMPLEMENTED); + Ok(()) +} + +/// +#[salvo::oapi::endpoint( + operation_id = "delete_pet", + tags("pet"), + security(("OAuth2" = ["write:pets", "read:pets"])) +)] +pub async fn delete_pet( + pet_id: PathParam, + api_key: HeaderParam, + res: &mut Response, +) -> Result<(), StatusError> { + // + + res.status_code(StatusCode::NOT_IMPLEMENTED); + Ok(()) +} + +/// Multiple status values can be provided with comma separated strings +#[salvo::oapi::endpoint( + operation_id = "find_pets_by_status", + tags("pet"), + security(("OAuth2" = ["read:pets"])) +)] +pub async fn find_pets_by_status( + status: QueryParam, true>, + res: &mut Response, +) -> Result<(), StatusError> { + // Multiple status values can be provided with comma separated strings + + res.status_code(StatusCode::NOT_IMPLEMENTED); + Ok(()) +} + +/// Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing. +#[salvo::oapi::endpoint( + operation_id = "find_pets_by_tags", + tags("pet"), + security(("OAuth2" = ["read:pets"])) +)] +pub async fn find_pets_by_tags( + tags: QueryParam, true>, + res: &mut Response, +) -> Result<(), StatusError> { + // Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing. + + res.status_code(StatusCode::NOT_IMPLEMENTED); + Ok(()) +} + +/// Returns a single pet +#[salvo::oapi::endpoint( + operation_id = "get_pet_by_id", + tags("pet"), + security(("ApiKeyAuth" = [])) +)] +pub async fn get_pet_by_id( + pet_id: PathParam, + res: &mut Response, +) -> Result<(), StatusError> { + // Returns a single pet + + res.status_code(StatusCode::NOT_IMPLEMENTED); + Ok(()) +} + +/// +#[salvo::oapi::endpoint( + operation_id = "update_pet", + tags("pet"), + security(("OAuth2" = ["write:pets", "read:pets"])) +)] +pub async fn update_pet( + pet: JsonBody, + res: &mut Response, +) -> Result<(), StatusError> { + // + + res.status_code(StatusCode::NOT_IMPLEMENTED); + Ok(()) +} + +/// +#[salvo::oapi::endpoint( + operation_id = "update_pet_with_form", + tags("pet"), + security(("OAuth2" = ["write:pets", "read:pets"])) +)] +pub async fn update_pet_with_form( + pet_id: PathParam, + name: FormBody, + status: FormBody, + res: &mut Response, +) -> Result<(), StatusError> { + // + + res.status_code(StatusCode::NOT_IMPLEMENTED); + Ok(()) +} + +/// +#[salvo::oapi::endpoint( + operation_id = "upload_file", + tags("pet"), + security(("OAuth2" = ["write:pets", "read:pets"])) +)] +pub async fn upload_file( + pet_id: PathParam, + additional_metadata: FormBody, + file: FormBody>, + res: &mut Response, +) -> Result<(), StatusError> { + // + + res.status_code(StatusCode::NOT_IMPLEMENTED); + Ok(()) +} + diff --git a/samples/server/petstore/rust-salvo/output/petstore/src/handlers/store.rs b/samples/server/petstore/rust-salvo/output/petstore/src/handlers/store.rs new file mode 100644 index 000000000000..d36cdc37c846 --- /dev/null +++ b/samples/server/petstore/rust-salvo/output/petstore/src/handlers/store.rs @@ -0,0 +1,82 @@ +/* + * OpenAPI Petstore + * + * This is a sample server Petstore server. For this sample, you can use the api key `special-key` to test the authorization filters. + * + * The version of the OpenAPI document: 1.0.0 + * + * Generated by: https://openapi-generator.tech + */ + +//! OpenAPI Petstore handlers +//! +//! API version: 1.0.0 +//! + +use salvo::prelude::*; +use salvo::oapi::extract::{JsonBody, PathParam, QueryParam, HeaderParam, FormBody}; +use serde::{Deserialize, Serialize}; +use validator::Validate; + +use crate::models::{self, *}; + +/// For valid response try integer IDs with value < 1000. Anything above 1000 or nonintegers will generate API errors +#[salvo::oapi::endpoint( + operation_id = "delete_order", + tags("store") +)] +pub async fn delete_order( + order_id: PathParam, + res: &mut Response, +) -> Result<(), StatusError> { + // For valid response try integer IDs with value < 1000. Anything above 1000 or nonintegers will generate API errors + + res.status_code(StatusCode::NOT_IMPLEMENTED); + Ok(()) +} + +/// Returns a map of status codes to quantities +#[salvo::oapi::endpoint( + operation_id = "get_inventory", + tags("store"), + security(("ApiKeyAuth" = [])) +)] +pub async fn get_inventory( + res: &mut Response, +) -> Result<(), StatusError> { + // Returns a map of status codes to quantities + + res.status_code(StatusCode::NOT_IMPLEMENTED); + Ok(()) +} + +/// For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions +#[salvo::oapi::endpoint( + operation_id = "get_order_by_id", + tags("store") +)] +pub async fn get_order_by_id( + order_id: PathParam, + res: &mut Response, +) -> Result<(), StatusError> { + // For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions + + res.status_code(StatusCode::NOT_IMPLEMENTED); + Ok(()) +} + +/// +#[salvo::oapi::endpoint( + operation_id = "place_order", + tags("store") +)] +pub async fn place_order( + order: JsonBody, + res: &mut Response, +) -> Result<(), StatusError> { + // + + res.status_code(StatusCode::NOT_IMPLEMENTED); + Ok(()) +} + diff --git a/samples/server/petstore/rust-salvo/output/petstore/src/handlers/user.rs b/samples/server/petstore/rust-salvo/output/petstore/src/handlers/user.rs new file mode 100644 index 000000000000..b26201c6fd06 --- /dev/null +++ b/samples/server/petstore/rust-salvo/output/petstore/src/handlers/user.rs @@ -0,0 +1,149 @@ +/* + * OpenAPI Petstore + * + * This is a sample server Petstore server. For this sample, you can use the api key `special-key` to test the authorization filters. + * + * The version of the OpenAPI document: 1.0.0 + * + * Generated by: https://openapi-generator.tech + */ + +//! OpenAPI Petstore handlers +//! +//! API version: 1.0.0 +//! + +use salvo::prelude::*; +use salvo::oapi::extract::{JsonBody, PathParam, QueryParam, HeaderParam, FormBody}; +use serde::{Deserialize, Serialize}; +use validator::Validate; + +use crate::models::{self, *}; + +/// This can only be done by the logged in user. +#[salvo::oapi::endpoint( + operation_id = "create_user", + tags("user"), + security(("ApiKeyAuth" = [])) +)] +pub async fn create_user( + user: JsonBody, + res: &mut Response, +) -> Result<(), StatusError> { + // This can only be done by the logged in user. + + res.status_code(StatusCode::NOT_IMPLEMENTED); + Ok(()) +} + +/// +#[salvo::oapi::endpoint( + operation_id = "create_users_with_array_input", + tags("user"), + security(("ApiKeyAuth" = [])) +)] +pub async fn create_users_with_array_input( + user: JsonBody>, + res: &mut Response, +) -> Result<(), StatusError> { + // + + res.status_code(StatusCode::NOT_IMPLEMENTED); + Ok(()) +} + +/// +#[salvo::oapi::endpoint( + operation_id = "create_users_with_list_input", + tags("user"), + security(("ApiKeyAuth" = [])) +)] +pub async fn create_users_with_list_input( + user: JsonBody>, + res: &mut Response, +) -> Result<(), StatusError> { + // + + res.status_code(StatusCode::NOT_IMPLEMENTED); + Ok(()) +} + +/// This can only be done by the logged in user. +#[salvo::oapi::endpoint( + operation_id = "delete_user", + tags("user"), + security(("ApiKeyAuth" = [])) +)] +pub async fn delete_user( + username: PathParam, + res: &mut Response, +) -> Result<(), StatusError> { + // This can only be done by the logged in user. + + res.status_code(StatusCode::NOT_IMPLEMENTED); + Ok(()) +} + +/// +#[salvo::oapi::endpoint( + operation_id = "get_user_by_name", + tags("user") +)] +pub async fn get_user_by_name( + username: PathParam, + res: &mut Response, +) -> Result<(), StatusError> { + // + + res.status_code(StatusCode::NOT_IMPLEMENTED); + Ok(()) +} + +/// +#[salvo::oapi::endpoint( + operation_id = "login_user", + tags("user") +)] +pub async fn login_user( + username: QueryParam, + password: QueryParam, + res: &mut Response, +) -> Result<(), StatusError> { + // + + res.status_code(StatusCode::NOT_IMPLEMENTED); + Ok(()) +} + +/// +#[salvo::oapi::endpoint( + operation_id = "logout_user", + tags("user"), + security(("ApiKeyAuth" = [])) +)] +pub async fn logout_user( + res: &mut Response, +) -> Result<(), StatusError> { + // + + res.status_code(StatusCode::NOT_IMPLEMENTED); + Ok(()) +} + +/// This can only be done by the logged in user. +#[salvo::oapi::endpoint( + operation_id = "update_user", + tags("user"), + security(("ApiKeyAuth" = [])) +)] +pub async fn update_user( + username: PathParam, + user: JsonBody, + res: &mut Response, +) -> Result<(), StatusError> { + // This can only be done by the logged in user. + + res.status_code(StatusCode::NOT_IMPLEMENTED); + Ok(()) +} + diff --git a/samples/server/petstore/rust-salvo/output/petstore/src/lib.rs b/samples/server/petstore/rust-salvo/output/petstore/src/lib.rs new file mode 100644 index 000000000000..1f029bc12076 --- /dev/null +++ b/samples/server/petstore/rust-salvo/output/petstore/src/lib.rs @@ -0,0 +1,44 @@ +#![allow( + missing_docs, + trivial_casts, + unused_variables, + unused_mut, + unused_extern_crates, + non_camel_case_types, + unused_imports, + unused_attributes +)] +#![allow( + clippy::derive_partial_eq_without_eq, + clippy::disallowed_names, + clippy::too_many_arguments +)] + +pub const BASE_PATH: &str = "/v2"; +pub const API_VERSION: &str = "1.0.0"; + +pub mod handlers; +pub mod models; +pub mod routes; + +pub use handlers::*; +pub use models::*; +pub use routes::*; + +use salvo::prelude::*; + + +/// Create and configure the Salvo service. +/// +/// Authentication middleware is attached per route (see `routes.rs`) so that +/// public operations are not blocked by a global auth hoop. +pub fn create_service() -> Service { + let router = create_router(); + + Service::new(router) +} + +/// Create the main router with all API routes +pub fn create_router() -> Router { + routes::create_router() +} diff --git a/samples/server/petstore/rust-salvo/output/petstore/src/main.rs b/samples/server/petstore/rust-salvo/output/petstore/src/main.rs new file mode 100644 index 000000000000..f22a8c112db3 --- /dev/null +++ b/samples/server/petstore/rust-salvo/output/petstore/src/main.rs @@ -0,0 +1,18 @@ +use petstore_salvo::create_service; +use salvo::prelude::*; +use tracing_subscriber; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize tracing + tracing_subscriber::fmt::init(); + + let service = create_service(); + let acceptor = TcpListener::new("0.0.0.0:7878").bind().await; + + tracing::info!("OpenAPI Petstore listening on http://0.0.0.0:7878"); + + Server::new(acceptor).serve(service).await; + + Ok(()) +} diff --git a/samples/server/petstore/rust-salvo/output/petstore/src/middleware.rs b/samples/server/petstore/rust-salvo/output/petstore/src/middleware.rs new file mode 100644 index 000000000000..9cf9ae61cd83 --- /dev/null +++ b/samples/server/petstore/rust-salvo/output/petstore/src/middleware.rs @@ -0,0 +1,92 @@ +/* + * OpenAPI Petstore + * + * This is a sample server Petstore server. For this sample, you can use the api key `special-key` to test the authorization filters. + * + * The version of the OpenAPI document: 1.0.0 + * + * Generated by: https://openapi-generator.tech + */ + +//! Authentication and middleware for OpenAPI Petstore +//! + +use salvo::prelude::*; +use serde::{Deserialize, Serialize}; +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; + +/// Case-insensitively strip an `Authorization` scheme prefix +/// (e.g. `Bearer ` or `Basic `) and return the trimmed credential. +fn strip_scheme_prefix<'a>(header: &'a str, scheme: &str) -> Option<&'a str> { + if header.len() <= scheme.len() { + return None; + } + let (prefix, rest) = header.split_at(scheme.len()); + if !prefix.eq_ignore_ascii_case(scheme) { + return None; + } + let rest = rest.strip_prefix(' ')?; + Some(rest.trim_start()) +} + + +/// API Key authentication middleware +#[derive(Debug, Clone)] +pub struct ApiKeyAuth { + pub key_name: String, + pub api_key: String, +} + +impl ApiKeyAuth { + pub fn new(key_name: impl Into, api_key: impl Into) -> Self { + Self { + key_name: key_name.into(), + api_key: api_key.into(), + } + } +} + +#[async_trait::async_trait] +impl Handler for ApiKeyAuth { + async fn handle(&self, req: &mut Request, depot: &mut Depot, res: &mut Response, ctrl: &mut FlowCtrl) { + let provided_key = req.headers().get(&self.key_name) + .and_then(|v| v.to_str().ok()); + + if let Some(key) = provided_key { + if key == self.api_key { + ctrl.call_next(req, depot, res).await; + return; + } + } + + res.status_code(StatusCode::UNAUTHORIZED); + res.render("Unauthorized: Invalid API Key"); + ctrl.skip_rest(); + } +} + +/// Factory used as a per-route `.hoop(api_key_auth())`. +/// Replace the placeholder with credentials sourced from your config. +pub fn api_key_auth() -> ApiKeyAuth { + ApiKeyAuth::new("api_key", "your-api-key") +} + + +/// Backwards-compatible entry point used by older generated code paths. +/// +/// Returns a no-op handler so attaching it as a hoop is harmless; per-route +/// authentication is wired up via the per-scheme factories above. +pub fn auth_middleware() -> impl Handler { + NoneHandler +} + +#[derive(Debug, Clone)] +pub struct NoneHandler; + +#[async_trait::async_trait] +impl Handler for NoneHandler { + async fn handle(&self, req: &mut Request, depot: &mut Depot, res: &mut Response, ctrl: &mut FlowCtrl) { + ctrl.call_next(req, depot, res).await; + } +} + diff --git a/samples/server/petstore/rust-salvo/output/petstore/src/models.rs b/samples/server/petstore/rust-salvo/output/petstore/src/models.rs new file mode 100644 index 000000000000..1e275250ff57 --- /dev/null +++ b/samples/server/petstore/rust-salvo/output/petstore/src/models.rs @@ -0,0 +1,219 @@ +/* + * OpenAPI Petstore + * + * This is a sample server Petstore server. For this sample, you can use the api key `special-key` to test the authorization filters. + * + * The version of the OpenAPI document: 1.0.0 + * + * Generated by: https://openapi-generator.tech + */ + +//! Data models for OpenAPI Petstore +//! +//! API version: 1.0.0 +//! + +#![allow(unused_qualifications)] + +use salvo::oapi::ToSchema; +use serde::{Deserialize, Serialize}; +use validator::Validate; + +// Self-reference so `models::Foo` (the form emitted by the codegen for +// cross-model references) resolves inside this file. +use crate::models; + +/// Describes the result of uploading an image resource +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema, Validate)] +pub struct ApiResponse { + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub r#type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +impl ApiResponse { + pub fn new() -> Self { + Self { + code: None, + r#type: None, + message: None, + } + } +} + +/// A category for a pet +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema, Validate)] +pub struct Category { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[validate()] + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +impl Category { + pub fn new() -> Self { + Self { + id: None, + name: None, + } + } +} + +/// An order for a pets from the pet store +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema, Validate)] +pub struct Order { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(rename = "petId", skip_serializing_if = "Option::is_none")] + pub pet_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub quantity: Option, + #[serde(rename = "shipDate", skip_serializing_if = "Option::is_none")] + pub ship_date: Option>, + /// Order Status + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub complete: Option, +} + +impl Order { + pub fn new() -> Self { + Self { + id: None, + pet_id: None, + quantity: None, + ship_date: None, + status: None, + complete: None, + } + } +} + +/// A pet for sale in the pet store +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema, Validate)] +pub struct Pet { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub category: Option, + pub name: String, + #[serde(rename = "photoUrls")] + pub photo_urls: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option>, + /// pet status in the store + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, +} + +impl Pet { + pub fn new(name: String, photo_urls: Vec) -> Self { + Self { + id: None, + category: None, + name, + photo_urls, + tags: None, + status: None, + } + } +} + +/// A tag for a pet +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema, Validate)] +pub struct Tag { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +impl Tag { + pub fn new() -> Self { + Self { + id: None, + name: None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema, Validate)] +pub struct UpdatePetWithFormRequest { + /// Updated name of the pet + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Updated status of the pet + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, +} + +impl UpdatePetWithFormRequest { + pub fn new() -> Self { + Self { + name: None, + status: None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema, Validate)] +pub struct UploadFileRequest { + /// Additional data to pass to server + #[serde(rename = "additionalMetadata", skip_serializing_if = "Option::is_none")] + pub additional_metadata: Option, + /// file to upload + #[serde(skip_serializing_if = "Option::is_none")] + pub file: Option>, +} + +impl UploadFileRequest { + pub fn new() -> Self { + Self { + additional_metadata: None, + file: None, + } + } +} + +/// A User who is purchasing from the pet store +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema, Validate)] +pub struct User { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, + #[serde(rename = "firstName", skip_serializing_if = "Option::is_none")] + pub first_name: Option, + #[serde(rename = "lastName", skip_serializing_if = "Option::is_none")] + pub last_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub password: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub phone: Option, + /// User Status + #[serde(rename = "userStatus", skip_serializing_if = "Option::is_none")] + pub user_status: Option, +} + +impl User { + pub fn new() -> Self { + Self { + id: None, + username: None, + first_name: None, + last_name: None, + email: None, + password: None, + phone: None, + user_status: None, + } + } +} + diff --git a/samples/server/petstore/rust-salvo/output/petstore/src/routes.rs b/samples/server/petstore/rust-salvo/output/petstore/src/routes.rs new file mode 100644 index 000000000000..aa401899a76a --- /dev/null +++ b/samples/server/petstore/rust-salvo/output/petstore/src/routes.rs @@ -0,0 +1,160 @@ +/* + * OpenAPI Petstore + * + * This is a sample server Petstore server. For this sample, you can use the api key `special-key` to test the authorization filters. + * + * The version of the OpenAPI document: 1.0.0 + * + * Generated by: https://openapi-generator.tech + */ + +//! API routing module +//! +//! This module defines the routing configuration for all API endpoints. + +use salvo::prelude::*; +use crate::handlers::*; + +/// Create the main router with all API routes +pub fn create_router() -> Router { + let mut router = Router::new(); + + // Routes for /pet + router = router.push( + Router::with_path("/pet") + .push( + Router::new() + .put(update_pet) + ) + .push( + Router::new() + .post(add_pet) + ) + ); + // Routes for /pet/findByStatus + router = router.push( + Router::with_path("/pet/findByStatus") + .push( + Router::new() + .get(find_pets_by_status) + ) + ); + // Routes for /pet/findByTags + router = router.push( + Router::with_path("/pet/findByTags") + .push( + Router::new() + .get(find_pets_by_tags) + ) + ); + // Routes for /pet/{petId} + router = router.push( + Router::with_path("/pet/{petId}") + .push( + Router::new() + .get(get_pet_by_id) + ) + .push( + Router::new() + .post(update_pet_with_form) + ) + .push( + Router::new() + .delete(delete_pet) + ) + ); + // Routes for /pet/{petId}/uploadImage + router = router.push( + Router::with_path("/pet/{petId}/uploadImage") + .push( + Router::new() + .post(upload_file) + ) + ); + // Routes for /store/inventory + router = router.push( + Router::with_path("/store/inventory") + .push( + Router::new() + .get(get_inventory) + ) + ); + // Routes for /store/order + router = router.push( + Router::with_path("/store/order") + .push( + Router::new() + .post(place_order) + ) + ); + // Routes for /store/order/{orderId} + router = router.push( + Router::with_path("/store/order/{orderId}") + .push( + Router::new() + .get(get_order_by_id) + ) + .push( + Router::new() + .delete(delete_order) + ) + ); + // Routes for /user + router = router.push( + Router::with_path("/user") + .push( + Router::new() + .post(create_user) + ) + ); + // Routes for /user/createWithArray + router = router.push( + Router::with_path("/user/createWithArray") + .push( + Router::new() + .post(create_users_with_array_input) + ) + ); + // Routes for /user/createWithList + router = router.push( + Router::with_path("/user/createWithList") + .push( + Router::new() + .post(create_users_with_list_input) + ) + ); + // Routes for /user/login + router = router.push( + Router::with_path("/user/login") + .push( + Router::new() + .get(login_user) + ) + ); + // Routes for /user/logout + router = router.push( + Router::with_path("/user/logout") + .push( + Router::new() + .get(logout_user) + ) + ); + // Routes for /user/{username} + router = router.push( + Router::with_path("/user/{username}") + .push( + Router::new() + .get(get_user_by_name) + ) + .push( + Router::new() + .put(update_user) + ) + .push( + Router::new() + .delete(delete_user) + ) + ); + + router +}