diff --git a/.travis.yml b/.travis.yml index 6830172c..08a64ea0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,12 +2,23 @@ language: java jdk: - oraclejdk8 sudo: false +before_cache: +- rm -f $HOME/.gradle/caches/modules-2/modules-2.lock +- rm -fr $HOME/.gradle/caches/*/plugin-resolution/ cache: directories: - - "$HOME/.gradle" + - "$HOME/.gradle/caches/" + - "$HOME/.gradle/wrapper/" install: true script: "./gradlew clean build coveralls" +deploy: + provider: script + script: "./gradlew binTrayUpload" + on: + tags: true env: global: - CI_NAME=travis-ci - secure: nOkY2AcR5Z7hKKzi+zCzmzPk+xiRRrJjGliDJnHnEO7g+iJ8SmT+thHuMFcXqvjxJ006LbR3GPB8M8qgFXFDPEUDRPppShi3waOm1ddlM2iXh9kbCoROoioyRO+F07deH6ExibkLbu/xq3WwAAw7lx/ZP+buc2R2VCbV0nLKP9iE4nHQX8msLgmU1LjWf0Au1Pgl2eWXV0J4/ZJOvcVu9hvBf1Ow4C7BNb0KZmMjeT9uMK0hiGpb9VVbwedOWfsbaFmqmKYqVKS8UtNmG+HBjvZfajrIARKRPc9w9uEvfl4E1H/J7PJLy2kOrCOauj8LMu6DduSrWcg08T5VnH51cavSAsWWG1ImTgEsUBNpllc9r1XLhQCLQ1TWCgGmleqYjZ2ySvg0MQpFjTgXMZC+aDkjZjcEeq3BgrncAR/bcG4ByrZBWyoEXDxBwoMZnkryTxK07UUgXRXdjJUldJ5CQW9oSfd+oEXKJyqQGNt0ob3S0sRS5uhrKniK+6Mwzxf6vXeYvn2fVP5j3hocUz4XzDJQoDDmJi6D4BpwksqJ81CNOHpyeDIOhdXxvA/J8DMCK0Q7tTSbnSOxItaht56CSxqqLSGax3/4Nr+5QX9jpmdJ03HdpP+MOuIl6dZCmE4/w1IFaFEfhpETPlnWZHl1BsOt7omDeW7yCttDq3Y1mXU= + - secure: 4DTaXQyT0oj4GkmDYRT0goQ7B/wGAjLov9xRssyz7aEKZaZZS/y4YSZ7LEI57dZYLkwtekiR1zN6gLnB6XGTMh2MVRGZ1vzcwsR1HebyjFHf5+2YHXRKq/mJk4K4q6t9GtXKZYz3yDnXtn7S2NgkIhye0E1t2874kEPcIEhKClLIGKjCsVenx1ui4WxBMVFPyhAe5KKn73HFTZORzPtTzoYDM9akhZm+Ko0ObreJZdy/P9pHU+RGQjPFWRbjNlrBg1uiBhYOWPGXrOwKMxNnl2fNUilF0QHaDt0oYbeBJvXKgqqC90Qrb067+ynL5S567uesY16A0MtYDIYiDAt0ytSH4B5fjWpFR5WWU+X9uPTAzP4HTwNYfdOBnvtUrRUlYNdxWxwS7ZOzoMFKt7UHs/42VoJBURBBhP7g6qFIJb+OtdDk6iSpqsa/VSMaB59HsKkitB2ZDh7ohmWBu0wZ1iIN6hEYd94/HFWv4C6Fkntbxgv+kVETVNmeA6yLMvLF46Jg8a1s2XptCUFb5kQpgaitbEPnso8lAwXCPRQlj064Ctym8oPCi4hYZcWHm4sF1RYFVweVaa1+5wQI5go6dTgrQajYwRJU0WmtmYfb9pr/l5NeAk2tuYEnX/ILs6TB1by5qJUQzCnaF7YgERrG84t4lqByBGdZaIiFMRIVXCw= + - secure: iEfnO6ZvdyY2Sx0uDafjRgyqPsUueEZtvWumhSRQCBEpQ6MDQiWQTX3SGYtgdX61eShiqwVo3bnp3eeCCojUCtXHFM+sE+bk3NOMuocq4p1Q6uZrt1oe7nK8wYBPdl+5iema1UtSiYiAw6FGlAnDsDjvBkIa7vGpoGyToa68hzLjwNWMi6Jz9JPEoCwIIKSZn8J8gYbeKr1CMLkwpLZYfsh077mXRC22yH0jh7c6kQSfdUuW7a6fELQiQXgccp8qR11oJ5AI17prFa+4q09xCk4pbrhCHMXRrOHDfRLicXWN3qgF8rqETLLRGM1LPpwsgkoFqBEs93RT+3USpWs2H8sC5LgG3m76QP4stRekEmmlJJnIT9ohTt6SAWvrszoYYusTfJbVwQewKXQppshcUIubL7GqaJ0gaTgU7O3tX2o7lTi0dw27FcXV2KpSBSkNok1AZy9iWhGoeSWupi8APe37IwUHjPTiu3on07hbuRXQ/7qvVxujftwiDR3Tbb5AoG3tvtr5+Ru58Ie4PyhxuP1AYbUkgWxVx2MOBHeePHm59wfXIytKoSAGIi0uFwlCqBV926sJCbKwv0c2Kh5SkJvA/1TNTFYITgiiIL9DyxziXUzvsJAkO2ZyA8FLCpMM+WqMnB/M5jxrFzqFFfvhP5dSZSd3OkUO2bhHJuCS2BU= diff --git a/README.md b/README.md index 6c161ecd..90d7655c 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,19 @@ -# Spring REST Docs OpenAPI Integration +# Spring REST Docs API specification Integration ![](https://img.shields.io/github/license/ePages-de/restdocs-openapi.svg) -[![Release](https://jitpack.io/v/ePages-de/restdocs-openapi.svg)](https://jitpack.io/#ePages-de/restdocs-openapi) +[ ![Download](https://api.bintray.com/packages/epages/maven/restdocs-api-spec/images/download.svg) ](https://bintray.com/epages/maven/restdocs-api-spec/_latestVersion) [![Build Status](https://travis-ci.org/ePages-de/restdocs-openapi.svg?branch=master)](https://travis-ci.org/ePages-de/restdocs-openapi) [![Coverage Status](https://coveralls.io/repos/github/ePages-de/restdocs-openapi/badge.svg?branch=master)](https://coveralls.io/github/ePages-de/restdocs-openapi?branch=master) [![Gitter](https://img.shields.io/gitter/room/nwjs/nw.js.svg)](https://gitter.im/restdocs-openapi/Lobby) -This is an extension that adds [OpenAPI](https://www.openapis.org) as an output format to [Spring REST Docs](https://projects.spring.io/spring-restdocs/). -It currently can output [OpenAPI 2.0](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md) in `json` and `yaml` +This is an extension that adds API specifications as an output format to [Spring REST Docs](https://projects.spring.io/spring-restdocs/). +It currently supports: +- [OpenAPI 2.0](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md) in `json` and `yaml` +- [OpenAPI 3.0.1](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md) in `json` and `yaml` + +We plan to add support for: +- [RAML](https://raml.org) +- [Postman Collections](https://schema.getpostman.com/json/collection/v2.1.0/docs/index.html) ## Motivation @@ -18,13 +24,13 @@ We especially like its test-driven approach and this is the main reason why we c It offers support for AsciiDoc and Markdown. This is great for generating simple HTML-based documentation. But both are markup languages and thus it is hard to get any further than statically generated HTML. -OpenAPI is a lot more flexible. -With OpenAPI you get a machine-readable description of your API. There is a rich ecosystem around it that contains tools to: +API specifications like OpenAPI are a lot more flexible. +With e.g. OpenAPI you get a machine-readable description of your API. There is a rich ecosystem around it that contains tools to: - generate a HTML representation of your API - [ReDoc](https://github.com/Rebilly/ReDoc) - generate an interactive API reference - e.g. using services like [stoplight.io](https://stoplight.io) or [readme.io](https://readme.io) -Also, OpenAPI is supported by many REST clients like [Postman](https://www.getpostman.com) and [Paw](https://paw.cloud). -Thus having a OpenAPI representation of an API is a great plus when starting to work with it. +Also, API specifications like OpenAPI are supported by many REST clients like [Postman](https://www.getpostman.com) and [Paw](https://paw.cloud). +Thus having an API specification for a REST API is a great plus when starting to work with it. The most common use case to generate an OpenAPI specification is code introspection and adding documentation related annotations to your code. We do not like enriching our production code with this information and clutter it with even more annotations. @@ -42,10 +48,15 @@ This is why we came up with this project. - [Usage with Spring REST Docs](#usage-with-spring-rest-docs) - [Documenting Bean Validation constraints](#documenting-bean-validation-constraints) - [Migrate existing Spring REST Docs tests](#migrate-existing-spring-rest-docs-tests) - - [Security Definitions](#security-definitions) + - [Security Definitions in OpenAPI](#security-definitions-in-openapi) - [Running the gradle plugin](#running-the-gradle-plugin) + - [OpenAPI 2.0](#openapi-20) + - [OpenAPI 3.0.1](#openapi-301) - [Gradle plugin configuration](#gradle-plugin-configuration) -- [Generate an HTML-based API reference](#generate-an-html-based-api-reference) + - [Common OpenAPI configuration](#common-openapi-configuration) + - [OpenAPI 2.0](#openapi-20-1) + - [OpenAPI 3.0.1](#openapi-301-1) +- [Generate an HTML-based API reference from OpenAPI](#generate-an-html-based-api-reference-from-openapi) - [RAML](#raml) - [Limitations](#limitations) - [Rest Assured](#rest-assured) @@ -58,10 +69,10 @@ This is why we came up with this project. The project consists of two components: -- [restdocs-openapi](restdocs-openapi) - contains the actual Spring REST Docs extension. -This is most importantly the [ResourceDocumentation](restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/ResourceDocumentation.kt) which is the entrypoint to use the extension in your tests. -The [ResourceSnippet](restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/ResourceSnippet.kt) is the snippet a json file `resource.json` containing all the details about the documented resource. -- [restdocs-openapi-gradle-plugin](restdocs-openapi-gradle-plugin) - adds a gradle plugin that aggregates the `resource.json` files produced by `ResourceSnippet` into one `OpenAPI` file for the whole project. +- [restdocs-api-spec](restdocs-api-spec) - contains the actual Spring REST Docs extension. +This is most importantly the [ResourceDocumentation](restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/ResourceDocumentation.kt) which is the entrypoint to use the extension in your tests. +The [ResourceSnippet](restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/ResourceSnippet.kt) is the snippet used to produce a json file `resource.json` containing all the details about the documented resource. +- [restdocs-api-spec-gradle-plugin](restdocs-api-spec-gradle-plugin) - adds a gradle plugin that aggregates the `resource.json` files produced by `ResourceSnippet` into an API specification file for the whole project. ### Build configuration @@ -71,55 +82,61 @@ The [ResourceSnippet](restdocs-openapi/src/main/kotlin/com/epages/restdocs/opena buildscript { repositories { //.. - maven { url = uri("https://jitpack.io") } //1 + jcenter() //1 + maven { url = uri("https://jitpack.io") } //1 } dependencies { //.. - classpath("com.github.epages-de.restdocs-openapi:restdocs-openapi-gradle-plugin:0.4.2") //2 + classpath("com.epages:restdocs-api-spec-gradle-plugin:0.5.0") //2 } } //.. -apply plugin: 'com.epages.restdocs-openapi' //3 +apply plugin: 'com.epages.restdocs-api-spec' //3 repositories { //4 - maven { url 'https://jitpack.io' } + jcenter() } //.. dependencies { //.. - testCompile 'com.github.epages-de.restdocs-openapi:restdocs-openapi:0.4.2' //5 - testCompile 'org.json:json:20170516' //6 spring boot 1 only + testCompile('com.epages:restdocs-api-spec:0.5.0') //5 } -openapi { //7 +openapi { //6 host = 'localhost:8080' basePath = '/api' title = 'My API' version = '1.0.0' format = 'json' } + +openapi3 { + server = 'https://localhost:8080' + title = 'My API' + version = '0.1.0' + format = 'yaml' +} ``` -1. add [jitpack](https://jitpack.io) repository to `buildscript` to resolve the `restdocs-openapi-gradle-plugin` -2. add the dependency to `restdocs-openapi-gradle-plugin` -3. apply `restdocs-openapi-gradle-plugin` -4. add repositories used for dependency resolution. We use [jitpack](https://jitpack.io) here. -5. add the actual `restdocs-openapi` dependency to the test scope -6. Only needed if you are using `spring-boot 1.x`. `Spring-boot` specifies an old version of `org.json:json`. We use [everit-org/json-schema](https://github.com/everit-org/json-schema) to generate json schema files. This project depends on a newer version of `org.json:json`. As versions from BOM always override transitive versions coming in through maven dependencies, you need to add an explicit dependency to `org.json:json:20170516` -7. add configuration options for restdocs-openapi-gradle-plugin`. See [Gradle plugin configuration](#gradle-plugin-configuration) +1. add `jcenter` and [jitpack](https://jitpack.io) repositories to `buildscript`. The first is used to resolve the `restdocs-api-spec-gradle-plugin`, the latter is needed for a dependency of the project. +2. add the dependency to `restdocs-api-spec-gradle-plugin` +3. apply `restdocs-api-spec-gradle-plugin` +4. add the `jcenter` repository used to resolve the `com.epages:restdocs-api-spec` module of the project. +5. add the actual `restdocs-api-spec` dependency to the test scope +6. add configuration options for restdocs-api-spec-gradle-plugin`. See [Gradle plugin configuration](#gradle-plugin-configuration) -See the [build.gradle](samples/restdocs-openapi-sample/build.gradle) for the setup used in the sample project. +See the [build.gradle](samples/restdocs-api-spec-sample/build.gradle) for the setup used in the sample project. #### Maven -The root project does not provide a gradle plugin. -But you can find a maven plugin that works with `restdocs-openapi` at [BerkleyTechnologyServices/restdocs-spec](https://github.com/BerkleyTechnologyServices/restdocs-spec). +The root project does not provide a maven plugin. +But you can find a plugin that works with `restdocs-api-spec` at [BerkleyTechnologyServices/restdocs-spec](https://github.com/BerkleyTechnologyServices/restdocs-spec). ### Usage with Spring REST Docs -The class [ResourceDocumentation](restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/ResourceDocumentation.kt) contains the entry point for using the [ResourceSnippet](restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/ResourceSnippet.kt). +The class [ResourceDocumentation](restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/ResourceDocumentation.kt) contains the entry point for using the [ResourceSnippet](restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/ResourceSnippet.kt). The most basic form does not take any parameters: @@ -131,7 +148,7 @@ mockMvc This test will produce the `resource.json` file in the snippets directory. This file just contains all the information that we can collect about the resource. -The format of this file is OpenAPI agnostic. +The format of this file is not specific to an API specification. ```json { @@ -161,9 +178,9 @@ The format of this file is OpenAPI agnostic. } ``` -Just like you are used to do with Spring REST Docs we can also describe request fields, response fields, path variables, parameters, headers, and links. +Just like with Spring REST Docs we can also describe request fields, response fields, path variables, parameters, headers, and links. Furthermore you can add a text description and a summary for your resource. -The extension also discovers `JWT` tokens in the `Authorization` header and will document the required scopes from it. +The extension also discovers `JWT` tokens in the `Authorization` header and will document the required scopes from it. Also basic auth headers are discovered and documented. The following example uses `ResourceSnippetParameters` to document response fields, path parameters, and links. We paid close attention to keep the API as similar as possible to what you already know from Spring REST Docs. @@ -191,7 +208,7 @@ mockMvc.perform(get("/carts/{id}", cartId) .build()))); ``` -Please see the [CartIntegrationTest](samples/restdocs-openapi-sample/src/test/java/com/epages/restdocs/openapi/sample/CartIntegrationTest.java) in the sample application for a detailed example. +Please see the [CartIntegrationTest](samples/restdocs-api-spec-sample/src/test/java/com/epages/restdocs/api-spec/sample/CartIntegrationTest.java) in the sample application for a detailed example. **:warning: Use `template URIs` to refer to path variables in your request** @@ -204,20 +221,20 @@ mockMvc.perform(get("/carts/{id}", cartId) ### Documenting Bean Validation constraints -Similar to the way Spring REST Docs allows to use [bean validation constraints](https://docs.spring.io/spring-restdocs/docs/current/reference/html5/#documenting-your-api-constraints) to enhance your documentation, you can also use the constraints from your model classes to let `restdocs-openapi` enrich the generated JsonSchemas. -`restdocs-openapi` provides the class [com.epages.restdocs.openapi.ConstrainedFields](restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/ConstrainedFields.kt) to generate `FieldDescriptor`s that contain information about the constraints on this field. +Similar to the way Spring REST Docs allows to use [bean validation constraints](https://docs.spring.io/spring-restdocs/docs/current/reference/html5/#documenting-your-api-constraints) to enhance your documentation, you can also use the constraints from your model classes to let `restdocs-api-spec` enrich the generated JsonSchemas. +`restdocs-api-spec` provides the class [com.epages.restdocs.apispec.ConstrainedFields](restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/ConstrainedFields.kt) to generate `FieldDescriptor`s that contain information about the constraints on this field. -Currently the following constraints are considered when generating JsonSchema from `FieldDescriptor`s that have been created via `com.epages.restdocs.openapi.ConstrainedFields` +Currently the following constraints are considered when generating JsonSchema from `FieldDescriptor`s that have been created via `com.epages.restdocs.apispec.ConstrainedFields` - `NotNull`, `NotEmpty`, and `NotBlank` annotated fields become required fields in the JsonSchema - for String fields annotated with `NotEmpty`, and `NotBlank` the `minLength` constraint in JsonSchema is set to 1 - for String fields annotated with `Length` the `minLength` and `maxLength` constraints in JsonSchema are set to the value of the corresponding attribute of the annotation -If you already have your own `ConstraintFields` implementation you can also add the logic from `com.epages.restdocs.openapi.ConstrainedFields` to your own class. +If you already have your own `ConstraintFields` implementation you can also add the logic from `com.epages.restdocs.apispec.ConstrainedFields` to your own class. Here it is important to add the constraints under the key `validationConstraints` into the attributes map if the `FieldDescriptor`. ### Migrate existing Spring REST Docs tests -For convenience when applying `restdocs-openapi` to an existing project that uses Spring REST Docs, we introduced [com.epages.restdocs.openapi.MockMvcRestDocumentationWrapper](restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/MockMvcRestDocumentationWrapper.kt). +For convenience when applying `restdocs-api-spec` to an existing project that uses Spring REST Docs, we introduced [com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper](restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/MockMvcRestDocumentationWrapper.kt). In your tests you can just replace calls to `MockMvcRestDocumentation.document` with the corresponding variant of `MockMvcRestDocumentationWrapper.document`. @@ -242,47 +259,55 @@ resultActions ); ``` -This will do exactly the same as using `MockMvcRestDocumentation.document` without `restdocs-openapi`. +This will do exactly what `MockMvcRestDocumentation.document` does. Additionally it will add a `ResourceSnippet` with the descriptors you provided in the `RequestFieldsSnippet`, `ResponseFieldsSnippet`, and `LinksSnippet`. -### Security Definitions +### Security Definitions in OpenAPI -The project has limited suport for describing security requirements of an API. -Currently we only suppert Oauth2 with [JWT](https://jwt.io/) tokens. +The project has limited support for describing security requirements of an API. +Currently we only support Oauth2 with [JWT](https://jwt.io/) tokens and HTTP Basic Auth. -`restdocs-openapi` inspects the `AUTHORIZATION` header of a request for a `JWT` token. -If such a token is found the scopes are extracted and added to the `resource.json` snippet. +`restdocs-api-spec` inspects the `AUTHORIZATION` header of a request for a `JWT` token. +Also the a HTTP basic authorization header is discovered and documented. +If such a token is found the scopes are extracted and added to the `resource.json` snippet. -The `restdocs-openapi-gradle-plugin` will consider this information if the `oauth2SecuritySchemeDefinition` configuration option is set (see [Gradle plugin configuration](#gradle-plugin-configuration)). +The `restdocs-api-spec-gradle-plugin` will consider this information if the `oauth2SecuritySchemeDefinition` configuration option is set (see [Gradle plugin configuration](#gradle-plugin-configuration)). This will result in a top-level `securityDefinitions` in the OpenAPI definition. Additionally the required scopes will be added in the `security` section of an `operation`. ### Running the gradle plugin -`restdocs-openapi-gradle-plugin` is responsible for picking up the generated `resource.json` files and aggregate them into an OpenAPI specification (at the moment we support 2.0 only). -For this purpose we use the `openapi` task: +`restdocs-api-spec-gradle-plugin` is responsible for picking up the generated `resource.json` files and aggregate them into an API specification. + +#### OpenAPI 2.0 +In order to generate an OpenAPI 2.0 specification we use the `openapi` task: ``` ./gradlew openapi ``` -For our [sample project](samples/restdocs-openapi-sample) this creates a `openapi.json` file in the output directory (`build/openapi`). +#### OpenAPI 3.0.1 +In order to generate an OpenAPI 3.0.1 specification we use the `openapi3` task: + +``` +./gradlew openapi3 +``` + +For our [sample project](samples/restdocs-api-spec-sample) this creates a `openapi3.yaml` file in the output directory (`build/openapi`). ### Gradle plugin configuration -The `restdocs-openapi-gradle-plugin` takes the following configuration options - all are optional. +#### Common OpenAPI configuration + +The `restdocs-api-spec-gradle-plugin` takes the following configuration options for OpenAPI 2.0 and OpenAPI 3.0.1 - all are optional. Name | Description | Default value ---- | ----------- | ------------- title | The title of the application. Used for the `title` attribute in the [Info object](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#info-object) | `API documentation` version | The version of the api. Used for the `version` attribute in the [Info object](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#info-object) | project version -host | The host serving the API - corresponds to the attribute with the same name in the [OpenAPI root object](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#swagger-object)| `localhost` -basePath | The base path on which the API is served - corresponds to the attribute with the same name in the [OpenAPI root object](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#swagger-object) | null -schemes | The supported transfer protocols of the API - corresponds to the attribute with the same name in the [OpenAPI root object](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#swagger-object) | `['http'"]` format | The format of the output OpenAPI file - supported values are `json` and `yaml` | `json` separatePublicApi | Should the plugin generate an additional OpenAPI specification file that does not contain the resources marked as private | `false` outputDirectory | The output directory | `build/openapi` -outputFileNamePrefix | The file name prefix of the output file. | `api` which results in e.g. `api.json` snippetsDirectory | The directory Spring REST Docs generated the snippets to | `build/generated-snippets` oauth2SecuritySchemeDefinition | Closure containing information to generate the [securityDefinitions](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#securityDefinitionsObject) object in the `OpenAPI` specification. | empty oauth2SecuritySchemeDefinition.flows | The Oauth2 flows the API supports. Use valid values from the [securityDefinitions](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#securityDefinitionsObject) specification. | no default - required if `oauth2SecuritySchemeDefinition` is set. @@ -290,6 +315,22 @@ oauth2SecuritySchemeDefinition.tokenUrl | The Oauth2 tokenUrl | no default - req oauth2SecuritySchemeDefinition.authorizationUrl | The Oauth2 authorizationUrl | no default - required for the flows `implicit`, `accessCode`. oauth2SecuritySchemeDefinition.scopeDescriptionsPropertiesFile | A yaml file mapping scope names to descriptions. These are used in the `securityDefinitions` as the [scope description](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#scopesObject) | no default - if not provided the scope descriptions default to `No description`. + +The `scopeDescriptionsPropertiesFile` is supposed to be a yaml file: +```yaml +scope-name: A description +``` +#### OpenAPI 2.0 + +The `restdocs-api-spec-gradle-plugin` takes the following configuration options for OpenAPI 2.0 - all are optional. + +Name | Description | Default value +---- | ----------- | ------------- +host | The host serving the API - corresponds to the attribute with the same name in the [OpenAPI root object](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#swagger-object)| `localhost` +basePath | The base path on which the API is served - corresponds to the attribute with the same name in the [OpenAPI root object](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#swagger-object) | null +schemes | The supported transfer protocols of the API - corresponds to the attribute with the same name in the [OpenAPI root object](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#swagger-object) | `['http'"]` +outputFileNamePrefix | The file name prefix of the output file. | `openapi` which results in e.g. `openapi.json` for the format `json` + Example configuration closure: ``` openapi { @@ -311,12 +352,59 @@ openapi { } ``` -The `scopeDescriptionsPropertiesFile` is supposed to be a yaml file: -```yaml -scope-name: A description +#### OpenAPI 3.0.1 + +The `restdocs-api-spec-gradle-plugin` takes the following configuration options for OpenAPI 3.0.1 - all are optional. + +Name | Description | Default value +---- | ----------- | ------------- +outputFileNamePrefix | The file name prefix of the output file. | `openapi3` which results in e.g. `openapi3.json` for the format `json` +servers | Specifies the servers the API is available from. Use this property to specify multiple server definitions. See example below. | `http://localhost` +server | Specifies the servers the API is available from. Use this property to specify just a single server definition. See example below | `http://localhost` + +Example configuration closure: +``` +openapi3 { + servers = [ { url = "http://some.api" } ] + title = 'My API title' + version = '1.0.1' + format = 'yaml' + separatePublicApi = $separatePublicApi + outputFileNamePrefix = '$outputFileNamePrefix' + oauth2SecuritySchemeDefinition = { + flows = ['authorizationCode'] + tokenUrl = 'http://example.com/token' + authorizationUrl = 'http://example.com/authorize' + scopeDescriptionsPropertiesFile = "scopeDescriptions.yaml" + } +} +``` + +The `servers` and `server` property can also contain variables. Is this case the` property can be specified like this: + +This configuration follows the same semantics as the ['Servers Object'](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.1.md#server-object) in the OpenAPI specification + +``` +servers = [ { + url = 'https://{host}/api' + variables = [ + host: [ + default: 'api-shop.beyondshop.cloud/api', + description: 'The hostname of your beyond shop', + enum: ['api-shop', 'oz'] + ] + ] +} ] +``` + +The same structure applies to `server`. +A single server can also be specified using a plain string: + +``` +server = 'http://some.api/api' ``` -## Generate an HTML-based API reference +## Generate an HTML-based API reference from OpenAPI We can use [redoc](https://github.com/Rebilly/ReDoc) to generate an HTML API reference from our OpenAPI specification. @@ -329,24 +417,25 @@ redoc-cli serve build/openapi/openapi.json ## RAML This project supersedes [restdocs-raml](https://github.com/ePages-de/restdocs-raml). -So if you are coming from `restdocs-raml` you might want to switch to `restdocs-openapi`. +So if you are coming from `restdocs-raml` you might want to switch to `restdocs-api-spec`. The API of both projects is fairly similar and it is easy to migrate. -Also there are several ways to convert an OpenAPI specification to RAML. +We plan to support RAML in the future. +In the meantime you can use one of several ways to convert an OpenAPI specification to RAML. There are converters around that can help you to achieve this conversion. - [oas-raml-converter](https://github.com/mulesoft/oas-raml-converter) - an npm project that provides a CLI to convert between OpenAPI and RAML - it also provides an [online converter](https://mulesoft.github.io/oas-raml-converter/) - [api-matic](https://apimatic.io/transformer) - an online converter capable of converting between many api specifications -In the [sample project](samples/restdocs-openapi-sample) you find a build configuration that uses the [oas-raml-converter-docker](https://hub.docker.com/r/zaddo/oas-raml-converter-docker/) docker image and the [gradle-docker-plugin](https://github.com/bmuschko/gradle-docker-plugin) to leverage the `oas-raml-converter` to convert the output of the `openapi` task to RAML. +In the [sample project](samples/restdocs-api-spec-sample) you find a build configuration that uses the [oas-raml-converter-docker](https://hub.docker.com/r/zaddo/oas-raml-converter-docker/) docker image and the [gradle-docker-plugin](https://github.com/bmuschko/gradle-docker-plugin) to leverage the `oas-raml-converter` to convert the output of the `openapi` task to RAML. Using this approach your gradle build can still output a RAML specification. -See [openapi2raml.gradle](samples/restdocs-openapi-sample/openapi2raml.gradle). +See [openapi2raml.gradle](samples/restdocs-api-spec-sample/openapi2raml.gradle). ``` -./gradlew restdocs-openapi-sample:openapi -./gradlew -b samples/restdocs-openapi-sample/openapi2raml.gradle openapi2raml +./gradlew restdocs-api-spec-sample:openapi +./gradlew -b samples/restdocs-api-spec-sample/openapi2raml.gradle openapi2raml ``` ## Limitations diff --git a/build.gradle.kts b/build.gradle.kts index 5aae9626..ec8023c3 100755 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,6 @@ +import com.jfrog.bintray.gradle.BintrayExtension +import com.jfrog.bintray.gradle.BintrayExtension.PackageConfig +import com.jfrog.bintray.gradle.tasks.BintrayUploadTask import org.gradle.internal.impldep.org.eclipse.jgit.lib.ObjectChecker.tag import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import org.kt3k.gradle.plugin.CoverallsPluginExtension @@ -9,12 +12,13 @@ import org.gradle.api.tasks.bundling.Jar plugins { java - kotlin("jvm") version "1.2.60" apply false + kotlin("jvm") version "1.2.51" apply false id("pl.allegro.tech.build.axion-release") version "1.9.2" jacoco `maven-publish` id("org.jmailen.kotlinter") version "1.17.0" apply false id("com.github.kt3k.coveralls") version "2.8.2" + id("com.jfrog.bintray") version "1.8.4" apply false } repositories { @@ -52,6 +56,7 @@ allprojects { apply(plugin = "jacoco") apply(plugin = "maven-publish") apply(plugin = "org.jmailen.kotlinter") + } } @@ -76,6 +81,7 @@ subprojects { } if (!isSampleProject()) { + tasks.withType { dependsOn("test") reports { @@ -86,23 +92,35 @@ subprojects { val sourcesJar by tasks.creating(Jar::class) { classifier = "sources" - from(sourceSets["main"].allSource) + from(java.sourceSets["main"].allSource) } publishing { - publications { - register("mavenJava", MavenPublication::class) { + (publications) { + "mavenJava"(MavenPublication::class) { from(components["java"]) artifact(sourcesJar) } } } + apply(plugin = "com.jfrog.bintray") + configure { + user = project.findProperty("bintrayUser") as String? ?: System.getenv("BINTRAY_USER") + key = project.findProperty("bintrayApiKey") as String? ?: System.getenv("BINTRAY_API_KEY") + publish = true + setPublications("mavenJava") + pkg(closureOf { + repo = "maven" + name = "restdocs-api-spec" + userOrg = "epages" + }) + } } } //coverall multi module plugin configuration starts here configure { - sourceDirs = nonSampleProjects.flatMap { it.sourceSets["main"].allSource.srcDirs }.filter { it.exists() }.map { it.path } + sourceDirs = nonSampleProjects.flatMap { it.java.sourceSets["main"].allSource.srcDirs }.filter { it.exists() }.map { it.path } jacocoReportPath = "$buildDir/reports/jacoco/jacocoRootReport/jacocoRootReport.xml" } @@ -122,8 +140,8 @@ tasks { description = "Generates an aggregate report from all subprojects" group = "Coverage reports" dependsOn(jacocoMerge) - sourceDirectories = files(nonSampleProjects.flatMap { it.sourceSets["main"].allSource.srcDirs.filter { it.exists() } } ) - classDirectories = files(nonSampleProjects.flatMap { it.sourceSets["main"].output } ) + sourceDirectories = files(nonSampleProjects.flatMap { it.java.sourceSets["main"].allSource.srcDirs.filter { it.exists() } } ) + classDirectories = files(nonSampleProjects.flatMap { it.java.sourceSets["main"].output } ) executionData(jacocoMerge.destinationFile) reports { html.isEnabled = true diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 115e6ac0..d2c45a4b 100755 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.10-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-4.8-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/restdocs-openapi-gradle-plugin/build.gradle.kts b/restdocs-api-spec-gradle-plugin/build.gradle.kts similarity index 70% rename from restdocs-openapi-gradle-plugin/build.gradle.kts rename to restdocs-api-spec-gradle-plugin/build.gradle.kts index 890983f4..f4c8ee45 100755 --- a/restdocs-openapi-gradle-plugin/build.gradle.kts +++ b/restdocs-api-spec-gradle-plugin/build.gradle.kts @@ -15,10 +15,10 @@ plugins { } gradlePlugin { - plugins { - register("com.epages.restdocs-openapi") { - id = "com.epages.restdocs-openapi" - implementationClass = "com.epages.restdocs.openapi.gradle.RestdocsOpenApiPlugin" + (plugins) { + "com.epages.restdocs-api-spec" { + id = "com.epages.restdocs-api-spec" + implementationClass = "com.epages.restdocs.apispec.gradle.RestdocsApiSpecPlugin" } } } @@ -32,12 +32,13 @@ dependencies { compile(kotlin("gradle-plugin")) compile(kotlin("stdlib-jdk8")) - implementation(project(":restdocs-openapi-model")) - implementation(project(":restdocs-openapi-generator")) + implementation(project(":restdocs-api-spec-openapi-generator")) + implementation(project(":restdocs-api-spec-openapi3-generator")) implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion") implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion") testImplementation("org.junit.jupiter:junit-jupiter-engine:$junitVersion") + testImplementation("org.junit-pioneer:junit-pioneer:0.2.2") testImplementation("org.assertj:assertj-core:3.10.0") testImplementation("com.jayway.jsonpath:json-path:2.4.0") diff --git a/restdocs-api-spec-gradle-plugin/src/main/kotlin/com/epages/restdocs/apispec/gradle/ApiSpecExtension.kt b/restdocs-api-spec-gradle-plugin/src/main/kotlin/com/epages/restdocs/apispec/gradle/ApiSpecExtension.kt new file mode 100644 index 00000000..622a0306 --- /dev/null +++ b/restdocs-api-spec-gradle-plugin/src/main/kotlin/com/epages/restdocs/apispec/gradle/ApiSpecExtension.kt @@ -0,0 +1,14 @@ +package com.epages.restdocs.apispec.gradle + +import org.gradle.api.Project + +abstract class ApiSpecExtension(protected val project: Project) { + + abstract var outputDirectory: String + + var snippetsDirectory = "build/generated-snippets" + + abstract var outputFileNamePrefix: String + + var separatePublicApi: Boolean = false +} diff --git a/restdocs-api-spec-gradle-plugin/src/main/kotlin/com/epages/restdocs/apispec/gradle/ApiSpecTask.kt b/restdocs-api-spec-gradle-plugin/src/main/kotlin/com/epages/restdocs/apispec/gradle/ApiSpecTask.kt new file mode 100644 index 00000000..12766f3d --- /dev/null +++ b/restdocs-api-spec-gradle-plugin/src/main/kotlin/com/epages/restdocs/apispec/gradle/ApiSpecTask.kt @@ -0,0 +1,65 @@ +package com.epages.restdocs.apispec.gradle + +import com.epages.restdocs.apispec.model.ResourceModel +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import org.gradle.api.DefaultTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.TaskAction +import java.io.File + +abstract class ApiSpecTask : DefaultTask() { + + @Input + var separatePublicApi: Boolean = false + + @Input + lateinit var outputDirectory: String + + @Input + lateinit var snippetsDirectory: String + + @Input + lateinit var outputFileNamePrefix: String + + private val outputDirectoryFile + get() = project.file(outputDirectory) + + private val snippetsDirectoryFile + get() = project.file(snippetsDirectory) + + private val objectMapper = jacksonObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + + open fun applyExtension(extension: ApiSpecExtension) { + outputDirectory = extension.outputDirectory + snippetsDirectory = extension.snippetsDirectory + outputFileNamePrefix = extension.outputFileNamePrefix + separatePublicApi = extension.separatePublicApi + } + + @TaskAction + fun aggregateResourceModels() { + + val resourceModels = snippetsDirectoryFile.walkTopDown() + .filter { it.name == "resource.json" } + .map { objectMapper.readValue(it.readText()) } + .toList() + + writeSpecificationFile(outputFileNamePrefix, generateSpecification(resourceModels)) + + if (separatePublicApi) { + val content = generateSpecification(resourceModels.filterNot { it.privateResource }) + writeSpecificationFile("$outputFileNamePrefix-public", content) + } + } + + private fun writeSpecificationFile(outputFilenamePrefix: String, content: String) { + outputDirectoryFile.mkdir() + File(outputDirectoryFile, "$outputFilenamePrefix.${outputFileExtension()}").writeText(content) + } + + protected abstract fun outputFileExtension(): String + + protected abstract fun generateSpecification(resourceModels: List): String +} diff --git a/restdocs-api-spec-gradle-plugin/src/main/kotlin/com/epages/restdocs/apispec/gradle/OpenApi3Task.kt b/restdocs-api-spec-gradle-plugin/src/main/kotlin/com/epages/restdocs/apispec/gradle/OpenApi3Task.kt new file mode 100644 index 00000000..c009e209 --- /dev/null +++ b/restdocs-api-spec-gradle-plugin/src/main/kotlin/com/epages/restdocs/apispec/gradle/OpenApi3Task.kt @@ -0,0 +1,30 @@ +package com.epages.restdocs.apispec.gradle + +import com.epages.restdocs.apispec.model.ResourceModel +import com.epages.restdocs.apispec.openapi3.OpenApi3Generator +import io.swagger.v3.oas.models.servers.Server +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Optional + +open class OpenApi3Task : OpenApiBaseTask() { + + @Input + @Optional + var servers: List = listOf() + + fun applyExtension(extension: OpenApi3Extension) { + super.applyExtension(extension) + servers = extension.servers + } + + override fun generateSpecification(resourceModels: List): String { + return OpenApi3Generator.generateAndSerialize( + resources = resourceModels, + servers = servers, + title = title, + version = apiVersion, + oauth2SecuritySchemeDefinition = oauth2SecuritySchemeDefinition, + format = format + ) + } +} diff --git a/restdocs-api-spec-gradle-plugin/src/main/kotlin/com/epages/restdocs/apispec/gradle/OpenApiBaseTask.kt b/restdocs-api-spec-gradle-plugin/src/main/kotlin/com/epages/restdocs/apispec/gradle/OpenApiBaseTask.kt new file mode 100644 index 00000000..35fe2d8a --- /dev/null +++ b/restdocs-api-spec-gradle-plugin/src/main/kotlin/com/epages/restdocs/apispec/gradle/OpenApiBaseTask.kt @@ -0,0 +1,31 @@ +package com.epages.restdocs.apispec.gradle + +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Optional + +abstract class OpenApiBaseTask : ApiSpecTask() { + @Input + @Optional + lateinit var title: String + + @Input + @Optional + lateinit var apiVersion: String + + @Input + @Optional + lateinit var format: String + + @Input @Optional + var oauth2SecuritySchemeDefinition: PluginOauth2Configuration? = null + + override fun outputFileExtension() = format + + fun applyExtension(extension: OpenApiBaseExtension) { + super.applyExtension(extension) + format = extension.format + oauth2SecuritySchemeDefinition = extension.oauth2SecuritySchemeDefinition + title = extension.title + apiVersion = extension.version + } +} diff --git a/restdocs-api-spec-gradle-plugin/src/main/kotlin/com/epages/restdocs/apispec/gradle/OpenApiExtension.kt b/restdocs-api-spec-gradle-plugin/src/main/kotlin/com/epages/restdocs/apispec/gradle/OpenApiExtension.kt new file mode 100644 index 00000000..a82bac24 --- /dev/null +++ b/restdocs-api-spec-gradle-plugin/src/main/kotlin/com/epages/restdocs/apispec/gradle/OpenApiExtension.kt @@ -0,0 +1,79 @@ +package com.epages.restdocs.apispec.gradle + +import com.epages.restdocs.apispec.model.Oauth2Configuration +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory +import com.fasterxml.jackson.module.kotlin.readValue +import groovy.lang.Closure +import io.swagger.v3.oas.models.servers.Server +import org.gradle.api.Project +import java.io.File + +abstract class OpenApiBaseExtension(project: Project) : ApiSpecExtension(project) { + override var outputDirectory = "build/openapi" + + private val objectMapper = ObjectMapper(YAMLFactory()) + + var title = "API documentation" + var version = project.version as? String ?: "1.0.0" + + var format = "json" + + var oauth2SecuritySchemeDefinition: PluginOauth2Configuration? = null + + fun setOauth2SecuritySchemeDefinition(closure: Closure) { + oauth2SecuritySchemeDefinition = project.configure(PluginOauth2Configuration(), closure) as PluginOauth2Configuration + with(oauth2SecuritySchemeDefinition!!) { + if (scopeDescriptionsPropertiesFile != null) { + scopes = scopeDescriptionSource(project.file(scopeDescriptionsPropertiesFile!!)) + } + } + } + + private fun scopeDescriptionSource(scopeDescriptionsPropertiesFile: File): Map { + return scopeDescriptionsPropertiesFile.let { objectMapper.readValue>(it) } ?: emptyMap() + } +} + +class PluginOauth2Configuration( + var scopeDescriptionsPropertiesFile: String? = null +) : Oauth2Configuration() + +open class OpenApiExtension(project: Project) : OpenApiBaseExtension(project) { + + override var outputFileNamePrefix = "openapi" + + var host: String = "localhost" + var basePath: String? = null + var schemes: Array = arrayOf("http") + + companion object { + const val name = "openapi" + } +} + +open class OpenApi3Extension(project: Project) : OpenApiBaseExtension(project) { + + override var outputFileNamePrefix = "openapi3" + + private var _servers: List = mutableListOf(Server().apply { url = "http://localhost" }) + + val servers + get() = _servers + + fun setServer(serverAction: Closure) { + _servers = listOf(project.configure(Server(), serverAction) as Server) + } + + fun setServer(serverUrl: String) { + _servers = listOf(Server().apply { url = serverUrl }) + } + + fun setServers(serversActions: List>) { + _servers = serversActions.map { project.configure(Server(), it) as Server } + } + + companion object { + const val name = "openapi3" + } +} diff --git a/restdocs-api-spec-gradle-plugin/src/main/kotlin/com/epages/restdocs/apispec/gradle/OpenApiTask.kt b/restdocs-api-spec-gradle-plugin/src/main/kotlin/com/epages/restdocs/apispec/gradle/OpenApiTask.kt new file mode 100644 index 00000000..88dcd977 --- /dev/null +++ b/restdocs-api-spec-gradle-plugin/src/main/kotlin/com/epages/restdocs/apispec/gradle/OpenApiTask.kt @@ -0,0 +1,39 @@ +package com.epages.restdocs.apispec.gradle + +import com.epages.restdocs.apispec.model.ResourceModel +import com.epages.restdocs.apispec.openapi2.OpenApi20Generator +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Optional + +open class OpenApiTask : OpenApiBaseTask() { + + @Input + @Optional + var basePath: String? = null + + @Input @Optional + lateinit var host: String + + @Input @Optional + lateinit var schemes: Array + + fun applyExtension(extension: OpenApiExtension) { + super.applyExtension(extension) + host = extension.host + basePath = extension.basePath + schemes = extension.schemes + } + + override fun generateSpecification(resourceModels: List): String { + return OpenApi20Generator.generateAndSerialize( + resources = resourceModels, + basePath = basePath, + host = host, + schemes = schemes.toList(), + title = title, + version = apiVersion, + oauth2SecuritySchemeDefinition = oauth2SecuritySchemeDefinition, + format = format + ) + } +} diff --git a/restdocs-api-spec-gradle-plugin/src/main/kotlin/com/epages/restdocs/apispec/gradle/RestdocsApiSpecPlugin.kt b/restdocs-api-spec-gradle-plugin/src/main/kotlin/com/epages/restdocs/apispec/gradle/RestdocsApiSpecPlugin.kt new file mode 100644 index 00000000..f3564651 --- /dev/null +++ b/restdocs-api-spec-gradle-plugin/src/main/kotlin/com/epages/restdocs/apispec/gradle/RestdocsApiSpecPlugin.kt @@ -0,0 +1,36 @@ +package com.epages.restdocs.apispec.gradle + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.create + +open class RestdocsApiSpecPlugin : Plugin { + + private fun T.applyWithCommonConfiguration(block: T.() -> Unit): T { + dependsOn("check") + group = "documentation" + block() + return this + } + + override fun apply(project: Project) { + with(project) { + extensions.create(OpenApiExtension.name, OpenApiExtension::class.java, project) + extensions.create(OpenApi3Extension.name, OpenApi3Extension::class.java, project) + + afterEvaluate { + val openapi = extensions.findByName(OpenApiExtension.name) as OpenApiExtension + tasks.create("openapi").applyWithCommonConfiguration { + description = "Aggregate resource fragments into an OpenAPI 2 specification" + applyExtension(openapi) + } + + val openapi3 = extensions.findByName(OpenApi3Extension.name) as OpenApi3Extension + tasks.create("openapi3").applyWithCommonConfiguration { + description = "Aggregate resource fragments into an OpenAPI 3 specification" + applyExtension(openapi3) + } + } + } + } +} diff --git a/restdocs-api-spec-gradle-plugin/src/test/kotlin/com/epages/restdocs/apispec/gradle/RestdocsOpenApi3TaskTest.kt b/restdocs-api-spec-gradle-plugin/src/test/kotlin/com/epages/restdocs/apispec/gradle/RestdocsOpenApi3TaskTest.kt new file mode 100644 index 00000000..4d39fc52 --- /dev/null +++ b/restdocs-api-spec-gradle-plugin/src/test/kotlin/com/epages/restdocs/apispec/gradle/RestdocsOpenApi3TaskTest.kt @@ -0,0 +1,133 @@ +package com.epages.restdocs.apispec.gradle + +import com.jayway.jsonpath.JsonPath +import org.assertj.core.api.BDDAssertions.then +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junitpioneer.jupiter.TempDirectory + +@ExtendWith(TempDirectory::class) +class RestdocsOpenApi3TaskTest : RestdocsOpenApiTaskTestBase() { + + override val taskName = "openapi3" + + override var outputFileNamePrefix = "openapi3" + @Test + override fun `should run openapi task`() { + super.`should run openapi task`() + + with(outputFileContext()) { + then(read>("servers[*].url")).hasSize(2) + then(read("servers[0].url")).isEqualTo("http://some.api/api/{id}") + then(read("servers[0].variables.id.default")).isEqualTo("some") + then(read>("servers[0].variables.id.enum")).containsOnly("some", "other") + } + } + + @Test + fun `should run openapi task with single server`() { + givenBuildFileWithOpenApiClosureWithSingleServer() + givenResourceSnippet() + + whenPluginExecuted() + + thenOpenApiTaskSuccessful() + thenOutputFileFound() + thenSingleServerContainedInOutput() + } + + @Test + fun `should run openapi task with single server string`() { + givenBuildFileWithOpenApiClosureWithSingleServerString() + givenResourceSnippet() + + whenPluginExecuted() + + thenOpenApiTaskSuccessful() + thenOutputFileFound() + thenSingleServerContainedInOutput() + } + + private fun thenSingleServerContainedInOutput() { + with(outputFileContext()) { + then(read>("servers[*].url")).containsOnly("http://some.api") + } + } + + fun givenBuildFileWithOpenApiClosureWithSingleServerString() { + givenBuildFileWithOpenApiClosure("server", """ 'http://some.api' """) + } + + fun givenBuildFileWithOpenApiClosureWithSingleServer() { + givenBuildFileWithOpenApiClosure("server", """{ url = 'http://some.api' }""") + } + + override fun givenBuildFileWithOpenApiClosure() { + givenBuildFileWithOpenApiClosure("servers", + """[ { + url = 'http://some.api/api/{id}' + variables = [ + id: [ + default: 'some', + description: 'some', + enum: ['some', 'other'] + ] + ] + }, + { + url = 'http://{host}.api/api/{id}' + variables = [ + id: [ + default: 'some', + description: 'some', + enum: ['some', 'other'] + ], + host: [ + default: 'host', + ] + ] + } + ]""".trimMargin()) + } + + private fun givenBuildFileWithOpenApiClosure(serverConfigurationFieldName: String, serversSection: String) { + buildFile.writeText(baseBuildFile() + """ + openapi3 { + $serverConfigurationFieldName = $serversSection + title = '$title' + version = '$version' + format = '$format' + separatePublicApi = $separatePublicApi + outputFileNamePrefix = '$outputFileNamePrefix' + } + """.trimIndent()) + } + + override fun givenBuildFileWithOpenApiClosureAndSecurityDefinitions() { + buildFile.writeText(baseBuildFile() + """ + openapi3 { + servers = [ { url = "http://some.api" } ] + title = '$title' + version = '$version' + format = '$format' + separatePublicApi = $separatePublicApi + outputFileNamePrefix = '$outputFileNamePrefix' + oauth2SecuritySchemeDefinition = { + flows = ['authorizationCode'] + tokenUrl = 'http://example.com/token' + authorizationUrl = 'http://example.com/authorize' + scopeDescriptionsPropertiesFile = "scopeDescriptions.yaml" + } + } + """.trimIndent()) + } + + override fun thenSecurityDefinitionsFoundInOutputFile() { + with(JsonPath.parse(outputFolder.resolve("$outputFileNamePrefix.$format").readText())) { + then(read("components.securitySchemes.oauth2.type")).isEqualTo("oauth2") + then(read("components.securitySchemes.oauth2.flows.authorizationCode.scopes.prod:r")).isEqualTo("Some text") + then(read("components.securitySchemes.oauth2.flows.authorizationCode.tokenUrl")).isNotEmpty() + then(read("components.securitySchemes.oauth2.flows.authorizationCode.authorizationUrl")).isNotEmpty() + } + } +} diff --git a/restdocs-api-spec-gradle-plugin/src/test/kotlin/com/epages/restdocs/apispec/gradle/RestdocsOpenApiTaskTest.kt b/restdocs-api-spec-gradle-plugin/src/test/kotlin/com/epages/restdocs/apispec/gradle/RestdocsOpenApiTaskTest.kt new file mode 100644 index 00000000..fb26cbed --- /dev/null +++ b/restdocs-api-spec-gradle-plugin/src/test/kotlin/com/epages/restdocs/apispec/gradle/RestdocsOpenApiTaskTest.kt @@ -0,0 +1,58 @@ +package com.epages.restdocs.apispec.gradle + +import com.jayway.jsonpath.JsonPath +import org.assertj.core.api.BDDAssertions.then +import org.junit.jupiter.api.extension.ExtendWith +import org.junitpioneer.jupiter.TempDirectory + +@ExtendWith(TempDirectory::class) +class RestdocsOpenApiTaskTest : RestdocsOpenApiTaskTestBase() { + + override val taskName = "openapi" + + override fun givenBuildFileWithOpenApiClosure() { + buildFile.writeText(baseBuildFile() + """ + openapi { + host = '$host' + basePath = '$basePath' + schemes = ${schemes.joinToString(",", "['", "']")} + title = '$title' + version = '$version' + format = '$format' + separatePublicApi = $separatePublicApi + outputFileNamePrefix = '$outputFileNamePrefix' + } + """.trimIndent()) + } + + override fun givenBuildFileWithOpenApiClosureAndSecurityDefinitions() { + buildFile.writeText(baseBuildFile() + """ + openapi { + host = '$host' + basePath = '$basePath' + schemes = ${schemes.joinToString(",", "['", "']")} + title = '$title' + version = '$version' + format = '$format' + separatePublicApi = $separatePublicApi + outputFileNamePrefix = '$outputFileNamePrefix' + oauth2SecuritySchemeDefinition = { + flows = ['accessCode'] + tokenUrl = 'http://example.com/token' + authorizationUrl = 'http://example.com/authorize' + scopeDescriptionsPropertiesFile = "scopeDescriptions.yaml" + } + } + """.trimIndent()) + } + + override fun thenSecurityDefinitionsFoundInOutputFile() { + with(JsonPath.parse(outputFolder.resolve("$outputFileNamePrefix.$format").readText())) { + then(read("securityDefinitions.oauth2_accessCode.scopes.prod:r")).isEqualTo("Some text") + then(read("securityDefinitions.oauth2_accessCode.type")).isEqualTo("oauth2") + then(read("securityDefinitions.oauth2_accessCode.tokenUrl")).isNotEmpty() + then(read("securityDefinitions.oauth2_accessCode.authorizationUrl")).isNotEmpty() + then(read("securityDefinitions.oauth2_accessCode.flow")).isNotEmpty() + } + } +} diff --git a/restdocs-openapi-gradle-plugin/src/test/kotlin/com/epages/restdocs/openapi/gradle/RestdocsOpenApiTaskTest.kt b/restdocs-api-spec-gradle-plugin/src/test/kotlin/com/epages/restdocs/apispec/gradle/RestdocsOpenApiTaskTestBase.kt similarity index 61% rename from restdocs-openapi-gradle-plugin/src/test/kotlin/com/epages/restdocs/openapi/gradle/RestdocsOpenApiTaskTest.kt rename to restdocs-api-spec-gradle-plugin/src/test/kotlin/com/epages/restdocs/apispec/gradle/RestdocsOpenApiTaskTestBase.kt index 37a24e3f..8a0403e3 100644 --- a/restdocs-openapi-gradle-plugin/src/test/kotlin/com/epages/restdocs/openapi/gradle/RestdocsOpenApiTaskTest.kt +++ b/restdocs-api-spec-gradle-plugin/src/test/kotlin/com/epages/restdocs/apispec/gradle/RestdocsOpenApiTaskTestBase.kt @@ -1,7 +1,6 @@ -package com.epages.restdocs.openapi.gradle +package com.epages.restdocs.apispec.gradle -import com.epages.restdocs.openapi.gradle.junit.TemporaryFolder -import com.epages.restdocs.openapi.gradle.junit.TemporaryFolderExtension +import com.jayway.jsonpath.DocumentContext import com.jayway.jsonpath.JsonPath import org.assertj.core.api.BDDAssertions.then import org.gradle.testkit.runner.BuildResult @@ -9,19 +8,21 @@ import org.gradle.testkit.runner.GradleRunner import org.gradle.testkit.runner.TaskOutcome import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.ExtendWith +import org.junitpioneer.jupiter.TempDirectory.TempDir import java.io.File import java.nio.file.Files +import java.nio.file.Path import kotlin.streams.toList -@ExtendWith(TemporaryFolderExtension::class) -class RestdocsOpenApiTaskTest(private val testProjectDir: TemporaryFolder) { +abstract class RestdocsOpenApiTaskTestBase { - private lateinit var snippetsFolder: File - private lateinit var outputFolder: File - private lateinit var buildFile: File + lateinit var snippetsFolder: File + lateinit var outputFolder: File + lateinit var buildFile: File - private lateinit var result: BuildResult + lateinit var result: BuildResult + + lateinit var testProjectDir: Path var host: String = "localhost" var basePath: String = "" @@ -34,18 +35,22 @@ class RestdocsOpenApiTaskTest(private val testProjectDir: TemporaryFolder) { var separatePublicApi: Boolean = false - var outputFileNamePrefix = "openapi" + open var outputFileNamePrefix = "openapi" - @BeforeEach - fun init() { - buildFile = testProjectDir.newFile("build.gradle") + abstract val taskName: String - snippetsFolder = testProjectDir.newFolder("build", "generated-snippets") - outputFolder = File(testProjectDir.root, "build/openapi") + @BeforeEach + fun init(@TempDir tempDir: Path) { + with(tempDir) { + testProjectDir = tempDir + buildFile = resolve("build.gradle").toFile() + snippetsFolder = resolve("build/generated-snippets").toFile().apply { mkdirs() } + outputFolder = resolve("build/openapi").toFile() + } } @Test - fun `should run openapi task`() { + open fun `should run openapi task`() { givenBuildFileWithOpenApiClosure() givenResourceSnippet() @@ -96,7 +101,7 @@ class RestdocsOpenApiTaskTest(private val testProjectDir: TemporaryFolder) { @Test fun `should consider security definitions`() { - givenBuildFileWithOpenApiClosureAndSecurityDefintions() + givenBuildFileWithOpenApiClosureAndSecurityDefinitions() givenResourceSnippet() givenScopeTextFile() @@ -107,29 +112,21 @@ class RestdocsOpenApiTaskTest(private val testProjectDir: TemporaryFolder) { thenSecurityDefinitionsFoundInOutputFile() } - private fun thenSecurityDefinitionsFoundInOutputFile() { - with(JsonPath.parse(outputFolder.resolve("$outputFileNamePrefix.$format").readText())) { - then(read("securityDefinitions.oauth2_accessCode.scopes.prod:r")).isEqualTo("Some text") - then(read("securityDefinitions.oauth2_accessCode.type")).isEqualTo("oauth2") - then(read("securityDefinitions.oauth2_accessCode.tokenUrl")).isNotEmpty() - then(read("securityDefinitions.oauth2_accessCode.authorizationUrl")).isNotEmpty() - then(read("securityDefinitions.oauth2_accessCode.flow")).isNotEmpty() - } - } + abstract fun thenSecurityDefinitionsFoundInOutputFile() private fun givenScopeTextFile() { - File(testProjectDir.root, "scopeDescriptions.yaml").writeText( + testProjectDir.resolve("scopeDescriptions.yaml").toFile().writeText( """ "prod:r": "Some text" """.trimIndent() ) } - private fun thenOpenApiTaskSuccessful() { - then(result.task(":openapi")!!.outcome).isEqualTo(TaskOutcome.SUCCESS) + protected fun thenOpenApiTaskSuccessful() { + then(result.task(":$taskName")!!.outcome).isEqualTo(TaskOutcome.SUCCESS) } - private fun thenOutputFileFound() { + protected fun thenOutputFileFound() { val expectedFile = "$outputFileNamePrefix.$format" thenExpectedFileFound(expectedFile) } @@ -185,7 +182,7 @@ class RestdocsOpenApiTaskTest(private val testProjectDir: TemporaryFolder) { ) } - private fun givenResourceSnippet() { + protected fun givenResourceSnippet() { val operationDir = File(snippetsFolder, "some-operation").apply { mkdir() } File(operationDir, "resource.json").writeText( """ @@ -225,56 +222,27 @@ class RestdocsOpenApiTaskTest(private val testProjectDir: TemporaryFolder) { buildFile.writeText(baseBuildFile()) } - private fun givenBuildFileWithOpenApiClosure() { - buildFile.writeText(baseBuildFile() + """ - openapi { - host = '$host' - basePath = '$basePath' - schemes = ${schemes.joinToString(",", "['", "']")} - title = '$title' - version = '$version' - format = '$format' - separatePublicApi = $separatePublicApi - outputFileNamePrefix = '$outputFileNamePrefix' - } - """.trimIndent()) - } + abstract fun givenBuildFileWithOpenApiClosure() - private fun givenBuildFileWithOpenApiClosureAndSecurityDefintions() { - buildFile.writeText(baseBuildFile() + """ - openapi { - host = '$host' - basePath = '$basePath' - schemes = ${schemes.joinToString(",", "['", "']")} - title = '$title' - version = '$version' - format = '$format' - separatePublicApi = $separatePublicApi - outputFileNamePrefix = '$outputFileNamePrefix' - oauth2SecuritySchemeDefinition = { - flows = ['accessCode'] - tokenUrl = 'http://example.com/token' - authorizationUrl = 'http://example.com/authorize' - scopeDescriptionsPropertiesFile = "scopeDescriptions.yaml" - } - } - """.trimIndent()) - } + abstract fun givenBuildFileWithOpenApiClosureAndSecurityDefinitions() - private fun baseBuildFile() = """ + fun baseBuildFile() = """ plugins { id 'java' - id 'com.epages.restdocs-openapi' + id 'com.epages.restdocs-api-spec' } """.trimIndent() - private fun whenPluginExecuted() { + protected fun whenPluginExecuted() { result = GradleRunner.create() - .withProjectDir(testProjectDir.root) - .withArguments("--info", "--stacktrace", "openapi") + .withProjectDir(testProjectDir.toFile()) + .withArguments("--info", "--stacktrace", taskName) .withPluginClasspath() .withDebug(false) .build() } + + protected fun outputFileContext(): DocumentContext = + JsonPath.parse(outputFolder.resolve("$outputFileNamePrefix.$format").readText().also { println(it) }) } diff --git a/restdocs-openapi-jsonschema/build.gradle.kts b/restdocs-api-spec-jsonschema/build.gradle.kts similarity index 94% rename from restdocs-openapi-jsonschema/build.gradle.kts rename to restdocs-api-spec-jsonschema/build.gradle.kts index 146e0015..740c3c86 100644 --- a/restdocs-openapi-jsonschema/build.gradle.kts +++ b/restdocs-api-spec-jsonschema/build.gradle.kts @@ -15,7 +15,7 @@ val junitVersion: String by extra dependencies { compile(kotlin("stdlib-jdk8")) - compile(project(":restdocs-openapi-model")) + compile(project(":restdocs-api-spec-model")) compile("com.github.everit-org.json-schema:org.everit.json.schema:1.9.1") compile("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion") compile("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion") diff --git a/restdocs-openapi-jsonschema/src/main/kotlin/com/epages/restdocs/openapi/jsonschema/ConstraintResolver.kt b/restdocs-api-spec-jsonschema/src/main/kotlin/com/epages/restdocs/apispec/jsonschema/ConstraintResolver.kt similarity index 73% rename from restdocs-openapi-jsonschema/src/main/kotlin/com/epages/restdocs/openapi/jsonschema/ConstraintResolver.kt rename to restdocs-api-spec-jsonschema/src/main/kotlin/com/epages/restdocs/apispec/jsonschema/ConstraintResolver.kt index 9e6a97ba..bf7df96f 100644 --- a/restdocs-openapi-jsonschema/src/main/kotlin/com/epages/restdocs/openapi/jsonschema/ConstraintResolver.kt +++ b/restdocs-api-spec-jsonschema/src/main/kotlin/com/epages/restdocs/apispec/jsonschema/ConstraintResolver.kt @@ -1,4 +1,7 @@ -package com.epages.restdocs.openapi.jsonschema +package com.epages.restdocs.apispec.jsonschema + +import com.epages.restdocs.apispec.model.Constraint +import com.epages.restdocs.apispec.model.FieldDescriptor internal object ConstraintResolver { @@ -19,7 +22,7 @@ internal object ConstraintResolver { private const val LENGTH_CONSTRAINT = "org.hibernate.validator.constraints.Length" - internal fun minLengthString(fieldDescriptor: com.epages.restdocs.openapi.model.FieldDescriptor): Int? { + internal fun minLengthString(fieldDescriptor: FieldDescriptor): Int? { return findConstraints(fieldDescriptor) .firstOrNull { constraint -> (NOT_EMPTY_CONSTRAINTS.contains(constraint.name) || @@ -29,16 +32,16 @@ internal object ConstraintResolver { ?.let { constraint -> if (LENGTH_CONSTRAINT == constraint.name) constraint.configuration["min"] as Int else 1 } } - internal fun maxLengthString(fieldDescriptor: com.epages.restdocs.openapi.model.FieldDescriptor): Int? { + internal fun maxLengthString(fieldDescriptor: FieldDescriptor): Int? { return findConstraints(fieldDescriptor) .firstOrNull { LENGTH_CONSTRAINT == it.name } ?.let { it.configuration["max"] as Int } } - internal fun isRequired(fieldDescriptor: com.epages.restdocs.openapi.model.FieldDescriptor): Boolean = + internal fun isRequired(fieldDescriptor: FieldDescriptor): Boolean = findConstraints(fieldDescriptor) .any { constraint -> REQUIRED_CONSTRAINTS.contains(constraint.name) } - private fun findConstraints(fieldDescriptor: com.epages.restdocs.openapi.model.FieldDescriptor): List = + private fun findConstraints(fieldDescriptor: FieldDescriptor): List = fieldDescriptor.attributes.validationConstraints } diff --git a/restdocs-openapi-jsonschema/src/main/kotlin/com/epages/restdocs/openapi/jsonschema/JsonFieldPath.kt b/restdocs-api-spec-jsonschema/src/main/kotlin/com/epages/restdocs/apispec/jsonschema/JsonFieldPath.kt similarity index 82% rename from restdocs-openapi-jsonschema/src/main/kotlin/com/epages/restdocs/openapi/jsonschema/JsonFieldPath.kt rename to restdocs-api-spec-jsonschema/src/main/kotlin/com/epages/restdocs/apispec/jsonschema/JsonFieldPath.kt index 9b0eca34..d5306293 100644 --- a/restdocs-openapi-jsonschema/src/main/kotlin/com/epages/restdocs/openapi/jsonschema/JsonFieldPath.kt +++ b/restdocs-api-spec-jsonschema/src/main/kotlin/com/epages/restdocs/apispec/jsonschema/JsonFieldPath.kt @@ -1,4 +1,4 @@ -package com.epages.restdocs.openapi.jsonschema +package com.epages.restdocs.apispec.jsonschema import java.util.ArrayList import java.util.regex.Pattern @@ -31,7 +31,8 @@ internal class JsonFieldPath private constructor( .compile("\\[([0-9]+|\\*){0,1}\\]") fun compile(descriptor: JsonSchemaFromFieldDescriptorsGenerator.FieldDescriptorWithSchemaType): JsonFieldPath { - val segments = extractSegments(descriptor.path) + val segments = + extractSegments(descriptor.path) return JsonFieldPath(segments, descriptor) } @@ -47,7 +48,11 @@ internal class JsonFieldPath private constructor( val segments = ArrayList() while (matcher.find()) { if (previous != matcher.start()) { - segments.addAll(extractDotSeparatedSegments(path.substring(previous, matcher.start()))) + segments.addAll( + extractDotSeparatedSegments( + path.substring(previous, matcher.start()) + ) + ) } if (matcher.group(1) != null) { segments.add(matcher.group(1)) @@ -58,7 +63,11 @@ internal class JsonFieldPath private constructor( } if (previous < path.length) { - segments.addAll(extractDotSeparatedSegments(path.substring(previous))) + segments.addAll( + extractDotSeparatedSegments( + path.substring(previous) + ) + ) } return segments diff --git a/restdocs-openapi-jsonschema/src/main/kotlin/com/epages/restdocs/openapi/jsonschema/JsonSchemaFromFieldDescriptorsGenerator.kt b/restdocs-api-spec-jsonschema/src/main/kotlin/com/epages/restdocs/apispec/jsonschema/JsonSchemaFromFieldDescriptorsGenerator.kt similarity index 87% rename from restdocs-openapi-jsonschema/src/main/kotlin/com/epages/restdocs/openapi/jsonschema/JsonSchemaFromFieldDescriptorsGenerator.kt rename to restdocs-api-spec-jsonschema/src/main/kotlin/com/epages/restdocs/apispec/jsonschema/JsonSchemaFromFieldDescriptorsGenerator.kt index 43e326ee..c8372e36 100644 --- a/restdocs-openapi-jsonschema/src/main/kotlin/com/epages/restdocs/openapi/jsonschema/JsonSchemaFromFieldDescriptorsGenerator.kt +++ b/restdocs-api-spec-jsonschema/src/main/kotlin/com/epages/restdocs/apispec/jsonschema/JsonSchemaFromFieldDescriptorsGenerator.kt @@ -1,8 +1,10 @@ -package com.epages.restdocs.openapi.jsonschema +package com.epages.restdocs.apispec.jsonschema -import com.epages.restdocs.openapi.jsonschema.ConstraintResolver.isRequired -import com.epages.restdocs.openapi.jsonschema.ConstraintResolver.maxLengthString -import com.epages.restdocs.openapi.jsonschema.ConstraintResolver.minLengthString +import com.epages.restdocs.apispec.jsonschema.ConstraintResolver.isRequired +import com.epages.restdocs.apispec.jsonschema.ConstraintResolver.maxLengthString +import com.epages.restdocs.apispec.jsonschema.ConstraintResolver.minLengthString +import com.epages.restdocs.apispec.model.Attributes +import com.epages.restdocs.apispec.model.FieldDescriptor import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import org.everit.json.schema.ArraySchema @@ -22,7 +24,7 @@ import java.util.function.Predicate class JsonSchemaFromFieldDescriptorsGenerator { - fun generateSchema(fieldDescriptors: List, title: String? = null): String { + fun generateSchema(fieldDescriptors: List, title: String? = null): String { val jsonFieldPaths = reduceFieldDescriptors(fieldDescriptors) .map { JsonFieldPath.compile(it) } @@ -36,9 +38,13 @@ class JsonSchemaFromFieldDescriptorsGenerator { * * The implementation will */ - private fun reduceFieldDescriptors(fieldDescriptors: List): List { + private fun reduceFieldDescriptors(fieldDescriptors: List): List { return fieldDescriptors - .map { FieldDescriptorWithSchemaType.fromFieldDescriptor(it) } + .map { + FieldDescriptorWithSchemaType.fromFieldDescriptor( + it + ) + } .foldRight(listOf()) { fieldDescriptor, groups -> groups .firstOrNull { it.equalsOnPathAndType(fieldDescriptor) } ?.let { groups } // omit the descriptor it is considered equal and can be omitted @@ -106,7 +112,9 @@ class JsonSchemaFromFieldDescriptorsGenerator { // we have a direct match when there are no remaining segments or when the only following element is an array return Predicate { jsonFieldPath -> val remainingSegments = jsonFieldPath.remainingSegments(traversedSegments) - remainingSegments.isEmpty() || remainingSegments.size == 1 && JsonFieldPath.isArraySegment(remainingSegments[0]) + remainingSegments.isEmpty() || remainingSegments.size == 1 && JsonFieldPath.isArraySegment( + remainingSegments[0] + ) } } @@ -125,7 +133,10 @@ class JsonSchemaFromFieldDescriptorsGenerator { description: String? ) { val remainingSegments = fields[0].remainingSegments(traversedSegments) - if (remainingSegments.isNotEmpty() && JsonFieldPath.isArraySegment(remainingSegments[0])) { + if (remainingSegments.isNotEmpty() && JsonFieldPath.isArraySegment( + remainingSegments[0] + ) + ) { traversedSegments.add(remainingSegments[0]) builder.addPropertySchema( propertyName, ArraySchema.builder() @@ -182,9 +193,13 @@ class JsonSchemaFromFieldDescriptorsGenerator { type: String, optional: Boolean, ignored: Boolean, - attributes: com.epages.restdocs.openapi.model.Attributes, - private val jsonSchemaPrimitiveTypes: Set = setOf(jsonSchemaPrimitiveTypeFromDescriptorType(type)) - ) : com.epages.restdocs.openapi.model.FieldDescriptor(path, description, type, optional, ignored, attributes) { + attributes: Attributes, + private val jsonSchemaPrimitiveTypes: Set = setOf( + jsonSchemaPrimitiveTypeFromDescriptorType( + type + ) + ) + ) : FieldDescriptor(path, description, type, optional, ignored, attributes) { fun jsonSchemaType(): Schema { val schemaBuilders = jsonSchemaPrimitiveTypes.map { typeToSchema(it) } @@ -192,7 +207,7 @@ class JsonSchemaFromFieldDescriptorsGenerator { else CombinedSchema.oneOf(schemaBuilders.map { it.build() }).description(description).build() } - fun merge(fieldDescriptor: com.epages.restdocs.openapi.model.FieldDescriptor): FieldDescriptorWithSchemaType { + fun merge(fieldDescriptor: FieldDescriptor): FieldDescriptorWithSchemaType { if (this.path != fieldDescriptor.path) throw IllegalArgumentException("path of fieldDescriptor is not equal to ${this.path}") @@ -203,7 +218,9 @@ class JsonSchemaFromFieldDescriptorsGenerator { optional = this.optional || fieldDescriptor.optional, // optional if one it optional ignored = this.ignored && fieldDescriptor.optional, // ignored if both are optional attributes = attributes, - jsonSchemaPrimitiveTypes = jsonSchemaPrimitiveTypes + jsonSchemaPrimitiveTypeFromDescriptorType(fieldDescriptor.type) + jsonSchemaPrimitiveTypes = jsonSchemaPrimitiveTypes + jsonSchemaPrimitiveTypeFromDescriptorType( + fieldDescriptor.type + ) ) } @@ -226,7 +243,7 @@ class JsonSchemaFromFieldDescriptorsGenerator { this.type == f.type) companion object { - fun fromFieldDescriptor(fieldDescriptor: com.epages.restdocs.openapi.model.FieldDescriptor) = + fun fromFieldDescriptor(fieldDescriptor: FieldDescriptor) = FieldDescriptorWithSchemaType( path = fieldDescriptor.path, description = fieldDescriptor.description, diff --git a/restdocs-openapi-jsonschema/src/test/kotlin/com/epages/restdocs/openapi/jsonschema/JsonFieldPathTest.kt b/restdocs-api-spec-jsonschema/src/test/kotlin/com/epages/restdocs/apispec/jsonschema/JsonFieldPathTest.kt similarity index 70% rename from restdocs-openapi-jsonschema/src/test/kotlin/com/epages/restdocs/openapi/jsonschema/JsonFieldPathTest.kt rename to restdocs-api-spec-jsonschema/src/test/kotlin/com/epages/restdocs/apispec/jsonschema/JsonFieldPathTest.kt index 96d4682c..87523bf6 100644 --- a/restdocs-openapi-jsonschema/src/test/kotlin/com/epages/restdocs/openapi/jsonschema/JsonFieldPathTest.kt +++ b/restdocs-api-spec-jsonschema/src/test/kotlin/com/epages/restdocs/apispec/jsonschema/JsonFieldPathTest.kt @@ -1,6 +1,7 @@ -package com.epages.restdocs.openapi.jsonschema +package com.epages.restdocs.apispec.jsonschema -import com.epages.restdocs.openapi.jsonschema.JsonFieldPath.Companion.compile +import com.epages.restdocs.apispec.jsonschema.JsonFieldPath.Companion.compile +import com.epages.restdocs.apispec.model.Attributes import org.assertj.core.api.BDDAssertions.then import org.junit.jupiter.api.Test import java.util.Collections.emptyList @@ -10,9 +11,11 @@ class JsonFieldPathTest { @Test fun should_get_remaining_segments() { with(compile( - JsonSchemaFromFieldDescriptorsGenerator.FieldDescriptorWithSchemaType("a.b.c", "", "", false, false, - com.epages.restdocs.openapi.model.Attributes() - ))) { + JsonSchemaFromFieldDescriptorsGenerator.FieldDescriptorWithSchemaType( + "a.b.c", "", "", false, false, + Attributes() + ) + )) { then(remainingSegments(listOf("a"))).contains("b", "c") then(remainingSegments(listOf("a", "b"))).contains("c") then(remainingSegments(listOf("a", "b", "c"))).isEmpty() diff --git a/restdocs-openapi-jsonschema/src/test/kotlin/com/epages/restdocs/openapi/jsonschema/JsonSchemaFromFieldDescriptorsGeneratorTest.kt b/restdocs-api-spec-jsonschema/src/test/kotlin/com/epages/restdocs/apispec/jsonschema/JsonSchemaFromFieldDescriptorsGeneratorTest.kt similarity index 80% rename from restdocs-openapi-jsonschema/src/test/kotlin/com/epages/restdocs/openapi/jsonschema/JsonSchemaFromFieldDescriptorsGeneratorTest.kt rename to restdocs-api-spec-jsonschema/src/test/kotlin/com/epages/restdocs/apispec/jsonschema/JsonSchemaFromFieldDescriptorsGeneratorTest.kt index 2d07b2dd..34510e56 100644 --- a/restdocs-openapi-jsonschema/src/test/kotlin/com/epages/restdocs/openapi/jsonschema/JsonSchemaFromFieldDescriptorsGeneratorTest.kt +++ b/restdocs-api-spec-jsonschema/src/test/kotlin/com/epages/restdocs/apispec/jsonschema/JsonSchemaFromFieldDescriptorsGeneratorTest.kt @@ -1,6 +1,8 @@ -package com.epages.restdocs.openapi.jsonschema +package com.epages.restdocs.apispec.jsonschema -import com.epages.restdocs.openapi.model.Constraint +import com.epages.restdocs.apispec.model.Attributes +import com.epages.restdocs.apispec.model.Constraint +import com.epages.restdocs.apispec.model.FieldDescriptor import com.github.fge.jackson.JsonLoader import com.github.fge.jsonschema.main.JsonSchemaFactory import com.jayway.jsonpath.JsonPath @@ -25,7 +27,7 @@ class JsonSchemaFromFieldDescriptorsGeneratorTest { private var schema: Schema? = null - private var fieldDescriptors: List? = null + private var fieldDescriptors: List? = null private var schemaString: String? = null @@ -196,43 +198,43 @@ class JsonSchemaFromFieldDescriptorsGeneratorTest { } private fun givenFieldDescriptorWithPrimitiveArray() { - fieldDescriptors = listOf(com.epages.restdocs.openapi.model.FieldDescriptor("a[]", "some", "ARRAY")) + fieldDescriptors = listOf(FieldDescriptor("a[]", "some", "ARRAY")) } private fun givenFieldDescriptorWithTopLevelArray() { - fieldDescriptors = listOf(com.epages.restdocs.openapi.model.FieldDescriptor("[]['id']", "some", "STRING")) + fieldDescriptors = listOf(FieldDescriptor("[]['id']", "some", "STRING")) } private fun givenFieldDescriptorWithTopLevelArrayOfAny() { - fieldDescriptors = listOf(com.epages.restdocs.openapi.model.FieldDescriptor("[]", "some", "ARRAY")) + fieldDescriptors = listOf(FieldDescriptor("[]", "some", "ARRAY")) } private fun givenFieldDescriptorWithTopLevelArrayOfArrayOfAny() { - fieldDescriptors = listOf(com.epages.restdocs.openapi.model.FieldDescriptor("[][]", "some", "ARRAY")) + fieldDescriptors = listOf(FieldDescriptor("[][]", "some", "ARRAY")) } private fun givenFieldDescriptorWithInvalidType() { - fieldDescriptors = listOf(com.epages.restdocs.openapi.model.FieldDescriptor("id", "some", "invalid-type")) + fieldDescriptors = listOf(FieldDescriptor("id", "some", "invalid-type")) } private fun givenEqualFieldDescriptorsWithSamePath() { fieldDescriptors = listOf( - com.epages.restdocs.openapi.model.FieldDescriptor("id", "some", "STRING"), - com.epages.restdocs.openapi.model.FieldDescriptor("id", "some", "STRING") + FieldDescriptor("id", "some", "STRING"), + FieldDescriptor("id", "some", "STRING") ) } private fun givenDifferentFieldDescriptorsWithSamePathAndDifferentTypes() { fieldDescriptors = listOf( - com.epages.restdocs.openapi.model.FieldDescriptor("id", "some", "STRING"), - com.epages.restdocs.openapi.model.FieldDescriptor("id", "some", "NULL"), - com.epages.restdocs.openapi.model.FieldDescriptor("id", "some", "BOOLEAN") + FieldDescriptor("id", "some", "STRING"), + FieldDescriptor("id", "some", "NULL"), + FieldDescriptor("id", "some", "BOOLEAN") ) } private fun givenFieldDescriptorsWithConstraints() { val constraintAttributeWithNotNull = - com.epages.restdocs.openapi.model.Attributes( + Attributes( listOf( Constraint( NotNull::class.java.name, @@ -242,9 +244,9 @@ class JsonSchemaFromFieldDescriptorsGeneratorTest { ) val constraintAttributeWithLength = - com.epages.restdocs.openapi.model.Attributes( + Attributes( listOf( - com.epages.restdocs.openapi.model.Constraint( + Constraint( "org.hibernate.validator.constraints.Length", mapOf( "min" to 2, "max" to 255 @@ -254,46 +256,46 @@ class JsonSchemaFromFieldDescriptorsGeneratorTest { ) fieldDescriptors = listOf( - com.epages.restdocs.openapi.model.FieldDescriptor( + FieldDescriptor( "id", "some", "STRING", attributes = constraintAttributeWithNotNull ), - com.epages.restdocs.openapi.model.FieldDescriptor( + FieldDescriptor( "lineItems[*].name", "some", "STRING", attributes = constraintAttributeWithLength ), - com.epages.restdocs.openapi.model.FieldDescriptor( + FieldDescriptor( "lineItems[*]._id", "some", "STRING", attributes = constraintAttributeWithNotNull ), - com.epages.restdocs.openapi.model.FieldDescriptor( + FieldDescriptor( "lineItems[*].quantity.value", "some", "NUMBER", attributes = constraintAttributeWithNotNull ), - com.epages.restdocs.openapi.model.FieldDescriptor("lineItems[*].quantity.unit", "some", "STRING"), - com.epages.restdocs.openapi.model.FieldDescriptor("shippingAddress", "some", "OBJECT"), - com.epages.restdocs.openapi.model.FieldDescriptor("billingAddress", "some", "OBJECT"), - com.epages.restdocs.openapi.model.FieldDescriptor( + FieldDescriptor("lineItems[*].quantity.unit", "some", "STRING"), + FieldDescriptor("shippingAddress", "some", "OBJECT"), + FieldDescriptor("billingAddress", "some", "OBJECT"), + FieldDescriptor( "billingAddress.firstName", "some", "STRING", - attributes = com.epages.restdocs.openapi.model.Attributes( + attributes = Attributes( listOf( - com.epages.restdocs.openapi.model.Constraint( + Constraint( "javax.validation.constraints.NotEmpty", emptyMap() ) ) ) ), - com.epages.restdocs.openapi.model.FieldDescriptor("billingAddress.valid", "some", "BOOLEAN"), - com.epages.restdocs.openapi.model.FieldDescriptor("paymentLineItem.lineItemTaxes", "some", "ARRAY") + FieldDescriptor("billingAddress.valid", "some", "BOOLEAN"), + FieldDescriptor("paymentLineItem.lineItemTaxes", "some", "ARRAY") ) } diff --git a/restdocs-openapi-model/build.gradle.kts b/restdocs-api-spec-model/build.gradle.kts similarity index 100% rename from restdocs-openapi-model/build.gradle.kts rename to restdocs-api-spec-model/build.gradle.kts diff --git a/restdocs-openapi-model/src/main/kotlin/com/epages/restdocs/openapi/model/Oauth2Configuration.kt b/restdocs-api-spec-model/src/main/kotlin/com/epages/restdocs/apispec/model/Oauth2Configuration.kt similarity index 87% rename from restdocs-openapi-model/src/main/kotlin/com/epages/restdocs/openapi/model/Oauth2Configuration.kt rename to restdocs-api-spec-model/src/main/kotlin/com/epages/restdocs/apispec/model/Oauth2Configuration.kt index b62e4746..a754409e 100644 --- a/restdocs-openapi-model/src/main/kotlin/com/epages/restdocs/openapi/model/Oauth2Configuration.kt +++ b/restdocs-api-spec-model/src/main/kotlin/com/epages/restdocs/apispec/model/Oauth2Configuration.kt @@ -1,10 +1,10 @@ -package com.epages.restdocs.openapi.model - -open class Oauth2Configuration( - var tokenUrl: String = "", // required for types "password", "application", "accessCode" - var authorizationUrl: String = "", // required for the "accessCode" type - var flows: Array = arrayOf(), - var scopes: Map = mapOf() -) { - fun securitySchemeName(flow: String) = "oauth2_$flow" -} \ No newline at end of file +package com.epages.restdocs.apispec.model + +open class Oauth2Configuration( + var tokenUrl: String = "", // required for types "password", "application", "accessCode" + var authorizationUrl: String = "", // required for the "accessCode" type + var flows: Array = arrayOf(), + var scopes: Map = mapOf() +) { + fun securitySchemeName(flow: String) = "oauth2_$flow" +} diff --git a/restdocs-openapi-model/src/main/kotlin/com/epages/restdocs/openapi/model/ResourceModel.kt b/restdocs-api-spec-model/src/main/kotlin/com/epages/restdocs/apispec/model/ResourceModel.kt similarity index 98% rename from restdocs-openapi-model/src/main/kotlin/com/epages/restdocs/openapi/model/ResourceModel.kt rename to restdocs-api-spec-model/src/main/kotlin/com/epages/restdocs/apispec/model/ResourceModel.kt index 03120742..14210713 100644 --- a/restdocs-openapi-model/src/main/kotlin/com/epages/restdocs/openapi/model/ResourceModel.kt +++ b/restdocs-api-spec-model/src/main/kotlin/com/epages/restdocs/apispec/model/ResourceModel.kt @@ -1,4 +1,4 @@ -package com.epages.restdocs.openapi.model +package com.epages.restdocs.apispec.model data class ResourceModel( val operationId: String, diff --git a/restdocs-openapi-generator/build.gradle.kts b/restdocs-api-spec-openapi-generator/build.gradle.kts similarity index 86% rename from restdocs-openapi-generator/build.gradle.kts rename to restdocs-api-spec-openapi-generator/build.gradle.kts index 201c268f..75e5027f 100644 --- a/restdocs-openapi-generator/build.gradle.kts +++ b/restdocs-api-spec-openapi-generator/build.gradle.kts @@ -15,8 +15,8 @@ val junitVersion: String by extra dependencies { compile(kotlin("stdlib-jdk8")) - compile(project(":restdocs-openapi-model")) - compile(project(":restdocs-openapi-jsonschema")) + compile(project(":restdocs-api-spec-model")) + compile(project(":restdocs-api-spec-jsonschema")) compile("io.swagger:swagger-core:1.5.21") compile("com.fasterxml.jackson.core:jackson-databind:2.9.5") compile("com.fasterxml.jackson.module:jackson-module-kotlin:2.9.5") diff --git a/restdocs-api-spec-openapi-generator/src/main/kotlin/com/epages/restdocs/apispec/openapi2/ApiSpecificationWriter.kt b/restdocs-api-spec-openapi-generator/src/main/kotlin/com/epages/restdocs/apispec/openapi2/ApiSpecificationWriter.kt new file mode 100644 index 00000000..4d2b0e8d --- /dev/null +++ b/restdocs-api-spec-openapi-generator/src/main/kotlin/com/epages/restdocs/apispec/openapi2/ApiSpecificationWriter.kt @@ -0,0 +1,29 @@ +package com.epages.restdocs.apispec.openapi2 + +import com.fasterxml.jackson.core.util.DefaultPrettyPrinter +import io.swagger.models.Swagger +import io.swagger.util.Json + +object ApiSpecificationWriter { + + private val yamlFormats = setOf("yaml", "yml") + private val jsonFormats = setOf("json") + + fun serialize(format: String, apiSpecification: Swagger): String { + validateFormat(format) + return if (yamlFormats.contains(format)) { + optimizedYaml().writeValueAsString(apiSpecification) + } else { + Json.pretty().writeValueAsString(apiSpecification) + } + } + + private fun optimizedYaml() = + OptimizedYamlSerializationObjectMapperFactory.createYaml().writer(DefaultPrettyPrinter()) + + fun supportedFormats() = yamlFormats + jsonFormats + + fun validateFormat(format: String) { + if (!supportedFormats().contains(format)) throw IllegalArgumentException("Format '$format' is invalid - supported formats are '${supportedFormats()}'") + } +} diff --git a/restdocs-openapi-generator/src/main/kotlin/com/epages/restdocs/openapi/generator/OpenApi20Generator.kt b/restdocs-api-spec-openapi-generator/src/main/kotlin/com/epages/restdocs/apispec/openapi2/OpenApi20Generator.kt similarity index 74% rename from restdocs-openapi-generator/src/main/kotlin/com/epages/restdocs/openapi/generator/OpenApi20Generator.kt rename to restdocs-api-spec-openapi-generator/src/main/kotlin/com/epages/restdocs/apispec/openapi2/OpenApi20Generator.kt index 9a7834b0..29ab3095 100644 --- a/restdocs-openapi-generator/src/main/kotlin/com/epages/restdocs/openapi/generator/OpenApi20Generator.kt +++ b/restdocs-api-spec-openapi-generator/src/main/kotlin/com/epages/restdocs/apispec/openapi2/OpenApi20Generator.kt @@ -1,15 +1,15 @@ -package com.epages.restdocs.openapi.generator - -import com.epages.restdocs.openapi.jsonschema.JsonSchemaFromFieldDescriptorsGenerator -import com.epages.restdocs.openapi.model.FieldDescriptor -import com.epages.restdocs.openapi.model.HTTPMethod -import com.epages.restdocs.openapi.model.HeaderDescriptor -import com.epages.restdocs.openapi.model.Oauth2Configuration -import com.epages.restdocs.openapi.model.ParameterDescriptor -import com.epages.restdocs.openapi.model.ResourceModel -import com.epages.restdocs.openapi.model.ResponseModel -import com.epages.restdocs.openapi.model.SecurityRequirements -import com.epages.restdocs.openapi.model.SecurityType +package com.epages.restdocs.apispec.openapi2 + +import com.epages.restdocs.apispec.jsonschema.JsonSchemaFromFieldDescriptorsGenerator +import com.epages.restdocs.apispec.model.FieldDescriptor +import com.epages.restdocs.apispec.model.HTTPMethod +import com.epages.restdocs.apispec.model.HeaderDescriptor +import com.epages.restdocs.apispec.model.Oauth2Configuration +import com.epages.restdocs.apispec.model.ParameterDescriptor +import com.epages.restdocs.apispec.model.ResourceModel +import com.epages.restdocs.apispec.model.ResponseModel +import com.epages.restdocs.apispec.model.SecurityRequirements +import com.epages.restdocs.apispec.model.SecurityType import com.fasterxml.jackson.module.kotlin.readValue import io.swagger.models.Info import io.swagger.models.Model @@ -37,7 +37,7 @@ object OpenApi20Generator { private const val API_KEY_SECURITY_NAME = "api_key" private const val BASIC_SECURITY_NAME = "basic" - fun generate( + internal fun generate( resources: List, basePath: String? = null, host: String = "localhost", @@ -55,10 +55,32 @@ object OpenApi20Generator { this.title = title this.version = version } - paths = generatePaths(resources, oauth2SecuritySchemeDefinition) + paths = generatePaths( + resources, + oauth2SecuritySchemeDefinition + ) extractDefinitions(this) - }.apply { addSecurityDefinitions(this, oauth2SecuritySchemeDefinition) } + }.apply { + addSecurityDefinitions( + this, + oauth2SecuritySchemeDefinition + ) + } + } + + fun generateAndSerialize( + resources: List, + basePath: String? = null, + host: String = "localhost", + schemes: List = listOf("http"), + title: String = "API", + version: String = "1.0.0", + oauth2SecuritySchemeDefinition: Oauth2Configuration? = null, + format: String + ): String { + val specification = generate(resources, basePath, host, schemes, title, version, oauth2SecuritySchemeDefinition) + return ApiSpecificationWriter.serialize(format, specification) } private fun extractDefinitions(swagger: Swagger): Swagger { @@ -80,13 +102,25 @@ object OpenApi20Generator { extractBodyParameter(operation.parameters) ?.takeIf { it.schema != null } ?.let { - it.schema(extractOrFindSchema(schemasToKeys, it.schema, generateSchemaName(pathKey))) + it.schema( + extractOrFindSchema( + schemasToKeys, + it.schema, + generateSchemaName(pathKey) + ) + ) } operation.responses.values .filter { it.responseSchema != null } .forEach { - it.responseSchema(extractOrFindSchema(schemasToKeys, it.responseSchema, generateSchemaName(pathKey))) + it.responseSchema( + extractOrFindSchema( + schemasToKeys, + it.responseSchema, + generateSchemaName(pathKey) + ) + ) } } @@ -132,7 +166,11 @@ object OpenApi20Generator { ): Map { return groupByPath(resources) .entries - .map { it.key to resourceModels2Path(it.value, oauth2SecuritySchemeDefinition) } + .map { it.key to resourceModels2Path( + it.value, + oauth2SecuritySchemeDefinition + ) + } .toMap() } @@ -164,13 +202,48 @@ object OpenApi20Generator { .entries .forEach { when (it.key) { - HTTPMethod.GET -> path.get(resourceModels2Operation(it.value, oauth2SecuritySchemeDefinition)) - HTTPMethod.POST -> path.post(resourceModels2Operation(it.value, oauth2SecuritySchemeDefinition)) - HTTPMethod.PUT -> path.put(resourceModels2Operation(it.value, oauth2SecuritySchemeDefinition)) - HTTPMethod.DELETE -> path.delete(resourceModels2Operation(it.value, oauth2SecuritySchemeDefinition)) - HTTPMethod.PATCH -> path.patch(resourceModels2Operation(it.value, oauth2SecuritySchemeDefinition)) - HTTPMethod.HEAD -> path.head(resourceModels2Operation(it.value, oauth2SecuritySchemeDefinition)) - HTTPMethod.OPTIONS -> path.options(resourceModels2Operation(it.value, oauth2SecuritySchemeDefinition)) + HTTPMethod.GET -> path.get( + resourceModels2Operation( + it.value, + oauth2SecuritySchemeDefinition + ) + ) + HTTPMethod.POST -> path.post( + resourceModels2Operation( + it.value, + oauth2SecuritySchemeDefinition + ) + ) + HTTPMethod.PUT -> path.put( + resourceModels2Operation( + it.value, + oauth2SecuritySchemeDefinition + ) + ) + HTTPMethod.DELETE -> path.delete( + resourceModels2Operation( + it.value, + oauth2SecuritySchemeDefinition + ) + ) + HTTPMethod.PATCH -> path.patch( + resourceModels2Operation( + it.value, + oauth2SecuritySchemeDefinition + ) + ) + HTTPMethod.HEAD -> path.head( + resourceModels2Operation( + it.value, + oauth2SecuritySchemeDefinition + ) + ) + HTTPMethod.OPTIONS -> path.options( + resourceModels2Operation( + it.value, + oauth2SecuritySchemeDefinition + ) + ) } } @@ -190,23 +263,30 @@ object OpenApi20Generator { consumes = modelsWithSamePathAndMethod.map { it.request.contentType }.distinct().filterNotNull().nullIfEmpty() produces = modelsWithSamePathAndMethod.map { it.response.contentType }.distinct().filterNotNull().nullIfEmpty() parameters = - extractPathParameters(firstModelForPathAndMethod).plus( + extractPathParameters( + firstModelForPathAndMethod + ).plus( firstModelForPathAndMethod.request.requestParameters.map { - requestParameterDescriptor2Parameter(it) + requestParameterDescriptor2Parameter( + it + ) }).plus( firstModelForPathAndMethod.request.headers.map { header2Parameter(it) } ).plus( listOfNotNull( - requestFieldDescriptor2Parameter(modelsWithSamePathAndMethod.flatMap { it.request.requestFields }, + requestFieldDescriptor2Parameter( + modelsWithSamePathAndMethod.flatMap { it.request.requestFields }, modelsWithSamePathAndMethod .filter { it.request.contentType != null && it.request.example != null } .map { it.request.contentType!! to it.request.example!! } .toMap()) ) ).nullIfEmpty() - responses = responsesByStatusCode(modelsWithSamePathAndMethod) + responses = responsesByStatusCode( + modelsWithSamePathAndMethod + ) .mapValues { responseModel2Response(it.value) } .nullIfEmpty() }.apply { @@ -215,7 +295,10 @@ object OpenApi20Generator { when (securityRequirements.type) { SecurityType.OAUTH2 -> oauth2SecuritySchemeDefinition?.flows?.map { addSecurity(oauth2SecuritySchemeDefinition.securitySchemeName(it), - securityRequirements2ScopesList(securityRequirements)) + securityRequirements2ScopesList( + securityRequirements + ) + ) } SecurityType.BASIC -> addSecurity(BASIC_SECURITY_NAME, null) SecurityType.API_KEY -> addSecurity(API_KEY_SECURITY_NAME, null) @@ -245,7 +328,8 @@ object OpenApi20Generator { private fun addSecurityDefinitions(openApi: Swagger, oauth2SecuritySchemeDefinition: Oauth2Configuration?) { oauth2SecuritySchemeDefinition?.flows?.map { flow -> val scopeAndDescriptions = oauth2SecuritySchemeDefinition.scopes - val allScopes = collectScopesFromOperations(openApi) + val allScopes = + collectScopesFromOperations(openApi) val oauth2Definition = when (flow) { "accessCode" -> OAuth2Definition().accessCode(oauth2SecuritySchemeDefinition.authorizationUrl, oauth2SecuritySchemeDefinition.tokenUrl) @@ -260,11 +344,19 @@ object OpenApi20Generator { } openApi.addSecurityDefinition(oauth2SecuritySchemeDefinition.securitySchemeName(flow), oauth2Definition) } - if (hasAnyOperationWithSecurityName(openApi, BASIC_SECURITY_NAME)) { + if (hasAnyOperationWithSecurityName( + openApi, + BASIC_SECURITY_NAME + ) + ) { openApi.addSecurityDefinition(BASIC_SECURITY_NAME, BasicAuthDefinition()) } - if (hasAnyOperationWithSecurityName(openApi, API_KEY_SECURITY_NAME)) { + if (hasAnyOperationWithSecurityName( + openApi, + API_KEY_SECURITY_NAME + ) + ) { openApi.addSecurityDefinition(API_KEY_SECURITY_NAME, ApiKeyAuthDefinition()) } } diff --git a/restdocs-api-spec-openapi-generator/src/main/kotlin/com/epages/restdocs/apispec/openapi2/OptimizedYamlSerializationObjectMapperFactory.kt b/restdocs-api-spec-openapi-generator/src/main/kotlin/com/epages/restdocs/apispec/openapi2/OptimizedYamlSerializationObjectMapperFactory.kt new file mode 100644 index 00000000..b72bcc83 --- /dev/null +++ b/restdocs-api-spec-openapi-generator/src/main/kotlin/com/epages/restdocs/apispec/openapi2/OptimizedYamlSerializationObjectMapperFactory.kt @@ -0,0 +1,50 @@ +package com.epages.restdocs.apispec.openapi2 + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.core.JsonFactory +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory +import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator +import io.swagger.jackson.mixin.ResponseSchemaMixin +import io.swagger.models.Response +import io.swagger.util.DeserializationModule +import io.swagger.util.ReferenceSerializationConfigurer + +internal object OptimizedYamlSerializationObjectMapperFactory { + + fun createYaml(): ObjectMapper { + return createYaml(true, true) + } + + fun createYaml(includePathDeserializer: Boolean, includeResponseDeserializer: Boolean): ObjectMapper { + val factory = YAMLFactory() + factory.disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER) + factory.enable(YAMLGenerator.Feature.MINIMIZE_QUOTES) + factory.enable(YAMLGenerator.Feature.SPLIT_LINES) + factory.enable(YAMLGenerator.Feature.ALWAYS_QUOTE_NUMBERS_AS_STRINGS) + return create(factory, includePathDeserializer, includeResponseDeserializer) + } + + private fun create( + jsonFactory: JsonFactory?, + includePathDeserializer: Boolean, + includeResponseDeserializer: Boolean + ): ObjectMapper { + val mapper = if (jsonFactory == null) ObjectMapper() else ObjectMapper(jsonFactory) + + val deserializerModule = DeserializationModule(includePathDeserializer, includeResponseDeserializer) + mapper.registerModule(deserializerModule) + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL) + mapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false) + mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false) + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + + mapper.addMixIn(Response::class.java, ResponseSchemaMixin::class.java) + + ReferenceSerializationConfigurer.serializeAsComputedRef(mapper) + + return mapper + } +} diff --git a/restdocs-openapi-generator/src/test/kotlin/com/epages/restdocs/openapi/generator/OpenApi20GeneratorTest.kt b/restdocs-api-spec-openapi-generator/src/test/kotlin/com/epages/restdocs/apispec/openapi2/OpenApi20GeneratorTest.kt similarity index 69% rename from restdocs-openapi-generator/src/test/kotlin/com/epages/restdocs/openapi/generator/OpenApi20GeneratorTest.kt rename to restdocs-api-spec-openapi-generator/src/test/kotlin/com/epages/restdocs/apispec/openapi2/OpenApi20GeneratorTest.kt index 074f5846..717bf1fa 100644 --- a/restdocs-openapi-generator/src/test/kotlin/com/epages/restdocs/openapi/generator/OpenApi20GeneratorTest.kt +++ b/restdocs-api-spec-openapi-generator/src/test/kotlin/com/epages/restdocs/apispec/openapi2/OpenApi20GeneratorTest.kt @@ -1,17 +1,17 @@ -package com.epages.restdocs.openapi.generator - -import com.epages.restdocs.openapi.model.AbstractParameterDescriptor -import com.epages.restdocs.openapi.model.FieldDescriptor -import com.epages.restdocs.openapi.model.HTTPMethod -import com.epages.restdocs.openapi.model.HeaderDescriptor -import com.epages.restdocs.openapi.model.Oauth2Configuration -import com.epages.restdocs.openapi.model.ParameterDescriptor -import com.epages.restdocs.openapi.model.RequestModel -import com.epages.restdocs.openapi.model.ResourceModel -import com.epages.restdocs.openapi.model.ResponseModel -import com.epages.restdocs.openapi.model.SecurityRequirements -import com.epages.restdocs.openapi.model.SecurityType.BASIC -import com.epages.restdocs.openapi.model.SecurityType.OAUTH2 +package com.epages.restdocs.apispec.openapi2 + +import com.epages.restdocs.apispec.model.AbstractParameterDescriptor +import com.epages.restdocs.apispec.model.FieldDescriptor +import com.epages.restdocs.apispec.model.HTTPMethod +import com.epages.restdocs.apispec.model.HeaderDescriptor +import com.epages.restdocs.apispec.model.Oauth2Configuration +import com.epages.restdocs.apispec.model.ParameterDescriptor +import com.epages.restdocs.apispec.model.RequestModel +import com.epages.restdocs.apispec.model.ResourceModel +import com.epages.restdocs.apispec.model.ResponseModel +import com.epages.restdocs.apispec.model.SecurityRequirements +import com.epages.restdocs.apispec.model.SecurityType.BASIC +import com.epages.restdocs.apispec.model.SecurityType.OAUTH2 import io.swagger.models.Model import io.swagger.models.Path import io.swagger.models.Response @@ -145,7 +145,7 @@ class OpenApi20GeneratorTest { ) ) - println(Json.pretty().writeValueAsString(openapi)) + println(ApiSpecificationWriter.serialize("yaml", openapi)) return openapi } @@ -299,13 +299,13 @@ class OpenApi20GeneratorTest { private fun givenResourceModelWithBasicSecurity(): List { return listOf( - ResourceModel( - operationId = "test", - privateResource = false, - deprecated = false, - request = getProductRequestWithBasicSecurity(), - response = getProduct200Response(getProductPayloadExample()) - ) + ResourceModel( + operationId = "test", + privateResource = false, + deprecated = false, + request = getProductRequestWithBasicSecurity(), + response = getProduct200Response(getProductPayloadExample()) + ) ) } @@ -335,47 +335,47 @@ class OpenApi20GeneratorTest { private fun givenPostProductResourceModel(): List { return listOf( - ResourceModel( - operationId = "test", - privateResource = false, - deprecated = false, - request = postProductRequest(), - response = postProduct200Response(getProductPayloadExample()) - ) + ResourceModel( + operationId = "test", + privateResource = false, + deprecated = false, + request = postProductRequest(), + response = postProduct200Response(getProductPayloadExample()) + ) ) } private fun givenResourceModelsWithDifferentResponsesForSameRequest(): List { return listOf( - ResourceModel( - operationId = "test", - privateResource = false, - deprecated = false, - tags = setOf("tag1", "tag2"), - request = getProductRequest(), - response = getProduct200Response(getProductPayloadExample()) - ), - ResourceModel( - operationId = "test", - privateResource = false, - deprecated = false, - request = getProductRequest(), - response = getProduct200Response(getProduct200ResponseAlternateExample()) - ), - ResourceModel( - operationId = "test", - privateResource = false, - deprecated = false, - request = getProductRequest(), - response = getProduct400Response() - ), - ResourceModel( - operationId = "test", - privateResource = false, - deprecated = false, - request = deleteProductRequest(), - response = deleteProduct204Response() - ) + ResourceModel( + operationId = "test", + privateResource = false, + deprecated = false, + tags = setOf("tag1", "tag2"), + request = getProductRequest(), + response = getProduct200Response(getProductPayloadExample()) + ), + ResourceModel( + operationId = "test", + privateResource = false, + deprecated = false, + request = getProductRequest(), + response = getProduct200Response(getProduct200ResponseAlternateExample()) + ), + ResourceModel( + operationId = "test", + privateResource = false, + deprecated = false, + request = getProductRequest(), + response = getProduct400Response() + ), + ResourceModel( + operationId = "test", + privateResource = false, + deprecated = false, + request = deleteProductRequest(), + response = deleteProduct204Response() + ) ) } @@ -436,39 +436,39 @@ class OpenApi20GeneratorTest { private fun getProduct200Response(example: String): ResponseModel { return ResponseModel( - status = 200, - contentType = "application/json", - headers = listOf( - HeaderDescriptor( - name = "SIGNATURE", - description = "This is some signature", - type = "STRING", - optional = false - ) - ), - responseFields = listOf( - FieldDescriptor( - path = "_id", - description = "ID of the product", - type = "STRING" - ), - FieldDescriptor( - path = "description", - description = "Product description, localized.", - type = "STRING" - ) + status = 200, + contentType = "application/json", + headers = listOf( + HeaderDescriptor( + name = "SIGNATURE", + description = "This is some signature", + type = "STRING", + optional = false + ) + ), + responseFields = listOf( + FieldDescriptor( + path = "_id", + description = "ID of the product", + type = "STRING" ), - example = example + FieldDescriptor( + path = "description", + description = "Product description, localized.", + type = "STRING" + ) + ), + example = example ) } private fun getProduct400Response(): ResponseModel { return ResponseModel( - status = 400, - contentType = "application/json", - headers = listOf(), - responseFields = listOf(), - example = "This is an ERROR!" + status = 400, + contentType = "application/json", + headers = listOf(), + responseFields = listOf(), + example = "This is an ERROR!" ) } @@ -498,54 +498,54 @@ class OpenApi20GeneratorTest { private fun getProductRequest(): RequestModel { return RequestModel( - path = "/products/{id}", - method = HTTPMethod.GET, - contentType = "application/json", - securityRequirements = SecurityRequirements( - type = OAUTH2, - requiredScopes = listOf("prod:r") - ), - headers = listOf( - HeaderDescriptor( - name = "Authorization", - description = "Access token", - type = "string", - optional = false - ) - ), - pathParameters = listOf( - ParameterDescriptor( - name = "id", - description = "Product ID", - type = "STRING", - optional = false, - ignored = false - ) - ), - requestParameters = listOf( - ParameterDescriptor( - name = "locale", - description = "Localizes the product fields to the given locale code", - type = "STRING", - optional = true, - ignored = false - ) - ), - requestFields = listOf() + path = "/products/{id}", + method = HTTPMethod.GET, + contentType = "application/json", + securityRequirements = SecurityRequirements( + type = OAUTH2, + requiredScopes = listOf("prod:r") + ), + headers = listOf( + HeaderDescriptor( + name = "Authorization", + description = "Access token", + type = "string", + optional = false + ) + ), + pathParameters = listOf( + ParameterDescriptor( + name = "id", + description = "Product ID", + type = "STRING", + optional = false, + ignored = false + ) + ), + requestParameters = listOf( + ParameterDescriptor( + name = "locale", + description = "Localizes the product fields to the given locale code", + type = "STRING", + optional = true, + ignored = false + ) + ), + requestFields = listOf() ) } private fun getProductRequestWithBasicSecurity(): RequestModel { return RequestModel( - path = "/products", - method = HTTPMethod.GET, - securityRequirements = SecurityRequirements( - type = BASIC - ), - headers = listOf(), - pathParameters = listOf(), - requestParameters = listOf(), - requestFields = listOf() + path = "/products", + method = HTTPMethod.GET, + securityRequirements = SecurityRequirements( + type = BASIC + ), + headers = listOf(), + pathParameters = listOf(), + requestParameters = listOf(), + requestFields = listOf() ) } @@ -567,64 +567,64 @@ class OpenApi20GeneratorTest { private fun postProductRequest(): RequestModel { return RequestModel( - path = "/products", - method = HTTPMethod.POST, - contentType = "application/json", - securityRequirements = SecurityRequirements( - type = OAUTH2, - requiredScopes = listOf("prod:c") - ), - headers = listOf( - HeaderDescriptor( - name = "Authorization", - description = "Access token", - type = "STRING", - optional = false - ) + path = "/products", + method = HTTPMethod.POST, + contentType = "application/json", + securityRequirements = SecurityRequirements( + type = OAUTH2, + requiredScopes = listOf("prod:c") + ), + headers = listOf( + HeaderDescriptor( + name = "Authorization", + description = "Access token", + type = "STRING", + optional = false + ) + ), + pathParameters = listOf(), + requestParameters = listOf(), + requestFields = listOf( + FieldDescriptor( + path = "description", + description = "Product description, localized.", + type = "STRING" ), - pathParameters = listOf(), - requestParameters = listOf(), - requestFields = listOf( - FieldDescriptor( - path = "description", - description = "Product description, localized.", - type = "STRING" - ), - FieldDescriptor( - path = "price.currency", - description = "Product currency.", - type = "STRING" - ), - FieldDescriptor( - path = "price.amount", - description = "Product price.", - type = "NUMBER" - ) + FieldDescriptor( + path = "price.currency", + description = "Product currency.", + type = "STRING" ), - example = getProductPayloadExample() + FieldDescriptor( + path = "price.amount", + description = "Product price.", + type = "NUMBER" + ) + ), + example = getProductPayloadExample() ) } private fun deleteProductRequest(): RequestModel { return RequestModel( - path = "/products/{id}", - method = HTTPMethod.DELETE, - securityRequirements = SecurityRequirements( - type = OAUTH2, - requiredScopes = listOf("prod:d") - ), - headers = listOf(), - pathParameters = listOf( - ParameterDescriptor( - name = "id", - description = "Product ID", - type = "STRING", - optional = false, - ignored = false - ) - ), - requestParameters = listOf(), - requestFields = listOf() + path = "/products/{id}", + method = HTTPMethod.DELETE, + securityRequirements = SecurityRequirements( + type = OAUTH2, + requiredScopes = listOf("prod:d") + ), + headers = listOf(), + pathParameters = listOf( + ParameterDescriptor( + name = "id", + description = "Product ID", + type = "STRING", + optional = false, + ignored = false + ) + ), + requestParameters = listOf(), + requestFields = listOf() ) } @@ -660,11 +660,11 @@ class OpenApi20GeneratorTest { private fun deleteProduct204Response(): ResponseModel { return ResponseModel( - status = 204, - contentType = "application/json", - headers = listOf(), - responseFields = listOf(), - example = "" + status = 204, + contentType = "application/json", + headers = listOf(), + responseFields = listOf(), + example = "" ) } diff --git a/restdocs-api-spec-openapi3-generator/build.gradle.kts b/restdocs-api-spec-openapi3-generator/build.gradle.kts new file mode 100644 index 00000000..042ed2d6 --- /dev/null +++ b/restdocs-api-spec-openapi3-generator/build.gradle.kts @@ -0,0 +1,33 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + kotlin("jvm") +} + +repositories { + mavenCentral() + jcenter() + maven { url = uri("https://jitpack.io") } +} + +val jacksonVersion: String by extra +val junitVersion: String by extra + +dependencies { + compile(kotlin("stdlib-jdk8")) + + compile(project(":restdocs-api-spec-model")) + compile(project(":restdocs-api-spec-jsonschema")) + + compile("io.swagger.core.v3:swagger-core:2.0.5") + compile("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion") + compile("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion") + + testImplementation("io.swagger:swagger-parser:2.0.0-rc1") + testImplementation("org.junit.jupiter:junit-jupiter-engine:$junitVersion") + testImplementation("org.assertj:assertj-core:3.10.0") + + testImplementation("com.jayway.jsonpath:json-path:2.4.0") +} + + diff --git a/restdocs-api-spec-openapi3-generator/src/main/kotlin/com/epages/restdocs/apispec/openapi3/ApiSpecificationWriter.kt b/restdocs-api-spec-openapi3-generator/src/main/kotlin/com/epages/restdocs/apispec/openapi3/ApiSpecificationWriter.kt new file mode 100644 index 00000000..241d3f39 --- /dev/null +++ b/restdocs-api-spec-openapi3-generator/src/main/kotlin/com/epages/restdocs/apispec/openapi3/ApiSpecificationWriter.kt @@ -0,0 +1,26 @@ +package com.epages.restdocs.apispec.openapi3 + +import io.swagger.v3.core.util.Json +import io.swagger.v3.core.util.Yaml +import io.swagger.v3.oas.models.OpenAPI + +internal object ApiSpecificationWriter { + + private val yamlFormats = setOf("yaml", "yml") + private val jsonFormats = setOf("json") + + fun serialize(format: String, openApi: OpenAPI): String { + validateFormat(format) + return if (yamlFormats.contains(format)) { + Yaml.pretty().writeValueAsString(openApi) + } else { + Json.pretty().writeValueAsString(openApi) + } + } + + fun supportedFormats() = yamlFormats + jsonFormats + + fun validateFormat(format: String) { + if (!supportedFormats().contains(format)) throw IllegalArgumentException("Format '$format' is invalid - supported formats are '${supportedFormats()}'") + } +} diff --git a/restdocs-api-spec-openapi3-generator/src/main/kotlin/com/epages/restdocs/apispec/openapi3/OpenApi3Generator.kt b/restdocs-api-spec-openapi3-generator/src/main/kotlin/com/epages/restdocs/apispec/openapi3/OpenApi3Generator.kt new file mode 100644 index 00000000..9cf8653f --- /dev/null +++ b/restdocs-api-spec-openapi3-generator/src/main/kotlin/com/epages/restdocs/apispec/openapi3/OpenApi3Generator.kt @@ -0,0 +1,409 @@ +package com.epages.restdocs.apispec.openapi3 + +import com.epages.restdocs.apispec.jsonschema.JsonSchemaFromFieldDescriptorsGenerator +import com.epages.restdocs.apispec.model.FieldDescriptor +import com.epages.restdocs.apispec.model.HTTPMethod +import com.epages.restdocs.apispec.model.HeaderDescriptor +import com.epages.restdocs.apispec.model.Oauth2Configuration +import com.epages.restdocs.apispec.model.ParameterDescriptor +import com.epages.restdocs.apispec.model.RequestModel +import com.epages.restdocs.apispec.model.ResourceModel +import com.epages.restdocs.apispec.model.ResponseModel +import com.epages.restdocs.apispec.model.SimpleType +import com.epages.restdocs.apispec.openapi3.SecuritySchemeGenerator.addSecurityDefinitions +import com.epages.restdocs.apispec.openapi3.SecuritySchemeGenerator.addSecurityItemFromSecurityRequirements +import com.fasterxml.jackson.module.kotlin.readValue +import io.swagger.v3.core.util.Json +import io.swagger.v3.oas.models.Components +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.Operation +import io.swagger.v3.oas.models.PathItem +import io.swagger.v3.oas.models.Paths +import io.swagger.v3.oas.models.examples.Example +import io.swagger.v3.oas.models.headers.Header +import io.swagger.v3.oas.models.info.Info +import io.swagger.v3.oas.models.media.BooleanSchema +import io.swagger.v3.oas.models.media.Content +import io.swagger.v3.oas.models.media.IntegerSchema +import io.swagger.v3.oas.models.media.MediaType +import io.swagger.v3.oas.models.media.NumberSchema +import io.swagger.v3.oas.models.media.Schema +import io.swagger.v3.oas.models.media.StringSchema +import io.swagger.v3.oas.models.parameters.HeaderParameter +import io.swagger.v3.oas.models.parameters.PathParameter +import io.swagger.v3.oas.models.parameters.QueryParameter +import io.swagger.v3.oas.models.parameters.RequestBody +import io.swagger.v3.oas.models.responses.ApiResponse +import io.swagger.v3.oas.models.responses.ApiResponses +import io.swagger.v3.oas.models.servers.Server +import java.util.Comparator.comparing +import java.util.Comparator.comparingInt + +object OpenApi3Generator { + + internal fun generate( + resources: List, + servers: List, + title: String = "API", + version: String = "1.0.0", + oauth2SecuritySchemeDefinition: Oauth2Configuration? = null + ): OpenAPI { + return OpenAPI().apply { + + this.servers = servers + info = Info().apply { + this.title = title + this.version = version + } + paths = generatePaths( + resources, + oauth2SecuritySchemeDefinition + ) + extractDefinitions() + addSecurityDefinitions(oauth2SecuritySchemeDefinition) + } + } + + fun generateAndSerialize( + resources: List, + servers: List, + title: String = "API", + version: String = "1.0.0", + oauth2SecuritySchemeDefinition: Oauth2Configuration? = null, + format: String + ) = + ApiSpecificationWriter.serialize(format, + generate( + resources = resources, + servers = servers, + title = title, + version = version, + oauth2SecuritySchemeDefinition = oauth2SecuritySchemeDefinition + )) + + private fun OpenAPI.extractDefinitions() { + val schemasToKeys = HashMap, String>() + val operationToPathKey = HashMap() + + paths.map { it.key to it.value.readOperations() } + .forEach { (path, operations) -> + operations.forEach { operation -> + operationToPathKey[operation] = path + } + } + + operationToPathKey.keys.forEach { operation -> + val path = operationToPathKey[operation]!! + + operation.requestBody?.content?.mapNotNull { it.value } + ?.extractSchemas(schemasToKeys, path) + + operation.responses.values.mapNotNull { it.content }.flatMap { it.values } + .extractSchemas(schemasToKeys, path) + } + + this.components = Components().apply { + schemas = schemasToKeys.keys.map { + schemasToKeys.getValue(it) to it + }.toMap() + } + } + + private fun List.extractSchemas( + schemasToKeys: MutableMap, String>, + path: String + ) { + this.filter { it.schema != null } + .forEach { + it.schema( + extractOrFindSchema( + schemasToKeys, + it.schema, + generateSchemaName(path) + ) + ) + } + } + + private fun extractOrFindSchema(schemasToKeys: MutableMap, String>, schema: Schema, schemaNameGenerator: (Schema) -> String): Schema { + val schemaKey = if (schemasToKeys.containsKey(schema)) { + schemasToKeys[schema]!! + } else { + val name = schemaNameGenerator(schema) + schemasToKeys[schema] = name + name + } + return Schema().apply { `$ref`("#/components/schemas/$schemaKey") } + } + + private fun generateSchemaName(path: String): (Schema) -> String { + return { schema -> path + .removePrefix("/") + .replace("/", "-") + .replace(Regex.fromLiteral("{"), "") + .replace(Regex.fromLiteral("}"), "") + .plus(schema.hashCode()) + } + } + + private fun generatePaths( + resources: List, + oauth2SecuritySchemeDefinition: Oauth2Configuration? + ): Paths { + return groupByPath(resources).entries + .map { it.key to resourceModels2PathItem( + it.value, + oauth2SecuritySchemeDefinition + ) + } + .let { pathAndPathItem -> + Paths().apply { pathAndPathItem.forEach { addPathItem(it.first, it.second) } } } + } + + private fun groupByPath(resources: List): Map> { + return resources.sortedWith( + // by first path segment, then path length, then path + comparing { it.request.path.split("/").firstOrNull { s -> s.isNotEmpty() }.orEmpty() } + .thenComparing(comparingInt { it.request.path.count { c -> c == '/' } }) + .thenComparing(comparing { it.request.path })) + .groupBy { it.request.path } + } + + private fun groupByHttpMethod(resources: List): Map> { + return resources.groupBy { it.request.method } + } + + private fun resourceModels2PathItem( + modelsWithSamePath: List, + oauth2SecuritySchemeDefinition: Oauth2Configuration? + ): PathItem { + val path = PathItem() + groupByHttpMethod(modelsWithSamePath) + .entries + .forEach { + addOperation( + method = it.key, + pathItem = path, + operation = resourceModels2Operation( + it.value, + oauth2SecuritySchemeDefinition + ) + ) + } + + return path + } + + private fun addOperation(method: HTTPMethod, pathItem: PathItem, operation: Operation) = + when (method) { + HTTPMethod.GET -> pathItem.get(operation) + HTTPMethod.POST -> pathItem.post(operation) + HTTPMethod.PUT -> pathItem.put(operation) + HTTPMethod.DELETE -> pathItem.delete(operation) + HTTPMethod.PATCH -> pathItem.patch(operation) + HTTPMethod.HEAD -> pathItem.head(operation) + HTTPMethod.OPTIONS -> pathItem.options(operation) + } + + private fun resourceModels2Operation( + modelsWithSamePathAndMethod: List, + oauth2SecuritySchemeDefinition: Oauth2Configuration? + ): Operation { + val firstModelForPathAndMethod = modelsWithSamePathAndMethod.first() + return Operation().apply { + operationId = firstModelForPathAndMethod.operationId + summary = firstModelForPathAndMethod.summary + description = firstModelForPathAndMethod.description + tags = modelsWithSamePathAndMethod.flatMap { it.tags }.distinct().nullIfEmpty() + deprecated = if (modelsWithSamePathAndMethod.all { it.deprecated }) true else null + parameters = + extractPathParameters( + firstModelForPathAndMethod + ).plus( + firstModelForPathAndMethod.request.requestParameters.map { + requestParameterDescriptor2Parameter( + it + ) + }).plus( + firstModelForPathAndMethod.request.headers.map { + header2Parameter(it) + } + ).nullIfEmpty() + requestBody = resourceModelsToRequestBody( + modelsWithSamePathAndMethod.map { + RequestModelWithOperationId( + it.operationId, + it.request + ) + }) + responses = resourceModelsToApiResponses( + modelsWithSamePathAndMethod.map { + ResponseModelWithOperationId( + it.operationId, + it.response + ) + }) + }.apply { addSecurityItemFromSecurityRequirements(firstModelForPathAndMethod.request.securityRequirements, oauth2SecuritySchemeDefinition) } + } + + private fun resourceModelsToRequestBody(requestModelsWithOperationId: List): RequestBody? { + val requestByContentType = requestModelsWithOperationId + .filter { it.request.contentType != null } + .groupBy { it.request.contentType!! } + + if (requestByContentType.isEmpty()) + return null + + return requestByContentType + .map { (contentType, requests) -> + toMediaType( + requestFields = requests.flatMap { it.request.requestFields }, + examplesWithOperationId = requests.filter { it.request.example != null }.map { it.operationId to it.request.example!! }.toMap(), + contentType = contentType + ) + }.toMap() + .let { contentTypeToMediaType -> + if (contentTypeToMediaType.isEmpty()) null + else RequestBody() + .apply { + content = Content().apply { contentTypeToMediaType.forEach { addMediaType(it.key, it.value) } } + } + } + } + + private fun resourceModelsToApiResponses(responseModelsWithOperationId: List): ApiResponses? { + val responsesByStatus = responseModelsWithOperationId + .groupBy { it.response.status } + + if (responsesByStatus.isEmpty()) + return null + + return responsesByStatus + .mapValues { (_, responses) -> + responsesWithSameStatusToApiResponse( + responses + ) + } + .let { ApiResponses().apply { + it.forEach { (status, apiResponse) -> addApiResponse(status.toString(), apiResponse) } + } } + } + + private fun responsesWithSameStatusToApiResponse(responseModelsSameStatus: List): ApiResponse { + val responsesByContentType = responseModelsSameStatus + .filter { it.response.contentType != null } + .groupBy { it.response.contentType!! } + + val apiResponse = ApiResponse().apply { + description = responseModelsSameStatus.first().response.status.toString() + headers = responseModelsSameStatus.flatMap { it.response.headers } + .map { it.name to Header().apply { + description(it.description) + schema = simpleTypeToSchema(it.type) + } + }.toMap().nullIfEmpty() + } + return responsesByContentType + .map { (contentType, requests) -> + toMediaType( + requestFields = requests.flatMap { it.response.responseFields }, + examplesWithOperationId = requests.map { it.operationId to it.response.example!! }.toMap(), + contentType = contentType + ) + }.toMap() + .let { contentTypeToMediaType -> + apiResponse + .apply { + content = + if (contentTypeToMediaType.isEmpty()) null + else Content().apply { contentTypeToMediaType.forEach { addMediaType(it.key, it.value) } } + } + } + } + + private fun toMediaType( + requestFields: List, + examplesWithOperationId: Map, + contentType: String + ): Pair { + val schema = JsonSchemaFromFieldDescriptorsGenerator().generateSchema(requestFields) + .let { Json.mapper().readValue>(it) } + return contentType to MediaType() + .schema(schema) + .examples(examplesWithOperationId.map { it.key to Example().apply { value(it.value) } }.toMap().nullIfEmpty()) + } + + private fun extractPathParameters(resourceModel: ResourceModel): List { + val pathParameterNames = resourceModel.request.path + .split("/") + .filter { it.startsWith("{") && it.endsWith("}") } + .map { it.removePrefix("{").removeSuffix("}") } + + return pathParameterNames.map { parameterName -> + resourceModel.request.pathParameters + .firstOrNull { it.name == parameterName } + ?.let { pathParameterDescriptor2Parameter(it) } + ?: parameterName2PathParameter(parameterName) + } + } + + private fun pathParameterDescriptor2Parameter(parameterDescriptor: ParameterDescriptor): PathParameter { + return PathParameter().apply { + name = parameterDescriptor.name + description = parameterDescriptor.description + } + } + + private fun parameterName2PathParameter(parameterName: String): PathParameter { + return PathParameter().apply { + name = parameterName + description = "" + } + } + + private fun requestParameterDescriptor2Parameter(parameterDescriptor: ParameterDescriptor): QueryParameter { + return QueryParameter().apply { + name = parameterDescriptor.name + description = parameterDescriptor.description + required = parameterDescriptor.optional.not() + schema = simpleTypeToSchema(parameterDescriptor.type) + } + } + + private fun header2Parameter(headerDescriptor: HeaderDescriptor): HeaderParameter { + return HeaderParameter().apply { + name = headerDescriptor.name + description = headerDescriptor.description + required = headerDescriptor.optional.not() + schema = simpleTypeToSchema(headerDescriptor.type) + } + } + + private fun simpleTypeToSchema(type: String): Schema<*>? { + return when (type.toLowerCase()) { + SimpleType.BOOLEAN.name.toLowerCase() -> BooleanSchema() + SimpleType.STRING.name.toLowerCase() -> StringSchema() + SimpleType.NUMBER.name.toLowerCase() -> NumberSchema() + SimpleType.INTEGER.name.toLowerCase() -> IntegerSchema() + else -> throw IllegalArgumentException("Unknown type '$type'") + } + } + + private fun Map.nullIfEmpty(): Map? { + return if (this.isEmpty()) null else this + } + + private fun List.nullIfEmpty(): List? { + return if (this.isEmpty()) null else this + } + + private data class RequestModelWithOperationId( + val operationId: String, + val request: RequestModel + ) + + private data class ResponseModelWithOperationId( + val operationId: String, + val response: ResponseModel + ) +} diff --git a/restdocs-api-spec-openapi3-generator/src/main/kotlin/com/epages/restdocs/apispec/openapi3/SecuritySchemeGenerator.kt b/restdocs-api-spec-openapi3-generator/src/main/kotlin/com/epages/restdocs/apispec/openapi3/SecuritySchemeGenerator.kt new file mode 100644 index 00000000..c5f0aa97 --- /dev/null +++ b/restdocs-api-spec-openapi3-generator/src/main/kotlin/com/epages/restdocs/apispec/openapi3/SecuritySchemeGenerator.kt @@ -0,0 +1,115 @@ +package com.epages.restdocs.apispec.openapi3 + +import com.epages.restdocs.apispec.model.Oauth2Configuration +import com.epages.restdocs.apispec.model.SecurityRequirements +import com.epages.restdocs.apispec.model.SecurityType +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.Operation +import io.swagger.v3.oas.models.security.OAuthFlow +import io.swagger.v3.oas.models.security.OAuthFlows +import io.swagger.v3.oas.models.security.Scopes +import io.swagger.v3.oas.models.security.SecurityRequirement +import io.swagger.v3.oas.models.security.SecurityScheme + +internal object SecuritySchemeGenerator { + + private const val API_KEY_SECURITY_NAME = "api_key" + private const val BASIC_SECURITY_NAME = "basic" + + fun OpenAPI.addSecurityDefinitions(oauth2SecuritySchemeDefinition: Oauth2Configuration?) { + if (oauth2SecuritySchemeDefinition?.flows?.isNotEmpty() == true) { + val flows = OAuthFlows() + components.addSecuritySchemes("oauth2", SecurityScheme().apply { + type = SecurityScheme.Type.OAUTH2 + this.flows = flows + }) + oauth2SecuritySchemeDefinition.flows.forEach { flow -> + val scopeAndDescriptions = oauth2SecuritySchemeDefinition.scopes + val allScopes = collectScopesFromOperations() + + when (flow) { + "authorizationCode" -> flows.authorizationCode( + OAuthFlow() + .authorizationUrl(oauth2SecuritySchemeDefinition.authorizationUrl) + .tokenUrl(oauth2SecuritySchemeDefinition.tokenUrl) + .scopes(allScopes, scopeAndDescriptions)) + "clientCredentials" -> flows.clientCredentials( + OAuthFlow() + .tokenUrl(oauth2SecuritySchemeDefinition.tokenUrl) + .scopes(allScopes, scopeAndDescriptions)) + "password" -> flows.password( + OAuthFlow() + .tokenUrl(oauth2SecuritySchemeDefinition.tokenUrl) + .scopes(allScopes, scopeAndDescriptions)) + "implicit" -> flows.implicit( + OAuthFlow() + .authorizationUrl(oauth2SecuritySchemeDefinition.authorizationUrl) + .scopes(allScopes, scopeAndDescriptions)) + else -> throw IllegalArgumentException("Unknown flow '$flow' in oauth2SecuritySchemeDefinition") + } + } + } + if (hasAnyOperationWithSecurityName(this, BASIC_SECURITY_NAME)) { + components.addSecuritySchemes(BASIC_SECURITY_NAME, SecurityScheme().apply { + type = SecurityScheme.Type.HTTP + scheme = "Basic" + }) + } + + if (hasAnyOperationWithSecurityName(this, API_KEY_SECURITY_NAME)) { + components.addSecuritySchemes(API_KEY_SECURITY_NAME, SecurityScheme().apply { + type = SecurityScheme.Type.APIKEY + `in` = SecurityScheme.In.HEADER + name = "Authorization" + }) + } + } + + fun Operation.addSecurityItemFromSecurityRequirements(securityRequirements: SecurityRequirements?, oauth2SecuritySchemeDefinition: Oauth2Configuration?) { + if (securityRequirements != null) { + when (securityRequirements.type) { + SecurityType.OAUTH2 -> oauth2SecuritySchemeDefinition?.flows?.map { + addSecurityItem( + SecurityRequirement().addList(oauth2SecuritySchemeDefinition.securitySchemeName(it), + securityRequirements2ScopesList(securityRequirements) + ) + ) + } + SecurityType.BASIC -> addSecurityItem(SecurityRequirement().addList(BASIC_SECURITY_NAME)) + SecurityType.API_KEY -> addSecurityItem(SecurityRequirement().addList(API_KEY_SECURITY_NAME)) + } + } + } + + private fun securityRequirements2ScopesList(securityRequirements: SecurityRequirements): List { + return if (securityRequirements.type == SecurityType.OAUTH2 && securityRequirements.requiredScopes != null) securityRequirements.requiredScopes!! else listOf() + } + + private fun OAuthFlow.scopes(scopes: Set, scopeAndDescriptions: Map) = + Scopes().apply { + scopes.forEach { + addString(it, scopeAndDescriptions.getOrDefault(it, "No description")) + } + }.also { this.scopes(it) }.let { this } + + private fun hasAnyOperationWithSecurityName(openApi: OpenAPI, name: String) = + openApi.paths + .flatMap { it.value.readOperations() } + .mapNotNull { it.security } + .flatMap { it } + .flatMap { it.keys } + .any { it == name } + + private fun OpenAPI.collectScopesFromOperations(): Set { + return paths + .flatMap { path -> + path.value.readOperations() + .flatMap { operation -> + operation?.security + ?.filter { s -> s.filterKeys { it.startsWith("oauth2") }.isNotEmpty() } + ?.flatMap { oauthSecurity -> oauthSecurity.values.flatMap { it } } + ?: listOf() + } + }.toSet() + } +} diff --git a/restdocs-api-spec-openapi3-generator/src/test/kotlin/com/epages/restdocs/apispec/openapi3/OpenApi3GeneratorTest.kt b/restdocs-api-spec-openapi3-generator/src/test/kotlin/com/epages/restdocs/apispec/openapi3/OpenApi3GeneratorTest.kt new file mode 100644 index 00000000..272baef9 --- /dev/null +++ b/restdocs-api-spec-openapi3-generator/src/test/kotlin/com/epages/restdocs/apispec/openapi3/OpenApi3GeneratorTest.kt @@ -0,0 +1,505 @@ +package com.epages.restdocs.apispec.openapi3 + +import com.epages.restdocs.apispec.model.FieldDescriptor +import com.epages.restdocs.apispec.model.HTTPMethod +import com.epages.restdocs.apispec.model.HeaderDescriptor +import com.epages.restdocs.apispec.model.Oauth2Configuration +import com.epages.restdocs.apispec.model.ParameterDescriptor +import com.epages.restdocs.apispec.model.RequestModel +import com.epages.restdocs.apispec.model.ResourceModel +import com.epages.restdocs.apispec.model.ResponseModel +import com.epages.restdocs.apispec.model.SecurityRequirements +import com.epages.restdocs.apispec.model.SecurityType +import com.jayway.jsonpath.Configuration +import com.jayway.jsonpath.DocumentContext +import com.jayway.jsonpath.JsonPath +import com.jayway.jsonpath.Option +import io.swagger.parser.OpenAPIParser +import io.swagger.parser.models.ParseOptions +import io.swagger.v3.oas.models.servers.Server +import org.assertj.core.api.BDDAssertions.then +import org.junit.jupiter.api.Test + +class OpenApi3GeneratorTest { + + lateinit var resources: List + lateinit var openApiSpecJsonString: String + lateinit var openApiJsonPathContext: DocumentContext + + @Test + fun `should convert single resource model to openapi`() { + givenGetProductResourceModel() + + whenOpenApiObjectGenerated() + + thenGetProductByIdOperationIsValid() + thenSecuritySchemesPresent() + thenInfoFieldsPresent() + thenServersPresent() + thenOpenApiSpecIsValid() + } + + @Test + fun `should convert single delete resource model to openapi`() { + givenDeleteProductResourceModel() + + whenOpenApiObjectGenerated() + + then(openApiJsonPathContext.read("paths./products/{id}.delete")).isNotNull() + then(openApiJsonPathContext.read("paths./products/{id}.delete.requestBody")).isNull() + + then(openApiJsonPathContext.read("paths./products/{id}.delete.responses.204")).isNotNull() + then(openApiJsonPathContext.read("paths./products/{id}.delete.responses.204.content")).isNull() + thenOpenApiSpecIsValid() + } + + @Test + fun `should aggregate responses with different content type`() { + givenResourcesWithSamePathAndDifferentContentType() + + whenOpenApiObjectGenerated() + + val productPatchByIdPath = "paths./products/{id}.patch" + then(openApiJsonPathContext.read("$productPatchByIdPath.requestBody.content.application/json.schema.\$ref")).isNotNull() + then(openApiJsonPathContext.read("$productPatchByIdPath.requestBody.content.application/json.examples.test")).isNotNull() + then(openApiJsonPathContext.read("$productPatchByIdPath.requestBody.content.application/json-patch+json.schema.\$ref")).isNotNull() + then(openApiJsonPathContext.read("$productPatchByIdPath.requestBody.content.application/json-patch+json.examples.test-1")).isNotNull() + + then(openApiJsonPathContext.read("$productPatchByIdPath.responses.200.content.application/json.schema.\$ref")).isNotNull() + then(openApiJsonPathContext.read("$productPatchByIdPath.responses.200.content.application/json.examples.test")).isNotNull() + then(openApiJsonPathContext.read("$productPatchByIdPath.responses.200.content.application/hal+json.schema.\$ref")).isNotNull() + then(openApiJsonPathContext.read("$productPatchByIdPath.responses.200.content.application/hal+json.examples.test-1")).isNotNull() + + thenOpenApiSpecIsValid() + } + + @Test + fun `should aggregate example responses with same path and status and content type`() { + givenResourcesWithSamePathAndContentType() + + whenOpenApiObjectGenerated() + + val productGetByIdPath = "paths./products/{id}.get" + then(openApiJsonPathContext.read("$productGetByIdPath.responses.200.content.application/json.schema.\$ref")).isNotNull() + then(openApiJsonPathContext.read("$productGetByIdPath.responses.200.content.application/json.examples.test")).isNotNull() + then(openApiJsonPathContext.read("$productGetByIdPath.responses.200.content.application/json.examples.test-1")).isNotNull() + + thenOpenApiSpecIsValid() + } + + @Test + fun `should aggregate responses with same path and content type but different status`() { + givenResourcesWithSamePathAndContentTypeButDifferentStatus() + + whenOpenApiObjectGenerated() + + val productGetByIdPath = "paths./products/{id}.get" + then(openApiJsonPathContext.read("$productGetByIdPath.responses.200.content.application/json.schema.\$ref")).isNotNull() + then(openApiJsonPathContext.read("$productGetByIdPath.responses.200.content.application/json.examples.test")).isNotNull() + then(openApiJsonPathContext.read("$productGetByIdPath.responses.400.content.application/json.schema.\$ref")).isNotNull() + then(openApiJsonPathContext.read("$productGetByIdPath.responses.400.content.application/json.examples.test-1")).isNotNull() + + thenOpenApiSpecIsValid() + } + + @Test + fun `should aggregate equal schemas across operations`() { + givenResourcesWithSamePathAndDifferentMethods() + + whenOpenApiObjectGenerated() + + val patchResponseSchemaRef = openApiJsonPathContext.read("paths./products/{id}.patch.responses.200.content.application/json.schema.\$ref") + val getResponseSchemaRef = openApiJsonPathContext.read("paths./products/{id}.get.responses.200.content.application/json.schema.\$ref") + then(patchResponseSchemaRef).isEqualTo(getResponseSchemaRef) + + val schemaId = getResponseSchemaRef.removePrefix("#/components/schemas/") + then(openApiJsonPathContext.read("components.schemas.$schemaId.type")).isEqualTo("object") + + thenOpenApiSpecIsValid() + } + + fun thenGetProductByIdOperationIsValid() { + val productGetByIdPath = "paths./products/{id}.get" + then(openApiJsonPathContext.read>("$productGetByIdPath.tags")).isNotNull() + then(openApiJsonPathContext.read("$productGetByIdPath.operationId")).isNotNull() + then(openApiJsonPathContext.read("$productGetByIdPath.summary")).isNotNull() + then(openApiJsonPathContext.read("$productGetByIdPath.description")).isNotNull() + then(openApiJsonPathContext.read("$productGetByIdPath.deprecated")).isNull() + + then(openApiJsonPathContext.read>("$productGetByIdPath.parameters[?(@.name == 'id')].in")).containsOnly("path") + then(openApiJsonPathContext.read>("$productGetByIdPath.parameters[?(@.name == 'id')].required")).containsOnly(true) + then(openApiJsonPathContext.read>("$productGetByIdPath.parameters[?(@.name == 'locale')].in")).containsOnly("query") + then(openApiJsonPathContext.read>("$productGetByIdPath.parameters[?(@.name == 'locale')].required")).containsOnly(false) + then(openApiJsonPathContext.read>("$productGetByIdPath.parameters[?(@.name == 'locale')].schema.type")).containsOnly("string") + then(openApiJsonPathContext.read>("$productGetByIdPath.parameters[?(@.name == 'Authorization')].in")).containsOnly("header") + then(openApiJsonPathContext.read>("$productGetByIdPath.parameters[?(@.name == 'Authorization')].required")).containsOnly(true) + then(openApiJsonPathContext.read>("$productGetByIdPath.parameters[?(@.name == 'Authorization')].schema.type")).containsOnly("string") + + then(openApiJsonPathContext.read("$productGetByIdPath.requestBody")).isNull() + + then(openApiJsonPathContext.read("$productGetByIdPath.responses.200.description")).isNotNull() + then(openApiJsonPathContext.read("$productGetByIdPath.responses.200.headers.SIGNATURE.schema.type")).isNotNull() + then(openApiJsonPathContext.read("$productGetByIdPath.responses.200.content.application/json.schema.\$ref")).isNotNull() + then(openApiJsonPathContext.read("$productGetByIdPath.responses.200.content.application/json.examples.test.value")).isNotNull() + + then(openApiJsonPathContext.read>>("$productGetByIdPath.security[*].oauth2_clientCredentials").flatMap { it }).containsOnly("prod:r") + then(openApiJsonPathContext.read>>("$productGetByIdPath.security[*].oauth2_authorizationCode").flatMap { it }).containsOnly("prod:r") + } + + private fun thenServersPresent() { + then(openApiJsonPathContext.read>("servers[*].url")).contains("https://localhost/api") + } + + private fun thenInfoFieldsPresent() { + then(openApiJsonPathContext.read("info.title")).isEqualTo("API") + then(openApiJsonPathContext.read("info.version")).isEqualTo("1.0.0") + } + + private fun thenSecuritySchemesPresent() { + then(openApiJsonPathContext.read("components.securitySchemes.oauth2.type")).isEqualTo("oauth2") + then(openApiJsonPathContext.read>("components.securitySchemes.oauth2.flows")) + .containsKeys("clientCredentials", "authorizationCode") + then(openApiJsonPathContext.read>("components.securitySchemes.oauth2.flows.clientCredentials.scopes")) + .containsKeys("prod:r") + then(openApiJsonPathContext.read>("components.securitySchemes.oauth2.flows.authorizationCode.scopes")) + .containsKeys("prod:r") + } + + private fun whenOpenApiObjectGenerated() { + openApiSpecJsonString = OpenApi3Generator.generateAndSerialize( + resources = resources, + servers = listOf(Server().apply { url = "https://localhost/api" }), + oauth2SecuritySchemeDefinition = Oauth2Configuration( + "http://example.com/token", + "http://example.com/authorize", + arrayOf("clientCredentials", "authorizationCode") + ), + format = "json" + ) + + println(openApiSpecJsonString) + openApiJsonPathContext = JsonPath.parse(openApiSpecJsonString, Configuration.defaultConfiguration().addOptions( + Option.SUPPRESS_EXCEPTIONS)) + } + + private fun givenResourcesWithSamePathAndContentType() { + resources = listOf( + ResourceModel( + operationId = "test", + summary = "summary", + description = "description", + privateResource = false, + deprecated = false, + tags = setOf("tag1", "tag2"), + request = getProductRequest(), + response = getProductResponse() + ), + ResourceModel( + operationId = "test-1", + summary = "summary 1", + description = "description 1", + privateResource = false, + deprecated = false, + tags = setOf("tag1", "tag2"), + request = getProductRequest(), + response = getProductResponse() + ) + ) + } + + private fun givenResourcesWithSamePathAndContentTypeButDifferentStatus() { + resources = listOf( + ResourceModel( + operationId = "test", + summary = "summary", + description = "description", + privateResource = false, + deprecated = false, + tags = setOf("tag1", "tag2"), + request = getProductRequest(), + response = getProductResponse() + ), + ResourceModel( + operationId = "test-1", + summary = "summary 1", + description = "description 1", + privateResource = false, + deprecated = false, + tags = setOf("tag1", "tag2"), + request = getProductRequest(), + response = getProductErrorResponse() + ) + ) + } + + private fun givenResourcesWithSamePathAndDifferentMethods() { + resources = listOf( + ResourceModel( + operationId = "test", + summary = "summary", + description = "description", + privateResource = false, + deprecated = false, + tags = setOf("tag1", "tag2"), + request = getProductPatchRequest(), + response = getProductResponse() + ), + ResourceModel( + operationId = "test-1", + summary = "summary 1", + description = "description 1", + privateResource = false, + deprecated = false, + tags = setOf("tag1", "tag2"), + request = getProductRequest(), + response = getProductResponse() + ) + ) + } + + private fun givenResourcesWithSamePathAndDifferentContentType() { + resources = listOf( + ResourceModel( + operationId = "test", + summary = "summary", + description = "description", + privateResource = false, + deprecated = false, + tags = setOf("tag1", "tag2"), + request = getProductPatchRequest(), + response = getProductResponse() + ), + ResourceModel( + operationId = "test-1", + summary = "summary 1", + description = "description 1", + privateResource = false, + deprecated = false, + tags = setOf("tag1", "tag2"), + request = getProductPatchJsonPatchRequest(), + response = getProductHalResponse() + ) + ) + } + + private fun givenDeleteProductResourceModel() { + resources = listOf( + ResourceModel( + operationId = "test", + summary = "summary", + description = "description", + privateResource = false, + deprecated = false, + request = RequestModel( + path = "/products/{id}", + method = HTTPMethod.DELETE, + headers = listOf(), + pathParameters = listOf(), + requestParameters = listOf(), + securityRequirements = null, + requestFields = listOf() + ), + response = ResponseModel( + status = 204, + contentType = null, + headers = emptyList(), + responseFields = listOf() + ) + ) + ) + } + + private fun givenGetProductResourceModel() { + resources = listOf( + ResourceModel( + operationId = "test", + summary = "summary", + description = "description", + privateResource = false, + deprecated = false, + tags = setOf("tag1", "tag2"), + request = getProductRequest(), + response = getProductResponse() + ) + ) + } + + private fun getProductErrorResponse(): ResponseModel { + return ResponseModel( + status = 400, + contentType = "application/json", + headers = listOf(), + responseFields = listOf( + FieldDescriptor( + path = "error", + description = "error message.", + type = "STRING" + ) + ), + example = """{ + "error": "bad stuff!" + }""" + ) + } + + private fun getProductResponse(): ResponseModel { + return ResponseModel( + status = 200, + contentType = "application/json", + headers = listOf( + HeaderDescriptor( + name = "SIGNATURE", + description = "This is some signature", + type = "STRING", + optional = false + ) + ), + responseFields = listOf( + FieldDescriptor( + path = "_id", + description = "ID of the product", + type = "STRING" + ), + FieldDescriptor( + path = "description", + description = "Product description, localized.", + type = "STRING" + ) + ), + example = """{ + "_id": "123", + "description": "Good stuff!" + }""" + ) + } + + private fun getProductHalResponse(): ResponseModel { + return ResponseModel( + status = 200, + contentType = "application/hal+json", + responseFields = listOf( + FieldDescriptor( + path = "_id", + description = "ID of the product", + type = "STRING" + ), + FieldDescriptor( + path = "description1", + description = "Product description, localized.", + type = "STRING" + ) + ), + headers = emptyList(), + example = """{ + "_id": "123", + "description": "Good stuff!", + "_links": { + "self": "http://localhost/" + } + }""" + ) + } + + private fun getProductPatchRequest(): RequestModel { + return RequestModel( + path = "/products/{id}", + method = HTTPMethod.PATCH, + headers = listOf(), + pathParameters = listOf(), + requestParameters = listOf(), + securityRequirements = null, + requestFields = listOf( + FieldDescriptor( + path = "description1", + description = "Product description, localized.", + type = "STRING" + ) + ), + contentType = "application/json", + example = """{ + "description": "Good stuff!", + }""" + ) + } + + private fun getProductPatchJsonPatchRequest(): RequestModel { + return RequestModel( + path = "/products/{id}", + method = HTTPMethod.PATCH, + headers = listOf(), + pathParameters = listOf(), + requestParameters = listOf(), + securityRequirements = null, + requestFields = listOf( + FieldDescriptor( + path = "[].op", + description = "operation", + type = "STRING" + ), + FieldDescriptor( + path = "[].path", + description = "path", + type = "STRING" + ), + FieldDescriptor( + path = "[].value", + description = "the new value", + type = "STRING" + ) + ), + contentType = "application/json-patch+json", + example = """ + [ + { + "op": "add", + "path": "/description", + "value": "updated + } + ] + """.trimIndent() + ) + } + + private fun getProductRequest(): RequestModel { + return RequestModel( + path = "/products/{id}", + method = HTTPMethod.GET, + securityRequirements = SecurityRequirements( + type = SecurityType.OAUTH2, + requiredScopes = listOf("prod:r") + ), + headers = listOf( + HeaderDescriptor( + name = "Authorization", + description = "Access token", + type = "string", + optional = false + ) + ), + pathParameters = listOf( + ParameterDescriptor( + name = "id", + description = "Product ID", + type = "STRING", + optional = false, + ignored = false + ) + ), + requestParameters = listOf( + ParameterDescriptor( + name = "locale", + description = "Localizes the product fields to the given locale code", + type = "STRING", + optional = true, + ignored = false + ) + ), + requestFields = listOf() + ) + } + + private fun thenOpenApiSpecIsValid() { + val messages = OpenAPIParser().readContents(openApiSpecJsonString, emptyList(), ParseOptions()).messages + then(messages).describedAs("OpenAPI validation messages should be empty").isEmpty() + } +} diff --git a/restdocs-openapi/build.gradle.kts b/restdocs-api-spec/build.gradle.kts similarity index 95% rename from restdocs-openapi/build.gradle.kts rename to restdocs-api-spec/build.gradle.kts index 71edbbe2..e0f82281 100755 --- a/restdocs-openapi/build.gradle.kts +++ b/restdocs-api-spec/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { exclude("junit") } testCompile("org.junit.jupiter:junit-jupiter-engine:$junitVersion") + testImplementation("org.junit-pioneer:junit-pioneer:0.2.2") testCompile("org.springframework.boot:spring-boot-starter-hateoas:$springBootVersion") testCompile("org.hibernate.validator:hibernate-validator:6.0.10.Final") testCompile("org.assertj:assertj-core:3.10.0") diff --git a/restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/ConstrainedFields.kt b/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/ConstrainedFields.kt similarity index 98% rename from restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/ConstrainedFields.kt rename to restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/ConstrainedFields.kt index c336cb67..f0518daf 100644 --- a/restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/ConstrainedFields.kt +++ b/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/ConstrainedFields.kt @@ -1,4 +1,4 @@ -package com.epages.restdocs.openapi +package com.epages.restdocs.apispec import org.springframework.restdocs.constraints.ValidatorConstraintResolver import org.springframework.restdocs.payload.FieldDescriptor diff --git a/restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/DescriptorExtractor.kt b/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/DescriptorExtractor.kt similarity index 99% rename from restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/DescriptorExtractor.kt rename to restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/DescriptorExtractor.kt index 7514ef00..870598c2 100644 --- a/restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/DescriptorExtractor.kt +++ b/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/DescriptorExtractor.kt @@ -1,5 +1,5 @@ -package com.epages.restdocs.openapi +package com.epages.restdocs.apispec import org.springframework.restdocs.headers.AbstractHeadersSnippet import org.springframework.restdocs.headers.HeaderDescriptor diff --git a/restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/DescriptorValidator.kt b/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/DescriptorValidator.kt similarity index 99% rename from restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/DescriptorValidator.kt rename to restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/DescriptorValidator.kt index 7feef6da..2f93b2b9 100644 --- a/restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/DescriptorValidator.kt +++ b/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/DescriptorValidator.kt @@ -1,4 +1,4 @@ -package com.epages.restdocs.openapi +package com.epages.restdocs.apispec import org.springframework.restdocs.headers.HeaderDescriptor import org.springframework.restdocs.headers.HeaderDocumentation.headerWithName diff --git a/restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/FieldDescriptors.kt b/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/FieldDescriptors.kt similarity index 96% rename from restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/FieldDescriptors.kt rename to restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/FieldDescriptors.kt index be4870a5..844cec47 100644 --- a/restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/FieldDescriptors.kt +++ b/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/FieldDescriptors.kt @@ -1,4 +1,4 @@ -package com.epages.restdocs.openapi +package com.epages.restdocs.apispec import org.springframework.restdocs.payload.FieldDescriptor import org.springframework.restdocs.payload.PayloadDocumentation.applyPathPrefix diff --git a/restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/JwtScopeHandler.kt b/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/JwtScopeHandler.kt similarity index 98% rename from restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/JwtScopeHandler.kt rename to restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/JwtScopeHandler.kt index 0cb1900e..3b2a57d4 100644 --- a/restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/JwtScopeHandler.kt +++ b/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/JwtScopeHandler.kt @@ -1,4 +1,4 @@ -package com.epages.restdocs.openapi +package com.epages.restdocs.apispec import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.readValue diff --git a/restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/MockMvcRestDocumentationWrapper.kt b/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/MockMvcRestDocumentationWrapper.kt similarity index 99% rename from restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/MockMvcRestDocumentationWrapper.kt rename to restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/MockMvcRestDocumentationWrapper.kt index 248ef962..d3eefb4f 100644 --- a/restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/MockMvcRestDocumentationWrapper.kt +++ b/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/MockMvcRestDocumentationWrapper.kt @@ -1,4 +1,4 @@ -package com.epages.restdocs.openapi +package com.epages.restdocs.apispec import org.springframework.restdocs.headers.HeaderDescriptor import org.springframework.restdocs.headers.RequestHeadersSnippet diff --git a/restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/ResourceDocumentation.kt b/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/ResourceDocumentation.kt similarity index 96% rename from restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/ResourceDocumentation.kt rename to restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/ResourceDocumentation.kt index 453db6a2..e4459ca3 100644 --- a/restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/ResourceDocumentation.kt +++ b/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/ResourceDocumentation.kt @@ -1,4 +1,4 @@ -package com.epages.restdocs.openapi +package com.epages.restdocs.apispec import org.springframework.restdocs.payload.FieldDescriptor diff --git a/restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/ResourceSnippet.kt b/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/ResourceSnippet.kt similarity index 99% rename from restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/ResourceSnippet.kt rename to restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/ResourceSnippet.kt index e2d53782..1ca95be7 100755 --- a/restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/ResourceSnippet.kt +++ b/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/ResourceSnippet.kt @@ -1,4 +1,4 @@ -package com.epages.restdocs.openapi +package com.epages.restdocs.apispec import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper diff --git a/restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/ResourceSnippetParameters.kt b/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/ResourceSnippetParameters.kt similarity index 99% rename from restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/ResourceSnippetParameters.kt rename to restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/ResourceSnippetParameters.kt index 8fe225e3..fcbdb7a0 100755 --- a/restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/ResourceSnippetParameters.kt +++ b/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/ResourceSnippetParameters.kt @@ -1,6 +1,6 @@ -package com.epages.restdocs.openapi +package com.epages.restdocs.apispec -import com.epages.restdocs.openapi.SimpleType.STRING +import com.epages.restdocs.apispec.SimpleType.STRING import org.springframework.restdocs.headers.HeaderDescriptor import org.springframework.restdocs.hypermedia.LinkDescriptor import org.springframework.restdocs.payload.FieldDescriptor diff --git a/restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/SecurityRequirementsHandler.kt b/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/SecurityRequirementsHandler.kt similarity index 97% rename from restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/SecurityRequirementsHandler.kt rename to restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/SecurityRequirementsHandler.kt index 8dfd4faf..72eefdbc 100644 --- a/restdocs-openapi/src/main/kotlin/com/epages/restdocs/openapi/SecurityRequirementsHandler.kt +++ b/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/SecurityRequirementsHandler.kt @@ -1,4 +1,4 @@ -package com.epages.restdocs.openapi +package com.epages.restdocs.apispec import org.springframework.http.HttpHeaders import org.springframework.restdocs.operation.Operation diff --git a/restdocs-openapi/src/test/kotlin/com/epages/restdocs/openapi/ConstrainedFieldsTest.kt b/restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/ConstrainedFieldsTest.kt similarity index 95% rename from restdocs-openapi/src/test/kotlin/com/epages/restdocs/openapi/ConstrainedFieldsTest.kt rename to restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/ConstrainedFieldsTest.kt index 763d28a8..4a9a25f0 100644 --- a/restdocs-openapi/src/test/kotlin/com/epages/restdocs/openapi/ConstrainedFieldsTest.kt +++ b/restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/ConstrainedFieldsTest.kt @@ -1,4 +1,4 @@ -package com.epages.restdocs.openapi +package com.epages.restdocs.apispec import org.assertj.core.api.BDDAssertions.then import org.junit.jupiter.api.Test diff --git a/restdocs-openapi/src/test/kotlin/com/epages/restdocs/openapi/FieldDescriptorsTest.kt b/restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/FieldDescriptorsTest.kt similarity index 96% rename from restdocs-openapi/src/test/kotlin/com/epages/restdocs/openapi/FieldDescriptorsTest.kt rename to restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/FieldDescriptorsTest.kt index d5797a0e..e174b967 100644 --- a/restdocs-openapi/src/test/kotlin/com/epages/restdocs/openapi/FieldDescriptorsTest.kt +++ b/restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/FieldDescriptorsTest.kt @@ -1,4 +1,4 @@ -package com.epages.restdocs.openapi +package com.epages.restdocs.apispec import org.assertj.core.api.BDDAssertions.then import org.junit.jupiter.api.Test diff --git a/restdocs-openapi/src/test/kotlin/com/epages/restdocs/openapi/JwtScopeHandlerTest.kt b/restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/JwtScopeHandlerTest.kt similarity index 98% rename from restdocs-openapi/src/test/kotlin/com/epages/restdocs/openapi/JwtScopeHandlerTest.kt rename to restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/JwtScopeHandlerTest.kt index f10fdc94..adc6c979 100644 --- a/restdocs-openapi/src/test/kotlin/com/epages/restdocs/openapi/JwtScopeHandlerTest.kt +++ b/restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/JwtScopeHandlerTest.kt @@ -1,4 +1,4 @@ -package com.epages.restdocs.openapi +package com.epages.restdocs.apispec import org.assertj.core.api.BDDAssertions.then import org.junit.jupiter.api.Test diff --git a/restdocs-openapi/src/test/kotlin/com/epages/restdocs/openapi/MockMvcRestDocumentationWrapperIntegrationTest.kt b/restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/MockMvcRestDocumentationWrapperIntegrationTest.kt similarity index 99% rename from restdocs-openapi/src/test/kotlin/com/epages/restdocs/openapi/MockMvcRestDocumentationWrapperIntegrationTest.kt rename to restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/MockMvcRestDocumentationWrapperIntegrationTest.kt index ec34999f..fba3811b 100644 --- a/restdocs-openapi/src/test/kotlin/com/epages/restdocs/openapi/MockMvcRestDocumentationWrapperIntegrationTest.kt +++ b/restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/MockMvcRestDocumentationWrapperIntegrationTest.kt @@ -1,4 +1,4 @@ -package com.epages.restdocs.openapi +package com.epages.restdocs.apispec import org.assertj.core.api.Assertions.assertThatCode import org.junit.jupiter.api.Test diff --git a/restdocs-openapi/src/test/kotlin/com/epages/restdocs/openapi/OperationBuilder.kt b/restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/OperationBuilder.kt similarity index 99% rename from restdocs-openapi/src/test/kotlin/com/epages/restdocs/openapi/OperationBuilder.kt rename to restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/OperationBuilder.kt index 396e6d31..5668d0e4 100644 --- a/restdocs-openapi/src/test/kotlin/com/epages/restdocs/openapi/OperationBuilder.kt +++ b/restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/OperationBuilder.kt @@ -1,4 +1,4 @@ -package com.epages.restdocs.openapi +package com.epages.restdocs.apispec import org.springframework.http.HttpHeaders import org.springframework.http.HttpMethod diff --git a/restdocs-openapi/src/test/kotlin/com/epages/restdocs/openapi/ResourceSnippetIntegrationTest.kt b/restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/ResourceSnippetIntegrationTest.kt similarity index 97% rename from restdocs-openapi/src/test/kotlin/com/epages/restdocs/openapi/ResourceSnippetIntegrationTest.kt rename to restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/ResourceSnippetIntegrationTest.kt index cb46a10f..d325fa46 100644 --- a/restdocs-openapi/src/test/kotlin/com/epages/restdocs/openapi/ResourceSnippetIntegrationTest.kt +++ b/restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/ResourceSnippetIntegrationTest.kt @@ -1,7 +1,7 @@ -package com.epages.restdocs.openapi +package com.epages.restdocs.apispec -import com.epages.restdocs.openapi.ResourceDocumentation.parameterWithName -import com.epages.restdocs.openapi.ResourceDocumentation.resource +import com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName +import com.epages.restdocs.apispec.ResourceDocumentation.resource import org.assertj.core.api.Assertions.assertThatCode import org.assertj.core.api.BDDAssertions.then import org.hibernate.validator.constraints.Length diff --git a/restdocs-openapi/src/test/kotlin/com/epages/restdocs/openapi/ResourceSnippetTest.kt b/restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/ResourceSnippetTest.kt similarity index 93% rename from restdocs-openapi/src/test/kotlin/com/epages/restdocs/openapi/ResourceSnippetTest.kt rename to restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/ResourceSnippetTest.kt index eada11de..372a1e81 100644 --- a/restdocs-openapi/src/test/kotlin/com/epages/restdocs/openapi/ResourceSnippetTest.kt +++ b/restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/ResourceSnippetTest.kt @@ -1,15 +1,15 @@ -package com.epages.restdocs.openapi +package com.epages.restdocs.apispec -import com.epages.restdocs.openapi.ResourceDocumentation.parameterWithName -import com.epages.restdocs.openapi.ResourceDocumentation.resource -import com.epages.restdocs.openapi.junit.TemporaryFolder -import com.epages.restdocs.openapi.junit.TemporaryFolderExtension +import com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName +import com.epages.restdocs.apispec.ResourceDocumentation.resource import com.jayway.jsonpath.DocumentContext import com.jayway.jsonpath.JsonPath import org.assertj.core.api.BDDAssertions.then import org.assertj.core.api.BDDAssertions.thenThrownBy +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +import org.junitpioneer.jupiter.TempDirectory import org.springframework.http.HttpHeaders.AUTHORIZATION import org.springframework.http.HttpHeaders.CONTENT_TYPE import org.springframework.http.HttpStatus @@ -22,9 +22,10 @@ import org.springframework.restdocs.payload.JsonFieldType import org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath import java.io.File import java.io.IOException +import java.nio.file.Path -@ExtendWith(TemporaryFolderExtension::class) -class ResourceSnippetTest(private val temporaryFolder: TemporaryFolder) { +@ExtendWith(TempDirectory::class) +class ResourceSnippetTest { lateinit var operation: Operation @@ -33,11 +34,14 @@ class ResourceSnippetTest(private val temporaryFolder: TemporaryFolder) { private val operationName: String get() = OPERATION_NAME - private val rootOutputDirectory: File - get() = temporaryFolder.root + private lateinit var rootOutputDirectory: File private lateinit var resourceSnippetJson: DocumentContext + @BeforeEach + fun init(@TempDirectory.TempDir tempDir: Path) { + rootOutputDirectory = tempDir.toFile() + } @Test fun should_generate_resource_snippet_for_operation_with_request_body() { givenOperationWithRequestBody() @@ -206,7 +210,7 @@ class ResourceSnippetTest(private val temporaryFolder: TemporaryFolder) { private fun generatedSnippetFile(operationName: String) = File(rootOutputDirectory, "$operationName/resource.json") private fun givenOperationWithoutBody() { - val operationBuilder = OperationBuilder("test", temporaryFolder.root) + val operationBuilder = OperationBuilder("test", rootOutputDirectory) .attribute(ATTRIBUTE_NAME_URL_TEMPLATE, "http://localhost:8080/some/{id}") operationBuilder .request("http://localhost:8080/some/123") @@ -218,7 +222,7 @@ class ResourceSnippetTest(private val temporaryFolder: TemporaryFolder) { } private fun givenOperationWithoutUrlTemplate() { - val operationBuilder = OperationBuilder("test", temporaryFolder.root) + val operationBuilder = OperationBuilder("test", rootOutputDirectory) operationBuilder .request("http://localhost:8080/some/123") .method("POST") @@ -229,7 +233,7 @@ class ResourceSnippetTest(private val temporaryFolder: TemporaryFolder) { } private fun givenOperationWithNamePlaceholders() { - operation = OperationBuilder("{class-name}/{method-name}", temporaryFolder.root) + operation = OperationBuilder("{class-name}/{method-name}", rootOutputDirectory) .attribute(ATTRIBUTE_NAME_URL_TEMPLATE, "http://localhost:8080/some/{id}") .testClass(ResourceSnippetTest::class.java) .testMethodName("getSomeById") @@ -241,7 +245,7 @@ class ResourceSnippetTest(private val temporaryFolder: TemporaryFolder) { } private fun givenOperationWithRequestBody() { - operation = OperationBuilder("test", temporaryFolder.root) + operation = OperationBuilder("test", rootOutputDirectory) .attribute(ATTRIBUTE_NAME_URL_TEMPLATE, "http://localhost:8080/some/{id}") .request("http://localhost:8080/some/123") .method("POST") @@ -251,7 +255,7 @@ class ResourceSnippetTest(private val temporaryFolder: TemporaryFolder) { } private fun givenOperationWithRequestBodyAndIgnoredRequestField() { - val operationBuilder = OperationBuilder("test", temporaryFolder.root) + val operationBuilder = OperationBuilder("test", rootOutputDirectory) operationBuilder .attribute(ATTRIBUTE_NAME_URL_TEMPLATE, "http://localhost:8080/some/{id}") @@ -289,7 +293,7 @@ class ResourceSnippetTest(private val temporaryFolder: TemporaryFolder) { } private fun givenOperationWithRequestAndResponseBody() { - val operationBuilder = OperationBuilder("test", temporaryFolder.root) + val operationBuilder = OperationBuilder("test", rootOutputDirectory) .attribute(ATTRIBUTE_NAME_URL_TEMPLATE, "http://localhost:8080/some/{id}") val content = "{\"comment\": \"some\"}" operationBuilder diff --git a/restdocs-openapi/src/test/kotlin/com/epages/restdocs/openapi/SecurityRequirementsHandlerTest.kt b/restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/SecurityRequirementsHandlerTest.kt similarity index 98% rename from restdocs-openapi/src/test/kotlin/com/epages/restdocs/openapi/SecurityRequirementsHandlerTest.kt rename to restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/SecurityRequirementsHandlerTest.kt index a85d171f..f36c788c 100644 --- a/restdocs-openapi/src/test/kotlin/com/epages/restdocs/openapi/SecurityRequirementsHandlerTest.kt +++ b/restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/SecurityRequirementsHandlerTest.kt @@ -1,4 +1,4 @@ -package com.epages.restdocs.openapi +package com.epages.restdocs.apispec import org.assertj.core.api.BDDAssertions.then import org.junit.jupiter.api.Test diff --git a/restdocs-openapi-generator/src/main/kotlin/com/epages/restdocs/openapi/generator/ApiSpecificationWriter.kt b/restdocs-openapi-generator/src/main/kotlin/com/epages/restdocs/openapi/generator/ApiSpecificationWriter.kt deleted file mode 100644 index 3dddb44c..00000000 --- a/restdocs-openapi-generator/src/main/kotlin/com/epages/restdocs/openapi/generator/ApiSpecificationWriter.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.epages.restdocs.openapi.generator - -import io.swagger.models.Swagger -import io.swagger.util.Json -import io.swagger.util.Yaml -import java.io.File - -object ApiSpecificationWriter { - - private val yamlFormats = setOf("yaml", "yml") - private val jsonFormats = setOf("json") - - fun write(format: String, outputDirectory: File, outputFilenamePrefix: String, apiSpecification: Swagger) { - outputDirectory.mkdir() - validateFormat(format) - val target = File(outputDirectory, "$outputFilenamePrefix.${outputFileExtension(format)}") - if (yamlFormats.contains(format)) { - Yaml.pretty().writeValue(target, apiSpecification) - } else { - Json.pretty().writeValue(target, apiSpecification) - } - } - - fun supportedFormats() = yamlFormats + jsonFormats - - fun validateFormat(format: String) { - if (!supportedFormats().contains(format)) throw IllegalArgumentException("Format '$format' is invalid - supported formats are '${supportedFormats()}'") - } - - private fun outputFileExtension(format: String) = - if (yamlFormats.contains(format)) - "yaml" - else - "json" -} diff --git a/restdocs-openapi-gradle-plugin/src/main/kotlin/com/epages/restdocs/openapi/gradle/RestdocsOpenApiPlugin.kt b/restdocs-openapi-gradle-plugin/src/main/kotlin/com/epages/restdocs/openapi/gradle/RestdocsOpenApiPlugin.kt deleted file mode 100644 index eba391ac..00000000 --- a/restdocs-openapi-gradle-plugin/src/main/kotlin/com/epages/restdocs/openapi/gradle/RestdocsOpenApiPlugin.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.epages.restdocs.openapi.gradle - -import org.gradle.api.Plugin -import org.gradle.api.Project - -open class RestdocsOpenApiPlugin : Plugin { - override fun apply(project: Project) { - with(project) { - extensions.create("openapi", RestdocsOpenApiPluginExtension::class.java, project) - afterEvaluate { - val openapi = extensions.findByName("openapi") as RestdocsOpenApiPluginExtension - - tasks.create("openapi", RestdocsOpenApiTask::class.java).apply { - dependsOn("check") - description = "Aggregate resource fragments into an OpenAPI API specification" - - basePath = openapi.basePath - host = openapi.host - schemes = openapi.schemes - - format = openapi.format - - title = openapi.title - apiVersion = openapi.version - separatePublicApi = openapi.separatePublicApi - - oauth2SecuritySchemeDefinition = openapi.oauth2SecuritySchemeDefinition - - outputDirectory = openapi.outputDirectory - snippetsDirectory = openapi.snippetsDirectory - - outputFileNamePrefix = openapi.outputFileNamePrefix - } - } - } - } -} diff --git a/restdocs-openapi-gradle-plugin/src/main/kotlin/com/epages/restdocs/openapi/gradle/RestdocsOpenApiPluginExtension.kt b/restdocs-openapi-gradle-plugin/src/main/kotlin/com/epages/restdocs/openapi/gradle/RestdocsOpenApiPluginExtension.kt deleted file mode 100644 index 8bb33e57..00000000 --- a/restdocs-openapi-gradle-plugin/src/main/kotlin/com/epages/restdocs/openapi/gradle/RestdocsOpenApiPluginExtension.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.epages.restdocs.openapi.gradle - -import com.epages.restdocs.openapi.model.Oauth2Configuration -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.dataformat.yaml.YAMLFactory -import com.fasterxml.jackson.module.kotlin.readValue -import groovy.lang.Closure -import org.gradle.api.Project -import java.io.File - -open class RestdocsOpenApiPluginExtension(val project: Project) { - - private val objectMapper = ObjectMapper(YAMLFactory()) - - var host: String = "localhost" - var basePath: String? = null - var schemes: Array = arrayOf("http") - - var title = "API documentation" - var version = project.version as? String ?: "1.0.0" - - var format = "json" - - var separatePublicApi: Boolean = false - - var oauth2SecuritySchemeDefinition: PluginOauth2Configuration? = null - - var outputDirectory = "build/openapi" - var snippetsDirectory = "build/generated-snippets" - - var outputFileNamePrefix = "openapi" - - @Suppress("NULLABILITY_MISMATCH_BASED_ON_JAVA_ANNOTATIONS") - fun setOauth2SecuritySchemeDefinition(closure: Closure<*>) { - oauth2SecuritySchemeDefinition = project.configure(PluginOauth2Configuration(), closure) as PluginOauth2Configuration - with(oauth2SecuritySchemeDefinition!!) { - if (scopeDescriptionsPropertiesFile != null) { - scopes = scopeDescriptionSource(project.file(scopeDescriptionsPropertiesFile!!)) - } - } - } - - private fun scopeDescriptionSource(scopeDescriptionsPropertiesFile: File): Map { - return scopeDescriptionsPropertiesFile.let { objectMapper.readValue(it) } ?: emptyMap() - } -} - -class PluginOauth2Configuration( - var scopeDescriptionsPropertiesFile: String? = null -) : Oauth2Configuration() \ No newline at end of file diff --git a/restdocs-openapi-gradle-plugin/src/main/kotlin/com/epages/restdocs/openapi/gradle/RestdocsOpenApiTask.kt b/restdocs-openapi-gradle-plugin/src/main/kotlin/com/epages/restdocs/openapi/gradle/RestdocsOpenApiTask.kt deleted file mode 100644 index e1ff9f4c..00000000 --- a/restdocs-openapi-gradle-plugin/src/main/kotlin/com/epages/restdocs/openapi/gradle/RestdocsOpenApiTask.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.epages.restdocs.openapi.gradle - -import com.epages.restdocs.openapi.generator.ApiSpecificationWriter -import com.epages.restdocs.openapi.generator.OpenApi20Generator -import com.epages.restdocs.openapi.model.ResourceModel -import com.fasterxml.jackson.databind.DeserializationFeature -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue -import org.gradle.api.DefaultTask -import org.gradle.api.tasks.Input -import org.gradle.api.tasks.Optional -import org.gradle.api.tasks.TaskAction - -open class RestdocsOpenApiTask : DefaultTask() { - - @Input @Optional - var basePath: String? = null - - @Input @Optional - lateinit var host: String - - @Input @Optional - lateinit var schemes: Array - - @Input @Optional - lateinit var title: String - - @Input @Optional - lateinit var apiVersion: String - - @Input @Optional - lateinit var format: String - - @Input - var separatePublicApi: Boolean = false - - @Input - lateinit var outputDirectory: String - - @Input - lateinit var snippetsDirectory: String - - @Input - lateinit var outputFileNamePrefix: String - - @Input @Optional - var oauth2SecuritySchemeDefinition: PluginOauth2Configuration? = null - - private val outputDirectoryFile - get() = project.file(outputDirectory) - - private val snippetsDirectoryFile - get() = project.file(snippetsDirectory) - - private val objectMapper = jacksonObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) - - @TaskAction - fun aggregateResourceModels() { - - val resourceModels = snippetsDirectoryFile.walkTopDown() - .filter { it.name == "resource.json" } - .map { objectMapper.readValue(it.readText()) } - .toList() - - generateAndWriteSpecification(resourceModels, outputFileNamePrefix) - - if (separatePublicApi) { - generateAndWriteSpecification(resourceModels.filterNot { it.privateResource }, "$outputFileNamePrefix-public") - } - } - - private fun generateAndWriteSpecification(resourceModels: List, fileNamePrefix: String) { - if (resourceModels.isNotEmpty()) { - val apiSpecification = OpenApi20Generator.generate( - resources = resourceModels, - basePath = basePath, - host = host, - schemes = schemes.toList(), - title = title, - version = apiVersion, - oauth2SecuritySchemeDefinition = oauth2SecuritySchemeDefinition - ) - - ApiSpecificationWriter.write(format, outputDirectoryFile, fileNamePrefix, apiSpecification) - } - } -} diff --git a/restdocs-openapi-gradle-plugin/src/test/kotlin/com/epages/restdocs/openapi/gradle/junit/TemporaryFolder.kt b/restdocs-openapi-gradle-plugin/src/test/kotlin/com/epages/restdocs/openapi/gradle/junit/TemporaryFolder.kt deleted file mode 100644 index eb969a5d..00000000 --- a/restdocs-openapi-gradle-plugin/src/test/kotlin/com/epages/restdocs/openapi/gradle/junit/TemporaryFolder.kt +++ /dev/null @@ -1,127 +0,0 @@ -package com.epages.restdocs.openapi.gradle.junit - -import java.io.File -import java.io.IOException - -class TemporaryFolder @JvmOverloads constructor(private val parentFolder: File? = null) { - private lateinit var folder: File - - /** - * @return the location of this temporary folder. - */ - val root: File - get() = folder - - // testing purposes only - - /** - * for testing purposes only. Do not use. - */ - @Throws(IOException::class) - fun create() { - folder = createTemporaryFolderIn(parentFolder) - } - - /** - * Returns a new fresh file with the given name under the temporary folder. - */ - @Throws(IOException::class) - fun newFile(fileName: String): File { - val file = File(root, fileName) - if (!file.createNewFile()) { - throw IOException( - "a file with the name \'$fileName\' already exists in the test folder" - ) - } - return file - } - - /** - * Returns a new fresh file with a random name under the temporary folder. - */ - @Throws(IOException::class) - fun newFile(): File { - return File.createTempFile("junit", null, root) - } - - /** - * Returns a new fresh folder with the given name under the temporary - * folder. - */ - @Throws(IOException::class) - fun newFolder(folder: String): File { - return newFolder(*arrayOf(folder)) - } - - /** - * Returns a new fresh folder with the given name(s) under the temporary - * folder. - */ - @Throws(IOException::class) - fun newFolder(vararg folderNames: String): File { - var file = root - for (i in folderNames.indices) { - val folderName = folderNames[i] - validateFolderName(folderName) - file = File(file, folderName) - if (!file.mkdir() && isLastElementInArray(i, folderNames)) { - throw IOException("a folder with the name \'$folderName\' already exists") - } - } - return file - } - - /** - * Validates if multiple path components were used while creating a folder. - * - * @param folderName - * Name of the folder being created - */ - @Throws(IOException::class) - private fun validateFolderName(folderName: String) { - val tempFile = File(folderName) - if (tempFile.parent != null) { - val errorMsg = - "Folder name cannot consist of multiple path components separated by a file separator." + " Please use newFolder('MyParentFolder','MyFolder') to create hierarchies of folders" - throw IOException(errorMsg) - } - } - - private fun isLastElementInArray(index: Int, array: Array): Boolean { - return index == array.size - 1 - } - - /** - * Returns a new fresh folder with a random name under the temporary folder. - */ - @Throws(IOException::class) - fun newFolder(): File { - return createTemporaryFolderIn(root) - } - - @Throws(IOException::class) - private fun createTemporaryFolderIn(parentFolder: File?): File { - val createdFolder = File.createTempFile("junit", "", parentFolder) - createdFolder.delete() - createdFolder.mkdir() - return createdFolder - } - - /** - * Delete all files and folders under the temporary folder. Usually not - * called directly, since it is automatically applied by the [Rule] - */ - fun delete() { - recursiveDelete(folder) - } - - private fun recursiveDelete(file: File) { - val files = file.listFiles() - if (files != null) { - for (each in files) { - recursiveDelete(each) - } - } - file.delete() - } -} diff --git a/restdocs-openapi-gradle-plugin/src/test/kotlin/com/epages/restdocs/openapi/gradle/junit/TemporaryFolderExtension.kt b/restdocs-openapi-gradle-plugin/src/test/kotlin/com/epages/restdocs/openapi/gradle/junit/TemporaryFolderExtension.kt deleted file mode 100644 index 7f498750..00000000 --- a/restdocs-openapi-gradle-plugin/src/test/kotlin/com/epages/restdocs/openapi/gradle/junit/TemporaryFolderExtension.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.epages.restdocs.openapi.gradle.junit - -import org.junit.jupiter.api.extension.AfterEachCallback -import org.junit.jupiter.api.extension.BeforeEachCallback -import org.junit.jupiter.api.extension.ExtensionContext -import org.junit.jupiter.api.extension.ParameterContext -import org.junit.jupiter.api.extension.ParameterResolver - -class TemporaryFolderExtension : BeforeEachCallback, AfterEachCallback, ParameterResolver { - - private val temporaryFolder = TemporaryFolder() - - override fun beforeEach(context: ExtensionContext?) { - temporaryFolder.create() - } - - override fun afterEach(context: ExtensionContext?) { - temporaryFolder.delete() - } - - override fun supportsParameter(parameterContext: ParameterContext, extensionContext: ExtensionContext?): Boolean = - TemporaryFolder::class.java.isAssignableFrom(parameterContext.parameter.type) - - override fun resolveParameter(parameterContext: ParameterContext?, extensionContext: ExtensionContext?): Any = - temporaryFolder -} diff --git a/restdocs-openapi/src/test/kotlin/com/epages/restdocs/openapi/junit/TemporaryFolder.kt b/restdocs-openapi/src/test/kotlin/com/epages/restdocs/openapi/junit/TemporaryFolder.kt deleted file mode 100644 index 2fcf370a..00000000 --- a/restdocs-openapi/src/test/kotlin/com/epages/restdocs/openapi/junit/TemporaryFolder.kt +++ /dev/null @@ -1,127 +0,0 @@ -package com.epages.restdocs.openapi.junit - -import java.io.File -import java.io.IOException - -class TemporaryFolder @JvmOverloads constructor(private val parentFolder: File? = null) { - private lateinit var folder: File - - /** - * @return the location of this temporary folder. - */ - val root: File - get() = folder - - // testing purposes only - - /** - * for testing purposes only. Do not use. - */ - @Throws(IOException::class) - fun create() { - folder = createTemporaryFolderIn(parentFolder) - } - - /** - * Returns a new fresh file with the given name under the temporary folder. - */ - @Throws(IOException::class) - fun newFile(fileName: String): File { - val file = File(root, fileName) - if (!file.createNewFile()) { - throw IOException( - "a file with the name \'$fileName\' already exists in the test folder" - ) - } - return file - } - - /** - * Returns a new fresh file with a random name under the temporary folder. - */ - @Throws(IOException::class) - fun newFile(): File { - return File.createTempFile("junit", null, root) - } - - /** - * Returns a new fresh folder with the given name under the temporary - * folder. - */ - @Throws(IOException::class) - fun newFolder(folder: String): File { - return newFolder(*arrayOf(folder)) - } - - /** - * Returns a new fresh folder with the given name(s) under the temporary - * folder. - */ - @Throws(IOException::class) - fun newFolder(vararg folderNames: String): File { - var file = root - for (i in folderNames.indices) { - val folderName = folderNames[i] - validateFolderName(folderName) - file = File(file, folderName) - if (!file.mkdir() && isLastElementInArray(i, folderNames)) { - throw IOException("a folder with the name \'$folderName\' already exists") - } - } - return file - } - - /** - * Validates if multiple path components were used while creating a folder. - * - * @param folderName - * Name of the folder being created - */ - @Throws(IOException::class) - private fun validateFolderName(folderName: String) { - val tempFile = File(folderName) - if (tempFile.parent != null) { - val errorMsg = - "Folder name cannot consist of multiple path components separated by a file separator." + " Please use newFolder('MyParentFolder','MyFolder') to create hierarchies of folders" - throw IOException(errorMsg) - } - } - - private fun isLastElementInArray(index: Int, array: Array): Boolean { - return index == array.size - 1 - } - - /** - * Returns a new fresh folder with a random name under the temporary folder. - */ - @Throws(IOException::class) - fun newFolder(): File { - return createTemporaryFolderIn(root) - } - - @Throws(IOException::class) - private fun createTemporaryFolderIn(parentFolder: File?): File { - val createdFolder = File.createTempFile("junit", "", parentFolder) - createdFolder.delete() - createdFolder.mkdir() - return createdFolder - } - - /** - * Delete all files and folders under the temporary folder. Usually not - * called directly, since it is automatically applied by the [Rule] - */ - fun delete() { - recursiveDelete(folder) - } - - private fun recursiveDelete(file: File) { - val files = file.listFiles() - if (files != null) { - for (each in files) { - recursiveDelete(each) - } - } - file.delete() - } -} diff --git a/restdocs-openapi/src/test/kotlin/com/epages/restdocs/openapi/junit/TemporaryFolderExtension.kt b/restdocs-openapi/src/test/kotlin/com/epages/restdocs/openapi/junit/TemporaryFolderExtension.kt deleted file mode 100644 index cd1e6846..00000000 --- a/restdocs-openapi/src/test/kotlin/com/epages/restdocs/openapi/junit/TemporaryFolderExtension.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.epages.restdocs.openapi.junit - -import org.junit.jupiter.api.extension.AfterEachCallback -import org.junit.jupiter.api.extension.BeforeEachCallback -import org.junit.jupiter.api.extension.ExtensionContext -import org.junit.jupiter.api.extension.ParameterContext -import org.junit.jupiter.api.extension.ParameterResolver - -class TemporaryFolderExtension : BeforeEachCallback, AfterEachCallback, ParameterResolver { - - private val temporaryFolder = TemporaryFolder() - - override fun beforeEach(context: ExtensionContext?) { - temporaryFolder.create() - } - - override fun afterEach(context: ExtensionContext?) { - temporaryFolder.delete() - } - - override fun supportsParameter(parameterContext: ParameterContext, extensionContext: ExtensionContext?): Boolean = - TemporaryFolder::class.java.isAssignableFrom(parameterContext.parameter.type) - - override fun resolveParameter(parameterContext: ParameterContext?, extensionContext: ExtensionContext?): Any = - temporaryFolder -} diff --git a/samples/restdocs-openapi-sample/.gitignore b/samples/restdocs-api-spec-sample/.gitignore similarity index 100% rename from samples/restdocs-openapi-sample/.gitignore rename to samples/restdocs-api-spec-sample/.gitignore diff --git a/samples/restdocs-openapi-sample/build.gradle b/samples/restdocs-api-spec-sample/build.gradle similarity index 73% rename from samples/restdocs-openapi-sample/build.gradle rename to samples/restdocs-api-spec-sample/build.gradle index de6973be..8cb28387 100755 --- a/samples/restdocs-openapi-sample/build.gradle +++ b/samples/restdocs-api-spec-sample/build.gradle @@ -4,27 +4,27 @@ buildscript { } repositories { mavenCentral() + maven { url "https://dl.bintray.com/epages/maven" } maven { url "https://jitpack.io" } maven { url "https://plugins.gradle.org/m2/" } } dependencies { classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}") - classpath("com.github.epages-de.restdocs-openapi:restdocs-openapi-gradle-plugin:0.4.2") + classpath("com.epages:restdocs-api-spec-gradle-plugin:0.0.6") } } apply plugin: 'java' apply plugin: 'org.springframework.boot' apply plugin: 'io.spring.dependency-management' -apply plugin: 'com.epages.restdocs-openapi' +apply plugin: 'com.epages.restdocs-api-spec' sourceCompatibility = 1.8 repositories { mavenCentral() jcenter() - maven { url = "https://jitpack.io" } - mavenLocal() + maven { url = "https://dl.bintray.com/epages/maven" } } ext { @@ -42,8 +42,8 @@ dependencies { testCompile('org.springframework.boot:spring-boot-starter-test') testCompile('org.springframework.restdocs:spring-restdocs-mockmvc') - testCompile('com.github.epages-de.restdocs-openapi:restdocs-openapi:0.4.2') - //testCompile project(':restdocs-openapi') //enable for depending on the submodule directly + testCompile('com.epages:restdocs-api-spec:0.0.6') + //testCompile project(':restdocs-api-spec') //enable for depending on the submodule directly testCompile('com.google.guava:guava:23.0') } @@ -58,3 +58,10 @@ openapi { version = '0.1.0' format = 'yaml' } + +openapi3 { + server = 'https://localhost:8080' + title = 'My API' + version = '0.1.0' + format = 'yaml' +} diff --git a/samples/restdocs-openapi-sample/gradle/wrapper/gradle-wrapper.jar b/samples/restdocs-api-spec-sample/gradle/wrapper/gradle-wrapper.jar similarity index 100% rename from samples/restdocs-openapi-sample/gradle/wrapper/gradle-wrapper.jar rename to samples/restdocs-api-spec-sample/gradle/wrapper/gradle-wrapper.jar diff --git a/samples/restdocs-openapi-sample/gradle/wrapper/gradle-wrapper.properties b/samples/restdocs-api-spec-sample/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from samples/restdocs-openapi-sample/gradle/wrapper/gradle-wrapper.properties rename to samples/restdocs-api-spec-sample/gradle/wrapper/gradle-wrapper.properties diff --git a/samples/restdocs-openapi-sample/openapi2raml.gradle b/samples/restdocs-api-spec-sample/openapi2raml.gradle similarity index 100% rename from samples/restdocs-openapi-sample/openapi2raml.gradle rename to samples/restdocs-api-spec-sample/openapi2raml.gradle diff --git a/samples/restdocs-openapi-sample/src/main/java/com/epages/restdocs/openapi/sample/Cart.java b/samples/restdocs-api-spec-sample/src/main/java/com/epages/restdocs/apispec/sample/Cart.java similarity index 95% rename from samples/restdocs-openapi-sample/src/main/java/com/epages/restdocs/openapi/sample/Cart.java rename to samples/restdocs-api-spec-sample/src/main/java/com/epages/restdocs/apispec/sample/Cart.java index 94790370..a1efa3d9 100644 --- a/samples/restdocs-openapi-sample/src/main/java/com/epages/restdocs/openapi/sample/Cart.java +++ b/samples/restdocs-api-spec-sample/src/main/java/com/epages/restdocs/apispec/sample/Cart.java @@ -1,4 +1,4 @@ -package com.epages.restdocs.openapi.sample; +package com.epages.restdocs.apispec.sample; import com.fasterxml.jackson.annotation.JsonIgnore; import lombok.Getter; diff --git a/samples/restdocs-openapi-sample/src/main/java/com/epages/restdocs/openapi/sample/CartController.java b/samples/restdocs-api-spec-sample/src/main/java/com/epages/restdocs/apispec/sample/CartController.java similarity index 82% rename from samples/restdocs-openapi-sample/src/main/java/com/epages/restdocs/openapi/sample/CartController.java rename to samples/restdocs-api-spec-sample/src/main/java/com/epages/restdocs/apispec/sample/CartController.java index 37818789..cfb48192 100644 --- a/samples/restdocs-openapi-sample/src/main/java/com/epages/restdocs/openapi/sample/CartController.java +++ b/samples/restdocs-api-spec-sample/src/main/java/com/epages/restdocs/apispec/sample/CartController.java @@ -1,6 +1,6 @@ -package com.epages.restdocs.openapi.sample; +package com.epages.restdocs.apispec.sample; -import com.epages.restdocs.openapi.sample.CartResourceResourceAssembler.CartResource; +import com.epages.restdocs.apispec.sample.CartResourceResourceAssembler.CartResource; import lombok.RequiredArgsConstructor; import org.springframework.data.rest.webmvc.RepositoryRestController; import org.springframework.hateoas.EntityLinks; @@ -26,14 +26,14 @@ public class CartController { private final CartResourceResourceAssembler cartResourceResourceAssembler; @PostMapping - public ResponseEntity create() { + public ResponseEntity create() { Cart cart = cartRepository.save(new Cart()); return ResponseEntity.created(entityLinks.linkForSingleResource(cart).toUri()) .body(cartResourceResourceAssembler.toResource(cart)); } @GetMapping("/{cartId}") - public ResponseEntity get(@PathVariable Long cartId) { + public ResponseEntity get(@PathVariable Long cartId) { return cartRepository.findById(cartId) .map(cartResourceResourceAssembler::toResource) .map(ResponseEntity::ok) @@ -41,7 +41,7 @@ public ResponseEntity get(@PathVariable Long cartId) { } @PostMapping("/{cartId}/order") - public ResponseEntity order(@PathVariable Long cartId) { + public ResponseEntity order(@PathVariable Long cartId) { return cartRepository.findById(cartId) .map(cart -> { cart.setOrdered(true); @@ -53,7 +53,7 @@ public ResponseEntity order(@PathVariable Long cartId) { } @PostMapping(value = "/{cartId}/products", consumes = TEXT_URI_LIST_VALUE) - public ResponseEntity addProducts(@PathVariable Long cartId, @RequestBody Resources resource) { + public ResponseEntity addProducts(@PathVariable Long cartId, @RequestBody Resources resource) { return cartRepository.findById(cartId) .map(cart -> { resource.getLinks().stream() diff --git a/samples/restdocs-openapi-sample/src/main/java/com/epages/restdocs/openapi/sample/CartRepository.java b/samples/restdocs-api-spec-sample/src/main/java/com/epages/restdocs/apispec/sample/CartRepository.java similarity index 86% rename from samples/restdocs-openapi-sample/src/main/java/com/epages/restdocs/openapi/sample/CartRepository.java rename to samples/restdocs-api-spec-sample/src/main/java/com/epages/restdocs/apispec/sample/CartRepository.java index 1c868b7f..3a5d8a0b 100644 --- a/samples/restdocs-openapi-sample/src/main/java/com/epages/restdocs/openapi/sample/CartRepository.java +++ b/samples/restdocs-api-spec-sample/src/main/java/com/epages/restdocs/apispec/sample/CartRepository.java @@ -1,4 +1,4 @@ -package com.epages.restdocs.openapi.sample; +package com.epages.restdocs.apispec.sample; import org.springframework.data.repository.PagingAndSortingRepository; import org.springframework.data.rest.core.annotation.RepositoryRestResource; diff --git a/samples/restdocs-openapi-sample/src/main/java/com/epages/restdocs/openapi/sample/CartResourceResourceAssembler.java b/samples/restdocs-api-spec-sample/src/main/java/com/epages/restdocs/apispec/sample/CartResourceResourceAssembler.java similarity index 98% rename from samples/restdocs-openapi-sample/src/main/java/com/epages/restdocs/openapi/sample/CartResourceResourceAssembler.java rename to samples/restdocs-api-spec-sample/src/main/java/com/epages/restdocs/apispec/sample/CartResourceResourceAssembler.java index 46c92c35..e2d85d70 100644 --- a/samples/restdocs-openapi-sample/src/main/java/com/epages/restdocs/openapi/sample/CartResourceResourceAssembler.java +++ b/samples/restdocs-api-spec-sample/src/main/java/com/epages/restdocs/apispec/sample/CartResourceResourceAssembler.java @@ -1,4 +1,4 @@ -package com.epages.restdocs.openapi.sample; +package com.epages.restdocs.apispec.sample; import lombok.Builder; import lombok.Getter; diff --git a/samples/restdocs-openapi-sample/src/main/java/com/epages/restdocs/openapi/sample/Product.java b/samples/restdocs-api-spec-sample/src/main/java/com/epages/restdocs/apispec/sample/Product.java similarity index 95% rename from samples/restdocs-openapi-sample/src/main/java/com/epages/restdocs/openapi/sample/Product.java rename to samples/restdocs-api-spec-sample/src/main/java/com/epages/restdocs/apispec/sample/Product.java index 9615e12a..cdb65bae 100644 --- a/samples/restdocs-openapi-sample/src/main/java/com/epages/restdocs/openapi/sample/Product.java +++ b/samples/restdocs-api-spec-sample/src/main/java/com/epages/restdocs/apispec/sample/Product.java @@ -1,4 +1,4 @@ -package com.epages.restdocs.openapi.sample; +package com.epages.restdocs.apispec.sample; import lombok.Getter; import lombok.NoArgsConstructor; diff --git a/samples/restdocs-openapi-sample/src/main/java/com/epages/restdocs/openapi/sample/ProductRepository.java b/samples/restdocs-api-spec-sample/src/main/java/com/epages/restdocs/apispec/sample/ProductRepository.java similarity index 78% rename from samples/restdocs-openapi-sample/src/main/java/com/epages/restdocs/openapi/sample/ProductRepository.java rename to samples/restdocs-api-spec-sample/src/main/java/com/epages/restdocs/apispec/sample/ProductRepository.java index 40822710..a0273f90 100644 --- a/samples/restdocs-openapi-sample/src/main/java/com/epages/restdocs/openapi/sample/ProductRepository.java +++ b/samples/restdocs-api-spec-sample/src/main/java/com/epages/restdocs/apispec/sample/ProductRepository.java @@ -1,4 +1,4 @@ -package com.epages.restdocs.openapi.sample; +package com.epages.restdocs.apispec.sample; import org.springframework.data.repository.PagingAndSortingRepository; diff --git a/samples/restdocs-openapi-sample/src/main/java/com/epages/restdocs/openapi/sample/SampleApplication.java b/samples/restdocs-api-spec-sample/src/main/java/com/epages/restdocs/apispec/sample/SampleApplication.java similarity index 96% rename from samples/restdocs-openapi-sample/src/main/java/com/epages/restdocs/openapi/sample/SampleApplication.java rename to samples/restdocs-api-spec-sample/src/main/java/com/epages/restdocs/apispec/sample/SampleApplication.java index abef83ce..dc762d23 100644 --- a/samples/restdocs-openapi-sample/src/main/java/com/epages/restdocs/openapi/sample/SampleApplication.java +++ b/samples/restdocs-api-spec-sample/src/main/java/com/epages/restdocs/apispec/sample/SampleApplication.java @@ -1,4 +1,4 @@ -package com.epages.restdocs.openapi.sample; +package com.epages.restdocs.apispec.sample; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; diff --git a/samples/restdocs-openapi-sample/src/main/resources/application.yml b/samples/restdocs-api-spec-sample/src/main/resources/application.yml similarity index 100% rename from samples/restdocs-openapi-sample/src/main/resources/application.yml rename to samples/restdocs-api-spec-sample/src/main/resources/application.yml diff --git a/samples/restdocs-openapi-sample/src/test/java/com/epages/restdocs/openapi/sample/BaseIntegrationTest.java b/samples/restdocs-api-spec-sample/src/test/java/com/epages/restdocs/apispec/sample/BaseIntegrationTest.java similarity index 97% rename from samples/restdocs-openapi-sample/src/test/java/com/epages/restdocs/openapi/sample/BaseIntegrationTest.java rename to samples/restdocs-api-spec-sample/src/test/java/com/epages/restdocs/apispec/sample/BaseIntegrationTest.java index 896b2693..b326c943 100644 --- a/samples/restdocs-openapi-sample/src/test/java/com/epages/restdocs/openapi/sample/BaseIntegrationTest.java +++ b/samples/restdocs-api-spec-sample/src/test/java/com/epages/restdocs/apispec/sample/BaseIntegrationTest.java @@ -1,4 +1,4 @@ -package com.epages.restdocs.openapi.sample; +package com.epages.restdocs.apispec.sample; import lombok.SneakyThrows; import lombok.experimental.FieldDefaults; diff --git a/samples/restdocs-openapi-sample/src/test/java/com/epages/restdocs/openapi/sample/CartIntegrationTest.java b/samples/restdocs-api-spec-sample/src/test/java/com/epages/restdocs/apispec/sample/CartIntegrationTest.java similarity index 95% rename from samples/restdocs-openapi-sample/src/test/java/com/epages/restdocs/openapi/sample/CartIntegrationTest.java rename to samples/restdocs-api-spec-sample/src/test/java/com/epages/restdocs/apispec/sample/CartIntegrationTest.java index ac5be96b..9fe955e3 100644 --- a/samples/restdocs-openapi-sample/src/test/java/com/epages/restdocs/openapi/sample/CartIntegrationTest.java +++ b/samples/restdocs-api-spec-sample/src/test/java/com/epages/restdocs/apispec/sample/CartIntegrationTest.java @@ -1,18 +1,8 @@ -package com.epages.restdocs.openapi.sample; +package com.epages.restdocs.apispec.sample; -import com.epages.restdocs.openapi.ResourceSnippetParameters; -import lombok.SneakyThrows; -import lombok.experimental.FieldDefaults; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.junit.jupiter.SpringExtension; - -import static com.epages.restdocs.openapi.MockMvcRestDocumentationWrapper.document; -import static com.epages.restdocs.openapi.ResourceDocumentation.parameterWithName; -import static com.epages.restdocs.openapi.ResourceDocumentation.resource; +import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document; +import static com.epages.restdocs.apispec.ResourceDocumentation.parameterWithName; +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; import static lombok.AccessLevel.PRIVATE; import static org.hamcrest.Matchers.*; import static org.springframework.data.rest.webmvc.RestMediaTypes.HAL_JSON; @@ -27,6 +17,16 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import lombok.SneakyThrows; +import lombok.experimental.FieldDefaults; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.junit.jupiter.SpringExtension; + @AutoConfigureMockMvc @AutoConfigureRestDocs @SpringBootTest diff --git a/samples/restdocs-openapi-sample/src/test/java/com/epages/restdocs/openapi/sample/ProductRestIntegrationTest.java b/samples/restdocs-api-spec-sample/src/test/java/com/epages/restdocs/apispec/sample/ProductRestIntegrationTest.java similarity index 96% rename from samples/restdocs-openapi-sample/src/test/java/com/epages/restdocs/openapi/sample/ProductRestIntegrationTest.java rename to samples/restdocs-api-spec-sample/src/test/java/com/epages/restdocs/apispec/sample/ProductRestIntegrationTest.java index 40b26aaf..bb038740 100644 --- a/samples/restdocs-openapi-sample/src/test/java/com/epages/restdocs/openapi/sample/ProductRestIntegrationTest.java +++ b/samples/restdocs-api-spec-sample/src/test/java/com/epages/restdocs/apispec/sample/ProductRestIntegrationTest.java @@ -1,22 +1,5 @@ -package com.epages.restdocs.openapi.sample; +package com.epages.restdocs.apispec.sample; -import com.epages.restdocs.openapi.ConstrainedFields; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; -import lombok.SneakyThrows; -import lombok.experimental.FieldDefaults; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.runner.RunWith; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.junit.jupiter.SpringExtension; -import org.springframework.test.context.junit4.SpringRunner; - -import static com.epages.restdocs.openapi.MockMvcRestDocumentationWrapper.document; import static lombok.AccessLevel.PRIVATE; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.notNullValue; @@ -24,6 +7,7 @@ import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.restdocs.hypermedia.HypermediaDocumentation.linkWithRel; import static org.springframework.restdocs.hypermedia.HypermediaDocumentation.links; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; import static org.springframework.restdocs.payload.PayloadDocumentation.*; @@ -33,6 +17,20 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.epages.restdocs.apispec.ConstrainedFields; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import lombok.SneakyThrows; +import lombok.experimental.FieldDefaults; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.junit.jupiter.SpringExtension; + @SpringBootTest @ExtendWith(SpringExtension.class) @FieldDefaults(level = PRIVATE) diff --git a/settings.gradle b/settings.gradle index 3fe5bd20..223be33c 100755 --- a/settings.gradle +++ b/settings.gradle @@ -1,8 +1,9 @@ -rootProject.name = 'restdocs-openapi-parent' -include 'restdocs-openapi' -include 'restdocs-openapi-model' -include 'restdocs-openapi-jsonschema' -include 'restdocs-openapi-generator' -include 'restdocs-openapi-gradle-plugin' -include 'restdocs-openapi-sample' -project(':restdocs-openapi-sample').projectDir = file('samples/restdocs-openapi-sample') +rootProject.name = 'restdocs-api-spec-parent' +include 'restdocs-api-spec' +include 'restdocs-api-spec-model' +include 'restdocs-api-spec-jsonschema' +include 'restdocs-api-spec-openapi-generator' +include 'restdocs-api-spec-openapi3-generator' +include 'restdocs-api-spec-gradle-plugin' +include 'restdocs-api-spec-sample' +project(':restdocs-api-spec-sample').projectDir = file('samples/restdocs-api-spec-sample')