diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..cc7ce395 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,41 @@ +name: Publish + +on: + push: + tags: + - "*" + +jobs: + publish: + name: Publish + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Set up JDK + uses: actions/setup-java@v3 + with: + java-version: 8 + distribution: 'temurin' + - name: Cache Gradle packages + uses: actions/cache@v3 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} + restore-keys: ${{ runner.os }}-gradle + - name: Test + run: ./ci_test.sh + - name: Publish to Gradle Plugin Portal + env: + GRADLE_PUBLISH_KEY: ${{ secrets.GRADLE_PUBLISH_KEY }} + GRADLE_PUBLISH_SECRET: ${{ secrets.GRADLE_PUBLISH_SECRET }} + run: ./ci_publish_gradle.sh + - name: Publish to Maven Central + env: + FILE_ENCRYPTION_PASSWORD: ${{ secrets.FILE_ENCRYPTION_PASSWORD }} + SIGNING_KEY_ID: ${{ secrets.SIGNING_KEY_ID }} + SIGNING_PASSWORD: ${{ secrets.SIGNING_PASSWORD }} + SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} + SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} + run: ./ci_publish_java.sh -s diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..4791944b --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,39 @@ +name: Test +on: + push: + branches: + - master + - 'maintenance/**' + pull_request: + types: [opened, synchronize, reopened] + +jobs: + build: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Set up JDK + uses: actions/setup-java@v3 + with: + java-version: 8 + distribution: 'temurin' + - name: Cache SonarCloud packages + uses: actions/cache@v3 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + - name: Cache Gradle packages + uses: actions/cache@v3 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} + restore-keys: ${{ runner.os }}-gradle + - name: Test + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: ./ci_test.sh diff --git a/.gitignore b/.gitignore index 5d467697..102fc0b6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,12 @@ *.gpg - .gradle/ .* !.gitignore -!.travis.yml -!.circleci +!.github/ .settings/ build/ out/ bin/ -gradle.properties *.iml *.ipr *.iws diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 6048d603..00000000 --- a/.travis.yml +++ /dev/null @@ -1,42 +0,0 @@ -dist: focal -before_install: - - sudo apt-get install -y openjdk-8-jdk -before_cache: -- rm -f $HOME/.gradle/caches/modules-2/modules-2.lock -- rm -fr $HOME/.gradle/caches/*/plugin-resolution/ -cache: - directories: - - "$HOME/.gradle/caches/" - - "$HOME/.gradle/wrapper/" -install: true -script: "./ci_build.sh" -deploy: -- provider: script - script: "./ci_publish_java.sh" - on: - tags: true - skip_cleanup: true -- provider: script - script: "./ci_publish_gradle.sh" - on: - tags: true - skip_cleanup: true -env: - global: - - CI_NAME=travis-ci - - SIGNING_KEYRING_FILE="${TRAVIS_BUILD_DIR}/secret-keys.gpg" - - JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64 - # coveralls - - secure: "nOkY2AcR5Z7hKKzi+zCzmzPk+xiRRrJjGliDJnHnEO7g+iJ8SmT+thHuMFcXqvjxJ006LbR3GPB8M8qgFXFDPEUDRPppShi3waOm1ddlM2iXh9kbCoROoioyRO+F07deH6ExibkLbu/xq3WwAAw7lx/ZP+buc2R2VCbV0nLKP9iE4nHQX8msLgmU1LjWf0Au1Pgl2eWXV0J4/ZJOvcVu9hvBf1Ow4C7BNb0KZmMjeT9uMK0hiGpb9VVbwedOWfsbaFmqmKYqVKS8UtNmG+HBjvZfajrIARKRPc9w9uEvfl4E1H/J7PJLy2kOrCOauj8LMu6DduSrWcg08T5VnH51cavSAsWWG1ImTgEsUBNpllc9r1XLhQCLQ1TWCgGmleqYjZ2ySvg0MQpFjTgXMZC+aDkjZjcEeq3BgrncAR/bcG4ByrZBWyoEXDxBwoMZnkryTxK07UUgXRXdjJUldJ5CQW9oSfd+oEXKJyqQGNt0ob3S0sRS5uhrKniK+6Mwzxf6vXeYvn2fVP5j3hocUz4XzDJQoDDmJi6D4BpwksqJ81CNOHpyeDIOhdXxvA/J8DMCK0Q7tTSbnSOxItaht56CSxqqLSGax3/4Nr+5QX9jpmdJ03HdpP+MOuIl6dZCmE4/w1IFaFEfhpETPlnWZHl1BsOt7omDeW7yCttDq3Y1mXU=" - # gradle plugin portal - - secure: "XZ4MKhQkOcOp7iRpq/DPJu4RkGs6+26ykgo1n+NZDJlamJ+hikW0JahBuJdi30z/JVnXcpbHkVfmUSrHyWg0irggtOB+E566pRw/iPzeH6ZRzs0gFYdWujPv7BTXDZzzfGck8gSnMFDStHK1MYOYNYc8T1hNytwPy4WygGwc8mcgdbda5TTJuI0gbNm+5gehjW+h5tjeR6UrUBSj2Gbx+/UdJ3oXPOHFFu0i4OuXTSkWZz7Dz1M7o9loJOUtHwKB5osm9+tUQqLiI9yRE4Z80ngOC8eoF7HqBKFydoezqE+DCxNg+Vy+RkkTpa+mvBD+k5bZ/7BeFmOQdzE61YM1/xN1CaAjjFEI0KEisZ3gycdnxUp7sLkBuCMPfuhix3Sqjt9ulEv4k1MRMsXBJ7Wpe+ZuovHuXFlu1ni6tU5pRs5BhCnXc+X7dDvaprJUbB1pSp6/Rjpf5nOIszVf/HkO6MmgMQkZLwlcu6I8QPnOCfkW8FJroyOTUr/MD/oPNVGRLvANZuyqqNtEc9g/Nt6f0qSvlkdEOVcgBlz95d8UbkhEYbYbB71gqOo+flhfgIDOWEXgkrGjY7p95g5R+VIKSBaXv36EYyNPVAQQ55riyPnDpU5JUnur6GZIY+jTuNS0Tp6tXhPksRcBNjKEUqfte8YqI8EvdZfzvloODsb+9Wk=" - # gradle plugin portal - - secure: "nd2+OblPYu7qdG0D/lkbKMhoxaEm/U/IWQQ7HJYK5Yjt6f/JOgux0OIA/m+OO/3wtWDTbT+ihRekIcIU/x3ab4b1P1cwP1pMDsqc8O3Nqa8K79AGOYx5c3vZ7M5DBiLYKZDSM7VAaz0ol1uoY7Lo0jCMJQsf74U4o2xunrok5bf511cAJZ4gcczZ3hfoTu98gonil71Ln9TrdE9f86dX/aI7hmD9dajjxz98JkQSR/Cf47W4oAbBla85rHlDdzzrnGuVyhpV3r1ib2lrpcrBHID5IhSXyCEJxdbCNAlTCGuXdBMG+m99h9hYw7Nq1OsxbO2gmqs77fXmwKpPGwtf5PLGlQdkcExsdvNORmTeIn5Ac8JNDiEz8w/jrpBqkVZ5x6yVeSUe5VqAR1GLCrqIud2W3dDefWsSEhHtvu/iJYPB8IN01S55V7Uzy7hfs4Z6DnjPxT5khRleUViQywhFZ4/+yQ5OELeLbrGaDwZuZI2WOMahvgV749NrdCZ6SeIZSptr6ywCH7OFUJIbgiYvv0wxgpqg3cYeQ5OsUFR/+dWkXA+rk+17N7PodCAHl9Cg1ckg1N5H2AYAQzxn8mSELb2STRZpViygbiDeadrjnJBr8kFYdWkFc4fTX2CeboD7MitUjOWXvvQrlyU+R9BVaxs1cV8iTCLnP1dt0HKXKBA=" - # SONATYPE_USERNAME - - secure: "IVEnzxQmCuquvSjHDI5Y82j6WKo+TRGoUFZBY1n/oX7H+aAcOAizVQjXS/usJp/lo7BR7Hpov6DmvfPyKNHh4uWR8x5gx32ibo8Wah385jdSADZ4uZS2mDwt+bw5pPnzANBL7CnXz5JQdHrws3Iz+47p4sWbDmwaLX+hi8CIza+jMiLO57cpMNcuMsf9Pv5C/y5Vp+RvjE16b4JlXcdfgkK1uQG2jP3jPi0AVgIj7jubiJNgbN6YzxwUe4lDFq42/hmnm2FIvuUaUPn5lkKvs5xBdJp7FMBlii+LUMB9aP7dQbXdKu/BJZ7yxY2hbdNfydPbRdwXICJzUM/mi37GWZD4TbXDHDd0Hh2HasLU23R8dy9rRmW+PajY0426zMkq7/NxUZhCIOhLjZloJjDV1hLDXfZgAnaw6k2ROgkJNjqbcwgNizF57IObU4+L+izLCPT27Z189Dw8oowjvgE0k9t/OqpBEwBh50ig9lh+/KU+JzmaiU5dNU7EddP6RicZeZBLjSrmaa+ZBZYfOTqvzrt6a8JThMaU14hFxwz6sOqiWyFKIzCVe6Hl30OkEdMgXT+r9r76Auxnc/1gY92Gc2bNkc6wQUHnREgHeHx8U8LF8RpxbAKs95SKA9CTFCEL1yX4nfnhyFHvtHuqD1nZ2WhHTWoAEOYxV+egZRxAcss=" - # SONATYPE_PASSWORD - - secure: "BMBpvpai5dgR5bw0gKDDVkHoyx+USwwqYI1yDAF6QCCdZnJSAzXwSCsfPHYOVUo290YVbC8d7MCEWIE0LmhWflBOd437rLuNvK0MVcCCsDWJwNuYRJgs21ot1BCMw0zLFbFGaGsbcZ5Daau1SdU/lss1UnhVSvNcMs4vs2Hwv8qE7eksVZm4FTtVHsYvDwCO4DgGGAVlQ1VTpsMm5gqdHm5HLiuWm2I67RBoyurAdlroCbyFoxMovE2pHczMJSJXL8z8lMvXm9pJnFyYvm0gQLlzFiKLY7oKvTawolhcXgWJDncMrBcOOAPEDDhlbg5pUZbRdXwlIQN4AQjXVKUDtcFo3OWK2s7d17TQQWrCPrUfhLwyY9Bt3jqw/6gEKxT3bfiEgZCe2/QhYsfIBujgIrzJBKdIXRi0DvxFiExy2LTvQfClTeTZzxmNAVtELw1SDji1j5g8aQQZ1LDIsf84Dxwi0ptYgXsn4aM5v3xJVQ7ksZbSLawxhJflpaxaqlNPcHPm3aa7bLbA3herGR1J6oMlA5NZ+jxaigj1Mb2RVoOa4g1MbyPusZbqvK1oeBr2o72GIL1E2XzVpoSkD+G4qgb65rva1Ujb/XQ3Rojs8qDqelLeH6XfxXD7iSUdhGcBNihrb2Meht/Vr3kH+yFV7RbbD1k08BA5lVBArjhX3yI=" - # SIGNING_KEY_ID - - secure: "fayTDjzNaVNUtTPZ/8VVWTOb2VgPHj1Kr4o65orYScEjwDkmRSquc9BcamrhWVlURxNeO927Bg9g3LC6Sjmj9jNi7YeGc9nwWuQGEtHLS6EAk8y6aRVg1HN9/gihljTo/syKRdQNO9+ZfMEU4FxCHaGQUg95k8R44Vwm0ijlCoEpSPEsTMMo9PigJ+ln/oQOD+aR7bOu5zSNDi+ZDFqQYr8C3dOYNtFtmyaV7+HLwT7xIdEJfeVSZ+j23wXBLqa6rbIO/SsEZJeTbzY+asBrdh6h7sCIfKPnphNw63dRg0yJed+XVv77HaH4/0iImCySnMoX/dBCxOnf0BArtsujzxjocgaTCmoMoU9QQAzrZl4lfKzPn0+92/sR4Jg6TSZv/vliGWpzCtB+Ic+v28Oe0OjOy8so3feEGDwjjUKNXOovQslo0T84OB/7MJRvAgRLWmcXbjdk8SizNZtr0mfmQ/vVoydZI0s12tLzp/SSAdY7vsw6en9PyCq/LpGSow6tI+lj6HSzHS6KsHeiSr55bRguzytaH7e1SpnsXY31uRT0wvyRi5DOPN8AbJWULKVn82PpC/EBqehRQbd4HuGaEzA5Fan9DxutHEgfZUVZWVnlzUyam+DoO/5zYhcc79UaJYr6IJpGP90gSUQqAJ55fV56jxRvlPrApGNtBUCLy6I=" - # SIGNING_PASSWORD - - secure: "OP9Y18m1ASYbZN9blGPYf8/nyQeHHUqQDIm/8ZTmpIXWkR+2dA4pcQtMahpFeFduNsju2YbY9dqvzUvfEXwIZF6IeeQ+NJmRCBbdr0vGqx/4HHQY4vt9FZHpsfRcNjE8xmygCYNrQpdgl8VRFdPmcveB54eg3XMrNznDVLSSx/cVrqw+xcPa2162vcs8XziOgglHorJCRz7LAavIEz/hiR9/9FBPTEWCQqnMA1pJn0yfHZtd0ADw+s2tGtB+IJJ8RXYl2fYvS47gK6RISP/QYskXKZgb5qwzbMgaE9lxJ4gXh09D/YCfz1LK+VreVkLAk9PQUQnIpcAggPbRQI+U3ZC/tjEG00L7dGp8a+B8mo3wRWCNgNzr6Mrft5WJ8NEXPiwr1U1FSeM+Zum9ealYqW0wU+ks/79K3GeWZ2ZU/tF1cioh69KbSRtC2zW2s2gBFUgGb63P+7h/QlKDE9tAUBSht4oaYdVALSbvQEYJrWQEZO87+LH0YSaIu+8XuD+OSzgjfWqRWTBUYLKb56iA5978mMtwfQASAwjhQFxdpgHchaM7CUospq09+M3CA0N/OMz9Xr+xgEzej4r5is4bQH1IN87TE6XlKUhy2ZaV98mZ7bQtlPHVlVjslWA8ztgUHKjoK0q3Ob8ot8d3eaqKgAUY6zVoVqrYUf/obYR+yfE=" \ No newline at end of file diff --git a/README.md b/README.md index ba4b9e82..64fb134a 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # Spring REST Docs API specification Integration +[![oss lifecycle](https://img.shields.io/badge/oss_lifecycle-maintenance-yellow.svg)](https://github.com/ePages-de/restdocs-api-spec/issues/204) +[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=ePages-de_restdocs-api-spec&metric=coverage)](https://sonarcloud.io/summary/new_code?id=ePages-de_restdocs-api-spec) ![](https://img.shields.io/github/license/ePages-de/restdocs-openapi.svg) -[![Build Status](https://travis-ci.com/ePages-de/restdocs-api-spec.svg?branch=master)](https://travis-ci.com/ePages-de/restdocs-api-spec) [![Maven Central](https://img.shields.io/maven-central/v/com.epages/restdocs-api-spec)](https://search.maven.org/artifact/com.epages/restdocs-api-spec) -[![Coverage Status](https://coveralls.io/repos/github/ePages-de/restdocs-api-spec/badge.svg?branch=master)](https://coveralls.io/github/ePages-de/restdocs-api-spec?branch=master) [![Gitter](https://img.shields.io/gitter/room/nwjs/nw.js.svg)](https://gitter.im/restdocs-api-spec/Lobby) This is an extension that adds API specifications as an output format to [Spring REST Docs](https://projects.spring.io/spring-restdocs/). @@ -40,6 +40,7 @@ This is why we came up with this project. - [Motivation](#motivation) - [Getting started](#getting-started) + - [Version compatibility](#version-compatibility) - [Project structure](#project-structure) - [Build configuration](#build-configuration) - [Gradle](#gradle) @@ -62,16 +63,24 @@ This is why we came up with this project. - [OpenAPI 3.0.1](#openapi-301-1) - [Postman](#postman-1) - [Generate an HTML-based API reference from OpenAPI](#generate-an-html-based-api-reference-from-openapi) -- [RAML](#raml) ## Getting started +### Version compatibility + +Spring Boot and Spring REST Docs 3.0.0 introduced [breaking chances to how request parameters are documented: `RequestParameterSnippet` was split into `QueryParameterSnippet` and `FormParameterSnippet`.](https://github.com/spring-projects/spring-restdocs/issues/832) + +|Spring Boot version | restdocs-api-spec version| +|---|---| +|3.x|0.17.1 or later| +|2.x|0.16.4| + ### Project structure The project consists of the following main components: - [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. +This is most importantly the [ResourceDocumentation](restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/ResourceDocumentation.kt) which is the entry point 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-mockmvc](restdocs-api-spec-mockmvc) - contains a wrapper for `MockMvcRestDocumentation` for easier migration to `restdocs-api-spec` from MockMvc tests that use plain `spring-rest-docs-mockmvc`. - [restdocs-api-spec-restassured](restdocs-api-spec-restassured) - contains a wrapper for `RestAssuredRestDocumentation` for easier migration to `restdocs-api-spec` from [Rest Assured](http://rest-assured.io) tests that use plain `spring-rest-docs-restassured`. @@ -85,7 +94,7 @@ The [ResourceSnippet](restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apis * Using the [plugins DSL](https://docs.gradle.org/current/userguide/plugins.html#sec:plugins_block): ```groovy plugins { - id 'com.epages.restdocs-api-spec' version '0.15.3' + id 'com.epages.restdocs-api-spec' version '0.18.2' } ``` Examples with Kotlin are also available [here](https://plugins.gradle.org/plugin/com.epages.restdocs-api-spec) @@ -101,7 +110,7 @@ The [ResourceSnippet](restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apis } } dependencies { - classpath "com.epages:restdocs-api-spec-gradle-plugin:0.15.3" //1.2 + classpath "com.epages:restdocs-api-spec-gradle-plugin:0.18.2" //1.2 } } @@ -111,7 +120,7 @@ The [ResourceSnippet](restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apis 2. Add required dependencies to your tests * *2.1* add the `mavenCentral` repository used to resolve the `com.epages:restdocs-api-spec` module of the project. * *2.2* add the actual `restdocs-api-spec-mockmvc` dependency to the test scope. Use `restdocs-api-spec-restassured` if you use `RestAssured` instead of `MockMvc`. - * *2.3* add configuration options for restdocs-api-spec-gradle-plugin`. See [Gradle plugin configuration](#gradle-plugin-configuration) + * *2.3* add configuration options for `restdocs-api-spec-gradle-plugin`. See [Gradle plugin configuration](#gradle-plugin-configuration) ```groovy repositories { //2.1 @@ -120,7 +129,7 @@ The [ResourceSnippet](restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apis dependencies { //.. - testCompile('com.epages:restdocs-api-spec-mockmvc:0.15.3') //2.2 + testImplementation('com.epages:restdocs-api-spec-mockmvc:0.18.2') //2.2 } openapi { //2.3 @@ -261,7 +270,7 @@ Here it is important to add the constraints under the key `validationConstraints #### MockMvc based tests -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). +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-mockmvc/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`. @@ -273,7 +282,7 @@ Here is an example: resultActions .andDo( MockMvcRestDocumentationWrapper.document(operationName, - requestFields(fieldDescriptors().getFieldDescriptors()), + requestFields(new FieldDescriptors().getFieldDescriptors()), responseFields( fieldWithPath("comment").description("the comment"), fieldWithPath("flag").description("the flag"), @@ -472,6 +481,10 @@ openapi3 { title = 'My API title' version = '1.0.1' format = 'yaml' + contact = { + name = 'John Doe' + email = 'john.doe@example.com' + } separatePublicApi = true outputFileNamePrefix = 'my-api-spec' oauth2SecuritySchemeDefinition = { @@ -556,52 +569,40 @@ redoc-cli bundle build/api-spec/openapi.json redoc-cli serve build/api-spec/openapi.json ``` -## RAML +## Maintenance -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-api-spec`. +This section of the README is targeted at project maintainers. -The API of both projects is fairly similar and it is easy to migrate. +### Publish project -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. +The project is published with the help of [GitHub Actions](./.github/workflows). +It's version number is determined by the Git tags (see [allegro/axion-release-plugin](https://axion-release-plugin.readthedocs.io)). +The Java dependencies are published to Sonatype with the help of the [gradle-nexus/publish-plugin](https://github.com/gradle-nexus/publish-plugin) and the Maven Publish Plugin. +The Gradle plugin is published to the [Gradle plugin portal](https://plugins.gradle.org/plugin/com.epages.restdocs-api-spec) with the help of the ['plugin-publish' plugin](https://plugins.gradle.org/plugin/com.gradle.plugin-publish) (see [docs.gradle.org](https://docs.gradle.org/current/userguide/publishing_gradle_plugins.html)). -- [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 +Given that the `master` branch on the upstream repository is in the state from which you want to create a release, execute the following steps: -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. +**(1) Create release** -See [openapi2raml.gradle](samples/restdocs-api-spec-sample/openapi2raml.gradle). +[Create release via the GitHub UI](https://github.com/ePages-de/restdocs-api-spec/releases/new). -``` -./gradlew restdocs-api-spec-sample:openapi -./gradlew -b samples/restdocs-api-spec-sample/openapi2raml.gradle openapi2raml -``` +Use the intended version number as "Tag version", e.g. "0.18.2". +This will automatically trigger a GitHub Action build which publishes the JAR files for this release to Sonatype. -## Maintenance +**(2) Login to Sonatype** -This section of the README is targeted at project maintainers. +Login to Sonatype and navigate to the [staging repositories](https://oss.sonatype.org/#stagingRepositories). -### Publish project +**(3) Close the staging repository** + +Select the generated staging repository and close it. +Check that there are no errors afterwards (e.g. missing signatures or Javadoc JARs). -The project is published with the help of [TravisCI](./.travis.yml). -It's version number is determined by the Git tags (see [allegro/axion-release-plugin](https://axion-release-plugin.readthedocs.io)). -The Java dependencies are published to Sonatype with the help of the [gradle-nexus/publish-plugin](https://github.com/gradle-nexus/publish-plugin) and the Maven Publish Plugin. -The Gradle plugin is published to the [Gradle plugin portal](https://plugins.gradle.org/plugin/com.epages.restdocs-api-spec) with the help of the ['plugin-publish' plugin](https://plugins.gradle.org/plugin/com.gradle.plugin-publish) (see [docs.gradle.org](https://docs.gradle.org/current/userguide/publishing_gradle_plugins.html)). +**(4) Release the repository** -Given that the `master` branch on the upstream repository is in the state from which you want to create a release, execute the following steps: +Select the generated staging repository and release it. +After few minutes, the release should be available in the ["Public Repositories" of ePages](https://oss.sonatype.org/service/local/repo_groups/public/content/com/epages/). + +**(5) Update documentation** -1. [Create release via the GitHub UI](https://github.com/ePages-de/restdocs-api-spec/releases/new)
- Use the intended version number as "Tag version", e.g. "0.15.3". - This will automatically trigger a Travis build which publishes the JAR files for this release to Sonatype. -2. Login to Sonatype and navigate to the [staging repositories](https://oss.sonatype.org/#stagingRepositories) -3. Close the staging repository
- Select the generated staging repository and close it. - Check that there are no errors afterwards (e.g. missing signatures or Javadoc JARs). -4. Release the repository
- Select the generated staging repository and release it. - Soon after, the release should be available in the ["Public Repositories" of ePages](https://oss.sonatype.org/service/local/repo_groups/public/content/com/epages/). -5. Update documentation
- Create a new commit which updates the version numbers in the `README` file. +Create a new commit which updates the version numbers in the `README` file. diff --git a/build.gradle.kts b/build.gradle.kts index 84862f7c..ccf960fd 100755 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,19 +1,18 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile -import org.kt3k.gradle.plugin.CoverallsPluginExtension import pl.allegro.tech.build.axion.release.domain.TagNameSerializationConfig import pl.allegro.tech.build.axion.release.domain.hooks.HooksConfig plugins { - id("com.github.kt3k.coveralls") version "2.8.2" + `maven-publish` id("io.github.gradle-nexus.publish-plugin") version "1.0.0" id("org.jmailen.kotlinter") version "3.3.0" apply false + id("org.sonarqube") version "4.0.0.2929" id("pl.allegro.tech.build.axion-release") version "1.9.2" jacoco java - kotlin("jvm") version "1.4.20" apply false - `maven-publish` + kotlin("jvm") version "1.7.22" apply false } repositories { @@ -85,12 +84,6 @@ subprojects { } } -//coverall multi module plugin configuration starts here -configure { - sourceDirs = nonSampleProjects.flatMap { it.sourceSets["main"].allSource.srcDirs }.filter { it.exists() }.map { it.path } - jacocoReportPath = "$buildDir/reports/jacoco/jacocoRootReport/jacocoRootReport.xml" -} - tasks { val jacocoMerge by creating(JacocoMerge::class) { executionData = files(nonSampleProjects.map { File(it.buildDir, "/jacoco/test.exec") }) @@ -115,7 +108,7 @@ tasks { xml.isEnabled = true } } - getByName("coveralls").dependsOn(jacocoRootReport) + getByName("sonar").dependsOn(jacocoRootReport) } nexusPublishing { @@ -123,3 +116,12 @@ nexusPublishing { sonatype () } } + +sonar { + properties { + property("sonar.projectKey", "ePages-de_restdocs-api-spec") + property("sonar.organization", "epages-de") + property("sonar.host.url", "https://sonarcloud.io") + property("sonar.exclusions", "**/samples/**") + } +} diff --git a/ci_build.sh b/ci_build.sh deleted file mode 100755 index 54317116..00000000 --- a/ci_build.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash -set -e - -./gradlew clean build coveralls diff --git a/ci_publish_gradle.sh b/ci_publish_gradle.sh index bf4a833c..eb636110 100755 --- a/ci_publish_gradle.sh +++ b/ci_publish_gradle.sh @@ -1,4 +1,15 @@ #!/bin/bash set -e +function check_variable_set() { + _VARIABLE_NAME=$1 + _VARIABLE_VALUE=${!_VARIABLE_NAME} + if [[ -z ${_VARIABLE_VALUE} ]]; then + echo "Missing env variable ${_VARIABLE_NAME}" + exit 1 + fi +} +check_variable_set GRADLE_PUBLISH_KEY +check_variable_set GRADLE_PUBLISH_SECRET + ./gradlew publishPlugins -p restdocs-api-spec-gradle-plugin diff --git a/ci_publish_java.sh b/ci_publish_java.sh index 2aa6c626..60076d02 100755 --- a/ci_publish_java.sh +++ b/ci_publish_java.sh @@ -1,16 +1,102 @@ #!/bin/bash -set -e - -openssl aes-256-cbc -K $encrypted_7b7bcfd5be68_key -iv $encrypted_7b7bcfd5be68_iv \ - -in secret-keys.gpg.enc \ - -out "${SIGNING_KEYRING_FILE}" \ - -d - -./gradlew publishToSonatype \ - --info \ - --exclude-task :restdocs-api-spec-gradle-plugin:publishToSonatype \ - -Dorg.gradle.project.sonatypeUsername="${SONATYPE_USERNAME}" \ - -Dorg.gradle.project.sonatypePassword="${SONATYPE_PASSWORD}" \ - -Dorg.gradle.project.signing.keyId="${SIGNING_KEY_ID}" \ - -Dorg.gradle.project.signing.password="${SIGNING_PASSWORD}" \ - -Dorg.gradle.project.signing.secretKeyRingFile="${SIGNING_KEYRING_FILE}" + +set -e # Exit with nonzero exit code if anything fails + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" +SECRET_KEYS_FILE="${SCRIPT_DIR}/secret-keys.gpg" + +############################################################################### +# Parameter handling +############################################################################### + +usage () { + cat << EOF +DESCRIPTION: +The script publishes the Java libraries of this project to Sonatype or +Maven Local (default). + +SYNOPSIS: +$0 [-s] [-h] + +OPTIONS: + -s Publish to Sonatype (Default: off) + -h Show this message. + -? Show this message. + +REQUIRED ENVIRONMENT VARIABLES: +- FILE_ENCRYPTION_PASSWORD: Passphrase for decrypting the signing keys +- SIGNING_KEY_ID +- SIGNING_PASSWORD +- SONATYPE_USERNAME +- SONATYPE_PASSWORD + +DEPENDENCIES: +- gpg: https://help.ubuntu.com/community/GnuPrivacyGuardHowto + +EOF +} + +while getopts "s h ?" option ; do + case $option in + s) PUBLISH_TO_SONATYPE='true' + ;; + h ) usage + exit 0;; + ? ) usage + exit 0;; + esac +done + + +############################################################################### +# Env variables and dependencies +############################################################################### + +function check_variable_set() { + _VARIABLE_NAME=$1 + _VARIABLE_VALUE=${!_VARIABLE_NAME} + if [[ -z ${_VARIABLE_VALUE} ]]; then + echo "Missing env variable ${_VARIABLE_NAME}" + exit 1 + fi +} +check_variable_set FILE_ENCRYPTION_PASSWORD +check_variable_set SIGNING_KEY_ID +check_variable_set SIGNING_PASSWORD +check_variable_set SONATYPE_USERNAME +check_variable_set SONATYPE_PASSWORD + +if ! command -v gpg &> /dev/null; then + echo "gpg not installed. See https://help.ubuntu.com/community/GnuPrivacyGuardHowto" + exit 1 +fi + +############################################################################### +# Parameter handling +############################################################################### + +# Decrypt signing key +gpg --quiet --batch --yes --decrypt --passphrase="${FILE_ENCRYPTION_PASSWORD}" \ + --output ${SECRET_KEYS_FILE} secret-keys.gpg.enc + +if [[ ! -f "${SECRET_KEYS_FILE}" ]]; then + echo "File ${SECRET_KEYS_FILE} does not exist" + exit 1 +fi + +# Determine where to publish the Java archives +if [[ "${PUBLISH_TO_SONATYPE}" == "true" ]]; then + PUBLISH_GRADLE_TASK="publishToSonatype" +else + PUBLISH_GRADLE_TASK="publishToMavenLocal" +fi + +# Publish +./gradlew ${PUBLISH_GRADLE_TASK} \ + --info \ + --exclude-task :restdocs-api-spec-gradle-plugin:publishToSonatype \ + -Dorg.gradle.project.sonatypeUsername="${SONATYPE_USERNAME}" \ + -Dorg.gradle.project.sonatypePassword="${SONATYPE_PASSWORD}" \ + -Dorg.gradle.project.signing.keyId="${SIGNING_KEY_ID}" \ + -Dorg.gradle.project.signing.password="${SIGNING_PASSWORD}" \ + -Dorg.gradle.project.signing.secretKeyRingFile="${SECRET_KEYS_FILE}" diff --git a/ci_test.sh b/ci_test.sh new file mode 100755 index 00000000..52defd74 --- /dev/null +++ b/ci_test.sh @@ -0,0 +1,14 @@ +#!/bin/bash +set -e # Exit with nonzero exit code if anything fails + +if [[ -n "${SONAR_TOKEN}" ]]; then + SONAR_GRADLE_TASK="sonar" +else + echo "INFO: Skipping sonar analysis as SONAR_TOKEN is not set" +fi + +./gradlew \ + clean \ + ${SONAR_GRADLE_TASK} \ + build \ + --info diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 00000000..f9683961 --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +org.gradle.jvmargs=-XX:MaxMetaspaceSize=512m -Xms256m -Xmx512m diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e708b1c0..41d9927a 100755 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 442d9132..92f06b50 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-6.8.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-all.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 4f906e0c..1b6c7873 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,67 +17,101 @@ # ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` +APP_BASE_NAME=${0##*/} # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar @@ -87,9 +121,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" + JAVACMD=java which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the @@ -106,80 +140,95 @@ location of your Java installation." fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=`expr $i + 1` + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/restdocs-api-spec-gradle-plugin/build.gradle.kts b/restdocs-api-spec-gradle-plugin/build.gradle.kts index 43c38967..0687ef50 100755 --- a/restdocs-api-spec-gradle-plugin/build.gradle.kts +++ b/restdocs-api-spec-gradle-plugin/build.gradle.kts @@ -45,8 +45,8 @@ val jacocoRuntime by configurations.creating dependencies { compileOnly(gradleKotlinDsl()) - compile(kotlin("gradle-plugin")) - compile(kotlin("stdlib-jdk8")) + implementation(kotlin("gradle-plugin")) + implementation(kotlin("stdlib-jdk8")) implementation(project(":restdocs-api-spec-openapi-generator")) implementation(project(":restdocs-api-spec-openapi3-generator")) @@ -60,7 +60,7 @@ dependencies { testImplementation("com.jayway.jsonpath:json-path:2.4.0") - testCompile(gradleTestKit()) + testImplementation(gradleTestKit()) jacocoRuntime("org.jacoco:org.jacoco.agent:0.8.2:runtime") } 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 index 4b290029..d19d396e 100644 --- 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 @@ -2,6 +2,7 @@ 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.info.Contact import io.swagger.v3.oas.models.servers.Server import org.gradle.api.tasks.Input import org.gradle.api.tasks.Optional @@ -12,9 +13,14 @@ open class OpenApi3Task : OpenApiBaseTask() { @Optional var servers: List = listOf() + @Input + @Optional + var contact: Contact? = null + fun applyExtension(extension: OpenApi3Extension) { super.applyExtension(extension) servers = extension.servers + contact = extension.contact } override fun generateSpecification(resourceModels: List): String { @@ -26,7 +32,8 @@ open class OpenApi3Task : OpenApiBaseTask() { tagDescriptions = tagDescriptions, version = apiVersion, oauth2SecuritySchemeDefinition = oauth2SecuritySchemeDefinition, - format = format + format = format, + contact = contact ) } } 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 index d508ff35..93d36eee 100644 --- 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 @@ -5,6 +5,7 @@ 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.info.Contact import io.swagger.v3.oas.models.servers.Server import org.gradle.api.Project import java.io.File @@ -63,6 +64,7 @@ open class OpenApi3Extension(project: Project) : OpenApiBaseExtension(project) { override var outputFileNamePrefix = "openapi3" private var _servers: List = mutableListOf(Server().apply { url = "http://localhost" }) + private var _contact: Contact? = null val servers get() = _servers @@ -79,6 +81,13 @@ open class OpenApi3Extension(project: Project) : OpenApiBaseExtension(project) { _servers = serversActions.map { project.configure(Server(), it) as Server } } + val contact + get() = _contact + + fun setContact(contact: Closure) { + _contact = project.configure(Contact(), contact) as Contact + } + companion object { const val name = "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 index 2f4ca7bf..fa7ef77d 100644 --- 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 @@ -37,6 +37,18 @@ class RestdocsOpenApi3TaskTest : RestdocsOpenApiTaskTestBase() { thenSingleServerContainedInOutput() } + @Test + fun `should run openapi task with contact`() { + givenBuildFileWithOpenApiClosureWithContact() + givenResourceSnippet() + + whenPluginExecuted() + + thenApiSpecTaskSuccessful() + thenOutputFileFound() + thenContactContainedInOutput() + } + @Test fun `should run openapi task with single server string`() { givenBuildFileWithOpenApiClosureWithSingleServerString() @@ -67,6 +79,12 @@ class RestdocsOpenApi3TaskTest : RestdocsOpenApiTaskTestBase() { } } + private fun thenContactContainedInOutput() { + with(outputFileContext()) { + then(read("info.contact.name")).isEqualTo("Test Contact") + } + } + private fun thenHeaderWithDefaultValuesContainedInOutput() { with(outputFileContext()) { then(read("paths./products/{id}.get.parameters[1].name")).isEqualTo("one") @@ -86,6 +104,10 @@ class RestdocsOpenApi3TaskTest : RestdocsOpenApiTaskTestBase() { givenBuildFileWithOpenApiClosure("server", """{ url = 'http://some.api' }""") } + fun givenBuildFileWithOpenApiClosureWithContact() { + givenBuildFileWithOpenApiClosure("contact", """{ name = 'Test Contact' }""") + } + override fun givenBuildFileWithOpenApiClosure() { givenBuildFileWithOpenApiClosure( "servers", @@ -136,6 +158,7 @@ class RestdocsOpenApi3TaskTest : RestdocsOpenApiTaskTestBase() { baseBuildFile() + """ openapi3 { servers = [ { url = "http://some.api" } ] + contact = { name = "Test Contact" } title = '$title' description = '$description' tagDescriptionsPropertiesFile = "tagDescriptions.yaml" 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 index 578f1f38..a10cd4c0 100644 --- 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 @@ -54,11 +54,11 @@ class RestdocsOpenApiTaskTest : RestdocsOpenApiTaskTestBase() { 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() + then(read("securityDefinitions.oauth2.scopes.prod:r")).isEqualTo("Some text") + then(read("securityDefinitions.oauth2.type")).isEqualTo("oauth2") + then(read("securityDefinitions.oauth2.tokenUrl")).isNotEmpty() + then(read("securityDefinitions.oauth2.authorizationUrl")).isNotEmpty() + then(read("securityDefinitions.oauth2.flow")).isNotEmpty() } } } diff --git a/restdocs-api-spec-jsonschema/build.gradle.kts b/restdocs-api-spec-jsonschema/build.gradle.kts index 66a178b0..6c3b4f4d 100644 --- a/restdocs-api-spec-jsonschema/build.gradle.kts +++ b/restdocs-api-spec-jsonschema/build.gradle.kts @@ -13,17 +13,17 @@ val jacksonVersion: String by extra val junitVersion: String by extra dependencies { - compile(kotlin("stdlib-jdk8")) - compile(project(":restdocs-api-spec-model")) - compile("com.github.erosb:everit-json-schema:1.11.0") - compile("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion") - compile("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion") + implementation(kotlin("stdlib-jdk8")) + implementation(project(":restdocs-api-spec-model")) + implementation("com.github.erosb:everit-json-schema:1.11.0") + implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion") - testCompile("org.junit.jupiter:junit-jupiter-engine:$junitVersion") - testCompile("com.github.java-json-tools:json-schema-validator:2.2.10") - testCompile("com.jayway.jsonpath:json-path:2.4.0") - testCompile("org.assertj:assertj-core:3.10.0") - testCompile("javax.validation:validation-api:2.0.1.Final") + testImplementation("org.junit.jupiter:junit-jupiter-engine:$junitVersion") + testImplementation("com.github.java-json-tools:json-schema-validator:2.2.10") + testImplementation("com.jayway.jsonpath:json-path:2.4.0") + testImplementation("org.assertj:assertj-core:3.10.0") + testImplementation("javax.validation:validation-api:2.0.1.Final") } publishing { diff --git a/restdocs-api-spec-jsonschema/src/main/kotlin/com/epages/restdocs/apispec/jsonschema/ConstraintResolver.kt b/restdocs-api-spec-jsonschema/src/main/kotlin/com/epages/restdocs/apispec/jsonschema/ConstraintResolver.kt index a3a8ab3f..982ab01f 100644 --- a/restdocs-api-spec-jsonschema/src/main/kotlin/com/epages/restdocs/apispec/jsonschema/ConstraintResolver.kt +++ b/restdocs-api-spec-jsonschema/src/main/kotlin/com/epages/restdocs/apispec/jsonschema/ConstraintResolver.kt @@ -36,7 +36,7 @@ internal object ConstraintResolver { private fun FieldDescriptor.maybeSizeConstraint() = findConstraints(this).firstOrNull { SIZE_CONSTRAINT == it.name } - internal fun maybePattern(fieldDescriptor: FieldDescriptor?) = fieldDescriptor?.maybePatternConstraint()?.let { it.configuration["pattern"] as? String } + internal fun maybePattern(fieldDescriptor: FieldDescriptor?) = fieldDescriptor?.maybePatternConstraint()?.let { it.configuration["regexp"] as? String } private fun FieldDescriptor.maybePatternConstraint() = findConstraints(this).firstOrNull { PATTERN_CONSTRAINT == it.name } @@ -82,9 +82,10 @@ internal object ConstraintResolver { .minOrNull() } - internal fun isRequired(fieldDescriptor: FieldDescriptor): Boolean = - findConstraints(fieldDescriptor) - .any { constraint -> REQUIRED_CONSTRAINTS.contains(constraint.name) } + internal fun isRequired(fieldDescriptor: FieldDescriptor): Boolean = findConstraints(fieldDescriptor) + .any { constraint -> + REQUIRED_CONSTRAINTS.contains(constraint.name) + } || !fieldDescriptor.optional private fun findConstraints(fieldDescriptor: FieldDescriptor): List = fieldDescriptor.attributes.validationConstraints diff --git a/restdocs-api-spec-jsonschema/src/main/kotlin/com/epages/restdocs/apispec/jsonschema/JsonSchemaFromFieldDescriptorsGenerator.kt b/restdocs-api-spec-jsonschema/src/main/kotlin/com/epages/restdocs/apispec/jsonschema/JsonSchemaFromFieldDescriptorsGenerator.kt index 569a6123..56f7adb6 100644 --- a/restdocs-api-spec-jsonschema/src/main/kotlin/com/epages/restdocs/apispec/jsonschema/JsonSchemaFromFieldDescriptorsGenerator.kt +++ b/restdocs-api-spec-jsonschema/src/main/kotlin/com/epages/restdocs/apispec/jsonschema/JsonSchemaFromFieldDescriptorsGenerator.kt @@ -15,6 +15,7 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import org.everit.json.schema.ArraySchema import org.everit.json.schema.BooleanSchema import org.everit.json.schema.CombinedSchema +import org.everit.json.schema.CombinedSchema.oneOf import org.everit.json.schema.EmptySchema import org.everit.json.schema.EnumSchema import org.everit.json.schema.NullSchema @@ -167,11 +168,13 @@ class JsonSchemaFromFieldDescriptorsGenerator { .build() ) } else { + val schemaName = propertyField?.fieldDescriptor?.attributes?.schemaName builder.addPropertySchema( propertyName, traverse( traversedSegments, fields, ObjectSchema.builder() + .title(schemaName) .description(propertyField?.fieldDescriptor?.description) as ObjectSchema.Builder ) ) @@ -206,9 +209,26 @@ class JsonSchemaFromFieldDescriptorsGenerator { ) : FieldDescriptor(path, description, type, optional, ignored, attributes) { fun jsonSchemaType(): Schema { - val schemaBuilders = jsonSchemaPrimitiveTypes.map { typeToSchema(it) } - return if (schemaBuilders.size == 1) schemaBuilders.first().description(description).build() - else CombinedSchema.oneOf(schemaBuilders.map { it.build() }).description(description).build() + val schemaBuilders: List> + if (jsonSchemaPrimitiveTypes.size > 1 && + optional && + !jsonSchemaPrimitiveTypes.contains("null") + ) { + schemaBuilders = jsonSchemaPrimitiveTypes + .plus(jsonSchemaPrimitiveTypeFromDescriptorType("null")) + .map { typeToSchema(it) } + } else { + schemaBuilders = jsonSchemaPrimitiveTypes.map { typeToSchema(it) } + } + return if (schemaBuilders.size == 1) schemaBuilders.first().description(description).checkNullable().build() + else oneOf(schemaBuilders.map { it.build() }).description(description).checkNullable().build() + } + + private fun Schema.Builder.checkNullable(): Schema.Builder { + if (optional) { + this.nullable(true) + } + return this } fun merge(fieldDescriptor: FieldDescriptor): FieldDescriptorWithSchemaType { @@ -230,7 +250,9 @@ class JsonSchemaFromFieldDescriptorsGenerator { private fun typeToSchema(type: String): Schema.Builder<*> = when (type) { - "null" -> NullSchema.builder() + "null" -> { + NullSchema.builder().nullable() + } "empty" -> EmptySchema.builder() "object" -> ObjectSchema.builder() "array" -> ArraySchema.builder().applyConstraints(this).allItemSchema(arrayItemsSchema()) @@ -246,9 +268,14 @@ class JsonSchemaFromFieldDescriptorsGenerator { else -> throw IllegalArgumentException("unknown field type $type") } + private fun NullSchema.Builder.nullable(): NullSchema.Builder { + this.nullable(true) + return this + } + private fun arrayItemsSchema(): Schema { return attributes.itemsType - ?.let { typeToSchema(it.toLowerCase()).build() } + ?.let { typeToSchema(it.lowercase()).build() } ?: CombinedSchema.oneOf( listOf( ObjectSchema.builder().build(), @@ -277,7 +304,7 @@ class JsonSchemaFromFieldDescriptorsGenerator { ) private fun jsonSchemaPrimitiveTypeFromDescriptorType(fieldDescriptorType: String) = - fieldDescriptorType.toLowerCase() + fieldDescriptorType.lowercase() .let { if (it == "varies") "empty" else it } // varies is used by spring rest docs if the type is ambiguous - in json schema we want to represent as empty } } diff --git a/restdocs-api-spec-jsonschema/src/test/kotlin/com/epages/restdocs/apispec/jsonschema/JsonSchemaFromFieldDescriptorsGeneratorTest.kt b/restdocs-api-spec-jsonschema/src/test/kotlin/com/epages/restdocs/apispec/jsonschema/JsonSchemaFromFieldDescriptorsGeneratorTest.kt index f0c7b1a1..a9ed3b12 100644 --- a/restdocs-api-spec-jsonschema/src/test/kotlin/com/epages/restdocs/apispec/jsonschema/JsonSchemaFromFieldDescriptorsGeneratorTest.kt +++ b/restdocs-api-spec-jsonschema/src/test/kotlin/com/epages/restdocs/apispec/jsonschema/JsonSchemaFromFieldDescriptorsGeneratorTest.kt @@ -19,6 +19,7 @@ import org.everit.json.schema.Schema import org.everit.json.schema.StringSchema import org.everit.json.schema.ValidationException import org.everit.json.schema.loader.SchemaLoader +import org.everit.json.schema.loader.internal.DefaultSchemaClient import org.json.JSONArray import org.json.JSONObject import org.junit.jupiter.api.Test @@ -39,6 +40,22 @@ class JsonSchemaFromFieldDescriptorsGeneratorTest { private var schemaString: String? = null + @Test + @Throws(IOException::class) + fun should_generate_reuse_schema() { + givenFieldDescriptorsWithSchemaName() + + whenSchemaGenerated() + + then(schema).isInstanceOf(ObjectSchema::class.java) + val objectSchema = schema as ObjectSchema? + val postSchema = objectSchema?.propertySchemas?.get("post") as ObjectSchema + val shippingAddressSchema = postSchema.propertySchemas["shippingAddress"] as ObjectSchema + then(shippingAddressSchema.title).isEqualTo("Address") + val billingAddressSchema = postSchema.propertySchemas["billingAddress"] as ObjectSchema + then(billingAddressSchema.title).isEqualTo("Address") + } + @Test @Throws(IOException::class) fun should_generate_complex_schema() { @@ -61,6 +78,7 @@ class JsonSchemaFromFieldDescriptorsGeneratorTest { val shippingAddressSchema = objectSchema.propertySchemas["shippingAddress"]!! then(shippingAddressSchema).isInstanceOf(ObjectSchema::class.java) then(shippingAddressSchema.description).isNotEmpty() + then(shippingAddressSchema.isNullable).isTrue() then(objectSchema.definesProperty("billingAddress")).isTrue() val billingAddressSchema = objectSchema.propertySchemas["billingAddress"] as ObjectSchema @@ -120,6 +138,7 @@ class JsonSchemaFromFieldDescriptorsGeneratorTest { then(pagePositiveSchema.minimum.toInt()).isEqualTo(1) then(pagePositiveSchema.maximum).isNull() then(pagePositiveSchema.requiresInteger()).isTrue + then(pagePositiveSchema.isNullable).isTrue() then(objectSchema.definesProperty("page100_200")).isTrue then(objectSchema.propertySchemas["page100_200"]).isInstanceOf(NumberSchema::class.java) @@ -445,7 +464,14 @@ class JsonSchemaFromFieldDescriptorsGeneratorTest { private fun whenSchemaGenerated() { schemaString = generator.generateSchema(fieldDescriptors!!) println(schemaString) - schema = SchemaLoader.load(JSONObject(schemaString)) + schema = SchemaLoader + .builder() + .nullableSupport(true) + .schemaJson(JSONObject(schemaString)) + .schemaClient(DefaultSchemaClient()) + .build() + .load() + .build() } private fun givenFieldDescriptorWithPrimitiveArray() { @@ -589,9 +615,9 @@ class JsonSchemaFromFieldDescriptorsGeneratorTest { private fun givenDifferentFieldDescriptorsWithSamePathAndDifferentTypes() { fieldDescriptors = listOf( - FieldDescriptor("id", "some", "STRING"), - FieldDescriptor("id", "some", "NULL"), - FieldDescriptor("id", "some", "BOOLEAN") + FieldDescriptor("id", "some", "STRING", true), + FieldDescriptor("id", "some", "NULL", true), + FieldDescriptor("id", "some", "BOOLEAN", true) ) } @@ -624,7 +650,7 @@ class JsonSchemaFromFieldDescriptorsGeneratorTest { listOf( Constraint( "javax.validation.constraints.Pattern", - mapOf("pattern" to "[a-z]") + mapOf("regexp" to "[a-z]") ) ) ) @@ -655,7 +681,7 @@ class JsonSchemaFromFieldDescriptorsGeneratorTest { ), FieldDescriptor("lineItems[*].quantity.unit", "some", "STRING"), - FieldDescriptor("shippingAddress", "some", "OBJECT"), + FieldDescriptor("shippingAddress", "some", "OBJECT", true), FieldDescriptor("billingAddress", "some", "OBJECT"), FieldDescriptor( "billingAddress.firstName", "some", "STRING", @@ -732,6 +758,7 @@ class JsonSchemaFromFieldDescriptorsGeneratorTest { "pagePositive", "some", "NUMBER", + true, attributes = Attributes( listOf( Constraint( @@ -778,6 +805,23 @@ class JsonSchemaFromFieldDescriptorsGeneratorTest { ) } + private fun givenFieldDescriptorsWithSchemaName() { + + fieldDescriptors = listOf( + FieldDescriptor( + "post", + "some", + "OBJECT", + ), + FieldDescriptor("post.shippingAddress", "some", "OBJECT", attributes = Attributes(schemaName = "Address")), + FieldDescriptor("post.shippingAddress.firstName", "some", "STRING"), + FieldDescriptor("post.shippingAddress.valid", "some", "BOOLEAN"), + FieldDescriptor("post.billingAddress", "some", "OBJECT", attributes = Attributes(schemaName = "Address")), + FieldDescriptor("post.billingAddress.firstName", "some", "STRING"), + FieldDescriptor("post.billingAddress.valid", "some", "BOOLEAN"), + ) + } + private fun thenSchemaValidatesJson(json: String) { schema!!.validate(if (json.startsWith("[")) JSONArray(json) else JSONObject(json)) } diff --git a/restdocs-api-spec-mockmvc/build.gradle.kts b/restdocs-api-spec-mockmvc/build.gradle.kts index a8d1546b..0f207f94 100644 --- a/restdocs-api-spec-mockmvc/build.gradle.kts +++ b/restdocs-api-spec-mockmvc/build.gradle.kts @@ -12,17 +12,17 @@ val springRestDocsVersion: String by extra val junitVersion: String by extra dependencies { - compile(kotlin("stdlib-jdk8")) + implementation(kotlin("stdlib-jdk8")) - compile(project(":restdocs-api-spec")) - compile("org.springframework.restdocs:spring-restdocs-mockmvc:$springRestDocsVersion") + api(project(":restdocs-api-spec")) + implementation("org.springframework.restdocs:spring-restdocs-mockmvc:$springRestDocsVersion") - testCompile("org.springframework.boot:spring-boot-starter-test:$springBootVersion") { + testImplementation("org.springframework.boot:spring-boot-starter-test:$springBootVersion") { exclude("junit") } - testCompile("org.junit.jupiter:junit-jupiter-engine:$junitVersion") + testImplementation("org.junit.jupiter:junit-jupiter-engine:$junitVersion") testImplementation("org.junit-pioneer:junit-pioneer:0.3.0") - testCompile("org.springframework.boot:spring-boot-starter-hateoas:$springBootVersion") + testImplementation("org.springframework.boot:spring-boot-starter-hateoas:$springBootVersion") } publishing { diff --git a/restdocs-api-spec-model/build.gradle.kts b/restdocs-api-spec-model/build.gradle.kts index d9aa3c49..86d5131b 100644 --- a/restdocs-api-spec-model/build.gradle.kts +++ b/restdocs-api-spec-model/build.gradle.kts @@ -12,7 +12,7 @@ repositories { } dependencies { - compile(kotlin("stdlib-jdk8")) + implementation(kotlin("stdlib-jdk8")) implementation("com.fasterxml.jackson.core:jackson-annotations:$jacksonVersion") } diff --git a/restdocs-api-spec-model/src/main/kotlin/com/epages/restdocs/apispec/model/Oauth2Configuration.kt b/restdocs-api-spec-model/src/main/kotlin/com/epages/restdocs/apispec/model/Oauth2Configuration.kt index a754409e..85e87ea9 100644 --- a/restdocs-api-spec-model/src/main/kotlin/com/epages/restdocs/apispec/model/Oauth2Configuration.kt +++ b/restdocs-api-spec-model/src/main/kotlin/com/epages/restdocs/apispec/model/Oauth2Configuration.kt @@ -6,5 +6,5 @@ open class Oauth2Configuration( var flows: Array = arrayOf(), var scopes: Map = mapOf() ) { - fun securitySchemeName(flow: String) = "oauth2_$flow" + fun securitySchemeName() = "oauth2" } diff --git a/restdocs-api-spec-model/src/main/kotlin/com/epages/restdocs/apispec/model/ResourceModel.kt b/restdocs-api-spec-model/src/main/kotlin/com/epages/restdocs/apispec/model/ResourceModel.kt index c7605f9e..7d53a63d 100644 --- a/restdocs-api-spec-model/src/main/kotlin/com/epages/restdocs/apispec/model/ResourceModel.kt +++ b/restdocs-api-spec-model/src/main/kotlin/com/epages/restdocs/apispec/model/ResourceModel.kt @@ -90,7 +90,8 @@ open class FieldDescriptor( data class Attributes( val validationConstraints: List = emptyList(), val enumValues: List = emptyList(), - val itemsType: String? = null + val itemsType: String? = null, + val schemaName: String? = null, ) data class Constraint( diff --git a/restdocs-api-spec-openapi-generator/build.gradle.kts b/restdocs-api-spec-openapi-generator/build.gradle.kts index a29d261e..def92a93 100644 --- a/restdocs-api-spec-openapi-generator/build.gradle.kts +++ b/restdocs-api-spec-openapi-generator/build.gradle.kts @@ -10,13 +10,13 @@ repositories { val junitVersion: String by extra dependencies { - compile(kotlin("stdlib-jdk8")) + implementation(kotlin("stdlib-jdk8")) - compile(project(":restdocs-api-spec-model")) - compile(project(":restdocs-api-spec-jsonschema")) - compile("io.swagger:swagger-core:1.5.22") - compile("com.fasterxml.jackson.core:jackson-databind:2.12.2") - compile("com.fasterxml.jackson.module:jackson-module-kotlin:2.12.2") + api(project(":restdocs-api-spec-model")) + api(project(":restdocs-api-spec-jsonschema")) + api("io.swagger:swagger-core:1.5.22") + implementation("com.fasterxml.jackson.core:jackson-databind:2.12.2") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.12.2") testImplementation("io.swagger:swagger-parser:1.0.36") testImplementation("org.junit.jupiter:junit-jupiter-engine:$junitVersion") diff --git a/restdocs-api-spec-openapi-generator/src/main/kotlin/com/epages/restdocs/apispec/openapi2/OpenApi20Generator.kt b/restdocs-api-spec-openapi-generator/src/main/kotlin/com/epages/restdocs/apispec/openapi2/OpenApi20Generator.kt index 3dfe5480..f888420d 100644 --- a/restdocs-api-spec-openapi-generator/src/main/kotlin/com/epages/restdocs/apispec/openapi2/OpenApi20Generator.kt +++ b/restdocs-api-spec-openapi-generator/src/main/kotlin/com/epages/restdocs/apispec/openapi2/OpenApi20Generator.kt @@ -40,6 +40,7 @@ object OpenApi20Generator { private const val API_KEY_SECURITY_NAME = "api_key" private const val BASIC_SECURITY_NAME = "basic" + private const val OAUTH2_SECURITY_NAME = "oauth2" private val PATH_PARAMETER_PATTERN = """\{([^/}]+)}""".toRegex() internal fun generate( resources: List, @@ -323,16 +324,10 @@ object OpenApi20Generator { val securityRequirements = firstModelForPathAndMethod.request.securityRequirements if (securityRequirements != null) { when (securityRequirements.type) { - SecurityType.OAUTH2 -> oauth2SecuritySchemeDefinition?.flows?.map { - addSecurity( - oauth2SecuritySchemeDefinition.securitySchemeName(it), - securityRequirements2ScopesList( - securityRequirements - ) - ) - } + SecurityType.OAUTH2 -> addSecurity(OAUTH2_SECURITY_NAME, securityRequirements2ScopesList(securityRequirements)) SecurityType.BASIC -> addSecurity(BASIC_SECURITY_NAME, null) SecurityType.API_KEY -> addSecurity(API_KEY_SECURITY_NAME, null) + SecurityType.JWT_BEARER -> { /* not specified for OpenApi 2.0 */ } } } } @@ -372,7 +367,7 @@ object OpenApi20Generator { addScope(it, scopeAndDescriptions.getOrDefault(it, "No description")) } } - openApi.addSecurityDefinition(oauth2SecuritySchemeDefinition.securitySchemeName(flow), oauth2Definition) + openApi.addSecurityDefinition(oauth2SecuritySchemeDefinition.securitySchemeName(), oauth2Definition) } if (hasAnyOperationWithSecurityName( openApi, diff --git a/restdocs-api-spec-openapi-generator/src/test/kotlin/com/epages/restdocs/apispec/openapi2/OpenApi20GeneratorTest.kt b/restdocs-api-spec-openapi-generator/src/test/kotlin/com/epages/restdocs/apispec/openapi2/OpenApi20GeneratorTest.kt index 5a587f36..251fd4a0 100644 --- a/restdocs-api-spec-openapi-generator/src/test/kotlin/com/epages/restdocs/apispec/openapi2/OpenApi20GeneratorTest.kt +++ b/restdocs-api-spec-openapi-generator/src/test/kotlin/com/epages/restdocs/apispec/openapi2/OpenApi20GeneratorTest.kt @@ -141,8 +141,8 @@ class OpenApi20GeneratorTest { val openapi = whenOpenApiObjectGenerated(api) with(openapi.securityDefinitions) { - then(this.containsKey("oauth2_accessCode")) - then(this["oauth2_accessCode"]) + then(this.containsKey("oauth2")) + then(this["oauth2"]) .isEqualToComparingFieldByField( OAuth2Definition().accessCode("http://example.com/authorize", "http://example.com/token") .apply { addScope("prod:r", "No description") } @@ -356,12 +356,12 @@ class OpenApi20GeneratorTest { then(productPath.get.operationId).isNotEmpty() then(productPath.get.consumes).contains(successfulGetProductModel.request.contentType) - then(productPath.get.security).hasSize(2) + then(productPath.get.security).hasSize(1) then(productPath.get.tags).containsOnly("tag1", "tag2") val combined = productPath.get.security.reduce { map1, map2 -> map1 + map2 } - then(combined).containsOnlyKeys("oauth2_application", "oauth2_accessCode") + then(combined).containsOnlyKeys("oauth2") then(combined.values).containsOnly(listOf("prod:r")) then(successfulGetResponse).isNotNull diff --git a/restdocs-api-spec-openapi3-generator/build.gradle.kts b/restdocs-api-spec-openapi3-generator/build.gradle.kts index d78e574c..9046640c 100644 --- a/restdocs-api-spec-openapi3-generator/build.gradle.kts +++ b/restdocs-api-spec-openapi3-generator/build.gradle.kts @@ -11,14 +11,14 @@ val jacksonVersion: String by extra val junitVersion: String by extra dependencies { - compile(kotlin("stdlib-jdk8")) + implementation(kotlin("stdlib-jdk8")) - compile(project(":restdocs-api-spec-model")) - compile(project(":restdocs-api-spec-jsonschema")) + api(project(":restdocs-api-spec-model")) + api(project(":restdocs-api-spec-jsonschema")) - compile("io.swagger.core.v3:swagger-core:2.1.3") - compile("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion") - compile("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion") + api("io.swagger.core.v3:swagger-core:2.1.3") + implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion") + implementation("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") 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 index 77ad287b..a1f6b09b 100644 --- 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 @@ -14,6 +14,7 @@ import com.epages.restdocs.apispec.model.SimpleType import com.epages.restdocs.apispec.model.groupByPath import com.epages.restdocs.apispec.openapi3.SecuritySchemeGenerator.addSecurityDefinitions import com.epages.restdocs.apispec.openapi3.SecuritySchemeGenerator.addSecurityItemFromSecurityRequirements +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import io.swagger.v3.core.util.Json import io.swagger.v3.oas.models.Components @@ -23,6 +24,7 @@ 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.Contact import io.swagger.v3.oas.models.info.Info import io.swagger.v3.oas.models.media.BooleanSchema import io.swagger.v3.oas.models.media.Content @@ -51,7 +53,8 @@ object OpenApi3Generator { description: String? = null, tagDescriptions: Map = emptyMap(), version: String = "1.0.0", - oauth2SecuritySchemeDefinition: Oauth2Configuration? = null + oauth2SecuritySchemeDefinition: Oauth2Configuration? = null, + contact: Contact? = null ): OpenAPI { return OpenAPI().apply { @@ -60,6 +63,7 @@ object OpenApi3Generator { this.title = title this.description = description this.version = version + this.contact = contact } this.tags( tagDescriptions.map { @@ -73,11 +77,41 @@ object OpenApi3Generator { resources, oauth2SecuritySchemeDefinition ) + extractDefinitions() + makeSubSchema() addSecurityDefinitions(oauth2SecuritySchemeDefinition) } } + private fun OpenAPI.makeSubSchema() { + val schemas = this.components.schemas + val subSchemas = mutableMapOf>() + schemas.forEach { + val schema = it.value + if (schema.properties != null) { + makeSubSchema(subSchemas, schema.properties) + } + } + + if (subSchemas.isNotEmpty()) { + this.components.schemas.putAll(subSchemas) + } + } + + private fun makeSubSchema(schemas: MutableMap>, properties: Map>) { + properties.asSequence().filter { it.value.title != null }.forEach { + val objectMapper = jacksonObjectMapper() + val subSchema = it.value + val strSubSchema = objectMapper.writeValueAsString(subSchema) + val copySchema = objectMapper.readValue(strSubSchema, subSchema.javaClass) + val schemaTitle = copySchema.title + subSchema.`$ref`("#/components/schemas/$schemaTitle") + schemas[schemaTitle] = copySchema + makeSubSchema(schemas, copySchema.properties) + } + } + fun generateAndSerialize( resources: List, servers: List, @@ -86,7 +120,8 @@ object OpenApi3Generator { tagDescriptions: Map = emptyMap(), version: String = "1.0.0", oauth2SecuritySchemeDefinition: Oauth2Configuration? = null, - format: String + format: String, + contact: Contact? = null ) = ApiSpecificationWriter.serialize( format, @@ -97,7 +132,8 @@ object OpenApi3Generator { description = description, tagDescriptions = tagDescriptions, version = version, - oauth2SecuritySchemeDefinition = oauth2SecuritySchemeDefinition + oauth2SecuritySchemeDefinition = oauth2SecuritySchemeDefinition, + contact = contact ) ) @@ -127,6 +163,8 @@ object OpenApi3Generator { schemasToKeys.getValue(it) to it }.toMap() } + + this.components } private fun List.extractSchemas( @@ -262,7 +300,7 @@ object OpenApi3Generator { ) } ) - }.apply { addSecurityItemFromSecurityRequirements(firstModelForPathAndMethod.request.securityRequirements, oauth2SecuritySchemeDefinition) } + }.apply { addSecurityItemFromSecurityRequirements(firstModelForPathAndMethod.request.securityRequirements) } } private fun operationId(operationIds: List): String { @@ -449,30 +487,28 @@ object OpenApi3Generator { .map { it as Boolean } .forEach { this.addEnumItem(it) } } + SimpleType.STRING.name.toLowerCase() -> StringSchema().apply { this._default(parameterDescriptor.defaultValue?.let { it as String }) parameterDescriptor.attributes.enumValues .map { it as String } .forEach { this.addEnumItem(it) } } + SimpleType.NUMBER.name.toLowerCase() -> NumberSchema().apply { - this._default(parameterDescriptor.defaultValue?.let { it as BigDecimal }) + this._default(parameterDescriptor.defaultValue?.asBigDecimal()) parameterDescriptor.attributes.enumValues - .map { - when (it) { - is Int -> it.toBigDecimal() - is Double -> it.toBigDecimal() - else -> it as BigDecimal - } - } + .map { it.asBigDecimal() } .forEach { this.addEnumItem(it) } } + SimpleType.INTEGER.name.toLowerCase() -> IntegerSchema().apply { - this._default(parameterDescriptor.defaultValue?.let { it as Int }) + this._default(parameterDescriptor.defaultValue?.asInt()) parameterDescriptor.attributes.enumValues - .map { it as Int } + .map { it.asInt() } .forEach { this.addEnumItem(it) } } + else -> throw IllegalArgumentException("Unknown type '${parameterDescriptor.type}'") } } @@ -485,6 +521,24 @@ object OpenApi3Generator { return if (this.isEmpty()) null else this } + private fun Any.asInt(): Int { + return when (this) { + is Int -> this + is Long -> toInt() + else -> this as Int + } + } + + private fun Any.asBigDecimal(): BigDecimal { + return when (this) { + is Int -> toBigDecimal() + is Long -> toBigDecimal() + is Double -> toBigDecimal() + is Float -> toBigDecimal() + else -> this as BigDecimal + } + } + private data class RequestModelWithOperationId( val operationId: String, val request: RequestModel 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 index d4d4b2ae..a2dd5d84 100644 --- 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 @@ -16,12 +16,13 @@ internal object SecuritySchemeGenerator { private const val API_KEY_SECURITY_NAME = "api_key" private const val BASIC_SECURITY_NAME = "basic" private const val JWT_BEARER_SECURITY_NAME = "bearerAuthJWT" + private const val OAUTH2_SECURITY_NAME = "oauth2" fun OpenAPI.addSecurityDefinitions(oauth2SecuritySchemeDefinition: Oauth2Configuration?) { if (oauth2SecuritySchemeDefinition?.flows?.isNotEmpty() == true) { val flows = OAuthFlows() components.addSecuritySchemes( - "oauth2", + OAUTH2_SECURITY_NAME, SecurityScheme().apply { type = SecurityScheme.Type.OAUTH2 this.flows = flows @@ -90,17 +91,10 @@ internal object SecuritySchemeGenerator { } } - fun Operation.addSecurityItemFromSecurityRequirements(securityRequirements: SecurityRequirements?, oauth2SecuritySchemeDefinition: Oauth2Configuration?) { + fun Operation.addSecurityItemFromSecurityRequirements(securityRequirements: SecurityRequirements?) { if (securityRequirements != null) { when (securityRequirements.type) { - SecurityType.OAUTH2 -> oauth2SecuritySchemeDefinition?.flows?.map { - addSecurityItem( - SecurityRequirement().addList( - oauth2SecuritySchemeDefinition.securitySchemeName(it), - securityRequirements2ScopesList(securityRequirements) - ) - ) - } + SecurityType.OAUTH2 -> addSecurityItem(SecurityRequirement().addList(OAUTH2_SECURITY_NAME, securityRequirements2ScopesList(securityRequirements))) SecurityType.BASIC -> addSecurityItem(SecurityRequirement().addList(BASIC_SECURITY_NAME)) SecurityType.API_KEY -> addSecurityItem(SecurityRequirement().addList(API_KEY_SECURITY_NAME)) SecurityType.JWT_BEARER -> addSecurityItem(SecurityRequirement().addList(JWT_BEARER_SECURITY_NAME)) 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 index 61eea513..97305f65 100644 --- 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 @@ -18,6 +18,7 @@ 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.info.Contact import io.swagger.v3.oas.models.servers.Server import org.assertj.core.api.BDDAssertions.then import org.junit.jupiter.api.Test @@ -29,6 +30,17 @@ class OpenApi3GeneratorTest { lateinit var openApiSpecJsonString: String lateinit var openApiJsonPathContext: DocumentContext + @Test + fun `should convert multi level schema model to openapi`() { + givenPutProductResourceModel() + + whenOpenApiObjectGenerated() + + val optionDTOPath = "components.schemas.OptionDTO" + then(openApiJsonPathContext.read>("$optionDTOPath.properties.name")).isNotNull() + then(openApiJsonPathContext.read>("$optionDTOPath.properties.id")).isNotNull() + } + @Test fun `should convert single resource model to openapi`() { givenGetProductResourceModel() @@ -287,6 +299,30 @@ class OpenApi3GeneratorTest { (it["schema"] as LinkedHashMap<*, *>)["type"] == "number" && (it["schema"] as LinkedHashMap<*, *>)["default"] == 1 } + then(params).anyMatch { + it["name"] == "intNumberParameter" && + it["description"] == "a int number parameter" && + (it["schema"] as LinkedHashMap<*, *>)["type"] == "number" && + (it["schema"] as LinkedHashMap<*, *>)["default"] == 1 + } + then(params).anyMatch { + it["name"] == "longNumberParameter" && + it["description"] == "a long number parameter" && + (it["schema"] as LinkedHashMap<*, *>)["type"] == "number" && + (it["schema"] as LinkedHashMap<*, *>)["default"] == 1 + } + then(params).anyMatch { + it["name"] == "doubleNumberParameter" && + it["description"] == "a double number parameter" && + (it["schema"] as LinkedHashMap<*, *>)["type"] == "number" && + (it["schema"] as LinkedHashMap<*, *>)["default"] == 1.0 + } + then(params).anyMatch { + it["name"] == "floatNumberParameter" && + it["description"] == "a float number parameter" && + (it["schema"] as LinkedHashMap<*, *>)["type"] == "number" && + (it["schema"] as LinkedHashMap<*, *>)["default"] == 1.0 + } then(params).anyMatch { it["name"] == "integerParameter" && it["description"] == "a integer parameter" && @@ -294,6 +330,13 @@ class OpenApi3GeneratorTest { (it["schema"] as LinkedHashMap<*, *>)["format"] == "int32" && (it["schema"] as LinkedHashMap<*, *>)["default"] == 2 } + then(params).anyMatch { + it["name"] == "longIntegerParameter" && + it["description"] == "a long integer parameter" && + (it["schema"] as LinkedHashMap<*, *>)["type"] == "integer" && + (it["schema"] as LinkedHashMap<*, *>)["format"] == "int32" && + (it["schema"] as LinkedHashMap<*, *>)["default"] == 2 + } then(params).anyMatch { it["name"] == "X-SOME-BOOLEAN" && it["description"] == "a header boolean parameter" && @@ -312,6 +355,30 @@ class OpenApi3GeneratorTest { (it["schema"] as LinkedHashMap<*, *>)["type"] == "number" && (it["schema"] as LinkedHashMap<*, *>)["default"] == 1 } + then(params).anyMatch { + it["name"] == "X-SOME-INT-NUMBER" && + it["description"] == "a header int number parameter" && + (it["schema"] as LinkedHashMap<*, *>)["type"] == "number" && + (it["schema"] as LinkedHashMap<*, *>)["default"] == 1 + } + then(params).anyMatch { + it["name"] == "X-SOME-LONG-NUMBER" && + it["description"] == "a header long number parameter" && + (it["schema"] as LinkedHashMap<*, *>)["type"] == "number" && + (it["schema"] as LinkedHashMap<*, *>)["default"] == 1 + } + then(params).anyMatch { + it["name"] == "X-SOME-DOUBLE-NUMBER" && + it["description"] == "a header double number parameter" && + (it["schema"] as LinkedHashMap<*, *>)["type"] == "number" && + (it["schema"] as LinkedHashMap<*, *>)["default"] == 1.0 + } + then(params).anyMatch { + it["name"] == "X-SOME-FLOAT-NUMBER" && + it["description"] == "a header float number parameter" && + (it["schema"] as LinkedHashMap<*, *>)["type"] == "number" && + (it["schema"] as LinkedHashMap<*, *>)["default"] == 1.0 + } then(params).anyMatch { it["name"] == "X-SOME-INTEGER" && it["description"] == "a header integer parameter" && @@ -319,7 +386,14 @@ class OpenApi3GeneratorTest { (it["schema"] as LinkedHashMap<*, *>)["format"] == "int32" && (it["schema"] as LinkedHashMap<*, *>)["default"] == 2 } - then(params).hasSize(9) + then(params).anyMatch { + it["name"] == "X-SOME-LONG-INTEGER" && + it["description"] == "a header long integer parameter" && + (it["schema"] as LinkedHashMap<*, *>)["type"] == "integer" && + (it["schema"] as LinkedHashMap<*, *>)["format"] == "int32" && + (it["schema"] as LinkedHashMap<*, *>)["default"] == 2 + } + then(params).hasSize(19) thenOpenApiSpecIsValid() } @@ -439,8 +513,7 @@ class OpenApi3GeneratorTest { 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") + then(openApiJsonPathContext.read>>("$productGetByIdPath.security[*].oauth2").flatMap { it }).containsOnly("prod:r") } private fun thenMultiplePathParametersExist() { @@ -457,6 +530,7 @@ class OpenApi3GeneratorTest { then(openApiJsonPathContext.read("info.title")).isEqualTo("API") then(openApiJsonPathContext.read("info.description")).isEqualTo("API Description") then(openApiJsonPathContext.read("info.version")).isEqualTo("1.0.0") + then(openApiJsonPathContext.read("info.contact.name")).isEqualTo("Test Contact") } private fun thenTagFieldsPresent() { @@ -517,7 +591,8 @@ class OpenApi3GeneratorTest { ), format = "json", description = "API Description", - tagDescriptions = mapOf("tag1" to "tag1 description", "tag2" to "tag2 description") + tagDescriptions = mapOf("tag1" to "tag1 description", "tag2" to "tag2 description"), + contact = Contact().apply { name = "Test Contact" } ) println(openApiSpecJsonString) @@ -864,6 +939,21 @@ class OpenApi3GeneratorTest { ) } + private fun givenPutProductResourceModel() { + resources = listOf( + ResourceModel( + operationId = "test", + summary = "summary", + description = "description", + privateResource = false, + deprecated = false, + tags = setOf("tag1", "tag2"), + request = getProductPutRequest(), + response = getProductPutResponse(Schema("ProductPutResponse")) + ) + ) + } + private fun givenGetProductResourceModel() { resources = listOf( ResourceModel( @@ -990,6 +1080,54 @@ class OpenApi3GeneratorTest { ) } + private fun getProductPutResponse(schema: Schema? = null): ResponseModel { + return ResponseModel( + status = 200, + contentType = "application/json", + schema = schema, + headers = listOf( + HeaderDescriptor( + name = "SIGNATURE", + description = "This is some signature", + type = "STRING", + optional = false + ) + ), + responseFields = listOf( + FieldDescriptor( + path = "id", + description = "product id", + type = "STRING" + ), + FieldDescriptor( + path = "option", + description = "option", + type = "OBJECT", + attributes = Attributes(schemaName = "OptionDTO") + ), + FieldDescriptor( + path = "option.id", + description = "option id", + type = "STRING" + ), + FieldDescriptor( + path = "option.name", + description = "option name", + type = "STRING" + ), + ), + example = """ + { + "id": "pid12312", + "option": { + "id": "otid00001", + "name": "Option name" + } + } + """.trimIndent(), + ) + } + private fun getProductHalResponse(schema: Schema? = null): ResponseModel { return ResponseModel( status = 200, @@ -1085,6 +1223,51 @@ class OpenApi3GeneratorTest { ) } + private fun getProductPutRequest(): RequestModel { + return RequestModel( + path = "/products/{id}", + method = HTTPMethod.PUT, + headers = listOf(), + pathParameters = listOf(), + requestParameters = listOf(), + securityRequirements = null, + requestFields = listOf( + FieldDescriptor( + path = "id", + description = "product id", + type = "STRING" + ), + FieldDescriptor( + path = "option", + description = "option", + type = "OBJECT", + attributes = Attributes(schemaName = "OptionDTO") + ), + FieldDescriptor( + path = "option.id", + description = "option id", + type = "STRING" + ), + FieldDescriptor( + path = "option.name", + description = "option name", + type = "STRING" + ), + ), + contentType = "application/json", + example = """ + { + "id": "pid12312", + "option": { + "id": "otid00001", + "name": "Option name" + } + } + """.trimIndent(), + schema = Schema("ProductPutRequest") + ) + } + private fun getProductRequestWithMultiplePathParameters(getSecurityRequirement: () -> SecurityRequirements = ::getOAuth2SecurityRequirement): RequestModel { return RequestModel( path = "/products/{id}-{subId}", @@ -1205,12 +1388,47 @@ class OpenApi3GeneratorTest { optional = true, defaultValue = 1.toBigDecimal() ), + HeaderDescriptor( + name = "X-SOME-INT-NUMBER", + description = "a header int number parameter", + type = "NUMBER", + optional = true, + defaultValue = 1 + ), + HeaderDescriptor( + name = "X-SOME-LONG-NUMBER", + description = "a header long number parameter", + type = "NUMBER", + optional = true, + defaultValue = 1L + ), + HeaderDescriptor( + name = "X-SOME-DOUBLE-NUMBER", + description = "a header double number parameter", + type = "NUMBER", + optional = true, + defaultValue = 1.0 + ), + HeaderDescriptor( + name = "X-SOME-FLOAT-NUMBER", + description = "a header float number parameter", + type = "NUMBER", + optional = true, + defaultValue = 1.toFloat() + ), HeaderDescriptor( name = "X-SOME-INTEGER", description = "a header integer parameter", type = "INTEGER", optional = true, defaultValue = 2 + ), + HeaderDescriptor( + name = "X-SOME-LONG-INTEGER", + description = "a header long integer parameter", + type = "INTEGER", + optional = true, + defaultValue = 2L ) ), requestParameters = listOf( @@ -1238,6 +1456,38 @@ class OpenApi3GeneratorTest { ignored = false, defaultValue = 1.toBigDecimal() ), + ParameterDescriptor( + name = "intNumberParameter", + description = "a int number parameter", + type = "NUMBER", + optional = true, + ignored = false, + defaultValue = 1 + ), + ParameterDescriptor( + name = "longNumberParameter", + description = "a long number parameter", + type = "NUMBER", + optional = true, + ignored = false, + defaultValue = 1L + ), + ParameterDescriptor( + name = "doubleNumberParameter", + description = "a double number parameter", + type = "NUMBER", + optional = true, + ignored = false, + defaultValue = 1.0 + ), + ParameterDescriptor( + name = "floatNumberParameter", + description = "a float number parameter", + type = "NUMBER", + optional = true, + ignored = false, + defaultValue = 1.toFloat() + ), ParameterDescriptor( name = "integerParameter", description = "a integer parameter", @@ -1245,6 +1495,14 @@ class OpenApi3GeneratorTest { optional = true, ignored = false, defaultValue = 2 + ), + ParameterDescriptor( + name = "longIntegerParameter", + description = "a long integer parameter", + type = "INTEGER", + optional = true, + ignored = false, + defaultValue = 2L ) ) ) diff --git a/restdocs-api-spec-postman-generator/build.gradle.kts b/restdocs-api-spec-postman-generator/build.gradle.kts index ca34a3b4..a65447b3 100644 --- a/restdocs-api-spec-postman-generator/build.gradle.kts +++ b/restdocs-api-spec-postman-generator/build.gradle.kts @@ -11,11 +11,11 @@ val junitVersion: String by extra val jacksonVersion: String by extra dependencies { - compile(kotlin("stdlib-jdk8")) + implementation(kotlin("stdlib-jdk8")) - compile(project(":restdocs-api-spec-model")) - compile("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion") - compile("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion") + implementation(project(":restdocs-api-spec-model")) + 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.assertj:assertj-core:3.10.0") diff --git a/restdocs-api-spec-restassured/build.gradle.kts b/restdocs-api-spec-restassured/build.gradle.kts index 2d9d02ca..757829e5 100644 --- a/restdocs-api-spec-restassured/build.gradle.kts +++ b/restdocs-api-spec-restassured/build.gradle.kts @@ -11,17 +11,17 @@ val springRestDocsVersion: String by extra val junitVersion: String by extra dependencies { - compile(kotlin("stdlib-jdk8")) + implementation(kotlin("stdlib-jdk8")) - compile(project(":restdocs-api-spec")) - compile("org.springframework.restdocs:spring-restdocs-restassured:$springRestDocsVersion") + implementation(project(":restdocs-api-spec")) + implementation("org.springframework.restdocs:spring-restdocs-restassured:$springRestDocsVersion") - testCompile("org.springframework.boot:spring-boot-starter-test:$springBootVersion") { + testImplementation("org.springframework.boot:spring-boot-starter-test:$springBootVersion") { exclude("junit") } - testCompile("org.junit.jupiter:junit-jupiter-engine:$junitVersion") + testImplementation("org.junit.jupiter:junit-jupiter-engine:$junitVersion") testImplementation("org.junit-pioneer:junit-pioneer:0.3.0") - testCompile("org.springframework.boot:spring-boot-starter-hateoas:$springBootVersion") + testImplementation("org.springframework.boot:spring-boot-starter-hateoas:$springBootVersion") } publishing { diff --git a/restdocs-api-spec-webtestclient/build.gradle.kts b/restdocs-api-spec-webtestclient/build.gradle.kts index be1358f9..2a595567 100644 --- a/restdocs-api-spec-webtestclient/build.gradle.kts +++ b/restdocs-api-spec-webtestclient/build.gradle.kts @@ -14,18 +14,18 @@ val springRestDocsVersion: String by extra val junitVersion: String by extra dependencies { - compile(kotlin("stdlib-jdk8")) + implementation(kotlin("stdlib-jdk8")) - compile(project(":restdocs-api-spec")) - compile("org.springframework.restdocs:spring-restdocs-webtestclient:$springRestDocsVersion") + implementation(project(":restdocs-api-spec")) + implementation("org.springframework.restdocs:spring-restdocs-webtestclient:$springRestDocsVersion") - testCompile("org.springframework.boot:spring-boot-starter-test:$springBootVersion") { + testImplementation("org.springframework.boot:spring-boot-starter-test:$springBootVersion") { exclude("junit") } - testCompile("org.junit.jupiter:junit-jupiter-engine:$junitVersion") + testImplementation("org.junit.jupiter:junit-jupiter-engine:$junitVersion") testImplementation("org.junit-pioneer:junit-pioneer:0.3.0") testImplementation("org.springframework.boot:spring-boot-starter-web:$springBootVersion") - testCompile("org.springframework.boot:spring-boot-starter-hateoas:$springBootVersion") + testImplementation("org.springframework.boot:spring-boot-starter-hateoas:$springBootVersion") testImplementation("io.projectreactor:reactor-core:3.2.8.RELEASE") } diff --git a/restdocs-api-spec/build.gradle.kts b/restdocs-api-spec/build.gradle.kts index ece57688..30fd1fde 100755 --- a/restdocs-api-spec/build.gradle.kts +++ b/restdocs-api-spec/build.gradle.kts @@ -14,22 +14,22 @@ val springRestDocsVersion: String by extra val junitVersion: String by extra dependencies { - compile(kotlin("stdlib-jdk8")) - compile(kotlin("reflect")) + implementation(kotlin("stdlib-jdk8")) + implementation(kotlin("reflect")) - compile("org.springframework.restdocs:spring-restdocs-core:$springRestDocsVersion") - compile("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion") - compile("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion") + implementation("org.springframework.restdocs:spring-restdocs-core:$springRestDocsVersion") + implementation("com.fasterxml.jackson.core:jackson-databind:$jacksonVersion") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin:$jacksonVersion") - testCompile("org.springframework.boot:spring-boot-starter-test:$springBootVersion") { + testImplementation("org.springframework.boot:spring-boot-starter-test:$springBootVersion") { exclude("junit") } - testCompile("org.junit.jupiter:junit-jupiter-engine:$junitVersion") + testImplementation("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") - testCompile("com.jayway.jsonpath:json-path:2.3.0") + testImplementation("org.springframework.boot:spring-boot-starter-hateoas:$springBootVersion") + testImplementation("org.hibernate.validator:hibernate-validator:6.0.10.Final") + testImplementation("org.assertj:assertj-core:3.10.0") + testImplementation("com.jayway.jsonpath:json-path:2.3.0") testImplementation("com.github.java-json-tools:json-schema-validator:2.2.10") testImplementation("com.github.erosb:everit-json-schema:1.11.0") diff --git a/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/JwtSecurityHandler.kt b/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/JwtSecurityHandler.kt index 7363db1b..2e06b9bf 100644 --- a/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/JwtSecurityHandler.kt +++ b/restdocs-api-spec/src/main/kotlin/com/epages/restdocs/apispec/JwtSecurityHandler.kt @@ -62,9 +62,13 @@ internal class JwtSecurityHandler : SecurityRequirementsExtractor { try { val jwtMap = ObjectMapper().readValue>(decodedPayload) val scope = jwtMap["scope"] + // some of oauth2 authorization servers might return scope claims as a set of string if (scope is List<*>) { return scope as List } + if (scope is String) { // standard way of expressing scope claim + return scope.trim().split("\\s+".toRegex()) + } } catch (e: IOException) { // probably not JWT } diff --git a/restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/JwtSecurityHandlerTest.kt b/restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/JwtSecurityHandlerTest.kt index 08e5b114..43d3f060 100644 --- a/restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/JwtSecurityHandlerTest.kt +++ b/restdocs-api-spec/src/test/kotlin/com/epages/restdocs/apispec/JwtSecurityHandlerTest.kt @@ -23,6 +23,16 @@ class JwtSecurityHandlerTest { then((securityRequirement as Oauth2).requiredScopes).containsExactly("scope1", "scope2") } + @Test + fun `should add scope list when standard oauth2 jwt is found in Authorization header`() { + givenRequestWithStandardOAuth2JwtInAuthorizationHeader() + + whenSecurityRequirementsExtracted(operation) + + then(securityRequirement).isNotNull + then((securityRequirement as Oauth2).requiredScopes).containsExactly("scope1", "scope2") + } + @Test fun `should return SecurityType of JWTBearer when non oauth2 jwt is found in Authorization header`() { givenRequestWithNonOAuth2JwtInAuthorizationHeader() @@ -72,6 +82,15 @@ class JwtSecurityHandlerTest { .build() } + private fun givenRequestWithStandardOAuth2JwtInAuthorizationHeader() { + operation = OperationBuilder().request("/some") + .header( + AUTHORIZATION, + "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzY29wZSI6InNjb3BlMSBzY29wZTIiLCJleHAiOjE1MDc3NTg0OTgsImlhdCI6MTUwNzcxNTI5OCwianRpIjoiNDJhMGE5MWEtZDZlZC00MGNjLWIxMDYtZTkwY2RhZTQzZDZkIn0.yLPUhfQ5IIWaTwLO1qcGzAjXtqXnx-FRiF_yGQkiO2M" + ) + .build() + } + private fun givenRequestWithNonOAuth2JwtInAuthorizationHeader() { operation = OperationBuilder().request("/some") .header( diff --git a/samples/restdocs-api-spec-sample/build.gradle b/samples/restdocs-api-spec-sample/build.gradle index fa08aa44..5b3b270c 100755 --- a/samples/restdocs-api-spec-sample/build.gradle +++ b/samples/restdocs-api-spec-sample/build.gradle @@ -28,17 +28,17 @@ ext { } dependencies { - compile('org.springframework.boot:spring-boot-starter-data-jpa') - compile('org.springframework.boot:spring-boot-starter-data-rest') - runtime('com.h2database:h2') - testCompile('org.junit.jupiter:junit-jupiter-engine') + implementation('org.springframework.boot:spring-boot-starter-data-jpa') + implementation('org.springframework.boot:spring-boot-starter-data-rest') + runtimeOnly('com.h2database:h2') + testImplementation('org.junit.jupiter:junit-jupiter-engine') - testCompile('org.springframework.boot:spring-boot-starter-test') - testCompile('org.springframework.restdocs:spring-restdocs-mockmvc') + testImplementation('org.springframework.boot:spring-boot-starter-test') + testImplementation('org.springframework.restdocs:spring-restdocs-mockmvc') - testCompile('com.epages:restdocs-api-spec-mockmvc:0.11.2') -// testCompile project(':restdocs-api-spec-mockmvc') //enable for depending on the submodule directly - testCompile('com.google.guava:guava:23.0') + testImplementation('com.epages:restdocs-api-spec-mockmvc:0.11.2') +// testImplementation project(':restdocs-api-spec-mockmvc') //enable for depending on the submodule directly + testImplementation('com.google.guava:guava:23.0') } configurations.all { diff --git a/secret-keys.gpg.enc b/secret-keys.gpg.enc index c3e23e32..e848d338 100644 Binary files a/secret-keys.gpg.enc and b/secret-keys.gpg.enc differ