From 2bfc5a3958f348fc1e019a3d0708726e8a80eb1d Mon Sep 17 00:00:00 2001 From: William Cheng Date: Wed, 10 Apr 2024 18:49:59 +0800 Subject: [PATCH] [Scala] added new scala-cask generator for the cask framework (#18344) * Ran `./new.sh -n scala-cask -s` to generate a new Scala Cask impl * removed scala-cask-petstore * Added Scala-cask param parser for BigDecimals * added scala cask to samples * splitting out validation and json * Added GitHub workflow support * regenerated cask samples * cask build fix for local builds * regenerated samples * trying to reproduce failed cask build. checking in compiles sources, which have been reformatted * reverted whaitespace change * cask fix - adding gitignored files * scala cask toLowreCase fix * scala cask toUpperCase fix * cask regenerated samples * improved exception handling for scala cask * File separator fix for windows * Noob fix for cask * regenerated api package * Removed environment variable settings, debug code * Updated samples * moved scala-cask output * Regenerated samples * scala cask fix * Updated scala cask settings for more typical package structure Removed cask client samples * cask - reran generate samples * Removed duplicate ScalaCaskServer entry * minor enhancements * update samples * update templates --------- Co-authored-by: aaron.pritzlaff --- .github/workflows/samples-scala.yaml | 1 + README.md | 3 +- bin/configs/scala-cask-petstore-new.yaml | 13 + docs/generators.md | 1 + docs/generators/scala-cask.md | 261 +++++ .../languages/ScalaCaskServerCodegen.java | 845 ++++++++++++++ .../org.openapitools.codegen.CodegenConfig | 1 + .../scala-cask/.scalafmt.conf.mustache | 4 + .../resources/scala-cask/Dockerfile.mustache | 13 + .../main/resources/scala-cask/README.mustache | 96 ++ .../main/resources/scala-cask/api.mustache | 0 .../resources/scala-cask/apiPackage.mustache | 155 +++ .../resources/scala-cask/apiRoutes.mustache | 65 ++ .../apiRoutesQueryParamsTyped.mustache | 1 + .../resources/scala-cask/apiService.mustache | 37 + .../resources/scala-cask/appPackage.mustache | 12 + .../resources/scala-cask/appRoutes.mustache | 40 + .../resources/scala-cask/baseApp.mustache | 49 + .../resources/scala-cask/build.sbt.mustache | 29 + .../resources/scala-cask/build.sc.mustache | 43 + .../scala-cask/bulidAndPublish.yml.mustache | 41 + .../resources/scala-cask/example.mustache | 60 + .../resources/scala-cask/exampleApp.mustache | 21 + .../resources/scala-cask/gitignore.mustache | 25 + .../resources/scala-cask/licenseInfo.mustache | 16 + .../main/resources/scala-cask/model.mustache | 60 + .../resources/scala-cask/modelData.mustache | 250 ++++ .../scala-cask/modelPackage.mustache | 53 + .../resources/scala-cask/modelTest.mustache | 37 + .../scala-cask/openapiRoute.mustache | 116 ++ .../scala-cask/parseHttpParams.mustache | 56 + .../scala-cask/pathExtractor.mustache | 1 + .../scala-cask/pathExtractorParams.mustache | 1 + .../scala-cask/project/build.properties | 1 + .../resources/scala-cask/project/plugins.sbt | 3 + .../resources/scala-cask/queryParams.mustache | 1 + .../.github/workflows/bulidAndPublish.yml | 41 + samples/server/petstore/scala-cask/.gitignore | 25 + .../scala-cask/.openapi-generator-ignore | 23 + .../scala-cask/.openapi-generator/FILES | 39 + .../scala-cask/.openapi-generator/VERSION | 1 + .../server/petstore/scala-cask/.scalafmt.conf | 4 + samples/server/petstore/scala-cask/README.md | 96 ++ samples/server/petstore/scala-cask/build.sbt | 29 + samples/server/petstore/scala-cask/build.sc | 43 + .../petstore/scala-cask/example/Dockerfile | 13 + .../petstore/scala-cask/example/Server.scala | 61 + .../scala-cask/project/build.properties | 1 + .../petstore/scala-cask/project/plugins.sbt | 3 + .../src/main/resources/openapi.json | 1032 +++++++++++++++++ .../main/scala/sample/cask/AppRoutes.scala | 52 + .../src/main/scala/sample/cask/BaseApp.scala | 59 + .../main/scala/sample/cask/ExampleApp.scala | 31 + .../scala/sample/cask/api/OpenApiRoutes.scala | 128 ++ .../scala/sample/cask/api/PetRoutes.scala | 212 ++++ .../scala/sample/cask/api/PetService.scala | 84 ++ .../scala/sample/cask/api/StoreRoutes.scala | 106 ++ .../scala/sample/cask/api/StoreService.scala | 58 + .../scala/sample/cask/api/UserRoutes.scala | 195 ++++ .../scala/sample/cask/api/UserService.scala | 83 ++ .../main/scala/sample/cask/api/package.scala | 167 +++ .../scala/sample/cask/model/ApiResponse.scala | 55 + .../sample/cask/model/ApiResponseData.scala | 171 +++ .../scala/sample/cask/model/Category.scala | 51 + .../sample/cask/model/CategoryData.scala | 153 +++ .../main/scala/sample/cask/model/Order.scala | 76 ++ .../scala/sample/cask/model/OrderData.scala | 245 ++++ .../main/scala/sample/cask/model/Pet.scala | 77 ++ .../scala/sample/cask/model/PetData.scala | 262 +++++ .../main/scala/sample/cask/model/Tag.scala | 51 + .../scala/sample/cask/model/TagData.scala | 147 +++ .../main/scala/sample/cask/model/User.scala | 76 ++ .../scala/sample/cask/model/UserData.scala | 292 +++++ .../scala/sample/cask/model/package.scala | 65 ++ .../src/main/scala/sample/cask/package.scala | 24 + .../sample/cask/model/ApiResponseTest.scala | 33 + .../sample/cask/model/CategoryTest.scala | 33 + .../scala/sample/cask/model/OrderTest.scala | 34 + .../scala/sample/cask/model/PetTest.scala | 35 + .../scala/sample/cask/model/TagTest.scala | 33 + .../scala/sample/cask/model/UserTest.scala | 33 + 81 files changed, 6942 insertions(+), 1 deletion(-) create mode 100644 bin/configs/scala-cask-petstore-new.yaml create mode 100644 docs/generators/scala-cask.md create mode 100644 modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaCaskServerCodegen.java create mode 100644 modules/openapi-generator/src/main/resources/scala-cask/.scalafmt.conf.mustache create mode 100644 modules/openapi-generator/src/main/resources/scala-cask/Dockerfile.mustache create mode 100644 modules/openapi-generator/src/main/resources/scala-cask/README.mustache create mode 100644 modules/openapi-generator/src/main/resources/scala-cask/api.mustache create mode 100644 modules/openapi-generator/src/main/resources/scala-cask/apiPackage.mustache create mode 100644 modules/openapi-generator/src/main/resources/scala-cask/apiRoutes.mustache create mode 100644 modules/openapi-generator/src/main/resources/scala-cask/apiRoutesQueryParamsTyped.mustache create mode 100644 modules/openapi-generator/src/main/resources/scala-cask/apiService.mustache create mode 100644 modules/openapi-generator/src/main/resources/scala-cask/appPackage.mustache create mode 100644 modules/openapi-generator/src/main/resources/scala-cask/appRoutes.mustache create mode 100644 modules/openapi-generator/src/main/resources/scala-cask/baseApp.mustache create mode 100644 modules/openapi-generator/src/main/resources/scala-cask/build.sbt.mustache create mode 100644 modules/openapi-generator/src/main/resources/scala-cask/build.sc.mustache create mode 100644 modules/openapi-generator/src/main/resources/scala-cask/bulidAndPublish.yml.mustache create mode 100644 modules/openapi-generator/src/main/resources/scala-cask/example.mustache create mode 100644 modules/openapi-generator/src/main/resources/scala-cask/exampleApp.mustache create mode 100644 modules/openapi-generator/src/main/resources/scala-cask/gitignore.mustache create mode 100644 modules/openapi-generator/src/main/resources/scala-cask/licenseInfo.mustache create mode 100644 modules/openapi-generator/src/main/resources/scala-cask/model.mustache create mode 100644 modules/openapi-generator/src/main/resources/scala-cask/modelData.mustache create mode 100644 modules/openapi-generator/src/main/resources/scala-cask/modelPackage.mustache create mode 100644 modules/openapi-generator/src/main/resources/scala-cask/modelTest.mustache create mode 100644 modules/openapi-generator/src/main/resources/scala-cask/openapiRoute.mustache create mode 100644 modules/openapi-generator/src/main/resources/scala-cask/parseHttpParams.mustache create mode 100644 modules/openapi-generator/src/main/resources/scala-cask/pathExtractor.mustache create mode 100644 modules/openapi-generator/src/main/resources/scala-cask/pathExtractorParams.mustache create mode 100644 modules/openapi-generator/src/main/resources/scala-cask/project/build.properties create mode 100644 modules/openapi-generator/src/main/resources/scala-cask/project/plugins.sbt create mode 100644 modules/openapi-generator/src/main/resources/scala-cask/queryParams.mustache create mode 100644 samples/server/petstore/scala-cask/.github/workflows/bulidAndPublish.yml create mode 100644 samples/server/petstore/scala-cask/.gitignore create mode 100644 samples/server/petstore/scala-cask/.openapi-generator-ignore create mode 100644 samples/server/petstore/scala-cask/.openapi-generator/FILES create mode 100644 samples/server/petstore/scala-cask/.openapi-generator/VERSION create mode 100644 samples/server/petstore/scala-cask/.scalafmt.conf create mode 100644 samples/server/petstore/scala-cask/README.md create mode 100644 samples/server/petstore/scala-cask/build.sbt create mode 100644 samples/server/petstore/scala-cask/build.sc create mode 100644 samples/server/petstore/scala-cask/example/Dockerfile create mode 100644 samples/server/petstore/scala-cask/example/Server.scala create mode 100644 samples/server/petstore/scala-cask/project/build.properties create mode 100644 samples/server/petstore/scala-cask/project/plugins.sbt create mode 100644 samples/server/petstore/scala-cask/src/main/resources/openapi.json create mode 100644 samples/server/petstore/scala-cask/src/main/scala/sample/cask/AppRoutes.scala create mode 100644 samples/server/petstore/scala-cask/src/main/scala/sample/cask/BaseApp.scala create mode 100644 samples/server/petstore/scala-cask/src/main/scala/sample/cask/ExampleApp.scala create mode 100644 samples/server/petstore/scala-cask/src/main/scala/sample/cask/api/OpenApiRoutes.scala create mode 100644 samples/server/petstore/scala-cask/src/main/scala/sample/cask/api/PetRoutes.scala create mode 100644 samples/server/petstore/scala-cask/src/main/scala/sample/cask/api/PetService.scala create mode 100644 samples/server/petstore/scala-cask/src/main/scala/sample/cask/api/StoreRoutes.scala create mode 100644 samples/server/petstore/scala-cask/src/main/scala/sample/cask/api/StoreService.scala create mode 100644 samples/server/petstore/scala-cask/src/main/scala/sample/cask/api/UserRoutes.scala create mode 100644 samples/server/petstore/scala-cask/src/main/scala/sample/cask/api/UserService.scala create mode 100644 samples/server/petstore/scala-cask/src/main/scala/sample/cask/api/package.scala create mode 100644 samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/ApiResponse.scala create mode 100644 samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/ApiResponseData.scala create mode 100644 samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/Category.scala create mode 100644 samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/CategoryData.scala create mode 100644 samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/Order.scala create mode 100644 samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/OrderData.scala create mode 100644 samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/Pet.scala create mode 100644 samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/PetData.scala create mode 100644 samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/Tag.scala create mode 100644 samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/TagData.scala create mode 100644 samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/User.scala create mode 100644 samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/UserData.scala create mode 100644 samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/package.scala create mode 100644 samples/server/petstore/scala-cask/src/main/scala/sample/cask/package.scala create mode 100644 samples/server/petstore/scala-cask/src/test/scala/sample/cask/model/ApiResponseTest.scala create mode 100644 samples/server/petstore/scala-cask/src/test/scala/sample/cask/model/CategoryTest.scala create mode 100644 samples/server/petstore/scala-cask/src/test/scala/sample/cask/model/OrderTest.scala create mode 100644 samples/server/petstore/scala-cask/src/test/scala/sample/cask/model/PetTest.scala create mode 100644 samples/server/petstore/scala-cask/src/test/scala/sample/cask/model/TagTest.scala create mode 100644 samples/server/petstore/scala-cask/src/test/scala/sample/cask/model/UserTest.scala diff --git a/.github/workflows/samples-scala.yaml b/.github/workflows/samples-scala.yaml index 953b35355c7..eb1a3ac43cf 100644 --- a/.github/workflows/samples-scala.yaml +++ b/.github/workflows/samples-scala.yaml @@ -32,6 +32,7 @@ jobs: - samples/server/petstore/scalatra - samples/server/petstore/scala-finch # cannot be tested with jdk11 - samples/server/petstore/scala-http4s-server + - samples/server/petstore/scala-cask steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 diff --git a/README.md b/README.md index a011deb8179..89b69acd365 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ OpenAPI Generator allows generation of API client libraries (SDK generation), se | | Languages/Frameworks | | -------------------------------- |-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | **API clients** | **ActionScript**, **Ada**, **Apex**, **Bash**, **C**, **C#** (.net 2.0, 3.5 or later, .NET Standard 1.3 - 2.1, .NET Core 3.1, .NET 5.0. Libraries: RestSharp, GenericHost, HttpClient), **C++** (Arduino, cpp-restsdk, Qt5, Tizen, Unreal Engine 4), **Clojure**, **Crystal**, **Dart**, **Elixir**, **Elm**, **Eiffel**, **Erlang**, **Go**, **Groovy**, **Haskell** (http-client, Servant), **Java** (Apache HttpClient 4.x, Apache HttpClient 5.x, Jersey2.x, OkHttp, Retrofit1.x, Retrofit2.x, Feign, RestTemplate, RESTEasy, Vertx, Google API Client Library for Java, Rest-assured, Spring 5 Web Client, MicroProfile Rest Client, Helidon), **Jetbrains HTTP Client**, **Julia**, **k6**, **Kotlin**, **Lua**, **N4JS**, **Nim**, **Node.js/JavaScript** (ES5, ES6, AngularJS with Google Closure Compiler annotations, Flow types, Apollo GraphQL DataStore), **Objective-C**, **OCaml**, **Perl**, **PHP**, **PowerShell**, **Python**, **R**, **Ruby**, **Rust** (hyper, reqwest, rust-server), **Scala** (akka, http4s, scalaz, sttp, swagger-async-httpclient, pekko), **Swift** (2.x, 3.x, 4.x, 5.x), **Typescript** (AngularJS, Angular (9.x - 17.x), Aurelia, Axios, Fetch, Inversify, jQuery, Nestjs, Node, redux-query, Rxjs), **XoJo**, **Zapier** | -| **Server stubs** | **Ada**, **C#** (ASP.NET Core, Azure Functions), **C++** (Pistache, Restbed, Qt5 QHTTPEngine), **Erlang**, **F#** (Giraffe), **Go** (net/http, Gin, Echo), **Haskell** (Servant, Yesod), **Java** (MSF4J, Spring, Undertow, JAX-RS: CDI, CXF, Inflector, Jersey, RestEasy, Play Framework, [PKMST](https://github.com/ProKarma-Inc/pkmst-getting-started-examples), [Vert.x](https://vertx.io/), [Apache Camel](https://camel.apache.org/), [Helidon](https://helidon.io/)), **Julia**, **Kotlin** (Spring Boot, [Ktor](https://github.com/ktorio/ktor), [Vert.x](https://vertx.io/)), **PHP** (Laravel, Lumen, [Mezzio (fka Zend Expressive)](https://github.com/mezzio/mezzio), Slim, Silex, [Symfony](https://symfony.com/)), **Python** (FastAPI, Flask), **NodeJS**, **Ruby** (Sinatra, Rails5), **Rust** ([rust-server](https://openapi-generator.tech/docs/generators/rust-server/)), **Scala** (Akka, [Finch](https://github.com/finagle/finch), [Lagom](https://github.com/lagom/lagom), [Play](https://www.playframework.com/), Scalatra) | +| **Server stubs** | **Ada**, **C#** (ASP.NET Core, Azure Functions), **C++** (Pistache, Restbed, Qt5 QHTTPEngine), **Erlang**, **F#** (Giraffe), **Go** (net/http, Gin, Echo), **Haskell** (Servant, Yesod), **Java** (MSF4J, Spring, Undertow, JAX-RS: CDI, CXF, Inflector, Jersey, RestEasy, Play Framework, [PKMST](https://github.com/ProKarma-Inc/pkmst-getting-started-examples), [Vert.x](https://vertx.io/), [Apache Camel](https://camel.apache.org/), [Helidon](https://helidon.io/)), **Julia**, **Kotlin** (Spring Boot, [Ktor](https://github.com/ktorio/ktor), [Vert.x](https://vertx.io/)), **PHP** (Laravel, Lumen, [Mezzio (fka Zend Expressive)](https://github.com/mezzio/mezzio), Slim, Silex, [Symfony](https://symfony.com/)), **Python** (FastAPI, Flask), **NodeJS**, **Ruby** (Sinatra, Rails5), **Rust** ([rust-server](https://openapi-generator.tech/docs/generators/rust-server/)), **Scala** (Akka, [Finch](https://github.com/finagle/finch), [Lagom](https://github.com/lagom/lagom), [Play](https://www.playframework.com/), [Cask](https://github.com/com-lihaoyi/cask), Scalatra) | | **API documentation generators** | **HTML**, **Confluence Wiki**, **Asciidoc**, **Markdown**, **PlantUML** | | **Configuration files** | [**Apache2**](https://httpd.apache.org/) | | **Others** | **GraphQL**, **JMeter**, **Ktorm**, **MySQL Schema**, **Postman Collection**, **Protocol Buffer**, **WSDL** | @@ -1102,6 +1102,7 @@ Here is a list of template creators: * Ruby on Rails 5: @zlx * Rust (rust-server): @metaswitch * Scala Akka: @Bouillie + * Scala Cask: @aaronp * Scala Finch: @jimschubert [:heart:](https://www.patreon.com/jimschubert) * Scala Lagom: @gmkumar2005 * Scala Play: @adigerber diff --git a/bin/configs/scala-cask-petstore-new.yaml b/bin/configs/scala-cask-petstore-new.yaml new file mode 100644 index 00000000000..bb37d3587e7 --- /dev/null +++ b/bin/configs/scala-cask-petstore-new.yaml @@ -0,0 +1,13 @@ +generatorName: scala-cask +outputDir: samples/server/petstore/scala-cask +inputSpec: modules/openapi-generator/src/test/resources/3_0/petstore.yaml +templateDir: modules/openapi-generator/src/main/resources/scala-cask +additionalProperties: + hideGenerationTimestamp: "true" + artifactId: scala-cask-petstore + groupId: "cask.groupId" + package: "sample.cask" + modelPackage: "sample.cask.model" + apiPackage: "sample.cask.api" + gitRepoId: "sample-cask-repo" + gitUserId: "sample-cask-user" \ No newline at end of file diff --git a/docs/generators.md b/docs/generators.md index ca6e426f9b9..749f43f15db 100644 --- a/docs/generators.md +++ b/docs/generators.md @@ -134,6 +134,7 @@ The following generators are available: * [rust-axum (beta)](generators/rust-axum.md) * [rust-server](generators/rust-server.md) * [scala-akka-http-server (beta)](generators/scala-akka-http-server.md) +* [scala-cask](generators/scala-cask.md) * [scala-finch](generators/scala-finch.md) * [scala-http4s-server](generators/scala-http4s-server.md) * [scala-lagom-server](generators/scala-lagom-server.md) diff --git a/docs/generators/scala-cask.md b/docs/generators/scala-cask.md new file mode 100644 index 00000000000..d187ba56939 --- /dev/null +++ b/docs/generators/scala-cask.md @@ -0,0 +1,261 @@ +--- +title: Documentation for the scala-cask Generator +--- + +## METADATA + +| Property | Value | Notes | +| -------- | ----- | ----- | +| generator name | scala-cask | pass this to the generate command after -g | +| generator stability | STABLE | | +| generator type | SERVER | | +| generator language | Scala | | +| generator default templating engine | mustache | | +| helpTxt | Generates a scala-cask server. | | + +## 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 | +| ------ | ----------- | ------ | ------- | +|allowUnicodeIdentifiers|boolean, toggles whether unicode identifiers are allowed in names or not, default is false| |false| +|apiPackage|package for generated api classes| |null| +|artifactId|artifactId in generated pom.xml. This also becomes part of the generated library's filename| |null| +|dateLibrary|Option. Date library to use|
**joda**
Joda (for legacy app)
**java8**
Java 8 native JSR310 (preferred for JDK 1.8+)
|java8| +|disallowAdditionalPropertiesIfNotPresent|If false, the 'additionalProperties' implementation (set to true by default) is compliant with the OAS and JSON schema specifications. If true (default), keep the old (incorrect) behaviour that 'additionalProperties' is set to false by default.|
**false**
The 'additionalProperties' implementation is compliant with the OAS and JSON schema specifications.
**true**
Keep the old (incorrect) behaviour that 'additionalProperties' is set to false by default.
|true| +|ensureUniqueParams|Whether to ensure parameter names are unique in an operation (rename parameters that are not).| |true| +|enumUnknownDefaultCase|If the server adds new enum cases, that are unknown by an old spec/client, the client will fail to parse the network response.With this option enabled, each enum will have a new case, 'unknown_default_open_api', so that when the server sends an enum case that is not known by the client/spec, they can safely fallback to this case.|
**false**
No changes to the enum's are made, this is the default option.
**true**
With this option enabled, each enum will have a new case, 'unknown_default_open_api', so that when the enum case sent by the server is not known by the client/spec, can safely be decoded to this case.
|false| +|gitRepoId|Git repo ID, e.g. openapi-generator.| |null| +|gitUserId|Git user ID, e.g. openapitools.| |null| +|groupId|groupId in generated pom.xml| |null| +|legacyDiscriminatorBehavior|Set to false for generators with better support for discriminators. (Python, Java, Go, PowerShell, C# have this enabled by default).|
**true**
The mapping in the discriminator includes descendent schemas that allOf inherit from self and the discriminator mapping schemas in the OAS document.
**false**
The mapping in the discriminator includes any descendent schemas that allOf inherit from self, any oneOf schemas, any anyOf schemas, any x-discriminator-values, and the discriminator mapping schemas in the OAS document AND Codegen validates that oneOf and anyOf schemas contain the required discriminator and throws an error if the discriminator is missing.
|true| +|modelPackage|package for generated models| |null| +|modelPropertyNaming|Naming convention for the property: 'camelCase', 'PascalCase', 'snake_case' and 'original', which keeps the original name| |camelCase| +|packageName|packageDescription| |null| +|prependFormOrBodyParameters|Add form or body parameters to the beginning of the parameter list.| |false| +|sortModelPropertiesByRequiredFlag|Sort model properties to place required parameters before optional parameters.| |true| +|sortParamsByRequiredFlag|Sort method arguments to place required parameters before optional parameters.| |true| +|sourceFolder|source folder for generated code| |null| + +## IMPORT MAPPING + +| Type/Alias | Imports | +| ---------- | ------- | +|Array|java.util.List| +|ArrayList|java.util.ArrayList| +|Date|java.util.Date| +|DateTime|org.joda.time.*| +|File|java.io.File| +|HashMap|java.util.HashMap| +|ListBuffer|scala.collection.mutable.ListBuffer| +|ListSet|scala.collection.immutable.ListSet| +|LocalDate|org.joda.time.*| +|LocalDateTime|org.joda.time.*| +|LocalTime|org.joda.time.*| +|Seq|scala.collection.immutable.Seq| +|Set|scala.collection.immutable.Set| +|Timestamp|java.sql.Timestamp| +|URI|java.net.URI| +|UUID|java.util.UUID| + + +## INSTANTIATION TYPES + +| Type/Alias | Instantiated By | +| ---------- | --------------- | +|set|Set| + + +## 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/ScalaCaskServerCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaCaskServerCodegen.java new file mode 100644 index 00000000000..1307ce65240 --- /dev/null +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/ScalaCaskServerCodegen.java @@ -0,0 +1,845 @@ +/* + * 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 io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.tags.Tag; +import org.apache.commons.io.FileUtils; +import org.openapitools.codegen.*; +import org.openapitools.codegen.model.ModelMap; +import org.openapitools.codegen.model.ModelsMap; +import org.openapitools.codegen.model.OperationsMap; +import org.openapitools.codegen.serializer.SerializerUtils; +import org.openapitools.codegen.utils.ModelUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.regex.PatternSyntaxException; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.openapitools.codegen.utils.StringUtils.camelize; + +public class ScalaCaskServerCodegen extends AbstractScalaCodegen implements CodegenConfig { + public static final String PROJECT_NAME = "projectName"; + + private final Logger LOGGER = LoggerFactory.getLogger(ScalaCaskServerCodegen.class); + + public CodegenType getTag() { + return CodegenType.SERVER; + } + + public String getName() { + return "scala-cask"; + } + + public String getHelp() { + return "Generates a scala-cask server."; + } + + protected String artifactVersion = "0.0.1"; + + static String ApiServiceTemplate = "apiService.mustache"; + + public ScalaCaskServerCodegen() { + super(); + + outputFolder = "generated-code/scala-cask"; + + embeddedTemplateDir = templateDir = "scala-cask"; + apiPackage = "Apis"; + modelPackage = "Models"; + supportingFiles.add(new SupportingFile("README.mustache", "", "README.md")); + + outputFolder = "generated-code/cask"; + + modelTestTemplateFiles.put("modelTest.mustache", ".scala"); + modelTemplateFiles.put("model.mustache", ".scala"); + modelTemplateFiles.put("modelData.mustache", "Data.scala"); + + apiTemplateFiles.put("api.mustache", ".scala"); + apiTemplateFiles.put("apiRoutes.mustache", ".scala"); + apiTemplateFiles.put(ApiServiceTemplate, "Service.scala"); + + embeddedTemplateDir = templateDir = "scala-cask"; + + setReservedWordsLowerCase( + Arrays.asList( + "abstract", "continue", "for", "new", "switch", "assert", + "default", "if", "package", "synchronized", "boolean", "do", "goto", "private", + "this", "break", "double", "implements", "protected", "throw", "byte", "else", + "import", "public", "throws", "case", "enum", "instanceof", "return", "transient", + "catch", "extends", "int", "short", "try", "char", "final", "interface", "static", + "void", "class", "finally", "long", "strictfp", "volatile", "const", "float", + "native", "super", "while", "type") + ); + + defaultIncludes = new HashSet( + Arrays.asList("double", + "Int", + "Long", + "Float", + "Double", + "char", + "float", + "String", + "boolean", + "Boolean", + "Double", + "Integer", + "Long", + "Float", + "List", + "Set", + "Map") + ); + + typeMapping.put("integer", "Int"); + typeMapping.put("long", "Long"); + //TODO binary should be mapped to byte array + // mapped to String as a workaround + typeMapping.put("binary", "String"); + + cliOptions.add(new CliOption(CodegenConstants.GROUP_ID, CodegenConstants.GROUP_ID_DESC)); + cliOptions.add(new CliOption(CodegenConstants.ARTIFACT_ID, CodegenConstants.ARTIFACT_ID_DESC)); + cliOptions.add(new CliOption(CodegenConstants.GIT_REPO_ID, CodegenConstants.GIT_REPO_ID_DESC)); + cliOptions.add(new CliOption(CodegenConstants.GIT_USER_ID, CodegenConstants.GIT_USER_ID_DESC)); + cliOptions.add(new CliOption(CodegenConstants.PACKAGE_NAME, CodegenConstants.PACKAGE_DESCRIPTION)); + } + + @Override + public String toDefaultValue(Schema p) { + if (ModelUtils.isMapSchema(p)) { + String inner = getSchemaType(ModelUtils.getAdditionalProperties(p)); + return "Map[String, " + inner + "]() "; + } + return super.toDefaultValue(p); + } + + @Override + public String testPackage() { + return "src/test/scala"; + } + + public String toModelTestFilename(String name) { + String n = super.toModelTestFilename(name); + return (modelPackage + "." + n).replace('.', '/'); + } + + private String ensureProp(String key, String defaultValue) { + if (additionalProperties.containsKey(key) && !additionalProperties.get(key).toString().trim().isEmpty()) { + return (String) additionalProperties.get(key); + } else { + additionalProperties.put(key, defaultValue); + return defaultValue; + } + } + @Override + public void processOpts() { + super.processOpts(); + + final String groupId = ensureProp(CodegenConstants.GROUP_ID, "org.openapitools"); + ensureProp(CodegenConstants.ARTIFACT_ID, "caskgen"); + artifactVersion = ensureProp(CodegenConstants.ARTIFACT_VERSION, "0.0.1"); + gitRepoId = ensureProp(CodegenConstants.GIT_REPO_ID, ""); + gitUserId = ensureProp(CodegenConstants.GIT_USER_ID, ""); + + String basePackage = ensureProp(CodegenConstants.PACKAGE_NAME, groupId + ".server"); + apiPackage = ensureProp(CodegenConstants.API_PACKAGE, basePackage + ".api"); + modelPackage = ensureProp(CodegenConstants.MODEL_PACKAGE, basePackage + ".model"); + + + final String apiPath = "src/main/scala/" + apiPackage.replace('.', '/'); + final String modelPath = "src/main/scala/" + modelPackage.replace('.', '/'); + + final List appFullPath = Arrays.stream(apiPath.split("/")).collect(Collectors.toList()); + final String appFolder = String.join("/", appFullPath.subList(0, appFullPath.size() - 1)); + + additionalProperties.put("appName", "Cask App"); + additionalProperties.put("appDescription", "A cask service"); + additionalProperties.put("infoUrl", "https://openapi-generator.tech"); + additionalProperties.put("infoEmail", infoEmail); + additionalProperties.put("licenseInfo", "All rights reserved"); + additionalProperties.put("licenseUrl", "http://apache.org/licenses/LICENSE-2.0.html"); + additionalProperties.put(CodegenConstants.INVOKER_PACKAGE, invokerPackage); + additionalProperties.put("openbrackets", "{{"); + additionalProperties.put("closebrackets", "}}"); + + supportingFiles.add(new SupportingFile("example.mustache", "example", "Server.scala")); + supportingFiles.add(new SupportingFile("Dockerfile.mustache", "example", "Dockerfile")); + supportingFiles.add(new SupportingFile("README.mustache", "", "README.md")); + supportingFiles.add(new SupportingFile("build.sbt.mustache", "", "build.sbt")); + supportingFiles.add(new SupportingFile("bulidAndPublish.yml.mustache", "", ".github/workflows/bulidAndPublish.yml")); + supportingFiles.add(new SupportingFile("build.sc.mustache", "", "build.sc")); + supportingFiles.add(new SupportingFile(".scalafmt.conf.mustache", "", ".scalafmt.conf")); + supportingFiles.add(new SupportingFile("gitignore.mustache", "", ".gitignore")); + supportingFiles.add(new SupportingFile("appPackage.mustache", appFolder, "package.scala")); + supportingFiles.add(new SupportingFile("apiPackage.mustache", apiPath, "package.scala")); + supportingFiles.add(new SupportingFile("modelPackage.mustache", modelPath, "package.scala")); + supportingFiles.add(new SupportingFile("exampleApp.mustache", appFolder, "ExampleApp.scala")); + supportingFiles.add(new SupportingFile("baseApp.mustache", appFolder, "BaseApp.scala")); + supportingFiles.add(new SupportingFile("openapiRoute.mustache", apiPath, "OpenApiRoutes.scala")); + supportingFiles.add(new SupportingFile("appRoutes.mustache", appFolder, "AppRoutes.scala")); + supportingFiles.add(new SupportingFile("project/build.properties", "project", "build.properties")); + supportingFiles.add(new SupportingFile("project/plugins.sbt", "project", "plugins.sbt")); + + + instantiationTypes.put("array", "Seq"); + instantiationTypes.put("map", "Map"); + + importMapping = new HashMap(); + importMapping.put("BigDecimal", "scala.math.BigDecimal"); + importMapping.put("UUID", "java.util.UUID"); + importMapping.put("File", "java.io.File"); + importMapping.put("Date", "java.time.LocalDate as Date"); + importMapping.put("Timestamp", "java.sql.Timestamp"); + importMapping.put("Map", "Map"); + importMapping.put("HashMap", "Map"); + importMapping.put("Array", "Seq"); + importMapping.put("ArrayList", "Seq"); + importMapping.put("List", "Seq"); + importMapping.put("DateTime", "java.time.LocalDateTime"); + importMapping.put("LocalDateTime", "java.time.LocalDateTime"); + importMapping.put("LocalDate", "java.time.LocalDate"); + importMapping.put("OffsetDateTime", "java.time.OffsetDateTime"); + importMapping.put("LocalTime", "java.time.LocalTime"); + } + + static boolean consumesMimetype(CodegenOperation op, String mimetype) { + // people don't always/often specify the 'consumes' property, so we assume true when + // the optional 'consumes' is null or empty + boolean defaultRetValue = true; + + final List> consumes = op.consumes; + if (consumes != null) { + for (Map c : consumes) { + final String mt = c.get("mediaType"); + if (mt.equalsIgnoreCase(mimetype)) { + return true; + } + } + return false; + } else { + return defaultRetValue; + } + } + + + static String formatMap(Map map) { + StringBuilder mapAsString = new StringBuilder("{"); + for (Object key : map.keySet().stream().sorted().collect(Collectors.toList())) { + mapAsString.append(key + " -- " + map.get(key) + ",\n"); + } + if (mapAsString.length() > 1) { + mapAsString.delete(mapAsString.length() - 2, mapAsString.length()); + } + mapAsString.append("}"); + return mapAsString.toString(); + } + + @Override + public String toApiName(String name) { + if (name.isEmpty()) { + return "DefaultApi"; + } + name = sanitizeName(name); + return camelize(name); + } + + @Override + public String apiFilename(String templateName, String tag) { + String suffix = apiTemplateFiles().get(templateName); + String fn = toApiFilename(tag); + if (templateName.equals(ApiServiceTemplate)) { + return apiFileFolder() + '/' + fn + suffix; + } else { + return apiFileFolder() + '/' + fn + "Routes" + suffix; + } + } + + static String capitalise(String p) { + if (p.length() < 2) { + return p.toUpperCase(Locale.ROOT); + } else { + String first = "" + p.charAt(0); + return first.toUpperCase(Locale.ROOT) + p.substring(1); + } + } + + + // thanks FlaskConnectionCodeGen + private static List> getOperations(Map objs) { + List> result = new ArrayList>(); + Map apiInfo = (Map) objs.get("apiInfo"); + List> apis = (List>) apiInfo.get("apis"); + for (Map api : apis) { + Map operations = (Map) api.get("operations"); + result.add(operations); + } + return result; + } + + @Override + public Map postProcessSupportingFileData(Map objs) { + List> operations = getOperations(objs); + for (int i = 0; i < operations.size(); i++) { + operations.get(i).put("hasMore", i < operations.size() - 1); + } + objs.put("operations", operations); + return super.postProcessSupportingFileData(objs); + } + + protected String getResourceFolder() { + String src = getSourceFolder(); + + List parts = Arrays.stream(src.split("/", -1)).collect(Collectors.toList()); + if (parts.isEmpty()) { + return "resources"; + } else { + String srcMain = String.join("/", parts.subList(0, parts.size() - 1)); + return srcMain + "/resources"; + } + } + + @Override + public void processOpenAPI(OpenAPI openAPI) { + String jsonOpenAPI = SerializerUtils.toJsonString(openAPI); + + try { + String outputFile = getOutputDir() + "/" + getResourceFolder() + "/openapi.json"; + FileUtils.writeStringToFile(new File(outputFile), jsonOpenAPI, StandardCharsets.UTF_8); + LOGGER.info("wrote file to {}", outputFile); + } catch (Exception e) { + LOGGER.error(e.getMessage(), e); + } + } + + + public static class ParamPart { + final CodegenParameter param; + final String name; + final boolean isParam; + + // flag for if there are more path parts + boolean hasMore; + // flag for if there are more path parts which are parameters + boolean hasMoreParams; + + final String conversion; + + public ParamPart(String name, CodegenParameter param) { + this.name = name; + this.param = param; + this.isParam = param != null; + this.hasMore = true; + this.conversion = !isParam || param.isString ? "" : ".to" + param.dataType; + } + } + + /** + * Cask will compile but 'initialize' can throw a route overlap exception: + *

+ * {{{ + * Routes overlap with wildcards: get /user/logout, get /user/:username, get /user/login + * }}} + *

+ * Note: The same error persists even if the suffixes are unique: + * {{{ + * Routes overlap with wildcards: get /user/logout/3, get /user/:username/1, get /user/login/2 + * }}} + *

+ * To fix this, we need to identify and resolve conflicts in our generated code. + *

+ * # How do we identify conflicts? + *

+ *

+ * 1. group routes by their non-param prefixes. + *

+ * 2. add an "x-annotation" vendor extension for operations + *

+ * 3. add a list of "RouteGroups" which can manually delegate as per below + *

+ *

+ * # How do we resolve conflicts? + *

+ * We leave out the cask route annotation on the conflicting operations, e.g. : + * {{{ + * //conflict: @cask.get("/user/:username") + * def getUserByName(username: String, request: cask.Request) = ... + * }}} + *

+ * and we introduce a new discriminator function to "manually" call those conflicts: + * {{{ + * + * @cask.get("/user", subpath = true) + * def userRouteDescriminator(request: cask.Request) = { + * request.remainingPathSegments match { + * case Seq("logout") => logoutUser(request) + * case Seq("login") => loginUser(request) + * case Seq(param) => getUserByName(param, request) + * } + * } + * }}} + */ + public static class OperationGroup { + List operations = new ArrayList<>(); + final String pathPrefix; + final String httpMethod; + final String caskAnnotation; + final String methodName; + + // TODO - multiple operations may have the same query params, so we'll need to somehow merge them (and take the right type) + public boolean hasGroupQueryParams() { + return operations.stream().flatMap(op -> op.queryParams.stream()).count() > 0; + } + + /** + * This is invoked from `scala-cask/apiRoutesQueryParamsTyped.mustache` + * + * @return the CodegenParameters + */ + public List getGroupQueryParams() { + List list = operations.stream().flatMap(op -> op.queryParams.stream()).map(p -> { + final CodegenParameter copy = p.copy(); + copy.vendorExtensions.put("x-default-value", defaultValue(p)); + copy.required = false; // all our query params are optional for our work-around as it's a super-set of a few different routes + copy.dataType = asScalaDataType(copy, false, true, true); + copy.defaultValue = defaultValue(copy); + return copy; + } + ).collect(Collectors.toList()); + + return list; + } + + @Override + public String toString() { + List ops = operations.stream().map(o -> o.path + "\n").collect(Collectors.toList()); + return httpMethod + " " + pathPrefix + " w/ " + operations.size() + " operations:\n" + String.join("", ops); + } + + public OperationGroup(String httpMethod, String pathPrefix) { + this.httpMethod = httpMethod; + this.pathPrefix = pathPrefix; + caskAnnotation = "@cask." + httpMethod.toLowerCase(Locale.ROOT); + + List stripped = Arrays.stream(pathPrefix.split("/", -1)) + .map(ScalaCaskServerCodegen::capitalise).collect(Collectors.toList()); + + methodName = "routeWorkAroundFor" + capitalise(httpMethod) + String.join("", stripped); + } + + public void add(CodegenOperation op) { + if (!op.path.startsWith(pathPrefix)) { + throw new IllegalArgumentException("inconsistent path: " + pathPrefix); + } + if (!op.httpMethod.equals(httpMethod)) { + throw new IllegalArgumentException("inconsistent method: " + httpMethod); + } + + final List pathParts = new ArrayList<>(); + final List parts = Arrays.stream(op.path.substring(pathPrefix.length()).split("/", -1)).filter(p -> !p.isEmpty()).collect(Collectors.toList()); + for (int i = 0; i < parts.size(); i++) { + String p = parts.get(i); + ScalaCaskServerCodegen.ParamPart pp = hasBrackets(p) ? new ScalaCaskServerCodegen.ParamPart(chompBrackets(p), pathParamForName(op, chompBrackets(p))) : new ScalaCaskServerCodegen.ParamPart(p, null); + pathParts.add(pp); + } + + List paramPathParts = pathParts.stream().filter(p -> p.isParam).collect(Collectors.toList()); + if (!paramPathParts.isEmpty()) { + final String lastParamName = paramPathParts.get(paramPathParts.size() - 1).name; + paramPathParts.forEach(p -> p.hasMoreParams = !p.name.equals(lastParamName)); + } + if (!pathParts.isEmpty()) { + pathParts.get(pathParts.size() - 1).hasMore = false; + } + + op.vendorExtensions.put("x-path-remaining", pathParts); + op.vendorExtensions.put("x-has-path-remaining", !paramPathParts.isEmpty()); + operations.add(op); + } + + public boolean contains(CodegenOperation op) { + return operations.contains(op); + } + + public void updateAnnotations() { + operations.forEach(op -> { + String annotation = op.vendorExtensions.get("x-annotation").toString(); + String conflicts = String.join(", ", operations.stream().map(o -> o.path).collect(Collectors.toList())); + op.vendorExtensions.put("x-annotation", "// conflicts with [" + conflicts + "] after" + pathPrefix + ", ignoring " + annotation); + }); + operations = operations.stream().sorted((a, b) -> a.pathParams.size() - b.pathParams.size()).collect(Collectors.toList()); + } + } + + + static List group(List operationList) { + Map groupedByPrefix = new HashMap<>(); + operationList.forEach(op -> { + String prefix = nonParamPathPrefix(op); + String key = op.httpMethod + " " + prefix; + if (!op.pathParams.isEmpty()) { + final ScalaCaskServerCodegen.OperationGroup group = groupedByPrefix.getOrDefault(key, new ScalaCaskServerCodegen.OperationGroup(op.httpMethod, prefix)); + group.add(op); + groupedByPrefix.put(key, group); + } + }); + return groupedByPrefix.values().stream().collect(Collectors.toList()); + } + + static String nonParamPathPrefix(CodegenOperation op) { + if (op.pathParams.isEmpty()) { + return op.path; + } + + final String firstParam = op.pathParams.stream().findFirst().get().paramName; + final int i = op.path.indexOf(firstParam); + final String path = chompSuffix(op.path.substring(0, i - 1), "/"); + return path; + } + + static List createRouteGroups(List operationList) { + + List groups = group(operationList); + operationList.forEach((op) -> { + for (final ScalaCaskServerCodegen.OperationGroup group : groups) { + // for the usage/call site + final String scalaPath = pathWithBracketPlaceholdersRemovedAndXPathIndexAdded(op); + op.vendorExtensions.put("x-cask-path", scalaPath); + + final String annotation = "@cask." + op.httpMethod.toLowerCase(Locale.ROOT); + op.vendorExtensions.put("x-annotation", annotation); + if (!group.contains(op)) { + if (op.path.startsWith(group.pathPrefix) && op.httpMethod.equalsIgnoreCase(group.httpMethod)) { + group.add(op); + } + } + } + }); + + List trimmed = groups.stream().filter(g -> g.operations.size() > 1).map(g -> { + g.updateAnnotations(); + return g; + }).collect(Collectors.toList()); + return trimmed; + } + + @Override + public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List allModels) { + final Map operations = (Map) objs.get("operations"); + final List operationList = (List) operations.get("operation"); + + objs.put("route-groups", createRouteGroups(operationList)); + + operationList.forEach(ScalaCaskServerCodegen::postProcessOperation); + return objs; + } + + @Override + public ModelsMap postProcessModels(ModelsMap objs) { + objs.getModels().stream().map(ModelMap::getModel).forEach(this::postProcessModel); + return objs; + } + + private void setDefaultValueForCodegenProperty(CodegenProperty p) { + + if (p.defaultValue == null || p.defaultValue.trim().isEmpty()) { + if (p.getIsEnumOrRef()) { + p.defaultValue = "null"; + } else { + p.defaultValue = defaultValueNonOption(p); + } + } else if (p.defaultValue.contains("Seq.empty")) { + p.defaultValue = "Nil"; + } + } + + private void postProcessModel(CodegenModel model) { + model.getAllVars().forEach(this::setDefaultValueForCodegenProperty); + model.getVars().forEach(this::setDefaultValueForCodegenProperty); + + model.getVars().forEach(ScalaCaskServerCodegen::postProcessProperty); + model.getAllVars().forEach(ScalaCaskServerCodegen::postProcessProperty); + } + + private static void postProcessOperation(CodegenOperation op) { + // force http method to lower case + op.httpMethod = op.httpMethod.toLowerCase(Locale.ROOT); + + /* Put in 'x-consumes-json' and 'x-consumes-xml' */ + op.vendorExtensions.put("x-consumes-json", consumesMimetype(op, "application/json")); + op.vendorExtensions.put("x-consumes-xml", consumesMimetype(op, "application/xml")); + + op.bodyParams.stream().filter((b) -> b.isBodyParam).forEach((p) -> { + p.vendorExtensions.put("x-consumes-json", consumesMimetype(op, "application/json")); + p.vendorExtensions.put("x-consumes-xml", consumesMimetype(op, "application/xml")); + }); + + /* put in 'x-container-type' to help with unmarshalling from json */ + op.allParams.forEach((p) -> p.vendorExtensions.put("x-container-type", containerType(p.dataType))); + op.bodyParams.forEach((p) -> p.vendorExtensions.put("x-container-type", containerType(p.dataType))); + + final String paramList = op.allParams.stream().map((p) -> p.paramName).collect(Collectors.joining(", ")); + op.vendorExtensions.put("x-param-list", paramList); + + final Stream typed = op.allParams.stream().map((p) -> p.paramName + " : " + asScalaDataType(p, p.required, false)); + op.vendorExtensions.put("x-param-list-typed", String.join(", ", typed.collect(Collectors.toList()))); + + final Stream typedJson = op.allParams.stream().map((p) -> p.paramName + " : " + asScalaDataType(p, p.required, true)); + op.vendorExtensions.put("x-param-list-typed-json", String.join(", ", typedJson.collect(Collectors.toList()))); + + // for the declaration site + op.vendorExtensions.put("x-cask-path-typed", routeArgs(op)); + op.vendorExtensions.put("x-query-args", queryArgs(op)); + + List responses = op.responses.stream().map(r -> r.dataType).filter(Objects::nonNull).collect(Collectors.toList()); + op.vendorExtensions.put("x-response-type", responses.isEmpty() ? "Unit" : String.join(" | ", responses)); + } + + private static void postProcessProperty(CodegenProperty p) { + p.vendorExtensions.put("x-datatype-model", asScalaDataType(p, p.required, false)); + p.vendorExtensions.put("x-defaultValue-model", defaultValue(p, p.required, p.defaultValue)); + String dataTypeData = asScalaDataType(p, p.required, true); + p.vendorExtensions.put("x-datatype-data", dataTypeData); + + + p.vendorExtensions.put("x-containertype-data", containerType(dataTypeData)); + + p.vendorExtensions.put("x-defaultValue-data", defaultValueNonOption(p, p.defaultValue)); + + // the 'asModel' logic for modelData.mustache + // + // if it's optional (not required), then wrap the value in Option() + // ... unless it's a map or array, in which case it can just be empty + // + p.vendorExtensions.put("x-wrap-in-optional", !p.required && !p.isArray && !p.isMap); + + // if it's an array or optional, we need to map it as a model -- unless it's a map, + // in which case we have to map the values + boolean hasItemModel = p.items != null && p.items.isModel; + boolean isObjectArray = p.isArray && hasItemModel; + boolean isOptionalObj = !p.required && p.isModel; + p.vendorExtensions.put("x-map-asModel", (isOptionalObj || isObjectArray) && !p.isMap); + + // when deserialising map objects, the logic is tricky. + p.vendorExtensions.put("x-deserialize-asModelMap", p.isMap && hasItemModel); + + // for some reason, an openapi spec with pattern field like this: + // pattern: '^[A-Za-z]+$' + // will result in the pattern property text of + // pattern: '/^[A-Za-z]+$/' + if (p.pattern != null && p.pattern.startsWith("/") && p.pattern.endsWith("/")) { + p.pattern = p.pattern.substring(1, p.pattern.length() - 1); + } + + } + + + /** + * Cask path params use the :pathParam syntax rather than the {pathParam} syntax + * + * @param op + * @return + */ + private static String pathWithBracketPlaceholdersRemovedAndXPathIndexAdded(CodegenOperation op) { + String[] items = op.path.split("/", -1); + String scalaPath = ""; + for (int i = 0; i < items.length; ++i) { + final String nextPart = hasBrackets(items[i]) ? ":" + chompBrackets(items[i]) : items[i]; + if (i != items.length - 1) { + scalaPath = scalaPath + nextPart + "/"; + } else { + scalaPath = scalaPath + nextPart; + } + } + return scalaPath; + } + + private static CodegenParameter pathParamForName(CodegenOperation op, String pathParam) { + final CodegenParameter param = op.pathParams.stream().filter(p -> p.paramName.equals(pathParam)).findFirst().get(); + if (param == null) { + throw new RuntimeException("Bug: path param " + pathParam + " not found"); + } + return param; + } + + /** + * The path placeholders as well as query parameters + * + * @param op the codegen operations + * @return a list of both the path and query parameters as typed arguments (e.g. "aPathArg : Int, request: cask.Request, aQueryArg : Option[Long]") + */ + private static String routeArgs(CodegenOperation op) { + final Stream pathParamNames = Arrays.stream(op.path.split("/", -1)).filter(ScalaCaskServerCodegen::hasBrackets).map(p -> { + final CodegenParameter param = pathParamForName(op, chompBrackets(p)); + return param.paramName + " : " + asScalaDataType(param, param.required, true); + }); + + + final List pathList = pathParamNames.collect(Collectors.toList()); + + // we always include the cask request + pathList.add("request: cask.Request"); + + final Stream queryParams = op.queryParams.stream().map(p -> { + p.vendorExtensions.put("x-default-value", defaultValue(p)); + return p.paramName + " : " + asScalaDataType(p, p.required, true, true); + }); + pathList.addAll(queryParams.collect(Collectors.toList())); + return pathList.isEmpty() ? "" : (String.join(", ", pathList)); + } + + private static String defaultValue(CodegenParameter p) { + return defaultValue(p, p.required, p.defaultValue); + } + + private static String defaultValue(IJsonSchemaValidationProperties p, boolean required, String fallbackDefaultValue) { + if (!required && !(p.getIsArray() || p.getIsMap())) { + return "None"; + } + return defaultValueNonOption(p, fallbackDefaultValue); + } + + private static String defaultValueNonOption(IJsonSchemaValidationProperties p, String fallbackDefaultValue) { + if (p.getIsArray()) { + if (p.getUniqueItems()) { + return "Set.empty"; + } + return "Nil"; + } + if (p.getIsMap()) { + return "Map.empty"; + } + if (p.getIsNumber()) { + return "0"; + } + if (p.getIsEnum()) { + return fallbackDefaultValue; + } + if (p.getIsBoolean()) { + return "false"; + } + if (p.getIsUuid()) { + return "java.util.UUID.randomUUID()"; + } + if (p.getIsString()) { + return "\"\""; + } + return fallbackDefaultValue; + } + + private static String defaultValueNonOption(CodegenProperty p) { + if (p.getIsArray()) { + return "Nil"; + } + if (p.getIsMap()) { + return "Map.empty"; + } + if (p.isNumber || p.isNumeric) { + return "0"; + } + if (p.isBoolean) { + return "false"; + } + if (p.isUuid) { + return "java.util.UUID.randomUUID()"; + } + if (p.isModel) { + return "null"; + } + if (p.isDate || p.isDateTime) { + return "null"; + } + if (p.isString) { + return "\"\""; + } + return p.defaultValue; + } + + private static String queryArgs(final CodegenOperation op) { + final List list = op.queryParams.stream().map(p -> p.paramName).collect(Collectors.toList()); + final String prefix = list.isEmpty() ? "" : ", "; + return prefix + String.join(", ", list); + } + + /** + * For our model classes, we have two variants: + *

+ * 1) a {model}.scala one which is a validated, model class + * 2) a {model}Data.scala one which is just our data-transfer-object (DTO) which is written primarily for e.g. json serialisation + *

+ * The data variant can have nulls and other non-scala things, but they know how to create validated model objects. + *

+ * This 'asScalaDataType' is used to ensure the type hierarchy is correct for both the model and data varients. + *

+ * e.g. consider this example: + * ``` + * case class Foo(bar : Bar, bazz :List[Bazz]) + * case class Bar(x : Option[String] = None) + * case class Bazz(y : Int) + *

+ * // vs + *

+ * case class FooData(bar : BarData, bazz :List[BazzData]) + * case class BarData(x : String = "") + * case class BazzData(y : Int) + * ``` + */ + private static String asScalaDataType(final IJsonSchemaValidationProperties param, boolean required, boolean useJason) { + return asScalaDataType(param, required, useJason, !useJason); + } + + private static String asScalaDataType(final IJsonSchemaValidationProperties param, boolean required, boolean useJason, boolean allowOptional) { + String dataType = (param.getIsModel() && useJason) ? param.getDataType() + "Data" : param.getDataType(); + + final String dataSuffix = useJason && param.getItems() != null && param.getItems().getIsModel() ? "Data" : ""; + if (dataType.startsWith("List[")) { + dataType = dataType.replace("List[", "Seq["); + dataType = dataType.replace("]", dataSuffix + "]"); + } else if (dataType.startsWith("Set[")) { + dataType = dataType.replace("]", dataSuffix + "]"); + } else if (!required && allowOptional) { + dataType = "Option[" + dataType + "]"; + } + return dataType; + } + + private static String chompBrackets(String str) { + return str.replace("{", "").replace("}", ""); + } + + private static String chompSuffix(String str, String suffix) { + return str.endsWith(suffix) ? chompSuffix(str.substring(0, str.length() - suffix.length()), suffix) : str; + } + + private static boolean hasBrackets(String str) { + return str.matches("^\\{(.*)\\}$"); + } + + static String containerType(String dataType) { + String fixedForList = dataType.replaceAll(".*\\[(.*)\\]", "$1"); + + // if it is a map, we want the value type + final String[] parts = fixedForList.split(","); + return parts[parts.length - 1]; + } +} 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 11c50f7c170..9849aa702f3 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 @@ -115,6 +115,7 @@ org.openapitools.codegen.languages.RustClientCodegen org.openapitools.codegen.languages.RustServerCodegen org.openapitools.codegen.languages.ScalatraServerCodegen org.openapitools.codegen.languages.ScalaAkkaClientCodegen +org.openapitools.codegen.languages.ScalaCaskServerCodegen org.openapitools.codegen.languages.ScalaPekkoClientCodegen org.openapitools.codegen.languages.ScalaAkkaHttpServerCodegen org.openapitools.codegen.languages.ScalaFinchServerCodegen diff --git a/modules/openapi-generator/src/main/resources/scala-cask/.scalafmt.conf.mustache b/modules/openapi-generator/src/main/resources/scala-cask/.scalafmt.conf.mustache new file mode 100644 index 00000000000..e150d4c409d --- /dev/null +++ b/modules/openapi-generator/src/main/resources/scala-cask/.scalafmt.conf.mustache @@ -0,0 +1,4 @@ +version = 3.6.1 +align.preset = more // For pretty alignment. +maxColumn = 100 +runner.dialect = scala3 \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/scala-cask/Dockerfile.mustache b/modules/openapi-generator/src/main/resources/scala-cask/Dockerfile.mustache new file mode 100644 index 00000000000..0c548753973 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/scala-cask/Dockerfile.mustache @@ -0,0 +1,13 @@ +FROM virtuslab/scala-cli:latest as build +WORKDIR /app +COPY ./Server.scala /app/ +# note: this assumes a published server stub jar. +# If you've published this locally, you would need to copy those into this image, +# perhaps by using coursier fetch +RUN scala-cli --power package /app/Server.scala --assembly -o app.jar + +# The main image +FROM openjdk:23-slim +WORKDIR /app +COPY --from=build /app/app.jar /app/ +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/modules/openapi-generator/src/main/resources/scala-cask/README.mustache b/modules/openapi-generator/src/main/resources/scala-cask/README.mustache new file mode 100644 index 00000000000..39e65480193 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/scala-cask/README.mustache @@ -0,0 +1,96 @@ +# REST Service + +This project contains the data models and REST services, generated from the [openapi-generator](https://github.com/OpenAPITools/openapi-generator) project. + +The server implementation is based on Li's excellent [cask](https://com-lihaoyi.github.io/cask/) library. + +# How to use this code + +This code was designed so that it can be packaged up and semantically-versioned alongside your open-api schema. + +That approach supports having separate "contract" repositories for microservice projects, where the pipeline for the +contract repo might produce versioned jar artefacts which can then easily be brought in / referenced by a separate service project which simply +implements the business logic. + +You can read more about this approach [here](https://github.com/kindservices/contract-first-rest) + +# How to implement your business logic +There are a few options for using this code/applying your business logic for the services. + +## Option 1 (preferred): Package and publish this boilerplate +Typically, OpenApi templates are written to generate code which co-exists alongside the handwritten business logic. + +While that works, it's also not ideal: + * You have to ensure the generated code isn't checked in + * Team members, build pipelines, etc all have to regenerate and recompile the same boilerplate code over and over + * People can encounter IDE issues with generated code + +Instead, you have the option of simply packaging/publishing this generated code, and then allowing service implementations +to simply bring in the published code as a dependency. + +The steps to do that are: + +### Build/Publish +This project is built using [sbt](https://www.scala-sbt.org/download/), so you can run `sbt publish` (or `sbt publishLocal`) + +Or, for a zero-install docker build: +```bash +docker run -it --rm -v $(pwd):/app -w /app sbtscala/scala-sbt:eclipse-temurin-17.0.4_1.7.1_3.2.0 sbt publishLocal +``` + +### Create a new separate implementation project +Once published, you can create your server implementation in a new, clean, separate project based on [the example](./example) + +This means all the boilerplate endpoint and model code is brought in as "just another jar", and you're free to +create a greenfield project in whatever language (scala, java, kotlin) and build system of your choosing. + +We show a simple, minimalistic example of a starting point in [the example project](./example) + +## Option 2: Extend this generated example +You can configure this project (for instance, setting up your own .gitignore rules and scripts) to leave the generated code as-is +and provide your implementation alongside the generated code. + +The place to start is by providing your own implementation of the Services defined in the `api` package - +perhaps by creating your 'MyService.scala' code in a new `impl` package. + +You then have several options for how to wire those in: + +1) Create a new BaseApp instance to create your own Main entry point +Follow the pattern in App.scala, but by passing your own implementations to BaseApp, +ensuring you call `start` to start the server + +```bash +@main def run() = BaseApp(/* your services here/*).start() +``` + +2) Extend either BaseApp class or mix in the AppRoutes trait +You can create your own main entry point with further control of the main cask app by extending +the BaseApp or otherwise creating your own CaskApp which mixes in the AppRoutes + +```bash +object MyApp extends BaseApp(/* your services here/*) { + // any overrides, new routes, etc here + start() +} +``` + + +# Customising the generated code + +A typical config.yml used to alter the generated code may look like this: +``` +groupId: "ex.amp.le" +artifactId: "pets-test" +apiPackage: "ex.ample.api" +modelPackage: "ex.ample.model" +``` + +Which you would then pass to the generator like this: +``` +docker run --rm \ +-v ${PWD}:/local openapitools/openapi-generator-cli generate \ +-i https://raw.githubusercontent.com/OAI/OpenAPI-Specification/main/examples/v3.0/petstore.yaml \ +-g scala-cask \ +-c /local/config.yml \ +-o /local/path/to/output_dir +``` diff --git a/modules/openapi-generator/src/main/resources/scala-cask/api.mustache b/modules/openapi-generator/src/main/resources/scala-cask/api.mustache new file mode 100644 index 00000000000..e69de29bb2d diff --git a/modules/openapi-generator/src/main/resources/scala-cask/apiPackage.mustache b/modules/openapi-generator/src/main/resources/scala-cask/apiPackage.mustache new file mode 100644 index 00000000000..6acc47b3100 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/scala-cask/apiPackage.mustache @@ -0,0 +1,155 @@ +{{>licenseInfo}} +package {{apiPackage}} + + +import cask.FormEntry +import io.undertow.server.handlers.form.{FormData, FormParserFactory} + +import java.io.File +import scala.jdk.CollectionConverters.* +import java.time.LocalDate +import java.util.UUID +import scala.reflect.ClassTag +import scala.util.* + +// needed for BigDecimal params +given cask.endpoints.QueryParamReader.SimpleParam[BigDecimal](BigDecimal.apply) + +// a parsed value from an HTTP request +opaque type Parsed[A] = Either[String, A] + +object Parsed { + def apply[A](value: A): Parsed[A] = Right(value) + + def eval[A](value: => A): Parsed[A] = Try(value) match { + case Failure(exp) => Left(s"Error: ${exp.getMessage}") + case Success(ok) => Right(ok) + } + + def fromTry[A](value : Try[A]) = value match { + case Failure(err) => Left(err.getMessage) + case Success(ok) => Right(ok) + } + + def fail[A](msg: String): Parsed[A] = Left(msg) + + def optionalValue(map: Map[String, collection.Seq[String]], key: String): Parsed[Option[String]] = { + map.get(key) match { + case Some(Seq(only: String)) => Parsed(Option(only)) + case Some(Seq()) => Parsed(None) + case Some(many) => Parsed.fail(s"${many.size} values set for '$key'") + case None => Parsed(None) + } + } + + def singleValue(map: Map[String, collection.Seq[String]], key : String): Parsed[String] = { + map.get(key) match { + case Some(Seq(only : String)) => Parsed(only) + case Some(Seq()) => Parsed("") + case Some(many) => Parsed.fail(s"${many.size} values set for '$key'") + case None => Parsed.fail(s"required '$key' was not set") + } + } + + def manyValues(map: Map[String, collection.Seq[String]], key : String, required: Boolean): Parsed[List[String]] = { + map.get(key) match { + case Some(many) => Parsed(many.toList) + case None if required => Parsed.fail(s"required '$key' was not set") + case None => Parsed(Nil) + } + } +} + +extension[A] (parsed: Parsed[A]) { + def toEither: Either[String, A] = parsed + + def asLong(using ev : A =:= String): Parsed[Long] = as[Long](_.toLongOption) + def asBoolean(using ev : A =:= String): Parsed[Boolean] = as[Boolean](_.toBooleanOption) + def asInt(using ev : A =:= String): Parsed[Int] = as[Int](_.toIntOption) + def asByte(using ev : A =:= String): Parsed[Byte] = as[Byte](_.toByteOption) + def asUuid(using ev : A =:= String): Parsed[UUID] = as[UUID](x => Try(UUID.fromString(x)).toOption) + def asFloat(using ev : A =:= String): Parsed[Float] = as[Float](_.toFloatOption) + def asDouble(using ev : A =:= String): Parsed[Double] = as[Double](_.toDoubleOption) + def asDate(using ev: A =:= String): Parsed[LocalDate] = as[LocalDate](x => Try(LocalDate.parse(x)).toOption) + + private def as[B : ClassTag](f : String => Option[B])(using ev : A =:= String): Parsed[B] = parsed.flatMap { str => + f(ev(str)) match { + case None => Parsed.fail(s"'$str' cannot be parsed as a ${implicitly[ClassTag[B]].runtimeClass}") + case Some(x) => Parsed(x) + } + } + + + def mapError(f : String => String) : Parsed[A] = parsed match { + case Left(msg) => Left(f(msg)) + case right => right + } + + def map[B](f: A => B): Parsed[B] = parsed match { + case Right(value) => Right(f(value)) + case Left(err) => Left(err) + } + def flatMap[B](f : A => Parsed[B]): Parsed[B] = parsed match { + case Right(value) => f(value) + case Left(err) => Left(err) + } +} + + +extension (request: cask.Request) { + + def formSingleValueRequired(paramName: String): Parsed[String] = { + val data = formDataForKey(paramName).map(_.getValue).toSeq + Parsed.singleValue(Map(paramName -> data), paramName) + } + def formSingleValueOptional(paramName: String): Parsed[Option[String]] = { + val data = formDataForKey(paramName).map(_.getValue).toSeq + Parsed.optionalValue(Map(paramName -> data), paramName) + } + + def formValueAsFileOptional(paramName: String): Parsed[Option[File]] = { + val data = formDataForKey(paramName) + data.map(_.getFileItem.getFile.toFile).toSeq match { + case Seq() => Parsed(None) + case Seq(file) => Parsed(Option(file)) + case many => Parsed.fail(s"${many.size} file values set for '$paramName'") + } + } + + def formValueAsFileRequired(paramName: String): Parsed[File] = { + val data = formDataForKey(paramName) + data.map(_.getFileItem.getFile.toFile).toSeq match { + case Seq() => Parsed.fail(s"No file form data was submitted for '$paramName'. The submitted form keys were: ${formDataKeys.mkString(",")}") + case Seq(file) => Parsed(file) + case many => Parsed.fail(s"${many.size} file values set for '$paramName'") + } + } + + def formManyValues(paramName: String, required: Boolean): Parsed[List[String]] = { + val data = formDataForKey(paramName).map(_.getValue).toSeq + Parsed.manyValues(Map(paramName -> data), paramName, required) + } + + def formData: FormData = FormParserFactory.builder().build().createParser(request.exchange).parseBlocking() + + def formDataKeys: Iterator[String] = formData.iterator().asScala + + def formDataForKey(paramName: String): Iterable[FormData.FormValue] = formData.get(paramName).asScala + + def headerSingleValueOptional(paramName: String): Parsed[Option[String]] = Parsed.optionalValue(request.headers, paramName) + def headerSingleValueRequired(paramName: String): Parsed[String] = Parsed.singleValue(request.headers, paramName) + + def headerManyValues(paramName: String, required: Boolean): Parsed[List[String]] = Parsed.manyValues(request.headers, paramName, required) + + def bodyAsString = new String(request.readAllBytes(), "UTF-8") + + def pathValue(index: Int, paramName: String, required : Boolean): Parsed[String] = { + request + .remainingPathSegments + .lift(index) match { + case Some(value) => Right(value) + case None if required => Left(s"'$paramName'' is a required path parameter at path position $index") + case None => Right("") + } + } +} diff --git a/modules/openapi-generator/src/main/resources/scala-cask/apiRoutes.mustache b/modules/openapi-generator/src/main/resources/scala-cask/apiRoutes.mustache new file mode 100644 index 00000000000..0d1ca45d0d5 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/scala-cask/apiRoutes.mustache @@ -0,0 +1,65 @@ +//> using scala "3.3.1" +//> using lib "com.lihaoyi::cask:0.8.3" +//> using lib "com.lihaoyi::scalatags:0.12.0" +{{>licenseInfo}} + +// this is generated from apiRoutes.mustache +package {{apiPackage}} + +import {{modelPackage}}.* + +import upickle.default.{ReadWriter => RW, macroRW} +import upickle.default.* + +{{#imports}}import {{import}} +{{/imports}} + +class {{classname}}Routes(service : {{classname}}Service) extends cask.Routes { + +{{#route-groups}} + // route group for {{methodName}} + {{caskAnnotation}}("{{pathPrefix}}", true) + def {{methodName}}(request: cask.Request{{>apiRoutesQueryParamsTyped}}) = { + request.remainingPathSegments match { + {{#operations}} + case Seq({{>pathExtractor}}) => {{operationId}}({{>pathExtractorParams}}request{{>queryParams}}) + {{/operations}} + case _ => cask.Response("Not Found", statusCode = 404) + } + } +{{/route-groups}} + +{{#operations}} + {{#operation}} + /** {{summary}} + * {{description}} + */ + {{vendorExtensions.x-annotation}}("{{vendorExtensions.x-cask-path}}") + def {{operationId}}({{vendorExtensions.x-cask-path-typed}}) = { + {{#authMethods}} + // auth method {{name}} : {{type}}, keyParamName: {{keyParamName}} + {{/authMethods}} + + def failFast = request.queryParams.keySet.contains("failFast") + + val result = {{>parseHttpParams}} + + result match { + case Left(error) => cask.Response(error, 500) + {{#vendorExtensions.x-has-response-types}} + {{#responses}} + {{#dataType}} + case Right(value : {{dataType}}) => cask.Response(data = write(value), {{code}}, headers = Seq("Content-Type" -> "application/json")) + {{/dataType}} + {{/responses}} + {{/vendorExtensions.x-has-response-types}} + {{^vendorExtensions.x-has-response-types}} + case Right(_) => cask.Response("", 200) + {{/vendorExtensions.x-has-response-types}} + } + } + {{/operation}} +{{/operations}} + + initialize() +} diff --git a/modules/openapi-generator/src/main/resources/scala-cask/apiRoutesQueryParamsTyped.mustache b/modules/openapi-generator/src/main/resources/scala-cask/apiRoutesQueryParamsTyped.mustache new file mode 100644 index 00000000000..8356421c485 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/scala-cask/apiRoutesQueryParamsTyped.mustache @@ -0,0 +1 @@ +{{#hasGroupQueryParams}},{{/hasGroupQueryParams}}{{#groupQueryParams}}{{paramName}} : {{dataType}} = {{defaultValue}}{{^-last}},{{/-last}}{{/groupQueryParams}} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/scala-cask/apiService.mustache b/modules/openapi-generator/src/main/resources/scala-cask/apiService.mustache new file mode 100644 index 00000000000..30ea17b60dd --- /dev/null +++ b/modules/openapi-generator/src/main/resources/scala-cask/apiService.mustache @@ -0,0 +1,37 @@ +//> using scala "3.3.1" +//> using lib "com.lihaoyi::cask:0.8.3" +//> using lib "com.lihaoyi::scalatags:0.12.0" +{{>licenseInfo}} + +// generated from apiService.mustache +package {{apiPackage}} + +{{#imports}}import _root_.{{import}} +{{/imports}} + +import _root_.{{modelPackage}}.* + +object {{classname}}Service { + def apply() : {{classname}}Service = new {{classname}}Service { +{{#operations}} + {{#operation}} + override def {{operationId}}({{vendorExtensions.x-param-list-typed}}) : {{vendorExtensions.x-response-type}} = ??? + {{/operation}} +{{/operations}} + } +} + +/** + * The {{classname}} business-logic + */ +trait {{classname}}Service { +{{#operations}} +{{#operation}} + /** {{{summary}}} + * {{{description}}} + * @return {{returnType}} + */ + def {{operationId}}({{vendorExtensions.x-param-list-typed}}) : {{vendorExtensions.x-response-type}} +{{/operation}} +{{/operations}} +} diff --git a/modules/openapi-generator/src/main/resources/scala-cask/appPackage.mustache b/modules/openapi-generator/src/main/resources/scala-cask/appPackage.mustache new file mode 100644 index 00000000000..614e06bb9e1 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/scala-cask/appPackage.mustache @@ -0,0 +1,12 @@ +{{>licenseInfo}} +package {{packageName}} + +def box(str: String): String = { + val lines = str.linesIterator.toList + val maxLen = (0 +: lines.map(_.length)).max + val boxed = lines.map { line => + s" | ${line.padTo(maxLen, ' ')} |" + } + val bar = " +-" + ("-" * maxLen) + "-+" + (bar +: boxed :+ bar).mkString("\n") +} diff --git a/modules/openapi-generator/src/main/resources/scala-cask/appRoutes.mustache b/modules/openapi-generator/src/main/resources/scala-cask/appRoutes.mustache new file mode 100644 index 00000000000..5006b467775 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/scala-cask/appRoutes.mustache @@ -0,0 +1,40 @@ +//> using scala "3.3.1" +//> using lib "com.lihaoyi::cask:0.9.2" +//> using lib "com.lihaoyi::scalatags:0.8.2" +{{>licenseInfo}} + +// this file was generated from app.mustache +package {{packageName}} + +{{#imports}}import {{import}} +{{/imports}} +import _root_.{{modelPackage}}.* +import _root_.{{apiPackage}}.* + +/** + * This trait encapsulates the business logic (services) and the + * http routes which handle the http requests sent to those services. + * + * There are default 'not implemented' implementations for the service. + * + * If you wanted fine-grained control over the routes and services, you could + * extend the cask.MainRoutes and mix in this trait by using this: + * + * \{\{\{ + * override def allRoutes = appRoutes + * \}\}\} + * + * More typically, however, you would extend the 'BaseApp' class + */ +trait AppRoutes { +{{#operations}} + def app{{classname}}Service : {{classname}}Service = {{classname}}Service() + def routeFor{{classname}} : {{classname}}Routes = {{classname}}Routes(app{{classname}}Service) +{{/operations}} + + def appRoutes = Seq( +{{#operations}} + routeFor{{classname}} {{^-last}},{{/-last}} +{{/operations}} + ) +} diff --git a/modules/openapi-generator/src/main/resources/scala-cask/baseApp.mustache b/modules/openapi-generator/src/main/resources/scala-cask/baseApp.mustache new file mode 100644 index 00000000000..b8f019873c5 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/scala-cask/baseApp.mustache @@ -0,0 +1,49 @@ +//> using scala "3.3.1" +//> using lib "com.lihaoyi::cask:0.9.2" +//> using lib "com.lihaoyi::scalatags:0.8.2" +{{>licenseInfo}} + +// this file was generated from app.mustache +package {{packageName}} + +{{#imports}}import {{import}} +{{/imports}} +import _root_.{{modelPackage}}.* +import _root_.{{apiPackage}}.* + +/** + * This class was created with the intention of being extended by some runnable object, + * passing in the custom business logic services + */ +class BaseApp({{#operations}} + override val app{{classname}}Service : {{classname}}Service = {{classname}}Service(), +{{/operations}} + override val port : Int = sys.env.get("PORT").map(_.toInt).getOrElse(8080)) extends cask.MainRoutes with AppRoutes { + + /** routes for the UI + * Subclasses can override to turn this off + */ + def openApiRoute: Option[cask.Routes] = Option(OpenApiRoutes(port)) + + override def allRoutes = appRoutes ++ openApiRoute + + {{^operations}} + // no operations! + {{/operations}} + + override def host: String = "0.0.0.0" + + def start() = locally { + initialize() + println(box(s""" 🚀 browse to localhost:$port 🚀 + | host : $host + | port : $port + | verbose : $verbose + | debugMode : $debugMode + |""".stripMargin)) + + // if java.awt.Desktop.isDesktopSupported then { + // java.awt.Desktop.getDesktop.browse(new java.net.URI(s"http://localhost:${port}")) + // } + } +} diff --git a/modules/openapi-generator/src/main/resources/scala-cask/build.sbt.mustache b/modules/openapi-generator/src/main/resources/scala-cask/build.sbt.mustache new file mode 100644 index 00000000000..ae0a5ae12e5 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/scala-cask/build.sbt.mustache @@ -0,0 +1,29 @@ +name := "{{artifactId}}" +organization:="{{groupId}}" +version := "0.0.1-SNAPSHOT" +scalaVersion := "3.3.1" +scalafmtOnCompile := true +libraryDependencies ++= Seq( + "com.lihaoyi" %% "cask" % "0.9.2" , + "com.lihaoyi" %% "upickle" % "3.2.0", + "org.scalatest" %% "scalatest" % "3.2.18" % Test +) + +publishMavenStyle := true + +val githubUser = "{{{gitUserId}}}" +val githubRepo = "{{{gitRepoId}}}" +publishTo := Some("GitHub Package Registry" at s"https://maven.pkg.github.com/$githubUser/$githubRepo") + +sys.env.get("GITHUB_TOKEN") match { + case Some(token) if !token.isEmpty => + credentials += Credentials( + "GitHub Package Registry", + "maven.pkg.github.com", + githubUser, + token + ) + case _ => + println("\n\t\tGITHUB_TOKEN not set - assuming a local build\n\n") + credentials ++= Nil +} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/scala-cask/build.sc.mustache b/modules/openapi-generator/src/main/resources/scala-cask/build.sc.mustache new file mode 100644 index 00000000000..a31414db150 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/scala-cask/build.sc.mustache @@ -0,0 +1,43 @@ +import mill._, scalalib._, scalafmt._, publish._ + +// Mill build file (see https://mill-build.com/mill/Intro_to_Mill.html). +// run with: +// +// mill _.compile +// mill _.reformat +// mill _.publishLocal +// mill _.test.test +object {{artifactId}} extends SbtModule with ScalafmtModule with PublishModule { + def scalaVersion = "3.3.1" + + def pomSettings = PomSettings( + description = "{{artifactId}}", + organization = "{{groupId}}", + url = "https://github.com//{{artifactId}}", + licenses = Seq(License.MIT), + versionControl = VersionControl.github("", "{{artifactId}}"), + developers = Seq( + // Developer("", "", "https://github.com/") + ) + ) + + def publishVersion: mill.T[String] = T("0.0.1-SNAPSHOT") + + def ivyDeps = Agg( + ivy"com.lihaoyi::cask:0.9.2" , + ivy"com.lihaoyi::upickle:3.2.0" + ) + + override def sources = T.sources(millSourcePath / os.up / "src" / "main" / "scala") + override def resources = T.sources(millSourcePath / os.up / "src" / "main" / "resources") + + object test extends SbtModuleTests { + def ivyDeps = Agg( + ivy"org.scalactic::scalactic:3.2.18", + ivy"org.scalatest::scalatest:3.2.18" + ) + + def testFramework = "org.scalatest.tools.Framework" + override def sources = T.sources(millSourcePath / os.up / "src" / "test" / "scala") + } +} diff --git a/modules/openapi-generator/src/main/resources/scala-cask/bulidAndPublish.yml.mustache b/modules/openapi-generator/src/main/resources/scala-cask/bulidAndPublish.yml.mustache new file mode 100644 index 00000000000..ead5b4c0dcb --- /dev/null +++ b/modules/openapi-generator/src/main/resources/scala-cask/bulidAndPublish.yml.mustache @@ -0,0 +1,41 @@ +name: Scala CI with sbt + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +jobs: + build-and-publish: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Set up JDK 11 + uses: actions/setup-java@v1 + with: + java-version: 11 + + - name: Cache sbt dependencies + uses: actions/cache@v2 + with: + path: | + ~/.ivy2/cache + ~/.sbt + ~/.m2 + key: ${{{openbrackets}}} runner.os {{{closebrackets}}}-sbt-${{{openbrackets}}} hashFiles('**/*.sbt') {{{closebrackets}}} + restore-keys: | + ${{{openbrackets}}} runner.os {{{closebrackets}}}-sbt- + + - name: Build with sbt + run: sbt clean compile + + - name: Test with sbt + run: sbt test + + - name: Publish to GitHub Packages + run: sbt publish + env: + GITHUB_TOKEN: ${{{openbrackets}}} secrets.GITHUB_TOKEN {{{closebrackets}}} diff --git a/modules/openapi-generator/src/main/resources/scala-cask/example.mustache b/modules/openapi-generator/src/main/resources/scala-cask/example.mustache new file mode 100644 index 00000000000..3d6b5cca779 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/scala-cask/example.mustache @@ -0,0 +1,60 @@ +//> using scala "3.3.1" +//> using lib "{{groupId}}::{{artifactId}}:0.0.1-SNAPSHOT" +//> using repositories https://maven.pkg.github.com/{{{gitUserId}}}/{{{gitRepoId}}} + + +/** +* This single file can contain the business logic for a REST service. +* ==================================== +* == zero-install build with docker == +* ==================================== +* +* +* ``` +* docker build . -t {{artifactId}}:latest +* ``` +* ====================== +* == Building Locally == +* ====================== +* This project can be built using [[scala-clit][https://scala-cli.virtuslab.org]] +* +* To simply run the project +* ``` +* scala-cli Server.scala +* ``` +* +* To create a runnable jar, run: +* ``` +* scala-cli --power package Server.scala -o app-assembly --assembly +* ``` +* +* To produce a docker image (no need for the Dockerfile), run: +* ``` +* scala-cli --power package --docker Server.scala --docker-image-repository app-docker +* ``` +* +* To generate an IDE project: +* ``` +* scala-cli setup-ide . --scala 3.3 +* ``` +*/ +package app + +import {{packageName}}.BaseApp +import {{apiPackage}}.* +import {{modelPackage}}.* + +import java.io.File + +// TODO - write your business logic for your services here (the defaults all return 'not implemented'): +{{#operations}} +val my{{classname}}Service : {{classname}}Service = {{classname}}Service() // <-- replace this with your implementation +{{/operations}} + +/** This is your main entry point for your REST service + * It extends BaseApp which defines the business logic for your services + */ +object Server extends BaseApp({{#operations}}app{{classname}}Service = my{{classname}}Service{{^-last}}, +{{/-last}}{{/operations}}): + start() + diff --git a/modules/openapi-generator/src/main/resources/scala-cask/exampleApp.mustache b/modules/openapi-generator/src/main/resources/scala-cask/exampleApp.mustache new file mode 100644 index 00000000000..f1247210dac --- /dev/null +++ b/modules/openapi-generator/src/main/resources/scala-cask/exampleApp.mustache @@ -0,0 +1,21 @@ +//> using scala "3.3.1" +//> using lib "com.lihaoyi::cask:0.9.2" +//> using lib "com.lihaoyi::scalatags:0.8.2" +{{>licenseInfo}} + +// this file was generated from app.mustache +package {{packageName}} + +{{#imports}}import {{import}} +{{/imports}} +import _root_.{{modelPackage}}.* +import _root_.{{apiPackage}}.* + +/** + * This is an example of how you might extends BaseApp for a runnable application. + * + * See the README.md for how to create your own app + */ +object ExampleApp extends BaseApp() { + start() +} diff --git a/modules/openapi-generator/src/main/resources/scala-cask/gitignore.mustache b/modules/openapi-generator/src/main/resources/scala-cask/gitignore.mustache new file mode 100644 index 00000000000..56f2b272a08 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/scala-cask/gitignore.mustache @@ -0,0 +1,25 @@ +# scala specific +*.class +*.log + +# sbt specific +.cache +.history +.lib/ +dist/* +target/ +lib_managed/ +src_managed/ +project/boot/ +project/plugins/project/ + +# Scala-IDE specific +.scala_dependencies +.worksheet + +# Mill specific +out + +# IntelliJ specific +.idea +*.iml diff --git a/modules/openapi-generator/src/main/resources/scala-cask/licenseInfo.mustache b/modules/openapi-generator/src/main/resources/scala-cask/licenseInfo.mustache new file mode 100644 index 00000000000..02c79936aa5 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/scala-cask/licenseInfo.mustache @@ -0,0 +1,16 @@ +/** + * {{{appName}}} + * {{{appDescription}}} + * + {{#version}} + * OpenAPI spec version: {{{version}}} + * + {{/version}} + {{#infoEmail}} + * Contact: {{{infoEmail}}} + * + {{/infoEmail}} + * NOTE: This class is auto generated by OpenAPI Generator. + * + * https://openapi-generator.tech + */ diff --git a/modules/openapi-generator/src/main/resources/scala-cask/model.mustache b/modules/openapi-generator/src/main/resources/scala-cask/model.mustache new file mode 100644 index 00000000000..c4b430b7ced --- /dev/null +++ b/modules/openapi-generator/src/main/resources/scala-cask/model.mustache @@ -0,0 +1,60 @@ +{{>licenseInfo}} +// this model was generated using model.mustache +package {{modelPackage}} +{{#imports}}import {{import}} +{{/imports}} +import scala.util.control.NonFatal + +// see https://com-lihaoyi.github.io/upickle/ +import upickle.default.{ReadWriter => RW, macroRW} +import upickle.default.* + +{{#models}} +{{#model}} +case class {{classname}}( + {{#vars}} + {{#description}} +/* {{{description}}} */ + {{/description}} + {{name}}: {{#isEnum}}{{^required}}Option[{{/required}}{{classname}}.{{datatypeWithEnum}}{{^required}}]{{/required}}{{/isEnum}}{{^isEnum}}{{{vendorExtensions.x-datatype-model}}}{{/isEnum}}{{^required}} = {{{vendorExtensions.x-defaultValue-model}}} {{/required}}{{^-last}},{{/-last}} + + {{/vars}}) { + + def asJson: String = asData.asJson + + def asData : {{classname}}Data = { + {{classname}}Data( + {{#vars}} + {{name}} = {{name}}{{#vendorExtensions.x-map-asModel}}.map(_.asData){{/vendorExtensions.x-map-asModel}}{{#vendorExtensions.x-wrap-in-optional}}.getOrElse({{{defaultValue}}}){{/vendorExtensions.x-wrap-in-optional}}{{^-last}},{{/-last}} + {{/vars}} + ) + } + +} + +object {{classname}}{ + + given RW[{{classname}}] = {{classname}}Data.readWriter.bimap[{{classname}}](_.asData, _.asModel) + + enum Fields(fieldName : String) extends Field(fieldName) { + {{#vars}} + case {{name}} extends Fields("{{name}}") + {{/vars}} + } + +{{#vars}} + {{#isEnum}} + // baseName={{{baseName}}} + // nameInCamelCase = {{{nameInCamelCase}}} + enum {{datatypeWithEnum}} derives ReadWriter { + {{#_enum}} + case {{.}} + {{/_enum}} + } + {{/isEnum}} +{{/vars}} + +} + +{{/model}} +{{/models}} diff --git a/modules/openapi-generator/src/main/resources/scala-cask/modelData.mustache b/modules/openapi-generator/src/main/resources/scala-cask/modelData.mustache new file mode 100644 index 00000000000..8dfdef358f6 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/scala-cask/modelData.mustache @@ -0,0 +1,250 @@ +{{>licenseInfo}} +// this model was generated using modelData.mustache +package {{modelPackage}} +{{#imports}}import {{import}} +{{/imports}} +import scala.util.control.NonFatal +import scala.util.* + +// see https://com-lihaoyi.github.io/upickle/ +import upickle.default.{ReadWriter => RW, macroRW} +import upickle.default.* + +{{#models}} +{{#model}} +/** {{classname}}Data a data transfer object, primarily for simple json serialisation. + * It has no validation - there may be nulls, values out of range, etc + */ +case class {{classname}}Data( + {{#vars}} + {{#description}} +/* {{{description}}} */ + {{/description}} + {{name}}: {{#isEnum}}{{classname}}.{{datatypeWithEnum}}{{/isEnum}}{{^isEnum}}{{{vendorExtensions.x-datatype-data}}}{{/isEnum}}{{^required}} = {{{vendorExtensions.x-defaultValue-data}}} {{/required}}{{^-last}},{{/-last}} + + {{/vars}}) { + + def asJson: String = write(this) + + def validationErrors(path : Seq[Field], failFast : Boolean) : Seq[ValidationError] = { + val errors = scala.collection.mutable.ListBuffer[ValidationError]() + {{#vars}} + // ================== + // {{name}} + {{#pattern}} + // validate against pattern '{{{pattern}}}' + if (errors.isEmpty || !failFast) { + val regex = """{{{pattern}}}""" + if {{name}} == null || !regex.r.matches({{name}}) then + errors += ValidationError(path :+ {{classname}}.Fields.{{name}}, s"value '${{name}}' doesn't match pattern $regex") + } + {{/pattern}} + + {{#minimum}} + // validate against {{#exclusiveMinimum}}exclusive {{/exclusiveMinimum}}minimum {{minimum}} + if (errors.isEmpty || !failFast) { + if !({{name}} >{{^exclusiveMinimum}}={{/exclusiveMinimum}} {{minimum}}) then + errors += ValidationError(path :+ {{classname}}.Fields.{{name}}, s"value '${{name}}' is not greater than the {{#exclusiveMinimum}}exclusive {{/exclusiveMinimum}}minimum value {{minimum}}") + } + {{/minimum}} + + {{#maximum}} + // validate against {{#exclusiveMaximum}}exclusive {{/exclusiveMaximum}}maximum {{maximum}} + if (errors.isEmpty || !failFast) { + if !({{name}} <{{^exclusiveMaximum}}={{/exclusiveMaximum}} {{maximum}}) then + errors += ValidationError(path :+ {{classname}}.Fields.{{name}}, s"value '${{name}}' is not greater than the {{#exclusiveMaximum}}exclusive {{/exclusiveMaximum}}maximum value {{maximum}}") + } + {{/maximum}} + + {{#minLength}} + // validate min length {{minLength}} + if (errors.isEmpty || !failFast) { + val len = if {{name}} == null then 0 else {{name}}.length + if (len < {{minLength}}) { + errors += ValidationError(path :+ {{classname}}.Fields.{{name}}, s"length $len is shorter than the min length {{minLength}}") + } + } + {{/minLength}} + + {{#maxLength}} + // validate max length {{maxLength}} + if (errors.isEmpty || !failFast) { + val len = if {{name}} == null then 0 else {{name}}.length + if (len < {{maxLength}}) { + errors += ValidationError(path :+ {{classname}}.Fields.{{name}}, s"length $len is longer than the max length {{maxLength}}") + } + } + {{/maxLength}} + + {{#isEmail}} + // validate {{name}} is a valid email address + if (errors.isEmpty || !failFast) { + val emailRegex = """^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$""" + // validate {{name}} is email + if ({{name}} == null || !emailRegex.r.matches({{name}})) { + errors += ValidationError(path :+ {{classname}}.Fields.{{name}}, s"${{name}} is not a valid email address according to the pattern $emailRegex") + } + } + {{/isEmail}} + + {{#required}}{{^isPrimitiveType}} + if (errors.isEmpty || !failFast) { + if ({{name}} == null) { + errors += ValidationError(path :+ {{classname}}.Fields.{{name}}, "{{name}} is a required field and cannot be null") + } + } + {{/isPrimitiveType}}{{/required}} + + {{#uniqueItems}} + // validate {{name}} has unique items + if (errors.isEmpty || !failFast) { + if ({{name}} != null) { + {{name}}.foldLeft(Set[{{{vendorExtensions.x-containertype-data}}}]()) { + case (set, next) if set.contains(next) => + errors += ValidationError( + path :+ {{classname}}.Fields.{{name}}, + s"duplicate value: $next" + ) + set + next + case (set, next) => set + next + } + } + } + {{/uniqueItems}} + + {{#multipleOf}} + if (errors.isEmpty || !failFast) { + // validate {{name}} multiple of {{multipleOf}} + if ({{name}} % {{multipleOf}} != 0) { + errors += ValidationError( + path :+ {{classname}}.Fields.{{name}}, + s"${{name}} is not a multiple of {{multipleOf}}" + ) + } + } + {{/multipleOf}} + + {{#minItems}} + // validate min items {{minItems}} + if (errors.isEmpty || !failFast) { + val len = if {{name}} == null then 0 else {{name}}.size + if (len < {{minItems}}) { + errors += ValidationError(path :+ {{classname}}.Fields.{{name}}, s"{{name}} has $len, which is less than the min items {{minItems}}") + } + } + {{/minItems}} + + {{#maxItems}} + // validate min items {{maxItems}} + if (errors.isEmpty || !failFast) { + val len = if {{name}} == null then 0 else {{name}}.size + if (len > {{maxItems}}) { + errors += ValidationError(path :+ {{classname}}.Fields.{{name}}, s"{{name}} has $len, which is greater than the max items {{maxItems}}") + } + } + {{/maxItems}} + + {{#minProperties}} + TODO - minProperties + {{/minProperties}} + + {{#maxProperties}} + TODO - maxProperties + {{/maxProperties}} + + {{#items}}{{#isModel}} + if (errors.isEmpty || !failFast) { + if ({{name}} != null) { + {{name}}.zipWithIndex.foreach { + case (value, i) if errors.isEmpty || !failFast => + errors ++= value.validationErrors( + path :+ {{classname}}.Fields.{{name}} :+ Field(i.toString), + failFast) + case (value, i) => + } + } + } + {{/isModel}}{{/items}} + {{#isModel}} + // validating {{name}} + if (errors.isEmpty || !failFast) { + if {{name}} != null then errors ++= {{name}}.validationErrors(path :+ {{classname}}.Fields.{{name}}, failFast) + } + {{/isModel}} + + {{/vars}} + errors.toSeq + } + + def validated(failFast : Boolean = false) : scala.util.Try[{{classname}}] = { + validationErrors(Vector(), failFast) match { + case Seq() => Success(asModel) + case first +: theRest => Failure(ValidationErrors(first, theRest)) + } + } + + /** use 'validated' to check validation */ + def asModel : {{classname}} = { + {{classname}}( + {{#vars}} + {{name}} = {{#vendorExtensions.x-wrap-in-optional}}Option({{/vendorExtensions.x-wrap-in-optional}} + {{name}} + {{#vendorExtensions.x-wrap-in-optional}}){{/vendorExtensions.x-wrap-in-optional}} + {{#vendorExtensions.x-map-asModel}}.map(_.asModel){{/vendorExtensions.x-map-asModel}}{{^-last}},{{/-last}} + {{/vars}} + ) + } +} + +object {{classname}}Data { + + given readWriter : RW[{{classname}}Data] = macroRW + + def fromJsonString(jason : String) : {{classname}}Data = try { + read[{{classname}}Data](jason) + } catch { + case NonFatal(e) => sys.error(s"Error parsing json '$jason': $e") + } + + def manyFromJsonString(jason : String) : Seq[{{classname}}Data] = try { + read[List[{{classname}}Data]](jason) + } catch { + case NonFatal(e) => sys.error(s"Error parsing json '$jason' as list: $e") + } + + def manyFromJsonStringValidated(jason : String, failFast : Boolean = false) : Try[Seq[{{classname}}]] = { + Try(manyFromJsonString(jason)).flatMap { list => + list.zipWithIndex.foldLeft(Try(Vector[{{classname}}]())) { + case (Success(list), (next, i)) => + next.validated(failFast) match { + case Success(ok) => Success(list :+ ok) + case Failure(err) => Failure(new Exception(s"Validation error on element $i: ${err.getMessage}", err)) + } + case (fail, _) => fail + } + } + } + + def mapFromJsonString(jason : String) : Map[String, {{classname}}Data] = try { + read[Map[String, {{classname}}Data]](jason) + } catch { + case NonFatal(e) => sys.error(s"Error parsing json '$jason' as map: $e") + } + + + def mapFromJsonStringValidated(jason : String, failFast : Boolean = false) : Try[Map[String, {{classname}}]] = { + Try(mapFromJsonString(jason)).flatMap { map => + map.foldLeft(Try(Map[String, {{classname}}]())) { + case (Success(map), (key, next)) => + next.validated(failFast) match { + case Success(ok) => Success(map.updated(key, ok)) + case Failure(err) => Failure(new Exception(s"Validation error on element $key: ${err.getMessage}", err)) + } + case (fail, _) => fail + } + } + } +} + +{{/model}} +{{/models}} diff --git a/modules/openapi-generator/src/main/resources/scala-cask/modelPackage.mustache b/modules/openapi-generator/src/main/resources/scala-cask/modelPackage.mustache new file mode 100644 index 00000000000..f582c3e9fb1 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/scala-cask/modelPackage.mustache @@ -0,0 +1,53 @@ +{{>licenseInfo}} +package {{modelPackage}} + +// model package +import upickle.default._ +import java.time.* +import java.time.format.DateTimeFormatter + +/** + * This base class lets us refer to fields in exceptions + */ +class Field(val name : String) + +final case class ValidationErrors( + first: ValidationError, + remaining: Seq[ValidationError], + message: String +) extends Exception(message) + +object ValidationErrors { + def apply(first: ValidationError, remaining: Seq[ValidationError]) = { + val noun = if remaining.isEmpty then "error" else "errors" + new ValidationErrors( + first, + remaining, + remaining.mkString(s"${remaining.size + 1} $noun found: ${first}", "\n\t", "") + ) + } +} + + +final case class ValidationError(path : Seq[Field], message : String) extends Exception(message) { + override def toString = s"ValidationError for ${path.mkString(".")}: $message" +} + +given ReadWriter[ZonedDateTime] = readwriter[String].bimap[ZonedDateTime]( + zonedDateTime => DateTimeFormatter.ISO_INSTANT.format(zonedDateTime), + str => ZonedDateTime.parse(str, DateTimeFormatter.ISO_INSTANT)) + +given ReadWriter[LocalDateTime] = readwriter[String].bimap[LocalDateTime]( + zonedDateTime => DateTimeFormatter.ISO_INSTANT.format(zonedDateTime), + str => LocalDateTime.parse(str, DateTimeFormatter.ISO_INSTANT)) + +given ReadWriter[LocalDate] = readwriter[String].bimap[LocalDate]( + zonedDateTime => DateTimeFormatter.ISO_INSTANT.format(zonedDateTime), + str => LocalDate.parse(str, DateTimeFormatter.ISO_INSTANT)) + +given ReadWriter[OffsetDateTime] = readwriter[String].bimap[OffsetDateTime]( + zonedDateTime => DateTimeFormatter.ISO_INSTANT.format(zonedDateTime), + str => scala.util.Try(OffsetDateTime.parse(str, DateTimeFormatter.ISO_DATE_TIME)).getOrElse( + OffsetDateTime.parse(str, DateTimeFormatter.ISO_INSTANT) + ) +) \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/scala-cask/modelTest.mustache b/modules/openapi-generator/src/main/resources/scala-cask/modelTest.mustache new file mode 100644 index 00000000000..cbeae14cecc --- /dev/null +++ b/modules/openapi-generator/src/main/resources/scala-cask/modelTest.mustache @@ -0,0 +1,37 @@ +{{>licenseInfo}} +// this model was generated using modelTest.mustache +package {{modelPackage}} +{{#imports}}import {{import}} +{{/imports}} + +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec +import scala.util.* + +{{#models}} +{{#model}} +class {{classname}}Test extends AnyWordSpec with Matchers { + +{{#operations}} + // operation {{classname}} + // + {{#examples}} + key = {{key}} + value= {{value}} + {{/examples}} +{{/operations}} + "{{classname}}.fromJson" should { + """not parse invalid json""" in { + val Failure(err) = Try({{classname}}Data.fromJsonString("invalid jason")) + err.getMessage should startWith ("Error parsing json 'invalid jason'") + } + """parse {{example}}""" ignore { + val Failure(err : ValidationErrors) = {{classname}}Data.fromJsonString("""{{example}}""").validated() + + sys.error("TODO") + } + } + +} +{{/model}} +{{/models}} diff --git a/modules/openapi-generator/src/main/resources/scala-cask/openapiRoute.mustache b/modules/openapi-generator/src/main/resources/scala-cask/openapiRoute.mustache new file mode 100644 index 00000000000..58c35063555 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/scala-cask/openapiRoute.mustache @@ -0,0 +1,116 @@ +{{>licenseInfo}} + +// generated from openapiRoute.mustache +package {{apiPackage}} + +import cask.model.Response + +import java.nio.file.{Files, Path, Paths} + +/** + * This code will try and download the swagger UI static files on startup + * + * That behaviour can be altered by: + * - setting the environment variable SWAGGER_ON to false + * - setting the environment variable SWAGGER_UI_URL to either the URL of a swagger UI zip or setting it to the empty string + * + */ +object OpenApiRoutes { + + def swaggerUIUrl: Option[String] = { + // flag to turn SWAGGER off + def useSwaggerUI = sys.env.get("SWAGGER_ON").map(_.toBoolean).getOrElse(true) + + val defaultUrl = "https://github.com/swagger-api/swagger-ui/archive/refs/tags/v5.11.9.zip" + Option(sys.env.getOrElse("SWAGGER_UI_URL", defaultUrl)) + .map(_.trim) + .filterNot(_.isEmpty) + .filter(_ => useSwaggerUI) + } + def apply(localPort: Int) = new OpenApiRoutes(localPort, swaggerUIUrl) +} + +class OpenApiRoutes(localPort: Int, swaggerUrl: Option[String]) extends cask.Routes { + + def openApiDir = "ui" + + @cask.get("/") + def index() = cask.Redirect("/ui/index.html") + + @cask.staticFiles("/ui") + def staticUI() = openApiDir + + @cask.staticResources("/openapi.json") + def staticOpenApi() = "openapi.json" + + /** This code will try and download the swagger UI artefacts to a local directory to serve up + */ + object extract { + + def openApiDirPath: Path = Paths.get(openApiDir) + def hasSwagger = Files.exists(openApiDirPath) && Files.isDirectory(openApiDirPath) + + import java.io.{BufferedInputStream, FileOutputStream, InputStream} + import java.net.URL + import java.util.zip.{ZipEntry, ZipInputStream} + import scala.util.Using + + def apply(url: String) = { + if !hasSwagger then downloadAndExtractZip(url, openApiDir) + } + + def downloadAndExtractZip(url: String, outputDir: String): Unit = { + val urlConn = new URL(url).openConnection() + urlConn.setRequestProperty("User-Agent", "Mozilla/5.0") + + Using(urlConn.getInputStream) { inputStream => + val zipIn = new ZipInputStream(new BufferedInputStream(inputStream)) + LazyList.continually(zipIn.getNextEntry).takeWhile(_ != null).foreach { entry => + + def isDist = entry.getName.contains("/dist/") + def isNotMap = !entry.getName.endsWith(".map") + + if (!entry.isDirectory && isDist && isNotMap) { + val fileName = entry.getName.split("/").last + extractFile(entry.getName, zipIn, s"$outputDir/$fileName") + } + zipIn.closeEntry() + } + } + } + + def extractFile(name: String, zipIn: ZipInputStream, filePath: String): Unit = { + val fullPath = Paths.get(filePath).toAbsolutePath + if !Files.exists(fullPath.getParent) then { + Files.createDirectories(fullPath.getParent) + } + + // config hack - we replace the default url from this swagger conf to use our localhost + // + if name.endsWith("swagger-initializer.js") then { + val textLines = scala.io.Source.fromInputStream(zipIn).getLines().map { + case line if line.contains("url:") => + s""" url: "http://localhost:$localPort/openapi.json",""" + case line => line + } + + // keeping this compatible for java 8, where this is from >= java 11: + // Files.writeString(fullPath, textLines.mkString("\n")) + scala.util.Using(new java.io.PrintWriter(fullPath.toFile))(_.write(textLines.mkString("\n"))) + } else { + Using(new FileOutputStream(filePath)) { outputStream => + val buffer = new Array[Byte](1024) + LazyList + .continually(zipIn.read(buffer)) + .takeWhile(_ != -1) + .foreach(outputStream.write(buffer, 0, _)) + } + } + } + } + + // extract the swagger UI resources to our local directory + swaggerUrl.foreach(url => extract(url)) + + initialize() +} diff --git a/modules/openapi-generator/src/main/resources/scala-cask/parseHttpParams.mustache b/modules/openapi-generator/src/main/resources/scala-cask/parseHttpParams.mustache new file mode 100644 index 00000000000..18193e0606c --- /dev/null +++ b/modules/openapi-generator/src/main/resources/scala-cask/parseHttpParams.mustache @@ -0,0 +1,56 @@ + for { +{{#pathParams}} + {{#isString}} + {{paramName}} <- Parsed({{paramName}}) + {{/isString}} + {{^isString}} + {{paramName}} <- Parsed({{paramName}}) + {{/isString}} +{{/pathParams}} +{{#headerParams}} + {{#required}} + {{paramName}} <- request.headerSingleValueRequired("{{paramName}}") + {{/required}} + {{^required}} + {{paramName}} <- request.headerSingleValueOptional("{{paramName}}") + {{/required}} +{{/headerParams}} +{{#formParams}} + {{#required}} + {{paramName}} <- {{#isFile}}request.formValueAsFileRequired("{{paramName}}"){{/isFile}}{{^isFile}}request.formSingleValueRequired("{{paramName}}"){{/isFile}} + {{/required}} + {{^required}} + {{paramName}} <- {{#isFile}}request.formValueAsFileOptional("{{paramName}}"){{/isFile}}{{^isFile}}request.formSingleValueOptional("{{paramName}}"){{/isFile}} + {{/required}} +{{/formParams}} +{{#bodyParams}} + {{#vendorExtensions.x-consumes-json}} + {{#isArray}} + {{paramName}} <- Parsed.fromTry({{vendorExtensions.x-container-type}}Data.manyFromJsonStringValidated(request.bodyAsString)).mapError(e => s"Error parsing json as an array of {{vendorExtensions.x-container-type}} from >${request.bodyAsString}< : ${e}") /* array */ + {{/isArray}} + {{^isArray}} + {{#isMap}} + {{#vendorExtensions.x-deserialize-asModelMap}} + {{paramName}} <- Parsed.eval(read[Map[String, {{vendorExtensions.x-container-type}}](request.bodyAsString)).mapError(e => s"Error parsing json as a string map of {{vendorExtensions.x-container-type}} from >${request.bodyAsString}< : ${e}") /* x-deserialize-asModelMap */ + {{/vendorExtensions.x-deserialize-asModelMap}} + {{^vendorExtensions.x-deserialize-asModelMap}} + {{paramName}} <- Parsed.eval(read[Map[String, {{vendorExtensions.x-container-type}}]](request.bodyAsString)).mapError(e => s"Error parsing json as a string map of {{vendorExtensions.x-container-type}} from >${request.bodyAsString}< : ${e}") /* not x-deserialize-asModelMap */ + {{/vendorExtensions.x-deserialize-asModelMap}} + {{/isMap}} + {{^isMap}} + {{paramName}}Data <- Parsed.eval({{vendorExtensions.x-container-type}}Data.fromJsonString(request.bodyAsString)).mapError(e => s"Error parsing json as {{vendorExtensions.x-container-type}} from >${request.bodyAsString}< : ${e}") /* not array or map */ + {{paramName}} <- Parsed.fromTry({{paramName}}Data.validated(failFast)) + {{/isMap}} + {{/isArray}} + {{/vendorExtensions.x-consumes-json}} + {{^vendorExtensions.x-consumes-json}} + {{#vendorExtensions.x-consumes-xml}} + {{paramName}} <- Parsed.fail("TODO - xml deserialisation not yet supported. see src/main/resources/scala-cask/parseHttpParams.mustache in https://github.com/OpenAPITools/openapi-generator") + {{/vendorExtensions.x-consumes-xml}} + {{^vendorExtensions.x-consumes-xml}} + {{paramName}} <- Parsed.fail("TODO - content deserialisation. see src/main/resources/scala-cask/parseHttpParams.mustache in https://github.com/OpenAPITools/openapi-generator") + {{/vendorExtensions.x-consumes-xml}} + {{/vendorExtensions.x-consumes-json}} +{{/bodyParams}} + result <- Parsed.eval(service.{{operationId}}({{vendorExtensions.x-param-list}})) + } yield result \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/scala-cask/pathExtractor.mustache b/modules/openapi-generator/src/main/resources/scala-cask/pathExtractor.mustache new file mode 100644 index 00000000000..9890a8865a2 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/scala-cask/pathExtractor.mustache @@ -0,0 +1 @@ +{{#vendorExtensions.x-path-remaining}}{{#isParam}}{{name}}{{/isParam}}{{^isParam}}"{{name}}"{{/isParam}}{{^-last}},{{/-last}}{{/vendorExtensions.x-path-remaining}} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/scala-cask/pathExtractorParams.mustache b/modules/openapi-generator/src/main/resources/scala-cask/pathExtractorParams.mustache new file mode 100644 index 00000000000..89a51b83b40 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/scala-cask/pathExtractorParams.mustache @@ -0,0 +1 @@ +{{#vendorExtensions.x-path-remaining}}{{#isParam}}{{name}}{{conversion}}{{/isParam}}{{#hasMoreParams}}, {{/hasMoreParams}}{{/vendorExtensions.x-path-remaining}}{{#vendorExtensions.x-has-path-remaining}},{{/vendorExtensions.x-has-path-remaining}} \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/scala-cask/project/build.properties b/modules/openapi-generator/src/main/resources/scala-cask/project/build.properties new file mode 100644 index 00000000000..04267b14af6 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/scala-cask/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.9.9 diff --git a/modules/openapi-generator/src/main/resources/scala-cask/project/plugins.sbt b/modules/openapi-generator/src/main/resources/scala-cask/project/plugins.sbt new file mode 100644 index 00000000000..4f3f02c2de4 --- /dev/null +++ b/modules/openapi-generator/src/main/resources/scala-cask/project/plugins.sbt @@ -0,0 +1,3 @@ +addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.6") + +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.6") \ No newline at end of file diff --git a/modules/openapi-generator/src/main/resources/scala-cask/queryParams.mustache b/modules/openapi-generator/src/main/resources/scala-cask/queryParams.mustache new file mode 100644 index 00000000000..fc9a800ed2b --- /dev/null +++ b/modules/openapi-generator/src/main/resources/scala-cask/queryParams.mustache @@ -0,0 +1 @@ +{{#hasQueryParams}},{{/hasQueryParams}}{{#queryParams}}{{paramName}}{{#required}}{{^isMap}}{{^isArray}}.getOrElse({{#isString}}""{{/isString}}{{#isNumber}}0{{/isNumber}}{{#isBoolean}}false{{/isBoolean}}){{/isArray}}{{/isMap}}{{/required}}{{^-last}}, {{/-last}}{{/queryParams}} \ No newline at end of file diff --git a/samples/server/petstore/scala-cask/.github/workflows/bulidAndPublish.yml b/samples/server/petstore/scala-cask/.github/workflows/bulidAndPublish.yml new file mode 100644 index 00000000000..fef794246b0 --- /dev/null +++ b/samples/server/petstore/scala-cask/.github/workflows/bulidAndPublish.yml @@ -0,0 +1,41 @@ +name: Scala CI with sbt + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +jobs: + build-and-publish: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - name: Set up JDK 11 + uses: actions/setup-java@v1 + with: + java-version: 11 + + - name: Cache sbt dependencies + uses: actions/cache@v2 + with: + path: | + ~/.ivy2/cache + ~/.sbt + ~/.m2 + key: ${{ runner.os }}-sbt-${{ hashFiles('**/*.sbt') }} + restore-keys: | + ${{ runner.os }}-sbt- + + - name: Build with sbt + run: sbt clean compile + + - name: Test with sbt + run: sbt test + + - name: Publish to GitHub Packages + run: sbt publish + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/samples/server/petstore/scala-cask/.gitignore b/samples/server/petstore/scala-cask/.gitignore new file mode 100644 index 00000000000..56f2b272a08 --- /dev/null +++ b/samples/server/petstore/scala-cask/.gitignore @@ -0,0 +1,25 @@ +# scala specific +*.class +*.log + +# sbt specific +.cache +.history +.lib/ +dist/* +target/ +lib_managed/ +src_managed/ +project/boot/ +project/plugins/project/ + +# Scala-IDE specific +.scala_dependencies +.worksheet + +# Mill specific +out + +# IntelliJ specific +.idea +*.iml diff --git a/samples/server/petstore/scala-cask/.openapi-generator-ignore b/samples/server/petstore/scala-cask/.openapi-generator-ignore new file mode 100644 index 00000000000..7484ee590a3 --- /dev/null +++ b/samples/server/petstore/scala-cask/.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/scala-cask/.openapi-generator/FILES b/samples/server/petstore/scala-cask/.openapi-generator/FILES new file mode 100644 index 00000000000..e65ebab478f --- /dev/null +++ b/samples/server/petstore/scala-cask/.openapi-generator/FILES @@ -0,0 +1,39 @@ +.github/workflows/bulidAndPublish.yml +.gitignore +.scalafmt.conf +README.md +README.md +build.sbt +build.sc +example/Dockerfile +example/Server.scala +project/build.properties +project/plugins.sbt +src/main/scala/sample/cask/AppRoutes.scala +src/main/scala/sample/cask/BaseApp.scala +src/main/scala/sample/cask/ExampleApp.scala +src/main/scala/sample/cask/api/OpenApiRoutes.scala +src/main/scala/sample/cask/api/PetRoutes.scala +src/main/scala/sample/cask/api/PetRoutes.scala +src/main/scala/sample/cask/api/PetService.scala +src/main/scala/sample/cask/api/StoreRoutes.scala +src/main/scala/sample/cask/api/StoreRoutes.scala +src/main/scala/sample/cask/api/StoreService.scala +src/main/scala/sample/cask/api/UserRoutes.scala +src/main/scala/sample/cask/api/UserRoutes.scala +src/main/scala/sample/cask/api/UserService.scala +src/main/scala/sample/cask/api/package.scala +src/main/scala/sample/cask/model/ApiResponse.scala +src/main/scala/sample/cask/model/ApiResponseData.scala +src/main/scala/sample/cask/model/Category.scala +src/main/scala/sample/cask/model/CategoryData.scala +src/main/scala/sample/cask/model/Order.scala +src/main/scala/sample/cask/model/OrderData.scala +src/main/scala/sample/cask/model/Pet.scala +src/main/scala/sample/cask/model/PetData.scala +src/main/scala/sample/cask/model/Tag.scala +src/main/scala/sample/cask/model/TagData.scala +src/main/scala/sample/cask/model/User.scala +src/main/scala/sample/cask/model/UserData.scala +src/main/scala/sample/cask/model/package.scala +src/main/scala/sample/cask/package.scala diff --git a/samples/server/petstore/scala-cask/.openapi-generator/VERSION b/samples/server/petstore/scala-cask/.openapi-generator/VERSION new file mode 100644 index 00000000000..08bfd0643b8 --- /dev/null +++ b/samples/server/petstore/scala-cask/.openapi-generator/VERSION @@ -0,0 +1 @@ +7.5.0-SNAPSHOT diff --git a/samples/server/petstore/scala-cask/.scalafmt.conf b/samples/server/petstore/scala-cask/.scalafmt.conf new file mode 100644 index 00000000000..e150d4c409d --- /dev/null +++ b/samples/server/petstore/scala-cask/.scalafmt.conf @@ -0,0 +1,4 @@ +version = 3.6.1 +align.preset = more // For pretty alignment. +maxColumn = 100 +runner.dialect = scala3 \ No newline at end of file diff --git a/samples/server/petstore/scala-cask/README.md b/samples/server/petstore/scala-cask/README.md new file mode 100644 index 00000000000..39e65480193 --- /dev/null +++ b/samples/server/petstore/scala-cask/README.md @@ -0,0 +1,96 @@ +# REST Service + +This project contains the data models and REST services, generated from the [openapi-generator](https://github.com/OpenAPITools/openapi-generator) project. + +The server implementation is based on Li's excellent [cask](https://com-lihaoyi.github.io/cask/) library. + +# How to use this code + +This code was designed so that it can be packaged up and semantically-versioned alongside your open-api schema. + +That approach supports having separate "contract" repositories for microservice projects, where the pipeline for the +contract repo might produce versioned jar artefacts which can then easily be brought in / referenced by a separate service project which simply +implements the business logic. + +You can read more about this approach [here](https://github.com/kindservices/contract-first-rest) + +# How to implement your business logic +There are a few options for using this code/applying your business logic for the services. + +## Option 1 (preferred): Package and publish this boilerplate +Typically, OpenApi templates are written to generate code which co-exists alongside the handwritten business logic. + +While that works, it's also not ideal: + * You have to ensure the generated code isn't checked in + * Team members, build pipelines, etc all have to regenerate and recompile the same boilerplate code over and over + * People can encounter IDE issues with generated code + +Instead, you have the option of simply packaging/publishing this generated code, and then allowing service implementations +to simply bring in the published code as a dependency. + +The steps to do that are: + +### Build/Publish +This project is built using [sbt](https://www.scala-sbt.org/download/), so you can run `sbt publish` (or `sbt publishLocal`) + +Or, for a zero-install docker build: +```bash +docker run -it --rm -v $(pwd):/app -w /app sbtscala/scala-sbt:eclipse-temurin-17.0.4_1.7.1_3.2.0 sbt publishLocal +``` + +### Create a new separate implementation project +Once published, you can create your server implementation in a new, clean, separate project based on [the example](./example) + +This means all the boilerplate endpoint and model code is brought in as "just another jar", and you're free to +create a greenfield project in whatever language (scala, java, kotlin) and build system of your choosing. + +We show a simple, minimalistic example of a starting point in [the example project](./example) + +## Option 2: Extend this generated example +You can configure this project (for instance, setting up your own .gitignore rules and scripts) to leave the generated code as-is +and provide your implementation alongside the generated code. + +The place to start is by providing your own implementation of the Services defined in the `api` package - +perhaps by creating your 'MyService.scala' code in a new `impl` package. + +You then have several options for how to wire those in: + +1) Create a new BaseApp instance to create your own Main entry point +Follow the pattern in App.scala, but by passing your own implementations to BaseApp, +ensuring you call `start` to start the server + +```bash +@main def run() = BaseApp(/* your services here/*).start() +``` + +2) Extend either BaseApp class or mix in the AppRoutes trait +You can create your own main entry point with further control of the main cask app by extending +the BaseApp or otherwise creating your own CaskApp which mixes in the AppRoutes + +```bash +object MyApp extends BaseApp(/* your services here/*) { + // any overrides, new routes, etc here + start() +} +``` + + +# Customising the generated code + +A typical config.yml used to alter the generated code may look like this: +``` +groupId: "ex.amp.le" +artifactId: "pets-test" +apiPackage: "ex.ample.api" +modelPackage: "ex.ample.model" +``` + +Which you would then pass to the generator like this: +``` +docker run --rm \ +-v ${PWD}:/local openapitools/openapi-generator-cli generate \ +-i https://raw.githubusercontent.com/OAI/OpenAPI-Specification/main/examples/v3.0/petstore.yaml \ +-g scala-cask \ +-c /local/config.yml \ +-o /local/path/to/output_dir +``` diff --git a/samples/server/petstore/scala-cask/build.sbt b/samples/server/petstore/scala-cask/build.sbt new file mode 100644 index 00000000000..4062a2fa45a --- /dev/null +++ b/samples/server/petstore/scala-cask/build.sbt @@ -0,0 +1,29 @@ +name := "scala-cask-petstore" +organization:="cask.groupId" +version := "0.0.1-SNAPSHOT" +scalaVersion := "3.3.1" +scalafmtOnCompile := true +libraryDependencies ++= Seq( + "com.lihaoyi" %% "cask" % "0.9.2" , + "com.lihaoyi" %% "upickle" % "3.2.0", + "org.scalatest" %% "scalatest" % "3.2.18" % Test +) + +publishMavenStyle := true + +val githubUser = "GIT_USER_ID" +val githubRepo = "GIT_REPO_ID" +publishTo := Some("GitHub Package Registry" at s"https://maven.pkg.github.com/$githubUser/$githubRepo") + +sys.env.get("GITHUB_TOKEN") match { + case Some(token) if !token.isEmpty => + credentials += Credentials( + "GitHub Package Registry", + "maven.pkg.github.com", + githubUser, + token + ) + case _ => + println("\n\t\tGITHUB_TOKEN not set - assuming a local build\n\n") + credentials ++= Nil +} \ No newline at end of file diff --git a/samples/server/petstore/scala-cask/build.sc b/samples/server/petstore/scala-cask/build.sc new file mode 100644 index 00000000000..b2ca2988987 --- /dev/null +++ b/samples/server/petstore/scala-cask/build.sc @@ -0,0 +1,43 @@ +import mill._, scalalib._, scalafmt._, publish._ + +// Mill build file (see https://mill-build.com/mill/Intro_to_Mill.html). +// run with: +// +// mill _.compile +// mill _.reformat +// mill _.publishLocal +// mill _.test.test +object scala-cask-petstore extends SbtModule with ScalafmtModule with PublishModule { + def scalaVersion = "3.3.1" + + def pomSettings = PomSettings( + description = "scala-cask-petstore", + organization = "cask.groupId", + url = "https://github.com//scala-cask-petstore", + licenses = Seq(License.MIT), + versionControl = VersionControl.github("", "scala-cask-petstore"), + developers = Seq( + // Developer("", "", "https://github.com/") + ) + ) + + def publishVersion: mill.T[String] = T("0.0.1-SNAPSHOT") + + def ivyDeps = Agg( + ivy"com.lihaoyi::cask:0.9.2" , + ivy"com.lihaoyi::upickle:3.2.0" + ) + + override def sources = T.sources(millSourcePath / os.up / "src" / "main" / "scala") + override def resources = T.sources(millSourcePath / os.up / "src" / "main" / "resources") + + object test extends SbtModuleTests { + def ivyDeps = Agg( + ivy"org.scalactic::scalactic:3.2.18", + ivy"org.scalatest::scalatest:3.2.18" + ) + + def testFramework = "org.scalatest.tools.Framework" + override def sources = T.sources(millSourcePath / os.up / "src" / "test" / "scala") + } +} diff --git a/samples/server/petstore/scala-cask/example/Dockerfile b/samples/server/petstore/scala-cask/example/Dockerfile new file mode 100644 index 00000000000..0c548753973 --- /dev/null +++ b/samples/server/petstore/scala-cask/example/Dockerfile @@ -0,0 +1,13 @@ +FROM virtuslab/scala-cli:latest as build +WORKDIR /app +COPY ./Server.scala /app/ +# note: this assumes a published server stub jar. +# If you've published this locally, you would need to copy those into this image, +# perhaps by using coursier fetch +RUN scala-cli --power package /app/Server.scala --assembly -o app.jar + +# The main image +FROM openjdk:23-slim +WORKDIR /app +COPY --from=build /app/app.jar /app/ +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/samples/server/petstore/scala-cask/example/Server.scala b/samples/server/petstore/scala-cask/example/Server.scala new file mode 100644 index 00000000000..77e65913712 --- /dev/null +++ b/samples/server/petstore/scala-cask/example/Server.scala @@ -0,0 +1,61 @@ +//> using scala "3.3.1" +//> using lib "cask.groupId::scala-cask-petstore:0.0.1-SNAPSHOT" +//> using repositories https://maven.pkg.github.com/GIT_USER_ID/GIT_REPO_ID + + +/** +* This single file can contain the business logic for a REST service. +* ==================================== +* == zero-install build with docker == +* ==================================== +* +* +* ``` +* docker build . -t scala-cask-petstore:latest +* ``` +* ====================== +* == Building Locally == +* ====================== +* This project can be built using [[scala-clit][https://scala-cli.virtuslab.org]] +* +* To simply run the project +* ``` +* scala-cli Server.scala +* ``` +* +* To create a runnable jar, run: +* ``` +* scala-cli --power package Server.scala -o app-assembly --assembly +* ``` +* +* To produce a docker image (no need for the Dockerfile), run: +* ``` +* scala-cli --power package --docker Server.scala --docker-image-repository app-docker +* ``` +* +* To generate an IDE project: +* ``` +* scala-cli setup-ide . --scala 3.3 +* ``` +*/ +package app + +import cask.groupId.server.BaseApp +import sample.cask.api.* +import sample.cask.model.* + +import java.io.File + +// TODO - write your business logic for your services here (the defaults all return 'not implemented'): +val myPetService : PetService = PetService() // <-- replace this with your implementation +val myStoreService : StoreService = StoreService() // <-- replace this with your implementation +val myUserService : UserService = UserService() // <-- replace this with your implementation + +/** This is your main entry point for your REST service + * It extends BaseApp which defines the business logic for your services + */ +object Server extends BaseApp(appPetService = myPetService, +appStoreService = myStoreService, +appUserService = myUserService): + start() + diff --git a/samples/server/petstore/scala-cask/project/build.properties b/samples/server/petstore/scala-cask/project/build.properties new file mode 100644 index 00000000000..04267b14af6 --- /dev/null +++ b/samples/server/petstore/scala-cask/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.9.9 diff --git a/samples/server/petstore/scala-cask/project/plugins.sbt b/samples/server/petstore/scala-cask/project/plugins.sbt new file mode 100644 index 00000000000..4f3f02c2de4 --- /dev/null +++ b/samples/server/petstore/scala-cask/project/plugins.sbt @@ -0,0 +1,3 @@ +addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.6") + +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.6") \ No newline at end of file diff --git a/samples/server/petstore/scala-cask/src/main/resources/openapi.json b/samples/server/petstore/scala-cask/src/main/resources/openapi.json new file mode 100644 index 00000000000..5c3f8dec9aa --- /dev/null +++ b/samples/server/petstore/scala-cask/src/main/resources/openapi.json @@ -0,0 +1,1032 @@ +{ + "openapi" : "3.0.0", + "info" : { + "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" : { + "name" : "Apache-2.0", + "url" : "https://www.apache.org/licenses/LICENSE-2.0.html" + }, + "title" : "OpenAPI Petstore", + "version" : "1.0.0" + }, + "externalDocs" : { + "description" : "Find out more about Swagger", + "url" : "http://swagger.io" + }, + "servers" : [ { + "url" : "http://petstore.swagger.io/v2" + } ], + "tags" : [ { + "description" : "Everything about your Pets", + "name" : "pet" + }, { + "description" : "Access to Petstore orders", + "name" : "store" + }, { + "description" : "Operations about user", + "name" : "user" + } ], + "paths" : { + "/pet" : { + "post" : { + "description" : "", + "operationId" : "addPet", + "requestBody" : { + "$ref" : "#/components/requestBodies/Pet" + }, + "responses" : { + "200" : { + "content" : { + "application/xml" : { + "schema" : { + "$ref" : "#/components/schemas/Pet" + } + }, + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/Pet" + } + } + }, + "description" : "successful operation" + }, + "405" : { + "description" : "Invalid input" + } + }, + "security" : [ { + "petstore_auth" : [ "write:pets", "read:pets" ] + } ], + "summary" : "Add a new pet to the store", + "tags" : [ "pet" ] + }, + "put" : { + "description" : "", + "externalDocs" : { + "description" : "API documentation for the updatePet operation", + "url" : "http://petstore.swagger.io/v2/doc/updatePet" + }, + "operationId" : "updatePet", + "requestBody" : { + "$ref" : "#/components/requestBodies/Pet" + }, + "responses" : { + "200" : { + "content" : { + "application/xml" : { + "schema" : { + "$ref" : "#/components/schemas/Pet" + } + }, + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/Pet" + } + } + }, + "description" : "successful operation" + }, + "400" : { + "description" : "Invalid ID supplied" + }, + "404" : { + "description" : "Pet not found" + }, + "405" : { + "description" : "Validation exception" + } + }, + "security" : [ { + "petstore_auth" : [ "write:pets", "read:pets" ] + } ], + "summary" : "Update an existing pet", + "tags" : [ "pet" ] + } + }, + "/pet/findByStatus" : { + "get" : { + "description" : "Multiple status values can be provided with comma separated strings", + "operationId" : "findPetsByStatus", + "parameters" : [ { + "deprecated" : true, + "description" : "Status values that need to be considered for filter", + "explode" : false, + "in" : "query", + "name" : "status", + "required" : true, + "schema" : { + "items" : { + "default" : "available", + "enum" : [ "available", "pending", "sold" ], + "type" : "string" + }, + "type" : "array" + }, + "style" : "form" + } ], + "responses" : { + "200" : { + "content" : { + "application/xml" : { + "schema" : { + "items" : { + "$ref" : "#/components/schemas/Pet" + }, + "type" : "array" + } + }, + "application/json" : { + "schema" : { + "items" : { + "$ref" : "#/components/schemas/Pet" + }, + "type" : "array" + } + } + }, + "description" : "successful operation" + }, + "400" : { + "description" : "Invalid status value" + } + }, + "security" : [ { + "petstore_auth" : [ "read:pets" ] + } ], + "summary" : "Finds Pets by status", + "tags" : [ "pet" ] + } + }, + "/pet/findByTags" : { + "get" : { + "deprecated" : true, + "description" : "Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.", + "operationId" : "findPetsByTags", + "parameters" : [ { + "description" : "Tags to filter by", + "explode" : false, + "in" : "query", + "name" : "tags", + "required" : true, + "schema" : { + "items" : { + "type" : "string" + }, + "type" : "array" + }, + "style" : "form" + } ], + "responses" : { + "200" : { + "content" : { + "application/xml" : { + "schema" : { + "items" : { + "$ref" : "#/components/schemas/Pet" + }, + "type" : "array" + } + }, + "application/json" : { + "schema" : { + "items" : { + "$ref" : "#/components/schemas/Pet" + }, + "type" : "array" + } + } + }, + "description" : "successful operation" + }, + "400" : { + "description" : "Invalid tag value" + } + }, + "security" : [ { + "petstore_auth" : [ "read:pets" ] + } ], + "summary" : "Finds Pets by tags", + "tags" : [ "pet" ] + } + }, + "/pet/{petId}" : { + "delete" : { + "description" : "", + "operationId" : "deletePet", + "parameters" : [ { + "explode" : false, + "in" : "header", + "name" : "api_key", + "required" : false, + "schema" : { + "type" : "string" + }, + "style" : "simple" + }, { + "description" : "Pet id to delete", + "explode" : false, + "in" : "path", + "name" : "petId", + "required" : true, + "schema" : { + "format" : "int64", + "type" : "integer" + }, + "style" : "simple" + } ], + "responses" : { + "400" : { + "description" : "Invalid pet value" + } + }, + "security" : [ { + "petstore_auth" : [ "write:pets", "read:pets" ] + } ], + "summary" : "Deletes a pet", + "tags" : [ "pet" ] + }, + "get" : { + "description" : "Returns a single pet", + "operationId" : "getPetById", + "parameters" : [ { + "description" : "ID of pet to return", + "explode" : false, + "in" : "path", + "name" : "petId", + "required" : true, + "schema" : { + "format" : "int64", + "type" : "integer" + }, + "style" : "simple" + } ], + "responses" : { + "200" : { + "content" : { + "application/xml" : { + "schema" : { + "$ref" : "#/components/schemas/Pet" + } + }, + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/Pet" + } + } + }, + "description" : "successful operation" + }, + "400" : { + "description" : "Invalid ID supplied" + }, + "404" : { + "description" : "Pet not found" + } + }, + "security" : [ { + "api_key" : [ ] + } ], + "summary" : "Find pet by ID", + "tags" : [ "pet" ] + }, + "post" : { + "description" : "", + "operationId" : "updatePetWithForm", + "parameters" : [ { + "description" : "ID of pet that needs to be updated", + "explode" : false, + "in" : "path", + "name" : "petId", + "required" : true, + "schema" : { + "format" : "int64", + "type" : "integer" + }, + "style" : "simple" + } ], + "requestBody" : { + "content" : { + "application/x-www-form-urlencoded" : { + "schema" : { + "$ref" : "#/components/schemas/updatePetWithForm_request" + } + } + } + }, + "responses" : { + "405" : { + "description" : "Invalid input" + } + }, + "security" : [ { + "petstore_auth" : [ "write:pets", "read:pets" ] + } ], + "summary" : "Updates a pet in the store with form data", + "tags" : [ "pet" ] + } + }, + "/pet/{petId}/uploadImage" : { + "post" : { + "description" : "", + "operationId" : "uploadFile", + "parameters" : [ { + "description" : "ID of pet to update", + "explode" : false, + "in" : "path", + "name" : "petId", + "required" : true, + "schema" : { + "format" : "int64", + "type" : "integer" + }, + "style" : "simple" + } ], + "requestBody" : { + "content" : { + "multipart/form-data" : { + "schema" : { + "$ref" : "#/components/schemas/uploadFile_request" + } + } + } + }, + "responses" : { + "200" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ApiResponse" + } + } + }, + "description" : "successful operation" + } + }, + "security" : [ { + "petstore_auth" : [ "write:pets", "read:pets" ] + } ], + "summary" : "uploads an image", + "tags" : [ "pet" ] + } + }, + "/store/inventory" : { + "get" : { + "description" : "Returns a map of status codes to quantities", + "operationId" : "getInventory", + "responses" : { + "200" : { + "content" : { + "application/json" : { + "schema" : { + "additionalProperties" : { + "format" : "int32", + "type" : "integer" + }, + "type" : "object" + } + } + }, + "description" : "successful operation" + } + }, + "security" : [ { + "api_key" : [ ] + } ], + "summary" : "Returns pet inventories by status", + "tags" : [ "store" ] + } + }, + "/store/order" : { + "post" : { + "description" : "", + "operationId" : "placeOrder", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/Order" + } + } + }, + "description" : "order placed for purchasing the pet", + "required" : true + }, + "responses" : { + "200" : { + "content" : { + "application/xml" : { + "schema" : { + "$ref" : "#/components/schemas/Order" + } + }, + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/Order" + } + } + }, + "description" : "successful operation" + }, + "400" : { + "description" : "Invalid Order" + } + }, + "summary" : "Place an order for a pet", + "tags" : [ "store" ] + } + }, + "/store/order/{orderId}" : { + "delete" : { + "description" : "For valid response try integer IDs with value < 1000. Anything above 1000 or nonintegers will generate API errors", + "operationId" : "deleteOrder", + "parameters" : [ { + "description" : "ID of the order that needs to be deleted", + "explode" : false, + "in" : "path", + "name" : "orderId", + "required" : true, + "schema" : { + "type" : "string" + }, + "style" : "simple" + } ], + "responses" : { + "400" : { + "description" : "Invalid ID supplied" + }, + "404" : { + "description" : "Order not found" + } + }, + "summary" : "Delete purchase order by ID", + "tags" : [ "store" ] + }, + "get" : { + "description" : "For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions", + "operationId" : "getOrderById", + "parameters" : [ { + "description" : "ID of pet that needs to be fetched", + "explode" : false, + "in" : "path", + "name" : "orderId", + "required" : true, + "schema" : { + "format" : "int64", + "maximum" : 5, + "minimum" : 1, + "type" : "integer" + }, + "style" : "simple" + } ], + "responses" : { + "200" : { + "content" : { + "application/xml" : { + "schema" : { + "$ref" : "#/components/schemas/Order" + } + }, + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/Order" + } + } + }, + "description" : "successful operation" + }, + "400" : { + "description" : "Invalid ID supplied" + }, + "404" : { + "description" : "Order not found" + } + }, + "summary" : "Find purchase order by ID", + "tags" : [ "store" ] + } + }, + "/user" : { + "post" : { + "description" : "This can only be done by the logged in user.", + "operationId" : "createUser", + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/User" + } + } + }, + "description" : "Created user object", + "required" : true + }, + "responses" : { + "default" : { + "description" : "successful operation" + } + }, + "security" : [ { + "api_key" : [ ] + } ], + "summary" : "Create user", + "tags" : [ "user" ] + } + }, + "/user/createWithArray" : { + "post" : { + "description" : "", + "operationId" : "createUsersWithArrayInput", + "requestBody" : { + "$ref" : "#/components/requestBodies/UserArray" + }, + "responses" : { + "default" : { + "description" : "successful operation" + } + }, + "security" : [ { + "api_key" : [ ] + } ], + "summary" : "Creates list of users with given input array", + "tags" : [ "user" ] + } + }, + "/user/createWithList" : { + "post" : { + "description" : "", + "operationId" : "createUsersWithListInput", + "requestBody" : { + "$ref" : "#/components/requestBodies/UserArray" + }, + "responses" : { + "default" : { + "description" : "successful operation" + } + }, + "security" : [ { + "api_key" : [ ] + } ], + "summary" : "Creates list of users with given input array", + "tags" : [ "user" ] + } + }, + "/user/login" : { + "get" : { + "description" : "", + "operationId" : "loginUser", + "parameters" : [ { + "description" : "The user name for login", + "explode" : true, + "in" : "query", + "name" : "username", + "required" : true, + "schema" : { + "pattern" : "^[a-zA-Z0-9]+[a-zA-Z0-9\\.\\-_]*[a-zA-Z0-9]+$", + "type" : "string" + }, + "style" : "form" + }, { + "description" : "The password for login in clear text", + "explode" : true, + "in" : "query", + "name" : "password", + "required" : true, + "schema" : { + "type" : "string" + }, + "style" : "form" + } ], + "responses" : { + "200" : { + "content" : { + "application/xml" : { + "schema" : { + "type" : "string" + } + }, + "application/json" : { + "schema" : { + "type" : "string" + } + } + }, + "description" : "successful operation", + "headers" : { + "Set-Cookie" : { + "description" : "Cookie authentication key for use with the `api_key` apiKey authentication.", + "explode" : false, + "schema" : { + "example" : "AUTH_KEY=abcde12345; Path=/; HttpOnly", + "type" : "string" + }, + "style" : "simple" + }, + "X-Rate-Limit" : { + "description" : "calls per hour allowed by the user", + "explode" : false, + "schema" : { + "format" : "int32", + "type" : "integer" + }, + "style" : "simple" + }, + "X-Expires-After" : { + "description" : "date in UTC when token expires", + "explode" : false, + "schema" : { + "format" : "date-time", + "type" : "string" + }, + "style" : "simple" + } + } + }, + "400" : { + "description" : "Invalid username/password supplied" + } + }, + "summary" : "Logs user into the system", + "tags" : [ "user" ] + } + }, + "/user/logout" : { + "get" : { + "description" : "", + "operationId" : "logoutUser", + "responses" : { + "default" : { + "description" : "successful operation" + } + }, + "security" : [ { + "api_key" : [ ] + } ], + "summary" : "Logs out current logged in user session", + "tags" : [ "user" ] + } + }, + "/user/{username}" : { + "delete" : { + "description" : "This can only be done by the logged in user.", + "operationId" : "deleteUser", + "parameters" : [ { + "description" : "The name that needs to be deleted", + "explode" : false, + "in" : "path", + "name" : "username", + "required" : true, + "schema" : { + "type" : "string" + }, + "style" : "simple" + } ], + "responses" : { + "400" : { + "description" : "Invalid username supplied" + }, + "404" : { + "description" : "User not found" + } + }, + "security" : [ { + "api_key" : [ ] + } ], + "summary" : "Delete user", + "tags" : [ "user" ] + }, + "get" : { + "description" : "", + "operationId" : "getUserByName", + "parameters" : [ { + "description" : "The name that needs to be fetched. Use user1 for testing.", + "explode" : false, + "in" : "path", + "name" : "username", + "required" : true, + "schema" : { + "type" : "string" + }, + "style" : "simple" + } ], + "responses" : { + "200" : { + "content" : { + "application/xml" : { + "schema" : { + "$ref" : "#/components/schemas/User" + } + }, + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/User" + } + } + }, + "description" : "successful operation" + }, + "400" : { + "description" : "Invalid username supplied" + }, + "404" : { + "description" : "User not found" + } + }, + "summary" : "Get user by user name", + "tags" : [ "user" ] + }, + "put" : { + "description" : "This can only be done by the logged in user.", + "operationId" : "updateUser", + "parameters" : [ { + "description" : "name that need to be deleted", + "explode" : false, + "in" : "path", + "name" : "username", + "required" : true, + "schema" : { + "type" : "string" + }, + "style" : "simple" + } ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/User" + } + } + }, + "description" : "Updated user object", + "required" : true + }, + "responses" : { + "400" : { + "description" : "Invalid user supplied" + }, + "404" : { + "description" : "User not found" + } + }, + "security" : [ { + "api_key" : [ ] + } ], + "summary" : "Updated user", + "tags" : [ "user" ] + } + } + }, + "components" : { + "requestBodies" : { + "UserArray" : { + "content" : { + "application/json" : { + "schema" : { + "items" : { + "$ref" : "#/components/schemas/User" + }, + "type" : "array" + } + } + }, + "description" : "List of user object", + "required" : true + }, + "Pet" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/Pet" + } + }, + "application/xml" : { + "schema" : { + "$ref" : "#/components/schemas/Pet" + } + } + }, + "description" : "Pet object that needs to be added to the store", + "required" : true + } + }, + "schemas" : { + "Order" : { + "description" : "An order for a pets from the pet store", + "properties" : { + "id" : { + "format" : "int64", + "type" : "integer" + }, + "petId" : { + "format" : "int64", + "type" : "integer" + }, + "quantity" : { + "format" : "int32", + "type" : "integer" + }, + "shipDate" : { + "format" : "date-time", + "type" : "string" + }, + "status" : { + "description" : "Order Status", + "enum" : [ "placed", "approved", "delivered" ], + "type" : "string" + }, + "complete" : { + "default" : false, + "type" : "boolean" + } + }, + "title" : "Pet Order", + "type" : "object", + "xml" : { + "name" : "Order" + } + }, + "Category" : { + "description" : "A category for a pet", + "properties" : { + "id" : { + "format" : "int64", + "type" : "integer" + }, + "name" : { + "pattern" : "^[a-zA-Z0-9]+[a-zA-Z0-9\\.\\-_]*[a-zA-Z0-9]+$", + "type" : "string" + } + }, + "title" : "Pet category", + "type" : "object", + "xml" : { + "name" : "Category" + } + }, + "User" : { + "description" : "A User who is purchasing from the pet store", + "properties" : { + "id" : { + "format" : "int64", + "type" : "integer" + }, + "username" : { + "type" : "string" + }, + "firstName" : { + "type" : "string" + }, + "lastName" : { + "type" : "string" + }, + "email" : { + "type" : "string" + }, + "password" : { + "type" : "string" + }, + "phone" : { + "type" : "string" + }, + "userStatus" : { + "description" : "User Status", + "format" : "int32", + "type" : "integer" + } + }, + "title" : "a User", + "type" : "object", + "xml" : { + "name" : "User" + } + }, + "Tag" : { + "description" : "A tag for a pet", + "properties" : { + "id" : { + "format" : "int64", + "type" : "integer" + }, + "name" : { + "type" : "string" + } + }, + "title" : "Pet Tag", + "type" : "object", + "xml" : { + "name" : "Tag" + } + }, + "Pet" : { + "description" : "A pet for sale in the pet store", + "properties" : { + "id" : { + "format" : "int64", + "type" : "integer" + }, + "category" : { + "$ref" : "#/components/schemas/Category" + }, + "name" : { + "example" : "doggie", + "type" : "string" + }, + "photoUrls" : { + "items" : { + "type" : "string" + }, + "type" : "array", + "xml" : { + "name" : "photoUrl", + "wrapped" : true + } + }, + "tags" : { + "items" : { + "$ref" : "#/components/schemas/Tag" + }, + "type" : "array", + "xml" : { + "name" : "tag", + "wrapped" : true + } + }, + "status" : { + "deprecated" : true, + "description" : "pet status in the store", + "enum" : [ "available", "pending", "sold" ], + "type" : "string" + } + }, + "required" : [ "name", "photoUrls" ], + "title" : "a Pet", + "type" : "object", + "xml" : { + "name" : "Pet" + } + }, + "ApiResponse" : { + "description" : "Describes the result of uploading an image resource", + "properties" : { + "code" : { + "format" : "int32", + "type" : "integer" + }, + "type" : { + "type" : "string" + }, + "message" : { + "type" : "string" + } + }, + "title" : "An uploaded response", + "type" : "object" + }, + "updatePetWithForm_request" : { + "properties" : { + "name" : { + "description" : "Updated name of the pet", + "type" : "string" + }, + "status" : { + "description" : "Updated status of the pet", + "type" : "string" + } + }, + "type" : "object" + }, + "uploadFile_request" : { + "properties" : { + "additionalMetadata" : { + "description" : "Additional data to pass to server", + "type" : "string" + }, + "file" : { + "description" : "file to upload", + "format" : "binary", + "type" : "string" + } + }, + "type" : "object" + } + }, + "securitySchemes" : { + "petstore_auth" : { + "flows" : { + "implicit" : { + "authorizationUrl" : "http://petstore.swagger.io/api/oauth/dialog", + "scopes" : { + "write:pets" : "modify pets in your account", + "read:pets" : "read your pets" + } + } + }, + "type" : "oauth2" + }, + "api_key" : { + "in" : "header", + "name" : "api_key", + "type" : "apiKey" + } + } + } +} \ No newline at end of file diff --git a/samples/server/petstore/scala-cask/src/main/scala/sample/cask/AppRoutes.scala b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/AppRoutes.scala new file mode 100644 index 00000000000..36501557371 --- /dev/null +++ b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/AppRoutes.scala @@ -0,0 +1,52 @@ +//> using scala "3.3.1" +//> using lib "com.lihaoyi::cask:0.9.2" +//> using lib "com.lihaoyi::scalatags:0.8.2" +/** + * 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. + * + * OpenAPI spec version: 1.0.0 + * + * Contact: team@openapitools.org + * + * NOTE: This class is auto generated by OpenAPI Generator. + * + * https://openapi-generator.tech + */ + + +// this file was generated from app.mustache +package cask.groupId.server + +import _root_.sample.cask.model.* +import _root_.sample.cask.api.* + +/** + * This trait encapsulates the business logic (services) and the + * http routes which handle the http requests sent to those services. + * + * There are default 'not implemented' implementations for the service. + * + * If you wanted fine-grained control over the routes and services, you could + * extend the cask.MainRoutes and mix in this trait by using this: + * + * \{\{\{ + * override def allRoutes = appRoutes + * \}\}\} + * + * More typically, however, you would extend the 'BaseApp' class + */ +trait AppRoutes { + def appPetService : PetService = PetService() + def routeForPet : PetRoutes = PetRoutes(appPetService) + def appStoreService : StoreService = StoreService() + def routeForStore : StoreRoutes = StoreRoutes(appStoreService) + def appUserService : UserService = UserService() + def routeForUser : UserRoutes = UserRoutes(appUserService) + + def appRoutes = Seq( + routeForPet , + routeForStore , + routeForUser + ) +} diff --git a/samples/server/petstore/scala-cask/src/main/scala/sample/cask/BaseApp.scala b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/BaseApp.scala new file mode 100644 index 00000000000..c854b216d20 --- /dev/null +++ b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/BaseApp.scala @@ -0,0 +1,59 @@ +//> using scala "3.3.1" +//> using lib "com.lihaoyi::cask:0.9.2" +//> using lib "com.lihaoyi::scalatags:0.8.2" +/** + * 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. + * + * OpenAPI spec version: 1.0.0 + * + * Contact: team@openapitools.org + * + * NOTE: This class is auto generated by OpenAPI Generator. + * + * https://openapi-generator.tech + */ + + +// this file was generated from app.mustache +package cask.groupId.server + +import _root_.sample.cask.model.* +import _root_.sample.cask.api.* + +/** + * This class was created with the intention of being extended by some runnable object, + * passing in the custom business logic services + */ +class BaseApp( + override val appPetService : PetService = PetService(), + + override val appStoreService : StoreService = StoreService(), + + override val appUserService : UserService = UserService(), + override val port : Int = sys.env.get("PORT").map(_.toInt).getOrElse(8080)) extends cask.MainRoutes with AppRoutes { + + /** routes for the UI + * Subclasses can override to turn this off + */ + def openApiRoute: Option[cask.Routes] = Option(OpenApiRoutes(port)) + + override def allRoutes = appRoutes ++ openApiRoute + + + override def host: String = "0.0.0.0" + + def start() = locally { + initialize() + println(box(s""" 🚀 browse to localhost:$port 🚀 + | host : $host + | port : $port + | verbose : $verbose + | debugMode : $debugMode + |""".stripMargin)) + + // if java.awt.Desktop.isDesktopSupported then { + // java.awt.Desktop.getDesktop.browse(new java.net.URI(s"http://localhost:${port}")) + // } + } +} diff --git a/samples/server/petstore/scala-cask/src/main/scala/sample/cask/ExampleApp.scala b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/ExampleApp.scala new file mode 100644 index 00000000000..9c5e733b73e --- /dev/null +++ b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/ExampleApp.scala @@ -0,0 +1,31 @@ +//> using scala "3.3.1" +//> using lib "com.lihaoyi::cask:0.9.2" +//> using lib "com.lihaoyi::scalatags:0.8.2" +/** + * 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. + * + * OpenAPI spec version: 1.0.0 + * + * Contact: team@openapitools.org + * + * NOTE: This class is auto generated by OpenAPI Generator. + * + * https://openapi-generator.tech + */ + + +// this file was generated from app.mustache +package cask.groupId.server + +import _root_.sample.cask.model.* +import _root_.sample.cask.api.* + +/** + * This is an example of how you might extends BaseApp for a runnable application. + * + * See the README.md for how to create your own app + */ +object ExampleApp extends BaseApp() { + start() +} diff --git a/samples/server/petstore/scala-cask/src/main/scala/sample/cask/api/OpenApiRoutes.scala b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/api/OpenApiRoutes.scala new file mode 100644 index 00000000000..a991ce2aaf4 --- /dev/null +++ b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/api/OpenApiRoutes.scala @@ -0,0 +1,128 @@ +/** + * 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. + * + * OpenAPI spec version: 1.0.0 + * + * Contact: team@openapitools.org + * + * NOTE: This class is auto generated by OpenAPI Generator. + * + * https://openapi-generator.tech + */ + + +// generated from openapiRoute.mustache +package sample.cask.api + +import cask.model.Response + +import java.nio.file.{Files, Path, Paths} + +/** + * This code will try and download the swagger UI static files on startup + * + * That behaviour can be altered by: + * - setting the environment variable SWAGGER_ON to false + * - setting the environment variable SWAGGER_UI_URL to either the URL of a swagger UI zip or setting it to the empty string + * + */ +object OpenApiRoutes { + + def swaggerUIUrl: Option[String] = { + // flag to turn SWAGGER off + def useSwaggerUI = sys.env.get("SWAGGER_ON").map(_.toBoolean).getOrElse(true) + + val defaultUrl = "https://github.com/swagger-api/swagger-ui/archive/refs/tags/v5.11.9.zip" + Option(sys.env.getOrElse("SWAGGER_UI_URL", defaultUrl)) + .map(_.trim) + .filterNot(_.isEmpty) + .filter(_ => useSwaggerUI) + } + def apply(localPort: Int) = new OpenApiRoutes(localPort, swaggerUIUrl) +} + +class OpenApiRoutes(localPort: Int, swaggerUrl: Option[String]) extends cask.Routes { + + def openApiDir = "ui" + + @cask.get("/") + def index() = cask.Redirect("/ui/index.html") + + @cask.staticFiles("/ui") + def staticUI() = openApiDir + + @cask.staticResources("/openapi.json") + def staticOpenApi() = "openapi.json" + + /** This code will try and download the swagger UI artefacts to a local directory to serve up + */ + object extract { + + def openApiDirPath: Path = Paths.get(openApiDir) + def hasSwagger = Files.exists(openApiDirPath) && Files.isDirectory(openApiDirPath) + + import java.io.{BufferedInputStream, FileOutputStream, InputStream} + import java.net.URL + import java.util.zip.{ZipEntry, ZipInputStream} + import scala.util.Using + + def apply(url: String) = { + if !hasSwagger then downloadAndExtractZip(url, openApiDir) + } + + def downloadAndExtractZip(url: String, outputDir: String): Unit = { + val urlConn = new URL(url).openConnection() + urlConn.setRequestProperty("User-Agent", "Mozilla/5.0") + + Using(urlConn.getInputStream) { inputStream => + val zipIn = new ZipInputStream(new BufferedInputStream(inputStream)) + LazyList.continually(zipIn.getNextEntry).takeWhile(_ != null).foreach { entry => + + def isDist = entry.getName.contains("/dist/") + def isNotMap = !entry.getName.endsWith(".map") + + if (!entry.isDirectory && isDist && isNotMap) { + val fileName = entry.getName.split("/").last + extractFile(entry.getName, zipIn, s"$outputDir/$fileName") + } + zipIn.closeEntry() + } + } + } + + def extractFile(name: String, zipIn: ZipInputStream, filePath: String): Unit = { + val fullPath = Paths.get(filePath).toAbsolutePath + if !Files.exists(fullPath.getParent) then { + Files.createDirectories(fullPath.getParent) + } + + // config hack - we replace the default url from this swagger conf to use our localhost + // + if name.endsWith("swagger-initializer.js") then { + val textLines = scala.io.Source.fromInputStream(zipIn).getLines().map { + case line if line.contains("url:") => + s""" url: "http://localhost:$localPort/openapi.json",""" + case line => line + } + + // keeping this compatible for java 8, where this is from >= java 11: + // Files.writeString(fullPath, textLines.mkString("\n")) + scala.util.Using(new java.io.PrintWriter(fullPath.toFile))(_.write(textLines.mkString("\n"))) + } else { + Using(new FileOutputStream(filePath)) { outputStream => + val buffer = new Array[Byte](1024) + LazyList + .continually(zipIn.read(buffer)) + .takeWhile(_ != -1) + .foreach(outputStream.write(buffer, 0, _)) + } + } + } + } + + // extract the swagger UI resources to our local directory + swaggerUrl.foreach(url => extract(url)) + + initialize() +} diff --git a/samples/server/petstore/scala-cask/src/main/scala/sample/cask/api/PetRoutes.scala b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/api/PetRoutes.scala new file mode 100644 index 00000000000..4224e229493 --- /dev/null +++ b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/api/PetRoutes.scala @@ -0,0 +1,212 @@ +//> using scala "3.3.1" +//> using lib "com.lihaoyi::cask:0.8.3" +//> using lib "com.lihaoyi::scalatags:0.12.0" +/** + * 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. + * + * OpenAPI spec version: 1.0.0 + * + * Contact: team@openapitools.org + * + * NOTE: This class is auto generated by OpenAPI Generator. + * + * https://openapi-generator.tech + */ + + +// this is generated from apiRoutes.mustache +package sample.cask.api + +import sample.cask.model.* + +import upickle.default.{ReadWriter => RW, macroRW} +import upickle.default.* + +import sample.cask.model.ApiResponse +import java.io.File +import sample.cask.model.Pet + +class PetRoutes(service : PetService) extends cask.Routes { + + // route group for routeWorkAroundForPOSTPet + @cask.post("/pet", true) + def routeWorkAroundForPOSTPet(request: cask.Request) = { + request.remainingPathSegments match { + case Seq() => addPet(request) + case Seq(petId) => updatePetWithForm(petId.toLong,request) + case Seq(petId,"uploadImage") => uploadFile(petId.toLong,request) + case _ => cask.Response("Not Found", statusCode = 404) + } + } + // route group for routeWorkAroundForGETPet + @cask.get("/pet", true) + def routeWorkAroundForGETPet(request: cask.Request,status : Seq[String] = Nil,tags : Seq[String] = Nil) = { + request.remainingPathSegments match { + case Seq("findByStatus") => findPetsByStatus(request,status) + case Seq("findByTags") => findPetsByTags(request,tags) + case Seq(petId) => getPetById(petId.toLong,request) + case _ => cask.Response("Not Found", statusCode = 404) + } + } + + /** Add a new pet to the store + * + */ + // conflicts with [/pet/{petId}, /pet/{petId}/uploadImage, /pet] after/pet, ignoring @cask.post("/pet") + def addPet(request: cask.Request) = { + // auth method petstore_auth : oauth2, keyParamName: + + def failFast = request.queryParams.keySet.contains("failFast") + + val result = for { + petData <- Parsed.eval(PetData.fromJsonString(request.bodyAsString)).mapError(e => s"Error parsing json as Pet from >${request.bodyAsString}< : ${e}") /* not array or map */ + pet <- Parsed.fromTry(petData.validated(failFast)) + result <- Parsed.eval(service.addPet(pet)) + } yield result + + result match { + case Left(error) => cask.Response(error, 500) + case Right(_) => cask.Response("", 200) + } + } + /** Deletes a pet + * + */ + @cask.delete("/pet/:petId") + def deletePet(petId : Long, request: cask.Request) = { + // auth method petstore_auth : oauth2, keyParamName: + + def failFast = request.queryParams.keySet.contains("failFast") + + val result = for { + petId <- Parsed(petId) + apiKey <- request.headerSingleValueOptional("apiKey") + result <- Parsed.eval(service.deletePet(petId, apiKey)) + } yield result + + result match { + case Left(error) => cask.Response(error, 500) + case Right(_) => cask.Response("", 200) + } + } + /** Finds Pets by status + * + */ + // conflicts with [/pet/{petId}, /pet/findByStatus, /pet/findByTags] after/pet, ignoring @cask.get("/pet/findByStatus") + def findPetsByStatus(request: cask.Request, status : Seq[String]) = { + // auth method petstore_auth : oauth2, keyParamName: + + def failFast = request.queryParams.keySet.contains("failFast") + + val result = for { + result <- Parsed.eval(service.findPetsByStatus(status)) + } yield result + + result match { + case Left(error) => cask.Response(error, 500) + case Right(_) => cask.Response("", 200) + } + } + /** Finds Pets by tags + * + */ + // conflicts with [/pet/{petId}, /pet/findByStatus, /pet/findByTags] after/pet, ignoring @cask.get("/pet/findByTags") + def findPetsByTags(request: cask.Request, tags : Seq[String]) = { + // auth method petstore_auth : oauth2, keyParamName: + + def failFast = request.queryParams.keySet.contains("failFast") + + val result = for { + result <- Parsed.eval(service.findPetsByTags(tags)) + } yield result + + result match { + case Left(error) => cask.Response(error, 500) + case Right(_) => cask.Response("", 200) + } + } + /** Find pet by ID + * + */ + // conflicts with [/pet/{petId}, /pet/findByStatus, /pet/findByTags] after/pet, ignoring @cask.get("/pet/:petId") + def getPetById(petId : Long, request: cask.Request) = { + // auth method api_key : apiKey, keyParamName: api_key + + def failFast = request.queryParams.keySet.contains("failFast") + + val result = for { + petId <- Parsed(petId) + result <- Parsed.eval(service.getPetById(petId)) + } yield result + + result match { + case Left(error) => cask.Response(error, 500) + case Right(_) => cask.Response("", 200) + } + } + /** Update an existing pet + * + */ + @cask.put("/pet") + def updatePet(request: cask.Request) = { + // auth method petstore_auth : oauth2, keyParamName: + + def failFast = request.queryParams.keySet.contains("failFast") + + val result = for { + petData <- Parsed.eval(PetData.fromJsonString(request.bodyAsString)).mapError(e => s"Error parsing json as Pet from >${request.bodyAsString}< : ${e}") /* not array or map */ + pet <- Parsed.fromTry(petData.validated(failFast)) + result <- Parsed.eval(service.updatePet(pet)) + } yield result + + result match { + case Left(error) => cask.Response(error, 500) + case Right(_) => cask.Response("", 200) + } + } + /** Updates a pet in the store with form data + * + */ + // conflicts with [/pet/{petId}, /pet/{petId}/uploadImage, /pet] after/pet, ignoring @cask.post("/pet/:petId") + def updatePetWithForm(petId : Long, request: cask.Request) = { + // auth method petstore_auth : oauth2, keyParamName: + + def failFast = request.queryParams.keySet.contains("failFast") + + val result = for { + petId <- Parsed(petId) + name <- request.formSingleValueOptional("name") + status <- request.formSingleValueOptional("status") + result <- Parsed.eval(service.updatePetWithForm(petId, name, status)) + } yield result + + result match { + case Left(error) => cask.Response(error, 500) + case Right(_) => cask.Response("", 200) + } + } + /** uploads an image + * + */ + // conflicts with [/pet/{petId}, /pet/{petId}/uploadImage, /pet] after/pet, ignoring @cask.post("/pet/:petId/uploadImage") + def uploadFile(petId : Long, request: cask.Request) = { + // auth method petstore_auth : oauth2, keyParamName: + + def failFast = request.queryParams.keySet.contains("failFast") + + val result = for { + petId <- Parsed(petId) + additionalMetadata <- request.formSingleValueOptional("additionalMetadata") + file <- request.formValueAsFileOptional("file") + result <- Parsed.eval(service.uploadFile(petId, additionalMetadata, file)) + } yield result + + result match { + case Left(error) => cask.Response(error, 500) + case Right(_) => cask.Response("", 200) + } + } + + initialize() +} diff --git a/samples/server/petstore/scala-cask/src/main/scala/sample/cask/api/PetService.scala b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/api/PetService.scala new file mode 100644 index 00000000000..7bed5032e2c --- /dev/null +++ b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/api/PetService.scala @@ -0,0 +1,84 @@ +//> using scala "3.3.1" +//> using lib "com.lihaoyi::cask:0.8.3" +//> using lib "com.lihaoyi::scalatags:0.12.0" +/** + * 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. + * + * OpenAPI spec version: 1.0.0 + * + * Contact: team@openapitools.org + * + * NOTE: This class is auto generated by OpenAPI Generator. + * + * https://openapi-generator.tech + */ + + +// generated from apiService.mustache +package sample.cask.api + +import _root_.sample.cask.model.ApiResponse +import _root_.java.io.File +import _root_.sample.cask.model.Pet + +import _root_.sample.cask.model.* + +object PetService { + def apply() : PetService = new PetService { + override def addPet(pet : Pet) : Pet = ??? + override def deletePet(petId : Long, apiKey : Option[String]) : Unit = ??? + override def findPetsByStatus(status : Seq[String]) : List[Pet] = ??? + override def findPetsByTags(tags : Seq[String]) : List[Pet] = ??? + override def getPetById(petId : Long) : Pet = ??? + override def updatePet(pet : Pet) : Pet = ??? + override def updatePetWithForm(petId : Long, name : Option[String], status : Option[String]) : Unit = ??? + override def uploadFile(petId : Long, additionalMetadata : Option[String], file : Option[File]) : ApiResponse = ??? + } +} + +/** + * The Pet business-logic + */ +trait PetService { + /** Add a new pet to the store + * + * @return Pet + */ + def addPet(pet : Pet) : Pet + /** Deletes a pet + * + * @return + */ + def deletePet(petId : Long, apiKey : Option[String]) : Unit + /** Finds Pets by status + * + * @return List[Pet] + */ + def findPetsByStatus(status : Seq[String]) : List[Pet] + /** Finds Pets by tags + * + * @return List[Pet] + */ + def findPetsByTags(tags : Seq[String]) : List[Pet] + /** Find pet by ID + * + * @return Pet + */ + def getPetById(petId : Long) : Pet + /** Update an existing pet + * + * @return Pet + */ + def updatePet(pet : Pet) : Pet + /** Updates a pet in the store with form data + * + * @return + */ + def updatePetWithForm(petId : Long, name : Option[String], status : Option[String]) : Unit + /** uploads an image + * + * @return ApiResponse + */ + def uploadFile(petId : Long, additionalMetadata : Option[String], file : Option[File]) : ApiResponse +} diff --git a/samples/server/petstore/scala-cask/src/main/scala/sample/cask/api/StoreRoutes.scala b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/api/StoreRoutes.scala new file mode 100644 index 00000000000..1452575b38c --- /dev/null +++ b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/api/StoreRoutes.scala @@ -0,0 +1,106 @@ +//> using scala "3.3.1" +//> using lib "com.lihaoyi::cask:0.8.3" +//> using lib "com.lihaoyi::scalatags:0.12.0" +/** + * 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. + * + * OpenAPI spec version: 1.0.0 + * + * Contact: team@openapitools.org + * + * NOTE: This class is auto generated by OpenAPI Generator. + * + * https://openapi-generator.tech + */ + + +// this is generated from apiRoutes.mustache +package sample.cask.api + +import sample.cask.model.* + +import upickle.default.{ReadWriter => RW, macroRW} +import upickle.default.* + +import sample.cask.model.Order + +class StoreRoutes(service : StoreService) extends cask.Routes { + + + /** Delete purchase order by ID + * + */ + @cask.delete("/store/order/:orderId") + def deleteOrder(orderId : String, request: cask.Request) = { + + def failFast = request.queryParams.keySet.contains("failFast") + + val result = for { + orderId <- Parsed(orderId) + result <- Parsed.eval(service.deleteOrder(orderId)) + } yield result + + result match { + case Left(error) => cask.Response(error, 500) + case Right(_) => cask.Response("", 200) + } + } + /** Returns pet inventories by status + * + */ + @cask.get("/store/inventory") + def getInventory(request: cask.Request) = { + // auth method api_key : apiKey, keyParamName: api_key + + def failFast = request.queryParams.keySet.contains("failFast") + + val result = for { + result <- Parsed.eval(service.getInventory()) + } yield result + + result match { + case Left(error) => cask.Response(error, 500) + case Right(_) => cask.Response("", 200) + } + } + /** Find purchase order by ID + * + */ + @cask.get("/store/order/:orderId") + def getOrderById(orderId : Long, request: cask.Request) = { + + def failFast = request.queryParams.keySet.contains("failFast") + + val result = for { + orderId <- Parsed(orderId) + result <- Parsed.eval(service.getOrderById(orderId)) + } yield result + + result match { + case Left(error) => cask.Response(error, 500) + case Right(_) => cask.Response("", 200) + } + } + /** Place an order for a pet + * + */ + @cask.post("/store/order") + def placeOrder(request: cask.Request) = { + + def failFast = request.queryParams.keySet.contains("failFast") + + val result = for { + orderData <- Parsed.eval(OrderData.fromJsonString(request.bodyAsString)).mapError(e => s"Error parsing json as Order from >${request.bodyAsString}< : ${e}") /* not array or map */ + order <- Parsed.fromTry(orderData.validated(failFast)) + result <- Parsed.eval(service.placeOrder(order)) + } yield result + + result match { + case Left(error) => cask.Response(error, 500) + case Right(_) => cask.Response("", 200) + } + } + + initialize() +} diff --git a/samples/server/petstore/scala-cask/src/main/scala/sample/cask/api/StoreService.scala b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/api/StoreService.scala new file mode 100644 index 00000000000..231f03fc3fe --- /dev/null +++ b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/api/StoreService.scala @@ -0,0 +1,58 @@ +//> using scala "3.3.1" +//> using lib "com.lihaoyi::cask:0.8.3" +//> using lib "com.lihaoyi::scalatags:0.12.0" +/** + * 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. + * + * OpenAPI spec version: 1.0.0 + * + * Contact: team@openapitools.org + * + * NOTE: This class is auto generated by OpenAPI Generator. + * + * https://openapi-generator.tech + */ + + +// generated from apiService.mustache +package sample.cask.api + +import _root_.sample.cask.model.Order + +import _root_.sample.cask.model.* + +object StoreService { + def apply() : StoreService = new StoreService { + override def deleteOrder(orderId : String) : Unit = ??? + override def getInventory() : Map[String, Int] = ??? + override def getOrderById(orderId : Long) : Order = ??? + override def placeOrder(order : Order) : Order = ??? + } +} + +/** + * The Store business-logic + */ +trait StoreService { + /** Delete purchase order by ID + * + * @return + */ + def deleteOrder(orderId : String) : Unit + /** Returns pet inventories by status + * + * @return Map[String, Int] + */ + def getInventory() : Map[String, Int] + /** Find purchase order by ID + * + * @return Order + */ + def getOrderById(orderId : Long) : Order + /** Place an order for a pet + * + * @return Order + */ + def placeOrder(order : Order) : Order +} diff --git a/samples/server/petstore/scala-cask/src/main/scala/sample/cask/api/UserRoutes.scala b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/api/UserRoutes.scala new file mode 100644 index 00000000000..7987883da6b --- /dev/null +++ b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/api/UserRoutes.scala @@ -0,0 +1,195 @@ +//> using scala "3.3.1" +//> using lib "com.lihaoyi::cask:0.8.3" +//> using lib "com.lihaoyi::scalatags:0.12.0" +/** + * 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. + * + * OpenAPI spec version: 1.0.0 + * + * Contact: team@openapitools.org + * + * NOTE: This class is auto generated by OpenAPI Generator. + * + * https://openapi-generator.tech + */ + + +// this is generated from apiRoutes.mustache +package sample.cask.api + +import sample.cask.model.* + +import upickle.default.{ReadWriter => RW, macroRW} +import upickle.default.* + +import java.time.OffsetDateTime +import sample.cask.model.User + +class UserRoutes(service : UserService) extends cask.Routes { + + // route group for routeWorkAroundForGETUser + @cask.get("/user", true) + def routeWorkAroundForGETUser(request: cask.Request,username : Option[String] = None,password : Option[String] = None) = { + request.remainingPathSegments match { + case Seq("login") => loginUser(request,username.getOrElse(""), password.getOrElse("")) + case Seq("logout") => logoutUser(request) + case Seq(username) => getUserByName(username,request) + case _ => cask.Response("Not Found", statusCode = 404) + } + } + + /** Create user + * + */ + @cask.post("/user") + def createUser(request: cask.Request) = { + // auth method api_key : apiKey, keyParamName: api_key + + def failFast = request.queryParams.keySet.contains("failFast") + + val result = for { + userData <- Parsed.eval(UserData.fromJsonString(request.bodyAsString)).mapError(e => s"Error parsing json as User from >${request.bodyAsString}< : ${e}") /* not array or map */ + user <- Parsed.fromTry(userData.validated(failFast)) + result <- Parsed.eval(service.createUser(user)) + } yield result + + result match { + case Left(error) => cask.Response(error, 500) + case Right(_) => cask.Response("", 200) + } + } + /** Creates list of users with given input array + * + */ + @cask.post("/user/createWithArray") + def createUsersWithArrayInput(request: cask.Request) = { + // auth method api_key : apiKey, keyParamName: api_key + + def failFast = request.queryParams.keySet.contains("failFast") + + val result = for { + user <- Parsed.fromTry(UserData.manyFromJsonStringValidated(request.bodyAsString)).mapError(e => s"Error parsing json as an array of User from >${request.bodyAsString}< : ${e}") /* array */ + result <- Parsed.eval(service.createUsersWithArrayInput(user)) + } yield result + + result match { + case Left(error) => cask.Response(error, 500) + case Right(_) => cask.Response("", 200) + } + } + /** Creates list of users with given input array + * + */ + @cask.post("/user/createWithList") + def createUsersWithListInput(request: cask.Request) = { + // auth method api_key : apiKey, keyParamName: api_key + + def failFast = request.queryParams.keySet.contains("failFast") + + val result = for { + user <- Parsed.fromTry(UserData.manyFromJsonStringValidated(request.bodyAsString)).mapError(e => s"Error parsing json as an array of User from >${request.bodyAsString}< : ${e}") /* array */ + result <- Parsed.eval(service.createUsersWithListInput(user)) + } yield result + + result match { + case Left(error) => cask.Response(error, 500) + case Right(_) => cask.Response("", 200) + } + } + /** Delete user + * + */ + @cask.delete("/user/:username") + def deleteUser(username : String, request: cask.Request) = { + // auth method api_key : apiKey, keyParamName: api_key + + def failFast = request.queryParams.keySet.contains("failFast") + + val result = for { + username <- Parsed(username) + result <- Parsed.eval(service.deleteUser(username)) + } yield result + + result match { + case Left(error) => cask.Response(error, 500) + case Right(_) => cask.Response("", 200) + } + } + /** Get user by user name + * + */ + // conflicts with [/user/{username}, /user/login, /user/logout] after/user, ignoring @cask.get("/user/:username") + def getUserByName(username : String, request: cask.Request) = { + + def failFast = request.queryParams.keySet.contains("failFast") + + val result = for { + username <- Parsed(username) + result <- Parsed.eval(service.getUserByName(username)) + } yield result + + result match { + case Left(error) => cask.Response(error, 500) + case Right(_) => cask.Response("", 200) + } + } + /** Logs user into the system + * + */ + // conflicts with [/user/{username}, /user/login, /user/logout] after/user, ignoring @cask.get("/user/login") + def loginUser(request: cask.Request, username : String, password : String) = { + + def failFast = request.queryParams.keySet.contains("failFast") + + val result = for { + result <- Parsed.eval(service.loginUser(username, password)) + } yield result + + result match { + case Left(error) => cask.Response(error, 500) + case Right(_) => cask.Response("", 200) + } + } + /** Logs out current logged in user session + * + */ + // conflicts with [/user/{username}, /user/login, /user/logout] after/user, ignoring @cask.get("/user/logout") + def logoutUser(request: cask.Request) = { + // auth method api_key : apiKey, keyParamName: api_key + + def failFast = request.queryParams.keySet.contains("failFast") + + val result = for { + result <- Parsed.eval(service.logoutUser()) + } yield result + + result match { + case Left(error) => cask.Response(error, 500) + case Right(_) => cask.Response("", 200) + } + } + /** Updated user + * + */ + @cask.put("/user/:username") + def updateUser(username : String, request: cask.Request) = { + // auth method api_key : apiKey, keyParamName: api_key + + def failFast = request.queryParams.keySet.contains("failFast") + + val result = for { + username <- Parsed(username) + userData <- Parsed.eval(UserData.fromJsonString(request.bodyAsString)).mapError(e => s"Error parsing json as User from >${request.bodyAsString}< : ${e}") /* not array or map */ + user <- Parsed.fromTry(userData.validated(failFast)) + result <- Parsed.eval(service.updateUser(username, user)) + } yield result + + result match { + case Left(error) => cask.Response(error, 500) + case Right(_) => cask.Response("", 200) + } + } + + initialize() +} diff --git a/samples/server/petstore/scala-cask/src/main/scala/sample/cask/api/UserService.scala b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/api/UserService.scala new file mode 100644 index 00000000000..4872f31e3c7 --- /dev/null +++ b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/api/UserService.scala @@ -0,0 +1,83 @@ +//> using scala "3.3.1" +//> using lib "com.lihaoyi::cask:0.8.3" +//> using lib "com.lihaoyi::scalatags:0.12.0" +/** + * 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. + * + * OpenAPI spec version: 1.0.0 + * + * Contact: team@openapitools.org + * + * NOTE: This class is auto generated by OpenAPI Generator. + * + * https://openapi-generator.tech + */ + + +// generated from apiService.mustache +package sample.cask.api + +import _root_.java.time.OffsetDateTime +import _root_.sample.cask.model.User + +import _root_.sample.cask.model.* + +object UserService { + def apply() : UserService = new UserService { + override def createUser(user : User) : Unit = ??? + override def createUsersWithArrayInput(user : Seq[User]) : Unit = ??? + override def createUsersWithListInput(user : Seq[User]) : Unit = ??? + override def deleteUser(username : String) : Unit = ??? + override def getUserByName(username : String) : User = ??? + override def loginUser(username : String, password : String) : String = ??? + override def logoutUser() : Unit = ??? + override def updateUser(username : String, user : User) : Unit = ??? + } +} + +/** + * The User business-logic + */ +trait UserService { + /** Create user + * + * @return + */ + def createUser(user : User) : Unit + /** Creates list of users with given input array + * + * @return + */ + def createUsersWithArrayInput(user : Seq[User]) : Unit + /** Creates list of users with given input array + * + * @return + */ + def createUsersWithListInput(user : Seq[User]) : Unit + /** Delete user + * + * @return + */ + def deleteUser(username : String) : Unit + /** Get user by user name + * + * @return User + */ + def getUserByName(username : String) : User + /** Logs user into the system + * + * @return String + */ + def loginUser(username : String, password : String) : String + /** Logs out current logged in user session + * + * @return + */ + def logoutUser() : Unit + /** Updated user + * + * @return + */ + def updateUser(username : String, user : User) : Unit +} diff --git a/samples/server/petstore/scala-cask/src/main/scala/sample/cask/api/package.scala b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/api/package.scala new file mode 100644 index 00000000000..ab25a84875a --- /dev/null +++ b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/api/package.scala @@ -0,0 +1,167 @@ +/** + * 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. + * + * OpenAPI spec version: 1.0.0 + * + * Contact: team@openapitools.org + * + * NOTE: This class is auto generated by OpenAPI Generator. + * + * https://openapi-generator.tech + */ + +package sample.cask.api + + +import cask.FormEntry +import io.undertow.server.handlers.form.{FormData, FormParserFactory} + +import java.io.File +import scala.jdk.CollectionConverters.* +import java.time.LocalDate +import java.util.UUID +import scala.reflect.ClassTag +import scala.util.* + +// needed for BigDecimal params +given cask.endpoints.QueryParamReader.SimpleParam[BigDecimal](BigDecimal.apply) + +// a parsed value from an HTTP request +opaque type Parsed[A] = Either[String, A] + +object Parsed { + def apply[A](value: A): Parsed[A] = Right(value) + + def eval[A](value: => A): Parsed[A] = Try(value) match { + case Failure(exp) => Left(s"Error: ${exp.getMessage}") + case Success(ok) => Right(ok) + } + + def fromTry[A](value : Try[A]) = value match { + case Failure(err) => Left(err.getMessage) + case Success(ok) => Right(ok) + } + + def fail[A](msg: String): Parsed[A] = Left(msg) + + def optionalValue(map: Map[String, collection.Seq[String]], key: String): Parsed[Option[String]] = { + map.get(key) match { + case Some(Seq(only: String)) => Parsed(Option(only)) + case Some(Seq()) => Parsed(None) + case Some(many) => Parsed.fail(s"${many.size} values set for '$key'") + case None => Parsed(None) + } + } + + def singleValue(map: Map[String, collection.Seq[String]], key : String): Parsed[String] = { + map.get(key) match { + case Some(Seq(only : String)) => Parsed(only) + case Some(Seq()) => Parsed("") + case Some(many) => Parsed.fail(s"${many.size} values set for '$key'") + case None => Parsed.fail(s"required '$key' was not set") + } + } + + def manyValues(map: Map[String, collection.Seq[String]], key : String, required: Boolean): Parsed[List[String]] = { + map.get(key) match { + case Some(many) => Parsed(many.toList) + case None if required => Parsed.fail(s"required '$key' was not set") + case None => Parsed(Nil) + } + } +} + +extension[A] (parsed: Parsed[A]) { + def toEither: Either[String, A] = parsed + + def asLong(using ev : A =:= String): Parsed[Long] = as[Long](_.toLongOption) + def asBoolean(using ev : A =:= String): Parsed[Boolean] = as[Boolean](_.toBooleanOption) + def asInt(using ev : A =:= String): Parsed[Int] = as[Int](_.toIntOption) + def asByte(using ev : A =:= String): Parsed[Byte] = as[Byte](_.toByteOption) + def asUuid(using ev : A =:= String): Parsed[UUID] = as[UUID](x => Try(UUID.fromString(x)).toOption) + def asFloat(using ev : A =:= String): Parsed[Float] = as[Float](_.toFloatOption) + def asDouble(using ev : A =:= String): Parsed[Double] = as[Double](_.toDoubleOption) + def asDate(using ev: A =:= String): Parsed[LocalDate] = as[LocalDate](x => Try(LocalDate.parse(x)).toOption) + + private def as[B : ClassTag](f : String => Option[B])(using ev : A =:= String): Parsed[B] = parsed.flatMap { str => + f(ev(str)) match { + case None => Parsed.fail(s"'$str' cannot be parsed as a ${implicitly[ClassTag[B]].runtimeClass}") + case Some(x) => Parsed(x) + } + } + + + def mapError(f : String => String) : Parsed[A] = parsed match { + case Left(msg) => Left(f(msg)) + case right => right + } + + def map[B](f: A => B): Parsed[B] = parsed match { + case Right(value) => Right(f(value)) + case Left(err) => Left(err) + } + def flatMap[B](f : A => Parsed[B]): Parsed[B] = parsed match { + case Right(value) => f(value) + case Left(err) => Left(err) + } +} + + +extension (request: cask.Request) { + + def formSingleValueRequired(paramName: String): Parsed[String] = { + val data = formDataForKey(paramName).map(_.getValue).toSeq + Parsed.singleValue(Map(paramName -> data), paramName) + } + def formSingleValueOptional(paramName: String): Parsed[Option[String]] = { + val data = formDataForKey(paramName).map(_.getValue).toSeq + Parsed.optionalValue(Map(paramName -> data), paramName) + } + + def formValueAsFileOptional(paramName: String): Parsed[Option[File]] = { + val data = formDataForKey(paramName) + data.map(_.getFileItem.getFile.toFile).toSeq match { + case Seq() => Parsed(None) + case Seq(file) => Parsed(Option(file)) + case many => Parsed.fail(s"${many.size} file values set for '$paramName'") + } + } + + def formValueAsFileRequired(paramName: String): Parsed[File] = { + val data = formDataForKey(paramName) + data.map(_.getFileItem.getFile.toFile).toSeq match { + case Seq() => Parsed.fail(s"No file form data was submitted for '$paramName'. The submitted form keys were: ${formDataKeys.mkString(",")}") + case Seq(file) => Parsed(file) + case many => Parsed.fail(s"${many.size} file values set for '$paramName'") + } + } + + def formManyValues(paramName: String, required: Boolean): Parsed[List[String]] = { + val data = formDataForKey(paramName).map(_.getValue).toSeq + Parsed.manyValues(Map(paramName -> data), paramName, required) + } + + def formData: FormData = FormParserFactory.builder().build().createParser(request.exchange).parseBlocking() + + def formDataKeys: Iterator[String] = formData.iterator().asScala + + def formDataForKey(paramName: String): Iterable[FormData.FormValue] = formData.get(paramName).asScala + + def headerSingleValueOptional(paramName: String): Parsed[Option[String]] = Parsed.optionalValue(request.headers, paramName) + def headerSingleValueRequired(paramName: String): Parsed[String] = Parsed.singleValue(request.headers, paramName) + + def headerManyValues(paramName: String, required: Boolean): Parsed[List[String]] = Parsed.manyValues(request.headers, paramName, required) + + def bodyAsString = new String(request.readAllBytes(), "UTF-8") + + def pathValue(index: Int, paramName: String, required : Boolean): Parsed[String] = { + request + .remainingPathSegments + .lift(index) match { + case Some(value) => Right(value) + case None if required => Left(s"'$paramName'' is a required path parameter at path position $index") + case None => Right("") + } + } +} diff --git a/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/ApiResponse.scala b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/ApiResponse.scala new file mode 100644 index 00000000000..ff5d8abaa14 --- /dev/null +++ b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/ApiResponse.scala @@ -0,0 +1,55 @@ +/** + * 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. + * + * OpenAPI spec version: 1.0.0 + * + * Contact: team@openapitools.org + * + * NOTE: This class is auto generated by OpenAPI Generator. + * + * https://openapi-generator.tech + */ + +// this model was generated using model.mustache +package sample.cask.model +import scala.util.control.NonFatal + +// see https://com-lihaoyi.github.io/upickle/ +import upickle.default.{ReadWriter => RW, macroRW} +import upickle.default.* + +case class ApiResponse( + code: Option[Int] = None , + + `type`: Option[String] = None , + + message: Option[String] = None + + ) { + + def asJson: String = asData.asJson + + def asData : ApiResponseData = { + ApiResponseData( + code = code.getOrElse(0), + `type` = `type`.getOrElse(""), + message = message.getOrElse("") + ) + } + +} + +object ApiResponse{ + + given RW[ApiResponse] = ApiResponseData.readWriter.bimap[ApiResponse](_.asData, _.asModel) + + enum Fields(fieldName : String) extends Field(fieldName) { + case code extends Fields("code") + case `type` extends Fields("`type`") + case message extends Fields("message") + } + + +} + diff --git a/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/ApiResponseData.scala b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/ApiResponseData.scala new file mode 100644 index 00000000000..b93b7eb44ef --- /dev/null +++ b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/ApiResponseData.scala @@ -0,0 +1,171 @@ +/** + * 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. + * + * OpenAPI spec version: 1.0.0 + * + * Contact: team@openapitools.org + * + * NOTE: This class is auto generated by OpenAPI Generator. + * + * https://openapi-generator.tech + */ + +// this model was generated using modelData.mustache +package sample.cask.model +import scala.util.control.NonFatal +import scala.util.* + +// see https://com-lihaoyi.github.io/upickle/ +import upickle.default.{ReadWriter => RW, macroRW} +import upickle.default.* + +/** ApiResponseData a data transfer object, primarily for simple json serialisation. + * It has no validation - there may be nulls, values out of range, etc + */ +case class ApiResponseData( + code: Int = 0 , + + `type`: String = "" , + + message: String = "" + + ) { + + def asJson: String = write(this) + + def validationErrors(path : Seq[Field], failFast : Boolean) : Seq[ValidationError] = { + val errors = scala.collection.mutable.ListBuffer[ValidationError]() + // ================== + // code + + + + + + + + + + + + + + + + + // ================== + // `type` + + + + + + + + + + + + + + + + + // ================== + // message + + + + + + + + + + + + + + + + + errors.toSeq + } + + def validated(failFast : Boolean = false) : scala.util.Try[ApiResponse] = { + validationErrors(Vector(), failFast) match { + case Seq() => Success(asModel) + case first +: theRest => Failure(ValidationErrors(first, theRest)) + } + } + + /** use 'validated' to check validation */ + def asModel : ApiResponse = { + ApiResponse( + code = Option( + code + ) + , + `type` = Option( + `type` + ) + , + message = Option( + message + ) + + ) + } +} + +object ApiResponseData { + + given readWriter : RW[ApiResponseData] = macroRW + + def fromJsonString(jason : String) : ApiResponseData = try { + read[ApiResponseData](jason) + } catch { + case NonFatal(e) => sys.error(s"Error parsing json '$jason': $e") + } + + def manyFromJsonString(jason : String) : Seq[ApiResponseData] = try { + read[List[ApiResponseData]](jason) + } catch { + case NonFatal(e) => sys.error(s"Error parsing json '$jason' as list: $e") + } + + def manyFromJsonStringValidated(jason : String, failFast : Boolean = false) : Try[Seq[ApiResponse]] = { + Try(manyFromJsonString(jason)).flatMap { list => + list.zipWithIndex.foldLeft(Try(Vector[ApiResponse]())) { + case (Success(list), (next, i)) => + next.validated(failFast) match { + case Success(ok) => Success(list :+ ok) + case Failure(err) => Failure(new Exception(s"Validation error on element $i: ${err.getMessage}", err)) + } + case (fail, _) => fail + } + } + } + + def mapFromJsonString(jason : String) : Map[String, ApiResponseData] = try { + read[Map[String, ApiResponseData]](jason) + } catch { + case NonFatal(e) => sys.error(s"Error parsing json '$jason' as map: $e") + } + + + def mapFromJsonStringValidated(jason : String, failFast : Boolean = false) : Try[Map[String, ApiResponse]] = { + Try(mapFromJsonString(jason)).flatMap { map => + map.foldLeft(Try(Map[String, ApiResponse]())) { + case (Success(map), (key, next)) => + next.validated(failFast) match { + case Success(ok) => Success(map.updated(key, ok)) + case Failure(err) => Failure(new Exception(s"Validation error on element $key: ${err.getMessage}", err)) + } + case (fail, _) => fail + } + } + } +} + diff --git a/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/Category.scala b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/Category.scala new file mode 100644 index 00000000000..d0bf01a2861 --- /dev/null +++ b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/Category.scala @@ -0,0 +1,51 @@ +/** + * 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. + * + * OpenAPI spec version: 1.0.0 + * + * Contact: team@openapitools.org + * + * NOTE: This class is auto generated by OpenAPI Generator. + * + * https://openapi-generator.tech + */ + +// this model was generated using model.mustache +package sample.cask.model +import scala.util.control.NonFatal + +// see https://com-lihaoyi.github.io/upickle/ +import upickle.default.{ReadWriter => RW, macroRW} +import upickle.default.* + +case class Category( + id: Option[Long] = None , + + name: Option[String] = None + + ) { + + def asJson: String = asData.asJson + + def asData : CategoryData = { + CategoryData( + id = id.getOrElse(0), + name = name.getOrElse("") + ) + } + +} + +object Category{ + + given RW[Category] = CategoryData.readWriter.bimap[Category](_.asData, _.asModel) + + enum Fields(fieldName : String) extends Field(fieldName) { + case id extends Fields("id") + case name extends Fields("name") + } + + +} + diff --git a/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/CategoryData.scala b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/CategoryData.scala new file mode 100644 index 00000000000..77a834683a8 --- /dev/null +++ b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/CategoryData.scala @@ -0,0 +1,153 @@ +/** + * 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. + * + * OpenAPI spec version: 1.0.0 + * + * Contact: team@openapitools.org + * + * NOTE: This class is auto generated by OpenAPI Generator. + * + * https://openapi-generator.tech + */ + +// this model was generated using modelData.mustache +package sample.cask.model +import scala.util.control.NonFatal +import scala.util.* + +// see https://com-lihaoyi.github.io/upickle/ +import upickle.default.{ReadWriter => RW, macroRW} +import upickle.default.* + +/** CategoryData a data transfer object, primarily for simple json serialisation. + * It has no validation - there may be nulls, values out of range, etc + */ +case class CategoryData( + id: Long = 0 , + + name: String = "" + + ) { + + def asJson: String = write(this) + + def validationErrors(path : Seq[Field], failFast : Boolean) : Seq[ValidationError] = { + val errors = scala.collection.mutable.ListBuffer[ValidationError]() + // ================== + // id + + + + + + + + + + + + + + + + + // ================== + // name + // validate against pattern '^[a-zA-Z0-9]+[a-zA-Z0-9\\.\\-_]*[a-zA-Z0-9]+$' + if (errors.isEmpty || !failFast) { + val regex = """^[a-zA-Z0-9]+[a-zA-Z0-9\\.\\-_]*[a-zA-Z0-9]+$""" + if name == null || !regex.r.matches(name) then + errors += ValidationError(path :+ Category.Fields.name, s"value '$name' doesn't match pattern $regex") + } + + + + + + + + + + + + + + + + + errors.toSeq + } + + def validated(failFast : Boolean = false) : scala.util.Try[Category] = { + validationErrors(Vector(), failFast) match { + case Seq() => Success(asModel) + case first +: theRest => Failure(ValidationErrors(first, theRest)) + } + } + + /** use 'validated' to check validation */ + def asModel : Category = { + Category( + id = Option( + id + ) + , + name = Option( + name + ) + + ) + } +} + +object CategoryData { + + given readWriter : RW[CategoryData] = macroRW + + def fromJsonString(jason : String) : CategoryData = try { + read[CategoryData](jason) + } catch { + case NonFatal(e) => sys.error(s"Error parsing json '$jason': $e") + } + + def manyFromJsonString(jason : String) : Seq[CategoryData] = try { + read[List[CategoryData]](jason) + } catch { + case NonFatal(e) => sys.error(s"Error parsing json '$jason' as list: $e") + } + + def manyFromJsonStringValidated(jason : String, failFast : Boolean = false) : Try[Seq[Category]] = { + Try(manyFromJsonString(jason)).flatMap { list => + list.zipWithIndex.foldLeft(Try(Vector[Category]())) { + case (Success(list), (next, i)) => + next.validated(failFast) match { + case Success(ok) => Success(list :+ ok) + case Failure(err) => Failure(new Exception(s"Validation error on element $i: ${err.getMessage}", err)) + } + case (fail, _) => fail + } + } + } + + def mapFromJsonString(jason : String) : Map[String, CategoryData] = try { + read[Map[String, CategoryData]](jason) + } catch { + case NonFatal(e) => sys.error(s"Error parsing json '$jason' as map: $e") + } + + + def mapFromJsonStringValidated(jason : String, failFast : Boolean = false) : Try[Map[String, Category]] = { + Try(mapFromJsonString(jason)).flatMap { map => + map.foldLeft(Try(Map[String, Category]())) { + case (Success(map), (key, next)) => + next.validated(failFast) match { + case Success(ok) => Success(map.updated(key, ok)) + case Failure(err) => Failure(new Exception(s"Validation error on element $key: ${err.getMessage}", err)) + } + case (fail, _) => fail + } + } + } +} + diff --git a/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/Order.scala b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/Order.scala new file mode 100644 index 00000000000..85bda97b815 --- /dev/null +++ b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/Order.scala @@ -0,0 +1,76 @@ +/** + * 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. + * + * OpenAPI spec version: 1.0.0 + * + * Contact: team@openapitools.org + * + * NOTE: This class is auto generated by OpenAPI Generator. + * + * https://openapi-generator.tech + */ + +// this model was generated using model.mustache +package sample.cask.model +import java.time.OffsetDateTime +import scala.util.control.NonFatal + +// see https://com-lihaoyi.github.io/upickle/ +import upickle.default.{ReadWriter => RW, macroRW} +import upickle.default.* + +case class Order( + id: Option[Long] = None , + + petId: Option[Long] = None , + + quantity: Option[Int] = None , + + shipDate: Option[OffsetDateTime] = None , + + /* Order Status */ + status: Option[Order.StatusEnum] = None , + + complete: Option[Boolean] = None + + ) { + + def asJson: String = asData.asJson + + def asData : OrderData = { + OrderData( + id = id.getOrElse(0), + petId = petId.getOrElse(0), + quantity = quantity.getOrElse(0), + shipDate = shipDate.getOrElse(null), + status = status.getOrElse(null), + complete = complete.getOrElse(false) + ) + } + +} + +object Order{ + + given RW[Order] = OrderData.readWriter.bimap[Order](_.asData, _.asModel) + + enum Fields(fieldName : String) extends Field(fieldName) { + case id extends Fields("id") + case petId extends Fields("petId") + case quantity extends Fields("quantity") + case shipDate extends Fields("shipDate") + case status extends Fields("status") + case complete extends Fields("complete") + } + + // baseName=status + // nameInCamelCase = status + enum StatusEnum derives ReadWriter { + case placed + case approved + case delivered + } + +} + diff --git a/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/OrderData.scala b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/OrderData.scala new file mode 100644 index 00000000000..0de58a7f0b3 --- /dev/null +++ b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/OrderData.scala @@ -0,0 +1,245 @@ +/** + * 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. + * + * OpenAPI spec version: 1.0.0 + * + * Contact: team@openapitools.org + * + * NOTE: This class is auto generated by OpenAPI Generator. + * + * https://openapi-generator.tech + */ + +// this model was generated using modelData.mustache +package sample.cask.model +import java.time.OffsetDateTime +import scala.util.control.NonFatal +import scala.util.* + +// see https://com-lihaoyi.github.io/upickle/ +import upickle.default.{ReadWriter => RW, macroRW} +import upickle.default.* + +/** OrderData a data transfer object, primarily for simple json serialisation. + * It has no validation - there may be nulls, values out of range, etc + */ +case class OrderData( + id: Long = 0 , + + petId: Long = 0 , + + quantity: Int = 0 , + + shipDate: OffsetDateTime = null , + + /* Order Status */ + status: Order.StatusEnum = null , + + complete: Boolean = false + + ) { + + def asJson: String = write(this) + + def validationErrors(path : Seq[Field], failFast : Boolean) : Seq[ValidationError] = { + val errors = scala.collection.mutable.ListBuffer[ValidationError]() + // ================== + // id + + + + + + + + + + + + + + + + + // ================== + // petId + + + + + + + + + + + + + + + + + // ================== + // quantity + + + + + + + + + + + + + + + + + // ================== + // shipDate + + + + + + + + + + + + + + + + + // ================== + // status + + + + + + + + + + + + + + + + + // ================== + // complete + + + + + + + + + + + + + + + + + errors.toSeq + } + + def validated(failFast : Boolean = false) : scala.util.Try[Order] = { + validationErrors(Vector(), failFast) match { + case Seq() => Success(asModel) + case first +: theRest => Failure(ValidationErrors(first, theRest)) + } + } + + /** use 'validated' to check validation */ + def asModel : Order = { + Order( + id = Option( + id + ) + , + petId = Option( + petId + ) + , + quantity = Option( + quantity + ) + , + shipDate = Option( + shipDate + ) + , + status = Option( + status + ) + , + complete = Option( + complete + ) + + ) + } +} + +object OrderData { + + given readWriter : RW[OrderData] = macroRW + + def fromJsonString(jason : String) : OrderData = try { + read[OrderData](jason) + } catch { + case NonFatal(e) => sys.error(s"Error parsing json '$jason': $e") + } + + def manyFromJsonString(jason : String) : Seq[OrderData] = try { + read[List[OrderData]](jason) + } catch { + case NonFatal(e) => sys.error(s"Error parsing json '$jason' as list: $e") + } + + def manyFromJsonStringValidated(jason : String, failFast : Boolean = false) : Try[Seq[Order]] = { + Try(manyFromJsonString(jason)).flatMap { list => + list.zipWithIndex.foldLeft(Try(Vector[Order]())) { + case (Success(list), (next, i)) => + next.validated(failFast) match { + case Success(ok) => Success(list :+ ok) + case Failure(err) => Failure(new Exception(s"Validation error on element $i: ${err.getMessage}", err)) + } + case (fail, _) => fail + } + } + } + + def mapFromJsonString(jason : String) : Map[String, OrderData] = try { + read[Map[String, OrderData]](jason) + } catch { + case NonFatal(e) => sys.error(s"Error parsing json '$jason' as map: $e") + } + + + def mapFromJsonStringValidated(jason : String, failFast : Boolean = false) : Try[Map[String, Order]] = { + Try(mapFromJsonString(jason)).flatMap { map => + map.foldLeft(Try(Map[String, Order]())) { + case (Success(map), (key, next)) => + next.validated(failFast) match { + case Success(ok) => Success(map.updated(key, ok)) + case Failure(err) => Failure(new Exception(s"Validation error on element $key: ${err.getMessage}", err)) + } + case (fail, _) => fail + } + } + } +} + diff --git a/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/Pet.scala b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/Pet.scala new file mode 100644 index 00000000000..068f593303c --- /dev/null +++ b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/Pet.scala @@ -0,0 +1,77 @@ +/** + * 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. + * + * OpenAPI spec version: 1.0.0 + * + * Contact: team@openapitools.org + * + * NOTE: This class is auto generated by OpenAPI Generator. + * + * https://openapi-generator.tech + */ + +// this model was generated using model.mustache +package sample.cask.model +import sample.cask.model.Category +import sample.cask.model.Tag +import scala.util.control.NonFatal + +// see https://com-lihaoyi.github.io/upickle/ +import upickle.default.{ReadWriter => RW, macroRW} +import upickle.default.* + +case class Pet( + id: Option[Long] = None , + + category: Option[Category] = None , + + name: String, + + photoUrls: Seq[String], + + tags: Seq[Tag] = Nil , + + /* pet status in the store */ + status: Option[Pet.StatusEnum] = None + + ) { + + def asJson: String = asData.asJson + + def asData : PetData = { + PetData( + id = id.getOrElse(0), + category = category.map(_.asData).getOrElse(null), + name = name, + photoUrls = photoUrls, + tags = tags.map(_.asData), + status = status.getOrElse(null) + ) + } + +} + +object Pet{ + + given RW[Pet] = PetData.readWriter.bimap[Pet](_.asData, _.asModel) + + enum Fields(fieldName : String) extends Field(fieldName) { + case id extends Fields("id") + case category extends Fields("category") + case name extends Fields("name") + case photoUrls extends Fields("photoUrls") + case tags extends Fields("tags") + case status extends Fields("status") + } + + // baseName=status + // nameInCamelCase = status + enum StatusEnum derives ReadWriter { + case available + case pending + case sold + } + +} + diff --git a/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/PetData.scala b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/PetData.scala new file mode 100644 index 00000000000..db6a3e1a512 --- /dev/null +++ b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/PetData.scala @@ -0,0 +1,262 @@ +/** + * 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. + * + * OpenAPI spec version: 1.0.0 + * + * Contact: team@openapitools.org + * + * NOTE: This class is auto generated by OpenAPI Generator. + * + * https://openapi-generator.tech + */ + +// this model was generated using modelData.mustache +package sample.cask.model +import sample.cask.model.Category +import sample.cask.model.Tag +import scala.util.control.NonFatal +import scala.util.* + +// see https://com-lihaoyi.github.io/upickle/ +import upickle.default.{ReadWriter => RW, macroRW} +import upickle.default.* + +/** PetData a data transfer object, primarily for simple json serialisation. + * It has no validation - there may be nulls, values out of range, etc + */ +case class PetData( + id: Long = 0 , + + category: CategoryData = null , + + name: String, + + photoUrls: Seq[String], + + tags: Seq[TagData] = Nil , + + /* pet status in the store */ + status: Pet.StatusEnum = null + + ) { + + def asJson: String = write(this) + + def validationErrors(path : Seq[Field], failFast : Boolean) : Seq[ValidationError] = { + val errors = scala.collection.mutable.ListBuffer[ValidationError]() + // ================== + // id + + + + + + + + + + + + + + + + + // ================== + // category + + + + + + + + + + + + + + + + // validating category + if (errors.isEmpty || !failFast) { + if category != null then errors ++= category.validationErrors(path :+ Pet.Fields.category, failFast) + } + + // ================== + // name + + + + + + + + + + + + + + + + + // ================== + // photoUrls + + + + + + + + + + + + + + + + + // ================== + // tags + + + + + + + + + + + + + + + + if (errors.isEmpty || !failFast) { + if (tags != null) { + tags.zipWithIndex.foreach { + case (value, i) if errors.isEmpty || !failFast => + errors ++= value.validationErrors( + path :+ Pet.Fields.tags :+ Field(i.toString), + failFast) + case (value, i) => + } + } + } + + + // ================== + // status + + + + + + + + + + + + + + + + + errors.toSeq + } + + def validated(failFast : Boolean = false) : scala.util.Try[Pet] = { + validationErrors(Vector(), failFast) match { + case Seq() => Success(asModel) + case first +: theRest => Failure(ValidationErrors(first, theRest)) + } + } + + /** use 'validated' to check validation */ + def asModel : Pet = { + Pet( + id = Option( + id + ) + , + category = Option( + category + ) + .map(_.asModel), + name = + name + + , + photoUrls = + photoUrls + + , + tags = + tags + + .map(_.asModel), + status = Option( + status + ) + + ) + } +} + +object PetData { + + given readWriter : RW[PetData] = macroRW + + def fromJsonString(jason : String) : PetData = try { + read[PetData](jason) + } catch { + case NonFatal(e) => sys.error(s"Error parsing json '$jason': $e") + } + + def manyFromJsonString(jason : String) : Seq[PetData] = try { + read[List[PetData]](jason) + } catch { + case NonFatal(e) => sys.error(s"Error parsing json '$jason' as list: $e") + } + + def manyFromJsonStringValidated(jason : String, failFast : Boolean = false) : Try[Seq[Pet]] = { + Try(manyFromJsonString(jason)).flatMap { list => + list.zipWithIndex.foldLeft(Try(Vector[Pet]())) { + case (Success(list), (next, i)) => + next.validated(failFast) match { + case Success(ok) => Success(list :+ ok) + case Failure(err) => Failure(new Exception(s"Validation error on element $i: ${err.getMessage}", err)) + } + case (fail, _) => fail + } + } + } + + def mapFromJsonString(jason : String) : Map[String, PetData] = try { + read[Map[String, PetData]](jason) + } catch { + case NonFatal(e) => sys.error(s"Error parsing json '$jason' as map: $e") + } + + + def mapFromJsonStringValidated(jason : String, failFast : Boolean = false) : Try[Map[String, Pet]] = { + Try(mapFromJsonString(jason)).flatMap { map => + map.foldLeft(Try(Map[String, Pet]())) { + case (Success(map), (key, next)) => + next.validated(failFast) match { + case Success(ok) => Success(map.updated(key, ok)) + case Failure(err) => Failure(new Exception(s"Validation error on element $key: ${err.getMessage}", err)) + } + case (fail, _) => fail + } + } + } +} + diff --git a/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/Tag.scala b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/Tag.scala new file mode 100644 index 00000000000..a8bd2a35866 --- /dev/null +++ b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/Tag.scala @@ -0,0 +1,51 @@ +/** + * 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. + * + * OpenAPI spec version: 1.0.0 + * + * Contact: team@openapitools.org + * + * NOTE: This class is auto generated by OpenAPI Generator. + * + * https://openapi-generator.tech + */ + +// this model was generated using model.mustache +package sample.cask.model +import scala.util.control.NonFatal + +// see https://com-lihaoyi.github.io/upickle/ +import upickle.default.{ReadWriter => RW, macroRW} +import upickle.default.* + +case class Tag( + id: Option[Long] = None , + + name: Option[String] = None + + ) { + + def asJson: String = asData.asJson + + def asData : TagData = { + TagData( + id = id.getOrElse(0), + name = name.getOrElse("") + ) + } + +} + +object Tag{ + + given RW[Tag] = TagData.readWriter.bimap[Tag](_.asData, _.asModel) + + enum Fields(fieldName : String) extends Field(fieldName) { + case id extends Fields("id") + case name extends Fields("name") + } + + +} + diff --git a/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/TagData.scala b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/TagData.scala new file mode 100644 index 00000000000..e8c66334bcb --- /dev/null +++ b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/TagData.scala @@ -0,0 +1,147 @@ +/** + * 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. + * + * OpenAPI spec version: 1.0.0 + * + * Contact: team@openapitools.org + * + * NOTE: This class is auto generated by OpenAPI Generator. + * + * https://openapi-generator.tech + */ + +// this model was generated using modelData.mustache +package sample.cask.model +import scala.util.control.NonFatal +import scala.util.* + +// see https://com-lihaoyi.github.io/upickle/ +import upickle.default.{ReadWriter => RW, macroRW} +import upickle.default.* + +/** TagData a data transfer object, primarily for simple json serialisation. + * It has no validation - there may be nulls, values out of range, etc + */ +case class TagData( + id: Long = 0 , + + name: String = "" + + ) { + + def asJson: String = write(this) + + def validationErrors(path : Seq[Field], failFast : Boolean) : Seq[ValidationError] = { + val errors = scala.collection.mutable.ListBuffer[ValidationError]() + // ================== + // id + + + + + + + + + + + + + + + + + // ================== + // name + + + + + + + + + + + + + + + + + errors.toSeq + } + + def validated(failFast : Boolean = false) : scala.util.Try[Tag] = { + validationErrors(Vector(), failFast) match { + case Seq() => Success(asModel) + case first +: theRest => Failure(ValidationErrors(first, theRest)) + } + } + + /** use 'validated' to check validation */ + def asModel : Tag = { + Tag( + id = Option( + id + ) + , + name = Option( + name + ) + + ) + } +} + +object TagData { + + given readWriter : RW[TagData] = macroRW + + def fromJsonString(jason : String) : TagData = try { + read[TagData](jason) + } catch { + case NonFatal(e) => sys.error(s"Error parsing json '$jason': $e") + } + + def manyFromJsonString(jason : String) : Seq[TagData] = try { + read[List[TagData]](jason) + } catch { + case NonFatal(e) => sys.error(s"Error parsing json '$jason' as list: $e") + } + + def manyFromJsonStringValidated(jason : String, failFast : Boolean = false) : Try[Seq[Tag]] = { + Try(manyFromJsonString(jason)).flatMap { list => + list.zipWithIndex.foldLeft(Try(Vector[Tag]())) { + case (Success(list), (next, i)) => + next.validated(failFast) match { + case Success(ok) => Success(list :+ ok) + case Failure(err) => Failure(new Exception(s"Validation error on element $i: ${err.getMessage}", err)) + } + case (fail, _) => fail + } + } + } + + def mapFromJsonString(jason : String) : Map[String, TagData] = try { + read[Map[String, TagData]](jason) + } catch { + case NonFatal(e) => sys.error(s"Error parsing json '$jason' as map: $e") + } + + + def mapFromJsonStringValidated(jason : String, failFast : Boolean = false) : Try[Map[String, Tag]] = { + Try(mapFromJsonString(jason)).flatMap { map => + map.foldLeft(Try(Map[String, Tag]())) { + case (Success(map), (key, next)) => + next.validated(failFast) match { + case Success(ok) => Success(map.updated(key, ok)) + case Failure(err) => Failure(new Exception(s"Validation error on element $key: ${err.getMessage}", err)) + } + case (fail, _) => fail + } + } + } +} + diff --git a/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/User.scala b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/User.scala new file mode 100644 index 00000000000..286cdb3b652 --- /dev/null +++ b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/User.scala @@ -0,0 +1,76 @@ +/** + * 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. + * + * OpenAPI spec version: 1.0.0 + * + * Contact: team@openapitools.org + * + * NOTE: This class is auto generated by OpenAPI Generator. + * + * https://openapi-generator.tech + */ + +// this model was generated using model.mustache +package sample.cask.model +import scala.util.control.NonFatal + +// see https://com-lihaoyi.github.io/upickle/ +import upickle.default.{ReadWriter => RW, macroRW} +import upickle.default.* + +case class User( + id: Option[Long] = None , + + username: Option[String] = None , + + firstName: Option[String] = None , + + lastName: Option[String] = None , + + email: Option[String] = None , + + password: Option[String] = None , + + phone: Option[String] = None , + + /* User Status */ + userStatus: Option[Int] = None + + ) { + + def asJson: String = asData.asJson + + def asData : UserData = { + UserData( + id = id.getOrElse(0), + username = username.getOrElse(""), + firstName = firstName.getOrElse(""), + lastName = lastName.getOrElse(""), + email = email.getOrElse(""), + password = password.getOrElse(""), + phone = phone.getOrElse(""), + userStatus = userStatus.getOrElse(0) + ) + } + +} + +object User{ + + given RW[User] = UserData.readWriter.bimap[User](_.asData, _.asModel) + + enum Fields(fieldName : String) extends Field(fieldName) { + case id extends Fields("id") + case username extends Fields("username") + case firstName extends Fields("firstName") + case lastName extends Fields("lastName") + case email extends Fields("email") + case password extends Fields("password") + case phone extends Fields("phone") + case userStatus extends Fields("userStatus") + } + + +} + diff --git a/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/UserData.scala b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/UserData.scala new file mode 100644 index 00000000000..8b8ca7908ab --- /dev/null +++ b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/UserData.scala @@ -0,0 +1,292 @@ +/** + * 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. + * + * OpenAPI spec version: 1.0.0 + * + * Contact: team@openapitools.org + * + * NOTE: This class is auto generated by OpenAPI Generator. + * + * https://openapi-generator.tech + */ + +// this model was generated using modelData.mustache +package sample.cask.model +import scala.util.control.NonFatal +import scala.util.* + +// see https://com-lihaoyi.github.io/upickle/ +import upickle.default.{ReadWriter => RW, macroRW} +import upickle.default.* + +/** UserData a data transfer object, primarily for simple json serialisation. + * It has no validation - there may be nulls, values out of range, etc + */ +case class UserData( + id: Long = 0 , + + username: String = "" , + + firstName: String = "" , + + lastName: String = "" , + + email: String = "" , + + password: String = "" , + + phone: String = "" , + + /* User Status */ + userStatus: Int = 0 + + ) { + + def asJson: String = write(this) + + def validationErrors(path : Seq[Field], failFast : Boolean) : Seq[ValidationError] = { + val errors = scala.collection.mutable.ListBuffer[ValidationError]() + // ================== + // id + + + + + + + + + + + + + + + + + // ================== + // username + + + + + + + + + + + + + + + + + // ================== + // firstName + + + + + + + + + + + + + + + + + // ================== + // lastName + + + + + + + + + + + + + + + + + // ================== + // email + + + + + + + + + + + + + + + + + // ================== + // password + + + + + + + + + + + + + + + + + // ================== + // phone + + + + + + + + + + + + + + + + + // ================== + // userStatus + + + + + + + + + + + + + + + + + errors.toSeq + } + + def validated(failFast : Boolean = false) : scala.util.Try[User] = { + validationErrors(Vector(), failFast) match { + case Seq() => Success(asModel) + case first +: theRest => Failure(ValidationErrors(first, theRest)) + } + } + + /** use 'validated' to check validation */ + def asModel : User = { + User( + id = Option( + id + ) + , + username = Option( + username + ) + , + firstName = Option( + firstName + ) + , + lastName = Option( + lastName + ) + , + email = Option( + email + ) + , + password = Option( + password + ) + , + phone = Option( + phone + ) + , + userStatus = Option( + userStatus + ) + + ) + } +} + +object UserData { + + given readWriter : RW[UserData] = macroRW + + def fromJsonString(jason : String) : UserData = try { + read[UserData](jason) + } catch { + case NonFatal(e) => sys.error(s"Error parsing json '$jason': $e") + } + + def manyFromJsonString(jason : String) : Seq[UserData] = try { + read[List[UserData]](jason) + } catch { + case NonFatal(e) => sys.error(s"Error parsing json '$jason' as list: $e") + } + + def manyFromJsonStringValidated(jason : String, failFast : Boolean = false) : Try[Seq[User]] = { + Try(manyFromJsonString(jason)).flatMap { list => + list.zipWithIndex.foldLeft(Try(Vector[User]())) { + case (Success(list), (next, i)) => + next.validated(failFast) match { + case Success(ok) => Success(list :+ ok) + case Failure(err) => Failure(new Exception(s"Validation error on element $i: ${err.getMessage}", err)) + } + case (fail, _) => fail + } + } + } + + def mapFromJsonString(jason : String) : Map[String, UserData] = try { + read[Map[String, UserData]](jason) + } catch { + case NonFatal(e) => sys.error(s"Error parsing json '$jason' as map: $e") + } + + + def mapFromJsonStringValidated(jason : String, failFast : Boolean = false) : Try[Map[String, User]] = { + Try(mapFromJsonString(jason)).flatMap { map => + map.foldLeft(Try(Map[String, User]())) { + case (Success(map), (key, next)) => + next.validated(failFast) match { + case Success(ok) => Success(map.updated(key, ok)) + case Failure(err) => Failure(new Exception(s"Validation error on element $key: ${err.getMessage}", err)) + } + case (fail, _) => fail + } + } + } +} + diff --git a/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/package.scala b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/package.scala new file mode 100644 index 00000000000..b0c893da671 --- /dev/null +++ b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/model/package.scala @@ -0,0 +1,65 @@ +/** + * 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. + * + * OpenAPI spec version: 1.0.0 + * + * Contact: team@openapitools.org + * + * NOTE: This class is auto generated by OpenAPI Generator. + * + * https://openapi-generator.tech + */ + +package sample.cask.model + +// model package +import upickle.default._ +import java.time.* +import java.time.format.DateTimeFormatter + +/** + * This base class lets us refer to fields in exceptions + */ +class Field(val name : String) + +final case class ValidationErrors( + first: ValidationError, + remaining: Seq[ValidationError], + message: String +) extends Exception(message) + +object ValidationErrors { + def apply(first: ValidationError, remaining: Seq[ValidationError]) = { + val noun = if remaining.isEmpty then "error" else "errors" + new ValidationErrors( + first, + remaining, + remaining.mkString(s"${remaining.size + 1} $noun found: ${first}", "\n\t", "") + ) + } +} + + +final case class ValidationError(path : Seq[Field], message : String) extends Exception(message) { + override def toString = s"ValidationError for ${path.mkString(".")}: $message" +} + +given ReadWriter[ZonedDateTime] = readwriter[String].bimap[ZonedDateTime]( + zonedDateTime => DateTimeFormatter.ISO_INSTANT.format(zonedDateTime), + str => ZonedDateTime.parse(str, DateTimeFormatter.ISO_INSTANT)) + +given ReadWriter[LocalDateTime] = readwriter[String].bimap[LocalDateTime]( + zonedDateTime => DateTimeFormatter.ISO_INSTANT.format(zonedDateTime), + str => LocalDateTime.parse(str, DateTimeFormatter.ISO_INSTANT)) + +given ReadWriter[LocalDate] = readwriter[String].bimap[LocalDate]( + zonedDateTime => DateTimeFormatter.ISO_INSTANT.format(zonedDateTime), + str => LocalDate.parse(str, DateTimeFormatter.ISO_INSTANT)) + +given ReadWriter[OffsetDateTime] = readwriter[String].bimap[OffsetDateTime]( + zonedDateTime => DateTimeFormatter.ISO_INSTANT.format(zonedDateTime), + str => scala.util.Try(OffsetDateTime.parse(str, DateTimeFormatter.ISO_DATE_TIME)).getOrElse( + OffsetDateTime.parse(str, DateTimeFormatter.ISO_INSTANT) + ) +) \ No newline at end of file diff --git a/samples/server/petstore/scala-cask/src/main/scala/sample/cask/package.scala b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/package.scala new file mode 100644 index 00000000000..f4c86e310e0 --- /dev/null +++ b/samples/server/petstore/scala-cask/src/main/scala/sample/cask/package.scala @@ -0,0 +1,24 @@ +/** + * 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. + * + * OpenAPI spec version: 1.0.0 + * + * Contact: team@openapitools.org + * + * NOTE: This class is auto generated by OpenAPI Generator. + * + * https://openapi-generator.tech + */ + +package cask.groupId.server + +def box(str: String): String = { + val lines = str.linesIterator.toList + val maxLen = (0 +: lines.map(_.length)).max + val boxed = lines.map { line => + s" | ${line.padTo(maxLen, ' ')} |" + } + val bar = " +-" + ("-" * maxLen) + "-+" + (bar +: boxed :+ bar).mkString("\n") +} diff --git a/samples/server/petstore/scala-cask/src/test/scala/sample/cask/model/ApiResponseTest.scala b/samples/server/petstore/scala-cask/src/test/scala/sample/cask/model/ApiResponseTest.scala new file mode 100644 index 00000000000..18906d1f384 --- /dev/null +++ b/samples/server/petstore/scala-cask/src/test/scala/sample/cask/model/ApiResponseTest.scala @@ -0,0 +1,33 @@ +/** + * 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. + * + * OpenAPI spec version: 1.0.0 + * Contact: team@openapitools.org + * + * NOTE: This class is auto generated by the swagger code generator program. + * https://github.com/swagger-api/swagger-codegen.git + */ + +// this model was generated using modelTest.mustache +package sample.cask.model + +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec +import scala.util.* + +class ApiResponseTest extends AnyWordSpec with Matchers { + + "ApiResponse.fromJson" should { + """not parse invalid json""" in { + val Failure(err) = Try(ApiResponseData.fromJsonString("invalid jason")) + err.getMessage should startWith ("Error parsing json 'invalid jason'") + } + """parse """ ignore { + val Failure(err : ValidationErrors) = ApiResponseData.fromJsonString("""""").validated() + + sys.error("TODO") + } + } + +} diff --git a/samples/server/petstore/scala-cask/src/test/scala/sample/cask/model/CategoryTest.scala b/samples/server/petstore/scala-cask/src/test/scala/sample/cask/model/CategoryTest.scala new file mode 100644 index 00000000000..a2d9d6c6251 --- /dev/null +++ b/samples/server/petstore/scala-cask/src/test/scala/sample/cask/model/CategoryTest.scala @@ -0,0 +1,33 @@ +/** + * 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. + * + * OpenAPI spec version: 1.0.0 + * Contact: team@openapitools.org + * + * NOTE: This class is auto generated by the swagger code generator program. + * https://github.com/swagger-api/swagger-codegen.git + */ + +// this model was generated using modelTest.mustache +package sample.cask.model + +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec +import scala.util.* + +class CategoryTest extends AnyWordSpec with Matchers { + + "Category.fromJson" should { + """not parse invalid json""" in { + val Failure(err) = Try(CategoryData.fromJsonString("invalid jason")) + err.getMessage should startWith ("Error parsing json 'invalid jason'") + } + """parse """ ignore { + val Failure(err : ValidationErrors) = CategoryData.fromJsonString("""""").validated() + + sys.error("TODO") + } + } + +} diff --git a/samples/server/petstore/scala-cask/src/test/scala/sample/cask/model/OrderTest.scala b/samples/server/petstore/scala-cask/src/test/scala/sample/cask/model/OrderTest.scala new file mode 100644 index 00000000000..3f536010113 --- /dev/null +++ b/samples/server/petstore/scala-cask/src/test/scala/sample/cask/model/OrderTest.scala @@ -0,0 +1,34 @@ +/** + * 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. + * + * OpenAPI spec version: 1.0.0 + * Contact: team@openapitools.org + * + * NOTE: This class is auto generated by the swagger code generator program. + * https://github.com/swagger-api/swagger-codegen.git + */ + +// this model was generated using modelTest.mustache +package sample.cask.model +import java.time.OffsetDateTime + +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec +import scala.util.* + +class OrderTest extends AnyWordSpec with Matchers { + + "Order.fromJson" should { + """not parse invalid json""" in { + val Failure(err) = Try(OrderData.fromJsonString("invalid jason")) + err.getMessage should startWith ("Error parsing json 'invalid jason'") + } + """parse """ ignore { + val Failure(err : ValidationErrors) = OrderData.fromJsonString("""""").validated() + + sys.error("TODO") + } + } + +} diff --git a/samples/server/petstore/scala-cask/src/test/scala/sample/cask/model/PetTest.scala b/samples/server/petstore/scala-cask/src/test/scala/sample/cask/model/PetTest.scala new file mode 100644 index 00000000000..1ab24f2e926 --- /dev/null +++ b/samples/server/petstore/scala-cask/src/test/scala/sample/cask/model/PetTest.scala @@ -0,0 +1,35 @@ +/** + * 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. + * + * OpenAPI spec version: 1.0.0 + * Contact: team@openapitools.org + * + * NOTE: This class is auto generated by the swagger code generator program. + * https://github.com/swagger-api/swagger-codegen.git + */ + +// this model was generated using modelTest.mustache +package sample.cask.model +import sample.cask.model.Category +import sample.cask.model.Tag + +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec +import scala.util.* + +class PetTest extends AnyWordSpec with Matchers { + + "Pet.fromJson" should { + """not parse invalid json""" in { + val Failure(err) = Try(PetData.fromJsonString("invalid jason")) + err.getMessage should startWith ("Error parsing json 'invalid jason'") + } + """parse """ ignore { + val Failure(err : ValidationErrors) = PetData.fromJsonString("""""").validated() + + sys.error("TODO") + } + } + +} diff --git a/samples/server/petstore/scala-cask/src/test/scala/sample/cask/model/TagTest.scala b/samples/server/petstore/scala-cask/src/test/scala/sample/cask/model/TagTest.scala new file mode 100644 index 00000000000..664e43e63c9 --- /dev/null +++ b/samples/server/petstore/scala-cask/src/test/scala/sample/cask/model/TagTest.scala @@ -0,0 +1,33 @@ +/** + * 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. + * + * OpenAPI spec version: 1.0.0 + * Contact: team@openapitools.org + * + * NOTE: This class is auto generated by the swagger code generator program. + * https://github.com/swagger-api/swagger-codegen.git + */ + +// this model was generated using modelTest.mustache +package sample.cask.model + +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec +import scala.util.* + +class TagTest extends AnyWordSpec with Matchers { + + "Tag.fromJson" should { + """not parse invalid json""" in { + val Failure(err) = Try(TagData.fromJsonString("invalid jason")) + err.getMessage should startWith ("Error parsing json 'invalid jason'") + } + """parse """ ignore { + val Failure(err : ValidationErrors) = TagData.fromJsonString("""""").validated() + + sys.error("TODO") + } + } + +} diff --git a/samples/server/petstore/scala-cask/src/test/scala/sample/cask/model/UserTest.scala b/samples/server/petstore/scala-cask/src/test/scala/sample/cask/model/UserTest.scala new file mode 100644 index 00000000000..622fad87b0d --- /dev/null +++ b/samples/server/petstore/scala-cask/src/test/scala/sample/cask/model/UserTest.scala @@ -0,0 +1,33 @@ +/** + * 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. + * + * OpenAPI spec version: 1.0.0 + * Contact: team@openapitools.org + * + * NOTE: This class is auto generated by the swagger code generator program. + * https://github.com/swagger-api/swagger-codegen.git + */ + +// this model was generated using modelTest.mustache +package sample.cask.model + +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec +import scala.util.* + +class UserTest extends AnyWordSpec with Matchers { + + "User.fromJson" should { + """not parse invalid json""" in { + val Failure(err) = Try(UserData.fromJsonString("invalid jason")) + err.getMessage should startWith ("Error parsing json 'invalid jason'") + } + """parse """ ignore { + val Failure(err : ValidationErrors) = UserData.fromJsonString("""""").validated() + + sys.error("TODO") + } + } + +}