diff --git a/bom/pom.xml b/bom/pom.xml index 5d8c04b819b..7e168024ef1 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -1045,6 +1045,11 @@ helidon-microprofile-openapi ${helidon.version} + + io.helidon.integrations.openapi-ui + helidon-integrations-openapi-ui + ${helidon.version} + io.helidon.webserver.cors diff --git a/common/common/src/main/java/io/helidon/common/FeatureCatalog.java b/common/common/src/main/java/io/helidon/common/FeatureCatalog.java index 82987967fd8..884bf838cbe 100644 --- a/common/common/src/main/java/io/helidon/common/FeatureCatalog.java +++ b/common/common/src/main/java/io/helidon/common/FeatureCatalog.java @@ -179,6 +179,11 @@ final class FeatureCatalog { .experimental(true) .nativeSupported(true) .flavor(HelidonFlavor.SE)); + add("io.helidon.integrations.openapi.ui", + FeatureDescriptor.builder() + .name("OpenAPI U/I") + .description("OpenAPI U/I integration") + .path("OpenAPI U/I")); add("io.helidon.integrations.vault", FeatureDescriptor.builder() .name("HCP Vault") diff --git a/docs/config/config_reference.adoc b/docs/config/config_reference.adoc index be22a1d07cc..c386cf19c06 100644 --- a/docs/config/config_reference.adoc +++ b/docs/config/config_reference.adoc @@ -63,6 +63,7 @@ The following section lists all configurable types in Helidon. - xref:{rootdir}/config/io_helidon_security_providers_oidc_common_OidcConfig.adoc[OidcConfig (security.providers.oidc.common)] - xref:{rootdir}/config/io_helidon_security_providers_oidc_OidcProvider.adoc[OidcProvider (security.providers.oidc)] - xref:{rootdir}/config/io_helidon_openapi_OpenAPISupport.adoc[OpenAPISupport (openapi)] +- xref:{rootdir}/config/io_helidon_openapi_OpenApiUi.adoc[OpenApiUi (openapi)] - xref:{rootdir}/config/io_helidon_security_providers_common_OutboundConfig.adoc[OutboundConfig (security.providers.common)] - xref:{rootdir}/config/io_helidon_security_providers_common_OutboundTarget.adoc[OutboundTarget (security.providers.common)] - xref:{rootdir}/config/io_helidon_common_pki_KeyConfig_PemBuilder.adoc[PemBuilder (common.pki.KeyConfig)] diff --git a/docs/config/io_helidon_openapi_OpenAPISupport.adoc b/docs/config/io_helidon_openapi_OpenAPISupport.adoc index 693ec8cd66a..4844e76722e 100644 --- a/docs/config/io_helidon_openapi_OpenAPISupport.adoc +++ b/docs/config/io_helidon_openapi_OpenAPISupport.adoc @@ -46,6 +46,7 @@ Type: link:{javadoc-base-url}/io.helidon.openapi/io/helidon/openapi/OpenAPISuppo |`cors` |xref:{rootdir}/config/io_helidon_webserver_cors_CrossOriginConfig.adoc[CrossOriginConfig] |{nbsp} |Assigns the CORS settings for the OpenAPI endpoint. |`static-file` |string |`META-INF/openapi.*` |Sets the file system path of the static OpenAPI document file. Default types are `json`, `yaml`, and `yml`. +|`ui` |xref:{rootdir}/config/io_helidon_openapi_OpenApiUi.adoc[OpenApiUi] |{nbsp} |Assigns the OpenAPI UI builder the `OpenAPISupport` service should use in preparing the U/I. |`web-context` |string |`/openapi` |Sets the web context path for the OpenAPI endpoint. |=== diff --git a/docs/config/io_helidon_openapi_OpenApiUi.adoc b/docs/config/io_helidon_openapi_OpenApiUi.adoc new file mode 100644 index 00000000000..5f543655702 --- /dev/null +++ b/docs/config/io_helidon_openapi_OpenApiUi.adoc @@ -0,0 +1,57 @@ +/////////////////////////////////////////////////////////////////////////////// + + Copyright (c) 2022 Oracle and/or its affiliates. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +/////////////////////////////////////////////////////////////////////////////// + +ifndef::rootdir[:rootdir: {docdir}/..] +:description: Configuration of io.helidon.openapi.OpenApiUi +:keywords: helidon, config, io.helidon.openapi.OpenApiUi +:basic-table-intro: The table below lists the configuration keys that configure io.helidon.openapi.OpenApiUi +include::{rootdir}/includes/attributes.adoc[] + += OpenApiUi (openapi) Configuration + +// tag::config[] + + +Type: link:{javadoc-base-url}/io.helidon.openapi/io/helidon/openapi/OpenApiUi.html[io.helidon.openapi.OpenApiUi] + + +[source,text] +.Config key +---- +ui +---- + + + +== Configuration options + + + +.Optional configuration options +[cols="3,3a,2,5a"] + +|=== +|key |type |default value |description + +|`enabled` |boolean |`true` |Sets whether the UI should be enabled. +|`options` |Map<string, string> |{nbsp} |Sets implementation-specific UI options. +|`web-context` |string |{nbsp} |web context (path) where the UI will respond + +|=== + +// end::config[] \ No newline at end of file diff --git a/docs/config/io_helidon_openapi_SEOpenAPISupport.adoc b/docs/config/io_helidon_openapi_SEOpenAPISupport.adoc index d111e6f6ce0..e6c4ceb68a5 100644 --- a/docs/config/io_helidon_openapi_SEOpenAPISupport.adoc +++ b/docs/config/io_helidon_openapi_SEOpenAPISupport.adoc @@ -58,6 +58,7 @@ openapi |`servers.operation.*` |string[] |{nbsp} |Sets alternative servers to service the indicated operation (represented here by '*'). Repeat for multiple operations. |`servers.path.*` |string[] |{nbsp} |Sets alternative servers to service all operations at the indicated path (represented here by '*'). Repeat for multiple paths. |`static-file` |string |`META-INF/openapi.*` |Sets the file system path of the static OpenAPI document file. Default types are `json`, `yaml`, and `yml`. +|`ui` |xref:{rootdir}/config/io_helidon_openapi_OpenApiUi.adoc[OpenApiUi] |{nbsp} |Assigns the OpenAPI U/I builder the `OpenAPISupport` service should use in preparing the U/I. |`web-context` |string |`/openapi` |Sets the web context path for the OpenAPI endpoint. |=== diff --git a/docs/images/openapi-ui-screen-capture-greeting-mp-expanded.png b/docs/images/openapi-ui-screen-capture-greeting-mp-expanded.png new file mode 100644 index 00000000000..b5a310594c0 Binary files /dev/null and b/docs/images/openapi-ui-screen-capture-greeting-mp-expanded.png differ diff --git a/docs/images/openapi-ui-screen-capture-greeting-mp-start.png b/docs/images/openapi-ui-screen-capture-greeting-mp-start.png new file mode 100644 index 00000000000..707e59bd771 Binary files /dev/null and b/docs/images/openapi-ui-screen-capture-greeting-mp-start.png differ diff --git a/docs/images/openapi-ui-screen-capture-greeting-se-expanded.png b/docs/images/openapi-ui-screen-capture-greeting-se-expanded.png new file mode 100644 index 00000000000..372ca09df30 Binary files /dev/null and b/docs/images/openapi-ui-screen-capture-greeting-se-expanded.png differ diff --git a/docs/images/openapi-ui-screen-capture-greeting-se-start.png b/docs/images/openapi-ui-screen-capture-greeting-se-start.png new file mode 100644 index 00000000000..29f47a63b95 Binary files /dev/null and b/docs/images/openapi-ui-screen-capture-greeting-se-start.png differ diff --git a/docs/includes/attributes.adoc b/docs/includes/attributes.adoc index 35c24218d31..e3f1d7d440a 100644 --- a/docs/includes/attributes.adoc +++ b/docs/includes/attributes.adoc @@ -77,6 +77,7 @@ endif::[] :version-plugin-jib: 0.10.1 :version-plugin-jandex: 1.0.6 :version-lib-micrometer: 1.6.6 +:version-lib-smallrye-open-api: 2.1.16 :jdk-version: 17 @@ -204,6 +205,7 @@ endif::[] :mp-tyrus-javadoc-base-url: {javadoc-base-url}/io.helidon.microprofile.tyrus :mp-restclient-javadoc-base-url: {javadoc-base-url}/io.helidon.microprofile.restclient :openapi-javadoc-base-url: {javadoc-base-url}/io.helidon.openapi +:openapi-ui-javadoc-base-url: {javadoc-base-url}/io.helidon.integrations.openapi.ui :reactive-base-url: {javadoc-base-url}/io.helidon.common.reactive :scheduling-javadoc-base-url: {javadoc-base-url}/io.helidon.microprofile.scheduling :security-integration-jersey-base-url: {javadoc-base-url}/io.helidon.security.integration.jersey @@ -271,4 +273,7 @@ endif::[] :openapi-generator-tool-generators-docs-url: {openapi-generator-tool-docs-url}/generators :openapi-generator-tool-site-url: https://openapi-generator.tech +// OpenAPI UI +:smallrye-openapi-ui-base-url: https://github.com/smallrye/smallrye-open-api/tree/{version-lib-smallrye-open-api}/ui/open-api-ui + endif::attributes-included[] diff --git a/docs/includes/openapi/openapi-generator.adoc b/docs/includes/openapi/openapi-generator.adoc index 7333a5d4c76..1589aca811a 100644 --- a/docs/includes/openapi/openapi-generator.adoc +++ b/docs/includes/openapi/openapi-generator.adoc @@ -140,7 +140,7 @@ For the Maven plug-in, use elements within the `` section of the [source,xml] ---- - petstore.yaml + petstore.yaml ---- * _additional properties_ diff --git a/docs/includes/openapi/openapi-ui.adoc b/docs/includes/openapi/openapi-ui.adoc new file mode 100644 index 00000000000..65a79f118ed --- /dev/null +++ b/docs/includes/openapi/openapi-ui.adoc @@ -0,0 +1,200 @@ +/////////////////////////////////////////////////////////////////////////////// + + Copyright (c) 2022 Oracle and/or its affiliates. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +/////////////////////////////////////////////////////////////////////////////// + +ifndef::rootdir[:rootdir: {docdir}/../..] +// Following make editing the included file a little easier +:flavor-lc: se +:flavor-uc: SE +:se-flavor: + +// tag::preamble[] +:feature-name: Helidon OpenAPI UI support +:screen-capture-start: openapi-ui-screen-capture-greeting-{flavor-lc}-start.png +:screen-capture-expanded: openapi-ui-screen-capture-greeting-{flavor-lc}-expanded.png +// end::preamble[] + +// tag::intro[] + +== Contents + +- <> +- <> +- <> +- <> +- <> +- <> + +// end::intro[] + +// tag::overview[] +== Overview + +SmallRye offers an link:{smallrye-openapi-ui-base-url}[OpenAPI user interface component] which displays a web page based on your application's OpenAPI document. +Through that UI, users can invoke the operations declared in the document. +While not generally suitable for end-users, the OpenAPI UI can be useful for demonstrating and "test driving" your service's endpoints. + +The Helidon OpenAPI component allows you to integrate the SmallRye UI into your application, adding the UI web page to your application very simply. + + +// end::overview[] + +// tag::dependencies[] +include::{rootdir}/includes/dependencies.adoc[] + +[source,xml,subs=+macros] +---- + + io.helidon.integrations.openapi-ui + helidon-integrations-openapi-ui +ifdef::mp-flavor[ runtime] + +---- + +// end::dependencies[] + +// tag::usage[] +// tag::usage-start[] +== Usage +ifdef::se-flavor[] +Make sure your application creates a Helidon `OpenAPISupport` instance and registers it for routing (described in detail in link:{openapi-page}[the Helidon OpenAPI documentation]). `OpenAPISupport` automatically prepares the OpenAPI UI with default settings if you also declare a dependency on the Helidon OpenAPI UI integration component as explained above. The <> section below illustrates adding OpenAPI to your application and customizing the UI behavior. +endif::se-flavor[] + +After you modify, build, and start your Helidon {flavor-uc} service, you can access the OpenAPI UI by default at `http://your-host:your-port/openapi/ui`. +Helidon also uses conventional content negotiation at `http://your-host:your-port/openapi` returning the UI to browsers (or any client that accepts HTML) and the OpenAPI document otherwise. + +You can customize the path using +ifdef::se-flavor[either the API or ] +xref:Configuration[configuration]. + +The example below shows the UI +ifdef::se-flavor[] +if you modify the Helidon SE QuickStart greeting application to contain a static OpenAPI file which describes the service endpoints. +endif::se-flavor[] +ifdef::mp-flavor[] +for the Helidon MP QuickStart greeting application. +endif::mp-flavor[] + +.OpenAPI UI Screen for Helidon {flavor-uc} QuickStart Greeting Application +image::{screen-capture-start}[align="center",title="Example OpenAPI UI Screen"] + +// end::usage-start[] + +// tag::usage-expanded-screen[] +With the OpenAPI UI displayed, follow these steps to access one of your service's operations. + +. Find the operation you want to run and click on its row in the list. +. The UI expands the operation, showing any input parameters and the possible responses. Click the "Try it out" button in the operation's row. +. The UI now allows you to type into the input parameter field(s) to the right of each parameter name. Enter any required parameter values (first highlighted rectangle) and any non-required values you wish, then click "Execute" (highlighted arrow). +. Just below the "Execute" button the UI shows several sections: + +* the equivalent `curl` command for submitting the request with your inputs, +* the URL used for the request, and +* a new "Server response" section (second highlighted rectangle) containing several items from the response: + +** HTTP status code +** body +** headers + +The next image shows the screen after you submit the "Returns a personalized greeting" operation. + +Note that the UI shows the actual response from invoking the operation in the "Server response" section. The "Responses" section farther below describes the possible responses from the operation as declared in the OpenAPI document for the application. + +.Example OpenAPI UI Screen +image::{screen-capture-expanded}[align="center",title="Example OpenAPI UI Screen"] + +// end::usage-expanded-screen[] +// end::usage[] + +// tag::config-intro[] +== Configuration +To use configuration to control how the Helidon OpenAPI UI service behaves, add an `openapi.ui` section to +ifdef::mp-flavor[your `META-INF/microprofile-config.properties` file.] +ifdef::se-flavor[your configuration file, such as `application.yaml`.] + +include::{rootdir}/config/io_helidon_openapi_OpenApiUi.adoc[tag=config,leveloffset=+1] +The default UI `web-context` value is the web context for your `OpenAPISupport` service with the added suffix `/ui`. If you use the default web context for both `OpenAPISupport` and the UI, the UI responds at `/openapi/ui`. + +// end::config-intro[] + +// tag::config-details[] + +You can use configuration to affect the UI path in two ways: + +* Configure the OpenAPI endpoint path (the `/openapi` part). ++ +Recall that you can xref:{openapi-page}#config[configure the Helidon OpenAPI component] to change where it serves the OpenAPI document. ++ +.Configuring the OpenAPI web context +ifdef::se-flavor[] +[source,yaml] +---- +openapi: + web-context: /my-openapi +---- +endif::se-flavor[] +ifdef::mp-flavor[] +[source,properties] +---- +openapi.web-context=/my-openapi +---- +endif::mp-flavor[] ++ +In this case, the path for the UI component is your customized OpenAPI path with `/ui` as a suffix. +With the example above, the UI responds at `/my-openapi/ui` and +Helidon uses standard content negotiation at `/my-openapi` to return either the OpenAPI document or the UI. +* Separately, configure the entire web context path for the UI independently from the web context for OpenAPI. ++ +.Configuring the OpenAPI UI web context +ifdef::se-flavor[] +[source,yaml] +---- +openapi: + ui: + web-context: /my-ui +---- +endif::se-flavor[] +ifdef::mp-flavor[] +[source,properties] +---- +openapi.ui.web-context=/my-ui +---- +endif::mp-flavor[] ++ +[NOTE] +==== +The `openapi.ui.web-context` setting assigns the _entire_ web-context for the UI, not the suffix appended to the `OpenAPISupport` endpoint. +==== +With this configuration, the UI responds at `/my-ui` regardless of the path for OpenAPI itself. + +The SmallRye OpenAPI UI component accepts several options, but they are of minimal use to application developers and they must be passed to the SmallRye UI code programmatically. +Helidon allows you to specify these values using configuration in the `openapi.ui.options` section. Helidon then passes the corresponding options to SmallRye for you. +To configure any of these settings, use the enum values--they are all lower case--declared in the SmallRye link:{smallrye-openapi-ui-base-url}/src/main/java/io/smallrye/openapi/ui/Option.java[`Option.java`] class as the keys in your Helidon configuration. + +[NOTE] +==== +Helidon prepares several of the SmallRye options automatically based on other settings. +Any options you configure override the values Helidon assigns, possibly interfering with the proper operation of the UI. +==== + +// end::config-details[] + +// tag::additional-info[] +== Additional Information + +xref:{openapi-page}[Helidon OpenAPI {flavor-uc} documentation] + +link:{smallrye-openapi-ui-base-url}[SmallRye OpenAPI UI GitHub site] +// end::additional-info[] \ No newline at end of file diff --git a/docs/includes/openapi/openapi.adoc b/docs/includes/openapi/openapi.adoc index e0a7952378f..fd2c8daac03 100644 --- a/docs/includes/openapi/openapi.adoc +++ b/docs/includes/openapi/openapi.adoc @@ -47,6 +47,14 @@ The SPI defines an interface you can implement in your application which can mas // end::overview[] +// tag::mp-depc[] + + io.helidon.microprofile.openapi + helidon-microprofile-openapi + runtime + +// end::mp-depc[] + // tag::furnish-openapi-info[] ==== Furnish OpenAPI information about your endpoints @@ -139,8 +147,8 @@ class name of your class. Once you add the {flavor-uc} OpenAPI dependency to your ifdef::mp-flavor[project,] ifdef::se-flavor[project and add code to create the `OpenAPISupport` object to your routing,] -your application will automatically respond to the built-in endpoint -- -`/openapi` -- and it will return the OpenAPI document describing the endpoints +your application automatically responds to the built-in endpoint -- +`/openapi` -- and it returns the OpenAPI document describing the endpoints in your application. By default, per the MicroProfile OpenAPI spec, the default format of the OpenAPI document is YAML. diff --git a/docs/mp/openapi/openapi-ui.adoc b/docs/mp/openapi/openapi-ui.adoc new file mode 100644 index 00000000000..f86b2a9d355 --- /dev/null +++ b/docs/mp/openapi/openapi-ui.adoc @@ -0,0 +1,57 @@ +/////////////////////////////////////////////////////////////////////////////// + + Copyright (c) 2022 Oracle and/or its affiliates. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +/////////////////////////////////////////////////////////////////////////////// + += OpenAPI UI +:toc: +:toc-placement: preamble +:description: Helidon MP OpenAPI UI Support +:keywords: helidon, mp, openapi ui +:rootdir: {docdir}/../.. +:incdir: {rootdir}/includes/openapi +:openapi-inc: {incdir}/openapi.adoc +:ui-inc: {incdir}/openapi-ui.adoc + +include::{rootdir}/includes/mp.adoc[] +include::{rootdir}/includes/pages.adoc[] +:javadoc-path: {openapi-ui-javadoc-base-url}/io/helidon/integrations/openapi/ui +:openapi-javadoc-path: {openapi-javadoc-base-url}/io/helidon/openapi + +include::{ui-inc}[tag=preamble] + +include::{ui-inc}[tags=intro;overview] + +include::{ui-inc}[tag=dependencies] + +Also make sure your project has the following dependency to include OpenAPI support in your Helidon MP application. +[source,xml] +---- +include::{openapi-inc}[tag=mp-depc] +---- + +include::{ui-inc}[tag=usage] + +== API + +Your Helidon MP application does not use any API to enable or control Helidon OpenAPI UI support. +Adding the dependency as described earlier is sufficient, and you can control the UI behavior using <>. + +include::{ui-inc}[tag=config-intro] + +include::{ui-inc}[tag=config-details] + +include::{ui-inc}[tag=additional-info] \ No newline at end of file diff --git a/docs/mp/openapi/openapi.adoc b/docs/mp/openapi/openapi.adoc index 5e3db05910c..9b5d0c05810 100644 --- a/docs/mp/openapi/openapi.adoc +++ b/docs/mp/openapi/openapi.adoc @@ -46,20 +46,16 @@ include::{rootdir}/includes/dependencies.adoc[] [source,xml,subs="attributes+"] ---- - - +include::{incdir}/openapi.adoc[tag=mp-depc] +---- +If you do not use the `helidon-microprofile-bundle` also add the following dependency which defines the MicroProfile OpenAPI annotations so you can use them in your code: +[source,xml] +---- + org.eclipse.microprofile.openapi microprofile-openapi-api - - io.helidon.microprofile.openapi - helidon-microprofile-openapi - runtime - - ---- -<1> Defines the MicroProfile OpenAPI annotations so you can use them in your code. -<2> Adds the Helidon MP OpenAPI runtime support. == Usage diff --git a/docs/se/openapi.adoc b/docs/se/openapi.adoc deleted file mode 100644 index 11d493551e8..00000000000 --- a/docs/se/openapi.adoc +++ /dev/null @@ -1,123 +0,0 @@ -/////////////////////////////////////////////////////////////////////////////// - - Copyright (c) 2019, 2022 Oracle and/or its affiliates. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - -/////////////////////////////////////////////////////////////////////////////// - -= OpenAPI -:toc: -:toc-placement: preamble -:description: Helidon SE OpenAPI Support -:keywords: helidon, se, openapi -:feature-name: OpenAPI -:rootdir: {docdir}/.. - -include::{rootdir}/includes/se.adoc[] - -== Contents - -- <> -- <> -- <> -- <> -- <> -- <> -- <> - -== Overview - -include::{rootdir}/includes/openapi.adoc[tag=overview] - -include::{rootdir}/includes/dependencies.adoc[] - -[source,xml] ----- - - io.helidon.openapi - helidon-openapi - ----- - -== Usage - -You can very simply add support for OpenAPI to your Helidon SE application. This -document shows what changes you need to make to your application and how to access -the OpenAPI document for your application at runtime. - -=== Changing your application - -==== Register `OpenAPISupport` in your application routing - -Helidon SE provides the link:{openapi-javadoc-base-url}/OpenAPISupport.html[`OpenAPISupport`] class which your application uses to assemble the in-memory model and expose the `/openapi` endpoint to clients. You can create an instance either using a static `create` method or by instantiating its link:{openapi-javadoc-base-url}/OpenAPISupport.Builder.html[`Builder`]. The xref:#register_openapisupport[example below] illustrates one way to do this. - -include::{rootdir}/includes/openapi.adoc[tag=furnish-openapi-info] - -==== Add OpenAPI dependency -If you implement either a model reader or a filter, add this dependency to your -`pom.xml`: - -[source,xml,subs="attributes+"] ----- - - org.eclipse.microprofile.openapi - microprofile-openapi-api - {microprofile-openapi-version} - ----- - -include::{rootdir}/includes/openapi.adoc[tag=usage-access-endpoint] - -== API - -include::{rootdir}/includes/openapi.adoc[tag=api] - -Helidon {flavor-uc} provides an API for creating and setting up the REST endpoint which serves OpenAPI documents to clients at the `/openapi` path. Use either static methods on link:{openapi-javadoc-base-url}/OpenAPISupport.html[`OpenAPISupport`] or use its link:{openapi-javadoc-base-url}/OpenAPISupport.Builder.html[`Builder`] to create an instance of `OpenAPISupport`. Then add that instance to your application's routing. The <<#register_openapisupport,example>> below shows how to do this. - -== Configuration - -Helidon SE OpenAPI configuration supports the following settings: - -include::{rootdir}/config/io_helidon_openapi_SEOpenAPISupport.adoc[leveloffset=+1,tag=config] - - - -== Examples - -Helidon SE provides a link:{helidon-github-tree-url}/examples/openapi[complete OpenAPI example] -based on the SE QuickStart sample app which includes a model reader and a filter. - -Most Helidon {flavor-uc} applications need only to create and register `OpenAPISupport`. - -[#register_openapisupport] -=== Register `OpenAPISupport` - -.Java Code to Register `OpenAPISupport` for Routing -[source,java] ----- -Config config = Config.create(); -return Routing.builder() - .register(JsonSupport.create()) - .register(OpenAPISupport.create(config)) // <1> - .register(health) // Health at "/health" - .register(metrics) // Metrics at "/metrics" - .register("/greet", greetService) - .build(); ----- -<1> Adds the `OpenAPISupport` service to your server. - -If you need more control over the `OpenAPISupport` instance, invoke `OpenAPISupport.builder()` to get an `OpenAPISupport.Builder` object and work with it. - -== Additional Information -include::{rootdir}/includes/openapi.adoc[tag=additional-building-jandex] \ No newline at end of file diff --git a/docs/se/openapi/openapi-ui.adoc b/docs/se/openapi/openapi-ui.adoc new file mode 100644 index 00000000000..9927147b707 --- /dev/null +++ b/docs/se/openapi/openapi-ui.adoc @@ -0,0 +1,133 @@ +/////////////////////////////////////////////////////////////////////////////// + + Copyright (c) 2022 Oracle and/or its affiliates. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +/////////////////////////////////////////////////////////////////////////////// + += OpenAPI UI +:toc: +:toc-placement: preamble +:description: Helidon SE OpenAPI UI Support +:keywords: helidon, se, openapi ui +:rootdir: {docdir}/../.. +:incdir: {rootdir}/includes/openapi +:ui-inc: {incdir}/openapi-ui.adoc + +include::{rootdir}/includes/se.adoc[] +include::{rootdir}/includes/pages.adoc[] +:javadoc-path: {openapi-ui-javadoc-base-url}/io/helidon/integrations/openapi/ui +:openapi-javadoc-path: {openapi-javadoc-base-url}/io/helidon/openapi + +include::{ui-inc}[tag=preamble] + +include::{ui-inc}[tags=intro;overview] + +include::{ui-inc}[tag=dependencies] + +Also, make sure your project has the following dependency. + +include::{openapi-page}[tag=depc] + +This dependency allows your application to create, configure, and register the `OpenAPISupport` service. + +include::{ui-inc}[tag=usage] + +== API +=== Creating `OpenAPISupport` with Automatic UI Behavior +With the Helidon OpenAPI UI dependency in your `pom.xml` file, any `OpenAPISupport` object your application builds prepares the default OpenAPI UI behavior, possibly modified by any UI settings you have in your configuration. + +[source,java] +.Create `OpenAPISupport` with automatic UI +---- +Config config = Config.create(); // <1> +Config openApiConfig = config.get(OpenAPISupport.Builder.CONFIG_KEY)); // <2> + +OpenAPISupport openApiSupport = + OpenAPISupport.builder() + .config(openApiConfig) // <3> + .build(); +---- +<1> Load the configuration. +<2> Extract the `OpenAPISupport` configuration. +<3> Build the `OpenAPISupport` instance using the configuration. + +If your code invokes the `OpenAPISupport.Builder` `config` method, Helidon automatically applies the `ui` section of the `openapi` configuration to the UI. + +=== Customizing the UI Behavior +You can control some of the behavior of the UI programmatically in two steps: + +. Create an link:{javadoc-path}/OpenApiUi.Builder.html[`OpenApiUi.Builder`] and invoke methods on it to set the UI behavior. +. Invoke the `ui` method on link:{openapi-javadoc-base-url}/io.helidon.openapi.OpenAPISupport.Builder.html[`OpenAPISupport.Builder`], passing the `OpenApiUi.Builder` you prepared above. + +The following example illustrates these steps, combining configuration with explicit programmatic settings. + +[source,java] +.Create `OpenApiUi` and `OpenAPISupport` instances +---- +Config config = Config.create(); // <1> +Config openApiConfig = config.get(OpenAPISupport.Builder.CONFIG_KEY)); // <2> + +OpenApiUi.Builder uiBuilder = + OpenApiUi.builder() // <3> + .webContext("/my-openapi-ui"); // <4> + +OpenAPISupport openApiSupport = + OpenAPISupport.builder() // <5> + .ui(uiBuilder) + .config(openApiConfig) // <6> + .build(); + + +---- +<1> Load the configuration. +<2> Extract the `OpenAPISupport` configuration. +<3> Create the `OpenApiUi.Builder` instance. +<4> Explicitly set the web context where the UI should respond. +<5> Create the `OpenAPISupport` instance using the `OpenApiUi.Builder` just created. +You can refine the behavior of the `OpenAPISupport` object by invoking additional methods on its builder before invoking its `build` method. +<6> Apply the `openapi` configuration to the `OpenAPISupport` builder. This also automatically applies any `openapi.ui` configuration to the UI. + +The order in which your code invokes the methods on `OpenApiUi.Builder` and `OpenAPISupport.Builder` determines the outcome. +For instance, the example above sets the UI on the `OpenAPISupport.Builder` _before_ applying configuration. +If the configuration contains a setting for the UI's `web-context` value then the UI uses the configured value, not the programmatic value, because your code applied the configuration later. +Your code should typically apply configuration _after_ setting any values programmatically. +Doing so allows users or deployers of your service to set the behavior using configuration according to their particular needs which your code might not be able to anticipate. + +[NOTE] +==== +The `webContext(String)` method on `OpenApiUi.Builder` sets the web context where the UI should respond instead of the default `/openapi/ui`. +Helidon uses the provided string to set the _entire_ web context for the UI, not as a suffix appended to the web context for the `OpenAPISupport` service. +==== + +=== Registering `OpenAPISupport` +Whether or not your code tailors the UI or `OpenAPISupport` behavior, it must register the resulting `OpenAPISupport` instance so that the OpenAPI and UI endpoints can respond correctly. + +[source,java] +.Register services for routing +---- +Routing.builder() + .register(openApiSupport) + // Add registrations of your service(s) and other Helidon services you need. + .build(); +---- + +The UI is implemented as part of the `OpenAPISupport` service which registers the UI automatically. +Your code does not register the UI explicitly. + +include::{ui-inc}[tag=config-intro] + +include::{ui-inc}[tag=config-details] + +include::{ui-inc}[tag=additional-info] \ No newline at end of file diff --git a/docs/se/openapi/openapi.adoc b/docs/se/openapi/openapi.adoc index 0c9f1b1e814..9a45837debd 100644 --- a/docs/se/openapi/openapi.adoc +++ b/docs/se/openapi/openapi.adoc @@ -26,6 +26,7 @@ :incdir: {rootdir}/includes/openapi include::{rootdir}/includes/se.adoc[] +:javadoc-path: {openapi-javadoc-base-url}/io.helidon.openapi == Contents @@ -43,6 +44,7 @@ include::{incdir}/openapi.adoc[tag=overview] include::{rootdir}/includes/dependencies.adoc[] +// tag::depc[] [source,xml] ---- @@ -50,6 +52,7 @@ include::{rootdir}/includes/dependencies.adoc[] helidon-openapi ---- +// end::depc[] == Usage @@ -61,7 +64,7 @@ the OpenAPI document for your application at runtime. ==== Register `OpenAPISupport` in your application routing -Helidon SE provides the link:{openapi-javadoc-base-url}/OpenAPISupport.html[`OpenAPISupport`] class which your application uses to assemble the in-memory model and expose the `/openapi` endpoint to clients. You can create an instance either using a static `create` method or by instantiating its link:{openapi-javadoc-base-url}/OpenAPISupport.Builder.html[`Builder`]. The xref:#register_openapisupport[example below] illustrates one way to do this. +Helidon SE provides the link:{javadoc-path}/OpenAPISupport.html[`OpenAPISupport`] class which your application uses to assemble the in-memory model and expose the `/openapi` endpoint to clients. You can create an instance either using a static `create` method or by instantiating its link:{javadoc-path}/OpenAPISupport.Builder.html[`Builder`]. The xref:#register_openapisupport[example below] illustrates one way to do this. include::{incdir}/openapi.adoc[tag=furnish-openapi-info] @@ -84,8 +87,9 @@ include::{incdir}/openapi.adoc[tag=usage-access-endpoint] include::{incdir}/openapi.adoc[tag=api] -Helidon {flavor-uc} provides an API for creating and setting up the REST endpoint which serves OpenAPI documents to clients at the `/openapi` path. Use either static methods on link:{openapi-javadoc-base-url}/OpenAPISupport.html[`OpenAPISupport`] or use its link:{openapi-javadoc-base-url}/OpenAPISupport.Builder.html[`Builder`] to create an instance of `OpenAPISupport`. Then add that instance to your application's routing. The <<#register_openapisupport,example>> below shows how to do this. +Helidon {flavor-uc} provides an API for creating and setting up the REST endpoint which serves OpenAPI documents to clients at the `/openapi` path. Use either static methods on link:{javadoc-path}/OpenAPISupport.html[`OpenAPISupport`] or use its link:{javadoc-base}/OpenAPISupport.Builder.html[`Builder`] to create an instance of `OpenAPISupport`. Then add that instance to your application's routing. The <<#register_openapisupport,example>> below shows how to do this. +[[config]] == Configuration Helidon SE OpenAPI configuration supports the following settings: diff --git a/docs/sitegen.yaml b/docs/sitegen.yaml index d300fe9b10f..cb72050e5fa 100644 --- a/docs/sitegen.yaml +++ b/docs/sitegen.yaml @@ -173,6 +173,7 @@ backend: sources: - "openapi.adoc" - "openapi-generator.adoc" + - "openapi-ui.adoc" - type: "MENU" title: "Integrations" dir: "integrations" @@ -384,6 +385,7 @@ backend: sources: - "openapi.adoc" - "openapi-generator.adoc" + - "openapi-ui.adoc" - type: "MENU" title: "Integrations" dir: "integrations" diff --git a/examples/quickstarts/helidon-quickstart-mp/src/main/java/io/helidon/examples/quickstart/mp/package-info.java b/examples/quickstarts/helidon-quickstart-mp/src/main/java/io/helidon/examples/quickstart/mp/package-info.java index 293e03bf641..87795470565 100644 --- a/examples/quickstarts/helidon-quickstart-mp/src/main/java/io/helidon/examples/quickstart/mp/package-info.java +++ b/examples/quickstarts/helidon-quickstart-mp/src/main/java/io/helidon/examples/quickstart/mp/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018, 2021 Oracle and/or its affiliates. + * Copyright (c) 2018, 2022 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,4 +17,11 @@ /** * Quickstart MicroProfile example. */ +@OpenAPIDefinition(info = @Info(title = "Helidon MP QuickStart Example", + version = "1.0.0", + description = "A very simple application to reply with friendly greetings") +) package io.helidon.examples.quickstart.mp; + +import org.eclipse.microprofile.openapi.annotations.OpenAPIDefinition; +import org.eclipse.microprofile.openapi.annotations.info.Info; diff --git a/examples/quickstarts/helidon-standalone-quickstart-mp/src/main/java/io/helidon/examples/quickstart/mp/package-info.java b/examples/quickstarts/helidon-standalone-quickstart-mp/src/main/java/io/helidon/examples/quickstart/mp/package-info.java index ca190d47ecf..fe70bc6d0d0 100644 --- a/examples/quickstarts/helidon-standalone-quickstart-mp/src/main/java/io/helidon/examples/quickstart/mp/package-info.java +++ b/examples/quickstarts/helidon-standalone-quickstart-mp/src/main/java/io/helidon/examples/quickstart/mp/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019, 2021 Oracle and/or its affiliates. + * Copyright (c) 2019, 2022 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,4 +17,12 @@ /** * Quickstart MicroProfile example. */ +@OpenAPIDefinition(info = @Info(title = "Helidon MP QuickStart Example", + version = "1.0.0", + description = "A very simple application to reply with friendly greetings") +) package io.helidon.examples.quickstart.mp; + +import org.eclipse.microprofile.openapi.annotations.OpenAPIDefinition; +import org.eclipse.microprofile.openapi.annotations.info.Info; + diff --git a/integrations/micrometer/cdi/pom.xml b/integrations/micrometer/cdi/pom.xml index 143978b1b43..38fdb01c1f7 100644 --- a/integrations/micrometer/cdi/pom.xml +++ b/integrations/micrometer/cdi/pom.xml @@ -115,6 +115,11 @@ helidon-microprofile-tests-junit5 test + + io.helidon.config + helidon-config-testing + test + diff --git a/integrations/micrometer/cdi/src/test/java/io/helidon/integrations/micrometer/cdi/HelloWorldTest.java b/integrations/micrometer/cdi/src/test/java/io/helidon/integrations/micrometer/cdi/HelloWorldTest.java index bbfb288db1a..c38ac5b19df 100644 --- a/integrations/micrometer/cdi/src/test/java/io/helidon/integrations/micrometer/cdi/HelloWorldTest.java +++ b/integrations/micrometer/cdi/src/test/java/io/helidon/integrations/micrometer/cdi/HelloWorldTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018, 2021 Oracle and/or its affiliates. + * Copyright (c) 2018, 2022 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -31,6 +31,8 @@ import jakarta.ws.rs.core.MediaType; import org.junit.jupiter.api.Test; +import static io.helidon.config.testing.MatcherWithRetry.assertThatWithRetry; + import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.notNullValue; @@ -83,7 +85,7 @@ void testTimer() { } @Test - public void testSlowTimer() { + public void testSlowTimer() throws InterruptedException { int exp = 3; AtomicReference result = new AtomicReference<>(); IntStream.range(0, exp).forEach( @@ -95,11 +97,11 @@ public void testSlowTimer() { assertThat("Returned from HTTP request", result.get(), is(HelloWorldResource.SLOW_RESPONSE)); Timer slowTimer = registry.timer(HelloWorldResource.SLOW_MESSAGE_TIMER); assertThat("Slow message timer", slowTimer, is(notNullValue())); - assertThat("Slow message timer count", slowTimer.count(), is((long) exp)); + assertThatWithRetry("Slow message timer count", slowTimer::count, is((long) exp)); } @Test - public void testFastFailCounter() { + public void testFastFailCounter() throws InterruptedException { int exp = 8; IntStream.range(0, exp).forEach( i -> { @@ -116,7 +118,7 @@ public void testFastFailCounter() { Counter counter = registry.counter(HelloWorldResource.FAST_MESSAGE_COUNTER); assertThat("Failed message counter", counter, is(notNullValue())); - assertThat("Failed message counter count", counter.count(), is((double) exp / 2)); + assertThatWithRetry("Failed message counter count", counter::count, is((double) exp / 2)); } @Test @@ -134,7 +136,7 @@ public void testSlowFailNoCounter() { } @Test - public void testSlowFailCounter() { + public void testSlowFailCounter() throws InterruptedException { int exp = 6; IntStream.range(0, exp).forEach( i -> { @@ -151,7 +153,7 @@ public void testSlowFailCounter() { Counter counter = registry.counter(HelloWorldResource.SLOW_MESSAGE_FAIL_COUNTER); assertThat("Failed message counter", counter, is(notNullValue())); - assertThat("Failed message counter count", counter.count(), is((double) 3)); + assertThatWithRetry("Failed message counter count", counter::count, is((double) 3)); } void checkMicrometerURL(int iterations) { diff --git a/integrations/micrometer/cdi/src/test/java/io/helidon/integrations/micrometer/cdi/MeteredBeanTest.java b/integrations/micrometer/cdi/src/test/java/io/helidon/integrations/micrometer/cdi/MeteredBeanTest.java index 88d80538485..27ad240967f 100644 --- a/integrations/micrometer/cdi/src/test/java/io/helidon/integrations/micrometer/cdi/MeteredBeanTest.java +++ b/integrations/micrometer/cdi/src/test/java/io/helidon/integrations/micrometer/cdi/MeteredBeanTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 Oracle and/or its affiliates. + * Copyright (c) 2021, 2022 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,6 +24,8 @@ import jakarta.inject.Inject; import org.junit.jupiter.api.Test; +import static io.helidon.config.testing.MatcherWithRetry.assertThatWithRetry; + import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -39,25 +41,29 @@ public class MeteredBeanTest { MeterRegistry registry; @Test - public void testSimpleCounted() { + public void testSimpleCounted() throws InterruptedException { int exp = 3; IntStream.range(0, exp).forEach(i -> meteredBean.count()); - assertThat("Value from simple counted meter", registry.counter(MeteredBean.COUNTED).count(), is((double) exp)); + assertThatWithRetry("Value from simple counted meter", () -> registry.counter(MeteredBean.COUNTED).count(), + is((double) exp)); } @Test - public void testSinglyTimed() { + public void testSinglyTimed() throws InterruptedException { int exp = 4; IntStream.range(0, exp).forEach(i -> meteredBean.timed()); - assertThat("Count from singly-timed meter", registry.timer(MeteredBean.TIMED_1).count(), is((long) exp)); + assertThatWithRetry("Count from singly-timed meter", () -> registry.timer(MeteredBean.TIMED_1).count(), + is((long) exp)); } @Test - public void testDoublyTimed() { + public void testDoublyTimed() throws InterruptedException { int exp = 5; IntStream.range(0, exp).forEach(i -> meteredBean.timedA()); - assertThat("Count from doubly-timed meter (A)", registry.timer(MeteredBean.TIMED_A).count(), is((long) exp)); - assertThat("Count from doubly-timed meter (B)", registry.timer(MeteredBean.TIMED_B).count(), is((long) exp)); + assertThatWithRetry("Count from doubly-timed meter (A)", () -> registry.timer(MeteredBean.TIMED_A).count(), + is((long) exp)); + assertThatWithRetry("Count from doubly-timed meter (B)", () -> registry.timer(MeteredBean.TIMED_B).count(), + is((long) exp)); } @Test diff --git a/integrations/openapi-ui/pom.xml b/integrations/openapi-ui/pom.xml new file mode 100644 index 00000000000..06f9936a70a --- /dev/null +++ b/integrations/openapi-ui/pom.xml @@ -0,0 +1,106 @@ + + + + + 4.0.0 + + io.helidon.integrations + helidon-integrations-project + 3.0.3-SNAPSHOT + + + io.helidon.integrations.openapi-ui + helidon-integrations-openapi-ui + + Helidon OpenAPI UI Integration + + + Integration in Helidon of the OpenAPI UI + + + + + + io.helidon.webserver + helidon-webserver + + + io.helidon.service-common + helidon-service-common-rest + + + io.helidon.openapi + helidon-openapi + + + io.helidon.webserver + helidon-webserver-static-content + + + io.smallrye + smallrye-open-api-ui + ${version.lib.smallrye-openapi} + + + io.helidon.config + helidon-config-metadata + provided + true + + + io.helidon.config + helidon-config-metadata-processor + provided + true + + + io.helidon.microprofile.bundles + helidon-microprofile + test + + + io.helidon.microprofile.tests + helidon-microprofile-tests-junit5 + + + io.helidon.webclient + helidon-webclient + test + + + io.helidon.config + helidon-config-testing + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-params + test + + + org.hamcrest + hamcrest-all + test + + + diff --git a/integrations/openapi-ui/src/main/java/io/helidon/integrations/openapi/ui/OpenApiUiFactoryFull.java b/integrations/openapi-ui/src/main/java/io/helidon/integrations/openapi/ui/OpenApiUiFactoryFull.java new file mode 100644 index 00000000000..4ac7744a4e9 --- /dev/null +++ b/integrations/openapi-ui/src/main/java/io/helidon/integrations/openapi/ui/OpenApiUiFactoryFull.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.integrations.openapi.ui; + +import io.helidon.openapi.OpenApiUiFactory; + +/** + * Implementation of the {@link io.helidon.openapi.OpenApiUiFactory} contract for a full implementation of + * {@link io.helidon.openapi.OpenApiUi}. + */ +public class OpenApiUiFactoryFull implements OpenApiUiFactory { + + /** + * Creates a new instance of the factory for a full UI implementation. + */ + public OpenApiUiFactoryFull() { + } + + @Override + public OpenApiUiFull.Builder builder() { + return OpenApiUiFull.builder(); + } +} diff --git a/integrations/openapi-ui/src/main/java/io/helidon/integrations/openapi/ui/OpenApiUiFull.java b/integrations/openapi-ui/src/main/java/io/helidon/integrations/openapi/ui/OpenApiUiFull.java new file mode 100644 index 00000000000..ad1d8f87abb --- /dev/null +++ b/integrations/openapi-ui/src/main/java/io/helidon/integrations/openapi/ui/OpenApiUiFull.java @@ -0,0 +1,267 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.integrations.openapi.ui; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; + +import io.helidon.common.http.Http; +import io.helidon.common.http.MediaType; +import io.helidon.config.Config; +import io.helidon.openapi.OpenApiUi; +import io.helidon.openapi.OpenApiUiBase; +import io.helidon.webserver.Routing; +import io.helidon.webserver.ServerRequest; +import io.helidon.webserver.ServerResponse; +import io.helidon.webserver.staticcontent.StaticContentSupport; + +import io.smallrye.openapi.ui.IndexHtmlCreator; +import io.smallrye.openapi.ui.Option; + +/** + * Support for the OpenAPI UI component from SmallRye. + *

+ * This service supports Helidon configuration of the UI and furnishes Helidon-specific defaults for some settings. + *

+ */ +class OpenApiUiFull extends OpenApiUiBase { + + /** + * + * @return new builder for an {@code OpenApiUiFull} service + */ + static OpenApiUiFull.Builder builder() { + return new Builder(); + } + + private static final String LOGO_RESOURCE = "logo.svg"; + private static final String HELIDON_IO_LINK = "https://helidon.io"; + + private static final Logger LOGGER = Logger.getLogger(OpenApiUiFull.class.getName()); + + private static final MediaType[] SUPPORTED_TEXT_MEDIA_TYPES_AT_UI_ENDPOINT = new MediaType[] { + MediaType.TEXT_HTML, + MediaType.TEXT_PLAIN, + MediaType.TEXT_YAML + }; + + private static final MediaType[] SUPPORTED_TEXT_MEDIA_TYPES_AT_OPENAPI_ENDPOINT = new MediaType[] { + MediaType.TEXT_HTML, + MediaType.TEXT_PLAIN + }; + + private final byte[] indexHtml; + + private OpenApiUiFull(Builder builder) { + super(builder, builder.documentPreparer(), builder.openApiSupportWebContext()); + Map options = new HashMap<>(builder.options); + + // Apply some Helidon-specific defaults. + Map.of(Option.title, "Helidon OpenAPI UI", + Option.logoHref, LOGO_RESOURCE, + Option.oauth2RedirectUrl, "-", // workaround for a bug in IndexHtmlCreator + Option.backHref, HELIDON_IO_LINK, // link applied to the rendered logo image + Option.selfHref, HELIDON_IO_LINK, // link applied to the title if there is no logo (but there is; set this anyway) + Option.url, builder.openApiSupportWebContext()) // location of the OpenAPI document + + .forEach((key, value) -> { + if (!options.containsKey(key)) { // Do not override values the developer provided. + options.put(key, value); + } + }); + try { + indexHtml = IndexHtmlCreator.createIndexHtml(options); + } catch (IOException e) { + throw new RuntimeException("Unable to initialize the index.html content for the OpenAPI UI", e); + } + } + + @Override + public MediaType[] supportedMediaTypes() { + return SUPPORTED_TEXT_MEDIA_TYPES_AT_OPENAPI_ENDPOINT; + } + + @Override + public boolean prepareTextResponseFromMainEndpoint(ServerRequest request, ServerResponse response) { + return request.headers() + .bestAccepted(SUPPORTED_TEXT_MEDIA_TYPES_AT_OPENAPI_ENDPOINT) + .map(mediaType -> { + if (!isEnabled()) { + request.next(); + return true; + } else { + return prepareTextResponse(request, response, mediaType); + } + }) + .orElse(false); + } + + @Override + public void update(Routing.Rules rules) { + if (!isEnabled()) { + return; + } + // Serve static content from the external UI component... + StaticContentSupport smallryeUiStaticSupport = StaticContentSupport.builder("META-INF/resources/openapi-ui") + .build(); + // ...and from here. + StaticContentSupport helidonOpenApiUiStaticSupport = StaticContentSupport.builder("helidon-openapi-ui") + .build(); + rules + .get(webContext() + "[/]", this::prepareTextResponseFromUiEndpoint) + .get(webContext() + "/index.html", this::displayIndex) + .register(webContext(), helidonOpenApiUiStaticSupport) + .register(webContext(), smallryeUiStaticSupport); + } + + /** + * Builder for the {@code OpenApiUiFull}. + */ + public static class Builder extends OpenApiUiBase.Builder { + + private Map options = new HashMap<>(); + + private Builder() { + super(); + } + + @Override + public OpenApiUiFull build() { + if (options.containsKey(Option.url)) { + LOGGER.log(Level.WARNING, + """ + Unexpected setting for the OpenAPI URL; \ + overriding the options value of 'url' ({1}) with \ + the actual endpoint of the Helidon OpenAPI service ({0}) + """, + new Object[] { + openApiSupportWebContext() + OpenApiUi.UI_WEB_SUBCONTEXT, + options.get(Option.url)} + ); + } + return new OpenApiUiFull(this); + } + + /** + * Sets the options map the UI should use. Other settings previously assigned will be respected unless the provided map + * sets the corresponding value. + * + * @param options UI options map + * @return updated builder + */ + @Override + public Builder options(Map options) { + this.options = convertOptions(options); + return this; + } + + /** + * Assigns the settings using the provided OpenAPI UI {@code Config} node. + * + * @param uiConfig OpenAPI UI config node + * @return updated builder + */ + @Override + public Builder config(Config uiConfig) { + super.config(uiConfig); + applyConfigToOptions(uiConfig.get(OPTIONS_CONFIG_KEY)); + return this; + } + + // For testing. + Map uiOptions() { + return options; + } + + private Map convertOptions(Map options) { + Map result = new HashMap<>(); + List unrecognizedKeys = new ArrayList<>(); + + nextKey: + for (Map.Entry entry : options.entrySet()) { + for (Option opt : Option.values()) { + if (opt.name().equals(entry.getKey())) { + result.put(opt, entry.getValue()); + break nextKey; + } + } + unrecognizedKeys.add(entry.getKey()); + } + if (!unrecognizedKeys.isEmpty()) { + LOGGER.log(Level.WARNING, + "Helidon OpenAPI UI builder found (and will ignore) unrecognized option names: \"{0}\"", + unrecognizedKeys); + } + return result; + } + + private void applyConfigToOptions(Config optionsConfig) { + if (!optionsConfig.exists() || optionsConfig.isLeaf()) { + return; + } + optionsConfig.detach() + .asMap() + .map(this::convertOptions) + .ifPresent(options::putAll); + } + } + + private void displayIndex(ServerRequest request, ServerResponse response) { + if (!acceptsHtml(request)) { + request.next(); + return; + } + response.addHeader(Http.Header.CONTENT_TYPE, MediaType.TEXT_HTML.toString()) + .send(indexHtml); + } + + private void prepareTextResponseFromUiEndpoint(ServerRequest request, ServerResponse response) { + request.headers() + .bestAccepted(SUPPORTED_TEXT_MEDIA_TYPES_AT_UI_ENDPOINT) + .ifPresentOrElse(mediaType -> prepareTextResponse(request, response, mediaType), + request::next); + } + + private boolean prepareTextResponse(ServerRequest request, ServerResponse response, MediaType mediaType) { + if (MediaType.TEXT_HTML.test(mediaType)) { + redirectToIndex(request, response); + } else { + sendStaticText(request, response, mediaType); + } + return true; + } + + private void redirectToIndex(ServerRequest request, ServerResponse response) { + // Redirect to the index.html temporarily because other requests to the UI endpoint + // might specify other media types. + response.status(Http.Status.TEMPORARY_REDIRECT_307); + response.addHeader(Http.Header.LOCATION, webContext() + "/index.html"); + response.send(); + } + + private boolean acceptsHtml(ServerRequest request) { + return request.headers() + .bestAccepted(SUPPORTED_TEXT_MEDIA_TYPES_AT_UI_ENDPOINT) + .map(candidate -> candidate.test(MediaType.TEXT_HTML)) + .orElse(false); + } +} diff --git a/integrations/openapi-ui/src/main/java/io/helidon/integrations/openapi/ui/package-info.java b/integrations/openapi-ui/src/main/java/io/helidon/integrations/openapi/ui/package-info.java new file mode 100644 index 00000000000..651e3f72a7b --- /dev/null +++ b/integrations/openapi-ui/src/main/java/io/helidon/integrations/openapi/ui/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Support for the OpenAPI UI in Helidon SE. + */ +package io.helidon.integrations.openapi.ui; diff --git a/integrations/openapi-ui/src/main/java/module-info.java b/integrations/openapi-ui/src/main/java/module-info.java new file mode 100644 index 00000000000..e3d03a16108 --- /dev/null +++ b/integrations/openapi-ui/src/main/java/module-info.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Helidon SE OpenAPI UI Support. + */ +module io.helidon.integrations.openapi.ui { + + requires java.logging; + requires transitive io.helidon.config; + requires io.helidon.config.metadata; + requires transitive io.helidon.openapi; + requires transitive io.helidon.servicecommon.rest; + requires transitive io.helidon.webserver; + requires io.helidon.webserver.staticcontent; + + requires smallrye.open.api.ui; + + exports io.helidon.integrations.openapi.ui; + provides io.helidon.openapi.OpenApiUiFactory with io.helidon.integrations.openapi.ui.OpenApiUiFactoryFull; +} diff --git a/integrations/openapi-ui/src/main/resources/helidon-openapi-ui/favicon.ico b/integrations/openapi-ui/src/main/resources/helidon-openapi-ui/favicon.ico new file mode 100644 index 00000000000..902ff2f811b Binary files /dev/null and b/integrations/openapi-ui/src/main/resources/helidon-openapi-ui/favicon.ico differ diff --git a/integrations/openapi-ui/src/main/resources/helidon-openapi-ui/logo.svg b/integrations/openapi-ui/src/main/resources/helidon-openapi-ui/logo.svg new file mode 100644 index 00000000000..32f107b0f2a --- /dev/null +++ b/integrations/openapi-ui/src/main/resources/helidon-openapi-ui/logo.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + diff --git a/integrations/openapi-ui/src/test/java/io/helidon/integrations/openapi/ui/CdiDefaultsTest.java b/integrations/openapi-ui/src/test/java/io/helidon/integrations/openapi/ui/CdiDefaultsTest.java new file mode 100644 index 00000000000..f78278fa5dc --- /dev/null +++ b/integrations/openapi-ui/src/test/java/io/helidon/integrations/openapi/ui/CdiDefaultsTest.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.integrations.openapi.ui; + +import io.helidon.microprofile.tests.junit5.AddBean; +import io.helidon.microprofile.tests.junit5.HelidonTest; +import io.helidon.openapi.OpenAPISupport; +import io.helidon.openapi.OpenApiUi; + +import jakarta.inject.Inject; +import jakarta.ws.rs.client.WebTarget; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.is; + +@HelidonTest +@AddBean(GreetResource.class) +@AddBean(GreetingProvider.class) +class CdiDefaultsTest { + + private static final String NON_DEFAULT_OPENAPI_WEB_CONTEXT = "/my-openapi"; + + @Inject + private WebTarget webTarget; + + @Test + void testDefaultPathWithSuffix() { + CdiTestsUtil.checkForPath(webTarget, OpenAPISupport.DEFAULT_WEB_CONTEXT + OpenApiUi.UI_WEB_SUBCONTEXT); + } + + @Test + void testDefaultPathWithoutSuffix() { + CdiTestsUtil.checkForPath(webTarget, OpenAPISupport.DEFAULT_WEB_CONTEXT); + } + +} diff --git a/integrations/openapi-ui/src/test/java/io/helidon/integrations/openapi/ui/CdiNonDefaultOpenApiUiWebContextTests.java b/integrations/openapi-ui/src/test/java/io/helidon/integrations/openapi/ui/CdiNonDefaultOpenApiUiWebContextTests.java new file mode 100644 index 00000000000..8b3c6c99f7a --- /dev/null +++ b/integrations/openapi-ui/src/test/java/io/helidon/integrations/openapi/ui/CdiNonDefaultOpenApiUiWebContextTests.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.integrations.openapi.ui; + +import io.helidon.microprofile.tests.junit5.AddBean; +import io.helidon.microprofile.tests.junit5.AddConfig; +import io.helidon.microprofile.tests.junit5.HelidonTest; +import io.helidon.openapi.OpenApiUi; + +import jakarta.inject.Inject; +import jakarta.ws.rs.client.WebTarget; +import org.junit.jupiter.api.Test; + +@HelidonTest +@AddBean(GreetService.class) +@AddBean(GreetingProvider.class) +@AddConfig(key = "openapi.web-context", value = CdiNonDefaultOpenApiUiWebContextTests.NON_DEFAULT_OPENAPI_WEB_CONTEXT) +@AddConfig(key = "openapi.ui.web-context", value = CdiNonDefaultOpenApiUiWebContextTests.NON_DEFAULT_OPENAPI_UI_WEB_CONTEXT) +class CdiNonDefaultOpenApiUiWebContextTests { + + static final String NON_DEFAULT_OPENAPI_WEB_CONTEXT = "/my-openapi"; + static final String NON_DEFAULT_OPENAPI_UI_WEB_CONTEXT = "/my-ui"; + + @Inject + private WebTarget webTarget; + + @Test + void testNonDefaultOpenApiUiPath() { + CdiTestsUtil.checkForPath(webTarget, NON_DEFAULT_OPENAPI_UI_WEB_CONTEXT); + } + + @Test + void testNonDefaultOpenApiPath() { + CdiTestsUtil.checkForPath(webTarget, NON_DEFAULT_OPENAPI_WEB_CONTEXT); + } +} diff --git a/integrations/openapi-ui/src/test/java/io/helidon/integrations/openapi/ui/CdiNonDefaultOpenApiWebContextTests.java b/integrations/openapi-ui/src/test/java/io/helidon/integrations/openapi/ui/CdiNonDefaultOpenApiWebContextTests.java new file mode 100644 index 00000000000..fa0706aecb8 --- /dev/null +++ b/integrations/openapi-ui/src/test/java/io/helidon/integrations/openapi/ui/CdiNonDefaultOpenApiWebContextTests.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.integrations.openapi.ui; + +import io.helidon.microprofile.tests.junit5.AddBean; +import io.helidon.microprofile.tests.junit5.AddConfig; +import io.helidon.microprofile.tests.junit5.HelidonTest; +import io.helidon.openapi.OpenApiUi; + +import jakarta.inject.Inject; +import jakarta.ws.rs.client.WebTarget; +import org.junit.jupiter.api.Test; + +@HelidonTest +@AddBean(GreetService.class) +@AddBean(GreetingProvider.class) +@AddConfig(key = "openapi.web-context", value = CdiNonDefaultOpenApiWebContextTests.NON_DEFAULT_OPENAPI_WEB_CONTEXT) +class CdiNonDefaultOpenApiWebContextTests { + + static final String NON_DEFAULT_OPENAPI_WEB_CONTEXT = "/my-openapi"; + + @Inject + private WebTarget webTarget; + + @Test + void testNonDefaultOpenApiPathWithSuffix() { + CdiTestsUtil.checkForPath(webTarget, NON_DEFAULT_OPENAPI_WEB_CONTEXT + OpenApiUi.UI_WEB_SUBCONTEXT); + } + + @Test + void testNonDefaultOpenApiPathWithoutSuffix() { + CdiTestsUtil.checkForPath(webTarget, NON_DEFAULT_OPENAPI_WEB_CONTEXT); + } +} diff --git a/integrations/openapi-ui/src/test/java/io/helidon/integrations/openapi/ui/CdiTestsUtil.java b/integrations/openapi-ui/src/test/java/io/helidon/integrations/openapi/ui/CdiTestsUtil.java new file mode 100644 index 00000000000..cb919167c1a --- /dev/null +++ b/integrations/openapi-ui/src/test/java/io/helidon/integrations/openapi/ui/CdiTestsUtil.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.integrations.openapi.ui; + +import io.helidon.common.http.Http; + +import jakarta.ws.rs.client.WebTarget; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; + +class CdiTestsUtil { + + static void checkForPath(WebTarget webTarget, String path) { + Response response = webTarget.path(path) + .request(MediaType.TEXT_HTML) + .get(); + assertThat("HTTP status accessing default UI endpoint", + response.getStatus(), is(Http.Status.OK_200.code())); + assertThat("Content accessing default UI endpoint", + response.readEntity(String.class), + allOf(containsString(" greeting = new AtomicReference<>(); + + private static final JsonBuilderFactory JSON = Json.createBuilderFactory(Collections.emptyMap()); + + private static final Logger LOGGER = Logger.getLogger(GreetService.class.getName()); + + GreetService(Config config) { + greeting.set(config.get("app.greeting").asString().orElse("Ciao")); + } + + /** + * A service registers itself by updating the routing rules. + * @param rules the routing rules. + */ + @Override + public void update(Routing.Rules rules) { + rules + .get("/", this::getDefaultMessageHandler) + .get("/{name}", this::getMessageHandler) + .put("/greeting", this::updateGreetingHandler); + } + + /** + * Return a worldly greeting message. + * @param request the server request + * @param response the server response + */ + private void getDefaultMessageHandler(ServerRequest request, + ServerResponse response) { + sendResponse(response, "World"); + } + + /** + * Return a greeting message using the name that was provided. + * @param request the server request + * @param response the server response + */ + private void getMessageHandler(ServerRequest request, + ServerResponse response) { + String name = request.path().param("name"); + sendResponse(response, name); + } + + private void sendResponse(ServerResponse response, String name) { + String msg = String.format("%s %s!", greeting.get(), name); + + JsonObject returnObject = JSON.createObjectBuilder() + .add("message", msg) + .build(); + response.send(returnObject); + } + + private static T processErrors(Throwable ex, ServerRequest request, ServerResponse response) { + + if (ex.getCause() instanceof JsonException){ + + LOGGER.log(Level.FINE, "Invalid JSON", ex); + JsonObject jsonErrorObject = JSON.createObjectBuilder() + .add("error", "Invalid JSON") + .build(); + response.status(Http.Status.BAD_REQUEST_400).send(jsonErrorObject); + } else { + + LOGGER.log(Level.FINE, "Internal error", ex); + JsonObject jsonErrorObject = JSON.createObjectBuilder() + .add("error", "Internal error") + .build(); + response.status(Http.Status.INTERNAL_SERVER_ERROR_500).send(jsonErrorObject); + } + + return null; + } + + private void updateGreetingFromJson(JsonObject jo, ServerResponse response) { + + if (!jo.containsKey("greeting")) { + JsonObject jsonErrorObject = JSON.createObjectBuilder() + .add("error", "No greeting provided") + .build(); + response.status(Http.Status.BAD_REQUEST_400) + .send(jsonErrorObject); + return; + } + + greeting.set(jo.getString("greeting")); + response.status(Http.Status.NO_CONTENT_204).send(); + } + + /** + * Set the greeting to use in future messages. + * @param request the server request + * @param response the server response + */ + private void updateGreetingHandler(ServerRequest request, + ServerResponse response) { + request.content().as(JsonObject.class) + .thenAccept(jo -> updateGreetingFromJson(jo, response)) + .exceptionally(ex -> processErrors(ex, request, response)); + } + +} diff --git a/integrations/openapi-ui/src/test/java/io/helidon/integrations/openapi/ui/GreetingProvider.java b/integrations/openapi-ui/src/test/java/io/helidon/integrations/openapi/ui/GreetingProvider.java new file mode 100644 index 00000000000..97c44abe656 --- /dev/null +++ b/integrations/openapi-ui/src/test/java/io/helidon/integrations/openapi/ui/GreetingProvider.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2018, 2022 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.integrations.openapi.ui; + +import java.util.concurrent.atomic.AtomicReference; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +/** + * Provider for greeting message. + */ +@ApplicationScoped +public class GreetingProvider { + private final AtomicReference message = new AtomicReference<>(); + + /** + * Create a new greeting provider, reading the message from configuration. + * + * @param message greeting to use + */ + @Inject + public GreetingProvider(@ConfigProperty(name = "app.greeting") String message) { + this.message.set(message); + } + + String getMessage() { + return message.get(); + } + + void setMessage(String message) { + this.message.set(message); + } +} diff --git a/integrations/openapi-ui/src/test/java/io/helidon/integrations/openapi/ui/MainTest.java b/integrations/openapi-ui/src/test/java/io/helidon/integrations/openapi/ui/MainTest.java new file mode 100644 index 00000000000..8b4c172bd36 --- /dev/null +++ b/integrations/openapi-ui/src/test/java/io/helidon/integrations/openapi/ui/MainTest.java @@ -0,0 +1,417 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.integrations.openapi.ui; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Stream; + +import io.helidon.common.LogConfig; +import io.helidon.common.http.Http; +import io.helidon.common.http.MediaType; +import io.helidon.common.reactive.Single; +import io.helidon.config.Config; +import io.helidon.config.ConfigSources; +import io.helidon.config.testing.OptionalMatcher; +import io.helidon.media.jsonp.JsonpSupport; +import io.helidon.openapi.OpenAPISupport; +import io.helidon.openapi.OpenApiUi; +import io.helidon.webclient.WebClient; +import io.helidon.webclient.WebClientRequestBuilder; +import io.helidon.webclient.WebClientResponse; +import io.helidon.webserver.Routing; +import io.helidon.webserver.WebServer; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; +import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +class MainTest { + + private static final String RUN_BROWSER_TEST_PROPERTY = "io.helidon.openapi.ui.test.runForBrowser"; + + private static final MediaType[] SIMULATED_BROWSER_ACCEPT = new MediaType[] { + MediaType.TEXT_HTML, + MediaType.APPLICATION_XHTML_XML, + MediaType.builder() + .type(MediaType.APPLICATION_XML.type()) + .subtype(MediaType.APPLICATION_XML.subtype()) + .parameters(Map.of("q", "0.9")) + .build(), + MediaType.builder() + .type("image") + .subtype("webp") + .build(), + MediaType.builder() + .type("image") + .subtype("apng") + .build(), + MediaType.builder() + .type(MediaType.WILDCARD_VALUE) + .subtype(MediaType.WILDCARD_VALUE) + .parameters(Map.of("q", "0.8")) + .build() + }; + /** + * Runs the server for as many minutes as specified by the property {@value RUN_BROWSER_TEST_PROPERTY} + * so you could use a browser to test the UI. If the property is present but not set to a value, the default is 5 minutes. + * If the property is not set, the test is skipped. For example, add + * + * {@code -Dsurefire.argLine="-Dio.helidon.openapi.ui.test.runForBrowser"} + * + * to the mvn command line to enable the test to use the default wait time. + * + * Use {@code -Dsurefire.argLine="-Dio.helidon.openapi.ui.test.runForBrowser=8"} to wait for 8 minutes. + * + * @throws InterruptedException in case of problems waiting for the time to pass + */ + @Test + @EnabledIfSystemProperty(named = RUN_BROWSER_TEST_PROPERTY, matches=".*") + void browserDefaults() throws InterruptedException { + browser(); + } + + @Test + void checkSimulatedBrowserAccessToMainEndpoint() { + String path = OpenAPISupport.DEFAULT_WEB_CONTEXT; + run(null, + path, + webClient -> + webClient.get() + .followRedirects(true) + .accept(SIMULATED_BROWSER_ACCEPT), + 200, + webClientResponse -> { + String text = null; + try { + text = webClientResponse.content() + .as(String.class) + .get(5, TimeUnit.SECONDS); + } catch (InterruptedException | ExecutionException | TimeoutException e) { + fail("Error retrieving " + Arrays.toString(SIMULATED_BROWSER_ACCEPT) + + " from " + path, e); + } + // Do a cursory check of the returned plain text which is yaml. + assertThat("Response media type from path " + path, webClientResponse.headers().contentType(), + OptionalMatcher.value(is(MediaType.TEXT_HTML))); + assertThat("Response code from path " + path, webClientResponse.status().code(), is(200)); + assertThat("Response from path " + path + " as " + MediaType.TEXT_HTML, + text, + allOf(containsString(" settings = Map.of("openapi.ui.web-context", "/openapi-ui-x"); + Config config = Config.create(ConfigSources.create(settings)); + loadAndCheckMainPage(config, + "/openapi-ui-x"); + } + + @Test + void testAlternateOpenApiPath() { + // Alternate web-context for OpenAPI, default for the UI. + // The UI should automatically take into account the non-default path for the OpenAPI document. + Map settings = Map.of("openapi.web-context", "/openapi-x"); + Config config = Config.create(ConfigSources.create(settings)); + loadAndCheckMainPage(config, + "/openapi-x" + OpenApiUi.UI_WEB_SUBCONTEXT); + } + + @ParameterizedTest + @ValueSource(strings = {OpenAPISupport.DEFAULT_WEB_CONTEXT, OpenAPISupport.DEFAULT_WEB_CONTEXT + OpenApiUi.UI_WEB_SUBCONTEXT}) + void testDisable(String path) { + Map settings = Map.of("openapi.ui.enabled", "false"); + Config config = Config.create(ConfigSources.create(settings)); + loadAndCheckMainPage(config, + path, + true, + 404); + } + + /** + * Makes sure redirection occurs correctly for HTML at /openapi and /openapi/. + * @param testPath the path to check + */ + @ParameterizedTest + @ValueSource(strings = {"/openapi", "/openapi/"}) + void testRedirect(String testPath) { + loadAndCheckMainPage(null, testPath, false, 307); + } + + static void loadAndCheckMainPage(Config config, + String uiPathToCheck) { + loadAndCheckMainPage(config, uiPathToCheck, true, 200); + } + + /** + * Makes sure we get plain and yaml text from /openapi and /openapi/ui. + * @param mediaType the media type to ask for + * @param path the path to probe + */ + @ParameterizedTest + @MethodSource + void testWithMediaType(MediaType mediaType, String path) { + testWithMediaTypeAndPath(mediaType, path); + } + + @Test + void simulateUiRetrievalOfOpenAPIDocument() { + run(null, + OpenAPISupport.DEFAULT_WEB_CONTEXT, + webClient -> webClient.get() + .followRedirects(true) + .accept(MediaType.APPLICATION_JSON, MediaType.WILDCARD), + 200, + webClientResponse -> { + String text = null; + try { + text = webClientResponse.content() + .as(String.class) + .get(5, TimeUnit.SECONDS); + } catch (InterruptedException | ExecutionException | TimeoutException e) { + fail("Error retrieving OpenAPI document", e); + } + assertThat("Retrieved OpenAPI document", + text, + allOf(containsString("openapi: 3"), + containsString("title:"), + not(containsString(" testWithMediaType() { + return Stream.of( + arguments(MediaType.TEXT_PLAIN, OpenAPISupport.DEFAULT_WEB_CONTEXT + OpenApiUi.UI_WEB_SUBCONTEXT), + arguments(MediaType.TEXT_YAML, OpenAPISupport.DEFAULT_WEB_CONTEXT + OpenApiUi.UI_WEB_SUBCONTEXT), + arguments(MediaType.TEXT_PLAIN, OpenAPISupport.DEFAULT_WEB_CONTEXT), + arguments(MediaType.TEXT_YAML, OpenAPISupport.DEFAULT_WEB_CONTEXT)); + } + + private void testWithMediaTypeAndPath(MediaType mediaType, String path) { + run(null, + path, + webClient -> webClient.get() + .followRedirects(true) + .accept(mediaType), + 200, + webClientResponse -> { + String text = null; + try { + text = webClientResponse.content() + .as(String.class) + .get(5, TimeUnit.SECONDS); + } catch (InterruptedException | ExecutionException | TimeoutException e) { + fail("Error retrieving " + mediaType + " from " + path, e); + } + // Do a cursory check of the returned plain text which is yaml. + assertThat("Response media type from path " + path, webClientResponse.headers().contentType(), + OptionalMatcher.value(is(mediaType))); + assertThat("Response code from path " + path, webClientResponse.status().code(), is(200)); + assertThat("Response from path " + path + " as " + mediaType, + text, + allOf(containsString("openapi: 3"), + containsString("title:"))); + }); + } + + static void loadAndCheckMainPage(Config config, + String uiPathToCheck, + boolean followRedirects, + int expectedStatus) { + run (config, + uiPathToCheck, + webClient -> + webClient.get() + .followRedirects(followRedirects) + .accept(MediaType.TEXT_HTML), + expectedStatus, + webClientResponse -> { + String html = null; + try { + html = webClientResponse.content() + .as(String.class) + .get(5, TimeUnit.SECONDS); + } catch (InterruptedException | ExecutionException | TimeoutException e) { + fail("Error retrieving initial UI page", e); + } + + // Don't inspect the HTML much; we don't want this test to depend too heavily on the UI impl. + if (expectedStatus == 200) { + assertThat("Returned HTML from UI", html, containsString("https://helidon.io")); + } + }); + } + + static void run(Config config, + String uiPathToTest, + Function operation, + int expectedStatus, + Consumer responseConsumer) { + WebServer server = null; + + try { + server = startServer(config).get(); + String baseUri = "http://localhost:" + server.port(); + WebClient.Builder webClientBuilder = WebClient.builder() + .baseUri(baseUri); + + WebClient webClient = webClientBuilder.build(); + System.out.printf("Checking %s%s%n", baseUri, uiPathToTest); + WebClientRequestBuilder reqBuilder = operation.apply(webClient); + List acceptedMediaTypes = reqBuilder.headers().acceptedTypes(); + WebClientResponse webClientResponse = reqBuilder + .path(uiPathToTest) + .request() + .await(5, TimeUnit.SECONDS); + + assertThat("Status code in response getting main page from path " + uiPathToTest + + " accepting media types " + acceptedMediaTypes, + webClientResponse.status().code(), is(expectedStatus)); + + try { + responseConsumer.accept(webClientResponse); + } catch (Exception e) { + fail("Error consuming response", e); + } finally { + webClientResponse.close(); + } + } catch (IOException | InterruptedException | ExecutionException e) { + fail("Error starting webserver", e); + } finally { + if (server != null) { + server.shutdown().await(5, TimeUnit.SECONDS); + }; + } + } + + static void browser() { + String minutesToStayUpText = System.getProperty(RUN_BROWSER_TEST_PROPERTY); + if (minutesToStayUpText == null) { + return; + } + // If the property is defined but not given a value, Java reports "true". + if (Boolean.parseBoolean(minutesToStayUpText)) { + minutesToStayUpText = "5"; + } + long minutesToStayUp = (minutesToStayUpText.length() > 0 ? Long.parseUnsignedLong(minutesToStayUpText) : 5); + + Config config = Config.create(); + Map portConfig = Map.of("server.port", "8080"); + Config configWithPort = Config.create(ConfigSources.create(portConfig), ConfigSources.create(config)); + WebServer webServer = null; + + try { + try { + // For the browser test set the server.port to 8080 so the human knows where to find the pages. + + webServer = startServer(configWithPort).get(5, TimeUnit.SECONDS); + } catch (Exception e) { + fail("Error starting webserver for browser test", e); + } + try { + Thread.sleep(minutesToStayUp * 60 * 1000); + } catch (InterruptedException e) { + fail("Error while waiting for timed test to end", e); + } + } finally { + if (webServer != null) { + webServer.shutdown().await(5, TimeUnit.SECONDS); + } + } + } + + static Single startServer(Config config) throws IOException { + + // load logging configuration + LogConfig.configureRuntime(); + + // By default this will pick up application.yaml from the classpath + if (config == null) { + config = Config.create(); + } + + // Get webserver config from the "server" section of application.yaml and register JSON support + Single server = WebServer.builder(createRouting(config)) + .config(config.get("server")) + .addMediaSupport(JsonpSupport.create()) + .build() + .start(); + + server.thenAccept(ws -> { + System.out.println( + "WEB server is up! http://localhost:" + ws.port() + "/greet"); + ws.whenShutdown().thenRun(() + -> System.out.println("WEB server is DOWN. Good bye!")); + }) + .exceptionally(t -> { + System.err.println("Startup failed: " + t.getMessage()); + t.printStackTrace(System.err); + return null; + }); + return server; + } + + /** + * Creates new {@link io.helidon.webserver.Routing}. + * + * @param config configuration of this server + * @return routing configured with a health check, and a service + */ + private static Routing createRouting(Config config) throws IOException { + + // OpenAPISupport builds an OpenApiUi internally if none is provided. + OpenAPISupport openApiSupport = OpenAPISupport.builder() + .config(config.get(OpenAPISupport.Builder.CONFIG_KEY)) + .build(); + + GreetService greetService = new GreetService(config); + Routing.Builder routingBuilder = Routing.builder() + .register(openApiSupport) + .register("/greet", greetService); + return routingBuilder.build(); + } +} diff --git a/integrations/openapi-ui/src/test/java/io/helidon/integrations/openapi/ui/TestConfig.java b/integrations/openapi-ui/src/test/java/io/helidon/integrations/openapi/ui/TestConfig.java new file mode 100644 index 00000000000..54f33fa55af --- /dev/null +++ b/integrations/openapi-ui/src/test/java/io/helidon/integrations/openapi/ui/TestConfig.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.integrations.openapi.ui; + +import java.util.Map; + +import io.helidon.config.Config; +import io.helidon.config.ConfigSources; +import io.helidon.openapi.OpenAPISupport; +import io.helidon.openapi.OpenApiUi; + +import io.smallrye.openapi.ui.Option; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +class TestConfig { + + @Test + void checkOptionsConfig() { + String newTitle = "New Title"; + String newContext = "/overThere"; + Map settings = Map.of("openapi.ui.options.title", newTitle, + "openapi.ui.options.notThere", "anything", + "openapi.web-context", newContext); + + Config config = Config.create(ConfigSources.create(settings)); + Config openApiConfig = config.get(OpenAPISupport.Builder.CONFIG_KEY); + OpenApiUiFull.Builder uiSupportBuilder = OpenApiUiFull.builder() + .config(openApiConfig.get(OpenApiUi.Builder.OPENAPI_UI_CONFIG_KEY)); + OpenAPISupport openAPISupportBuilder = OpenAPISupport.builder() + .config(openApiConfig) + .ui(uiSupportBuilder) + .build(); + + // Check a simple option setting. + assertThat("Overridden title value", uiSupportBuilder.uiOptions().get(Option.title), is(newTitle)); + } +} diff --git a/integrations/openapi-ui/src/test/resources/META-INF/openapi.yml b/integrations/openapi-ui/src/test/resources/META-INF/openapi.yml new file mode 100644 index 00000000000..314269e28e5 --- /dev/null +++ b/integrations/openapi-ui/src/test/resources/META-INF/openapi.yml @@ -0,0 +1,79 @@ +# +# Copyright (c) 2022 Oracle and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +--- +openapi: 3.0.0 +info: + title: Helidon SE Quickstart Example + description: A very simple application to reply with friendly greetings + version: 1.0.0 + +servers: + - url: http://localhost:8080 + description: Local test server + +paths: + /greet: + get: + summary: Returns a generic greeting + description: Greets the user generically + responses: + default: + description: Simple JSON containing the greeting + content: + application/json: + schema: + $ref: '#/components/schemas/GreetingMessage' + /greet/greeting: + put: + summary: Set the greeting prefix + description: Permits the client to set the prefix part of the greeting ("Hello") + requestBody: + description: Conveys the new greeting prefix to use in building greetings + content: + application/json: + schema: + $ref: '#/components/schemas/GreetingMessage' + examples: + greeting: + summary: Example greeting message to update + value: New greeting message + responses: + "200": + description: OK + content: + application/json: {} + /greet/{name}: + get: + summary: Returns a personalized greeting + parameters: + - name: name + in: path + required: true + schema: + type: string + responses: + default: + description: Simple JSON containing the greeting + content: + application/json: + schema: + $ref: '#/components/schemas/GreetingMessage' +components: + schemas: + GreetingMessage: + properties: + message: + type: string diff --git a/integrations/openapi-ui/src/test/resources/application.yaml b/integrations/openapi-ui/src/test/resources/application.yaml new file mode 100644 index 00000000000..a459041920e --- /dev/null +++ b/integrations/openapi-ui/src/test/resources/application.yaml @@ -0,0 +1,25 @@ +# +# Copyright (c) 2022 Oracle and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +app: + greeting: "Hello" + +server: + port: -1 + host: 0.0.0.0 + +# The following would change the endpoint path for retrieving the OpenAPI document +# web-context: /myopenapi diff --git a/integrations/pom.xml b/integrations/pom.xml index 3f22fd1f520..b1fe8aef1a8 100644 --- a/integrations/pom.xml +++ b/integrations/pom.xml @@ -50,6 +50,7 @@ neo4j micrometer oci + openapi-ui vault microstream diff --git a/metrics/metrics/src/test/java/io/helidon/metrics/TestPeriodicExecutor.java b/metrics/metrics/src/test/java/io/helidon/metrics/TestPeriodicExecutor.java index 6f7fa108c5e..c8cc4d857ad 100644 --- a/metrics/metrics/src/test/java/io/helidon/metrics/TestPeriodicExecutor.java +++ b/metrics/metrics/src/test/java/io/helidon/metrics/TestPeriodicExecutor.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 Oracle and/or its affiliates. + * Copyright (c) 2021, 2022 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,6 +18,8 @@ import java.time.Duration; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.logging.Handler; import java.util.logging.Level; @@ -33,20 +35,20 @@ import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.greaterThan; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.fail; class TestPeriodicExecutor { - private static final int SLEEP_TIME_MS = 1500; - private static final int SLEEP_TIME_NO_DATA_MS = 100; + private static final int APPROX_TEST_DURATION_MS = 1500; + private static final int MAX_TEST_WAIT_TIME_MS = APPROX_TEST_DURATION_MS * 15 / 10; // 1.5 * approx test duration private static final int FAST_INTERVAL = 250; private static final int SLOW_INTERVAL = 400; - private static final double SLOWDOWN_FACTOR = 0.80; // for slow pipelines! - - private static final double MIN_FAST_COUNT = 1500 / FAST_INTERVAL * SLOWDOWN_FACTOR; - private static final double MIN_SLOW_COUNT = 1500 / SLOW_INTERVAL * SLOWDOWN_FACTOR; + private static final int MIN_FAST_COUNT = APPROX_TEST_DURATION_MS / FAST_INTERVAL; + private static final int MIN_SLOW_COUNT = APPROX_TEST_DURATION_MS / SLOW_INTERVAL; private static final Logger PERIODIC_EXECUTOR_LOGGER = Logger.getLogger(PeriodicExecutor.class.getName()); @@ -59,13 +61,28 @@ void testWithNoDeferrals() throws InterruptedException { AtomicInteger countA = new AtomicInteger(); AtomicInteger countB = new AtomicInteger(); - exec.enrollRunner(() -> countA.incrementAndGet(), Duration.ofMillis(FAST_INTERVAL)); - exec.enrollRunner(() -> countB.incrementAndGet(), Duration.ofMillis(SLOW_INTERVAL)); + CountDownLatch latchA = new CountDownLatch(MIN_FAST_COUNT); + CountDownLatch latchB = new CountDownLatch(MIN_SLOW_COUNT); + + long startTime = System.nanoTime(); + exec.enrollRunner(() -> { + countA.incrementAndGet(); + latchA.countDown(); + }, Duration.ofMillis(FAST_INTERVAL)); + + exec.enrollRunner(() -> { + countB.incrementAndGet(); + latchB.countDown(); + }, Duration.ofMillis(SLOW_INTERVAL)); - Thread.sleep(SLEEP_TIME_MS); + assertThat("Wait latch for fast interval", latchA.await(MAX_TEST_WAIT_TIME_MS, TimeUnit.MILLISECONDS), is(true)); + assertThat("Wait latch for slow interval", latchB.await(MAX_TEST_WAIT_TIME_MS, TimeUnit.MILLISECONDS), is(true)); - assertThat("CountA", (double) countA.get(), is(greaterThan(MIN_FAST_COUNT))); - assertThat("CountB", (double) countB.get(), is(greaterThan(MIN_SLOW_COUNT))); + Duration elapsedTime = Duration.ofNanos(System.nanoTime() - startTime); + + assertThat("CountA", countA.get(), is(greaterThanOrEqualTo(MIN_FAST_COUNT))); + assertThat("CountB", countB.get(), is(greaterThanOrEqualTo(MIN_SLOW_COUNT))); + assertThat("Wait duration", elapsedTime, greaterThanOrEqualTo(Duration.ofMillis(1500))); } finally { if (exec.executorState() == PeriodicExecutor.State.STARTED) { exec.stopExecutor(); @@ -80,16 +97,23 @@ void testWithDeferredEnrollments() throws InterruptedException { AtomicInteger countA = new AtomicInteger(); AtomicInteger countB = new AtomicInteger(); - exec.enrollRunner(() -> countA.incrementAndGet(), Duration.ofMillis(FAST_INTERVAL)); + CountDownLatch latchA = new CountDownLatch(MIN_FAST_COUNT); + CountDownLatch latchB = new CountDownLatch(MIN_SLOW_COUNT); - exec.startExecutor(); + exec.enrollRunner(() -> { + countA.incrementAndGet(); + latchA.countDown(); + }, Duration.ofMillis(FAST_INTERVAL)); - exec.enrollRunner(() -> countB.incrementAndGet(), Duration.ofMillis(SLOW_INTERVAL)); + exec.startExecutor(); - Thread.sleep(SLEEP_TIME_MS); + exec.enrollRunner(() -> { + countB.incrementAndGet(); + latchB.countDown(); + }, Duration.ofMillis(SLOW_INTERVAL)); - assertThat("CountA", (double) countA.get(), is(greaterThan(MIN_FAST_COUNT))); - assertThat("CountB", (double) countB.get(), is(greaterThan(MIN_SLOW_COUNT))); + assertThat("Wait latch for fast interval", latchA.await(MAX_TEST_WAIT_TIME_MS, TimeUnit.MILLISECONDS), is(true)); + assertThat("Wait latch for slow interval", latchB.await(MAX_TEST_WAIT_TIME_MS, TimeUnit.MILLISECONDS), is(true)); } finally { if (exec.executorState() == PeriodicExecutor.State.STARTED) { exec.stopExecutor(); @@ -104,17 +128,25 @@ void testWithLateEnrollment() throws InterruptedException { AtomicInteger countA = new AtomicInteger(); AtomicInteger countB = new AtomicInteger(); - exec.enrollRunner(() -> countA.incrementAndGet(), Duration.ofMillis(FAST_INTERVAL)); + CountDownLatch latchA = new CountDownLatch(MIN_FAST_COUNT); + + exec.enrollRunner(() -> { + countA.incrementAndGet(); + latchA.countDown(); + }, Duration.ofMillis(FAST_INTERVAL)); exec.startExecutor(); - Thread.sleep(SLEEP_TIME_MS); + assertThat("Wait latch", latchA.await(MAX_TEST_WAIT_TIME_MS, TimeUnit.MILLISECONDS), is(true)); exec.stopExecutor(); exec.enrollRunner(() -> countB.incrementAndGet(), Duration.ofMillis(SLOW_INTERVAL)); - assertThat("CountA", (double) countA.get(), is(greaterThan(MIN_FAST_COUNT))); // should be 8 - assertThat("CountB", (double) countB.get(), is(0.0)); + // The executor is no longer running, so we cannot use a countdown latch to know when to check countB. Use time. + Thread.sleep(MAX_TEST_WAIT_TIME_MS); + + assertThat("CountA", countA.get(), is(greaterThanOrEqualTo(MIN_FAST_COUNT))); // should be 8 + assertThat("CountB", countB.get(), is(0)); } finally { if (exec.executorState() == PeriodicExecutor.State.STARTED) { exec.stopExecutor(); @@ -130,7 +162,8 @@ void testNoWarningOnStopWhenStopped() throws InterruptedException { try { PeriodicExecutor executor = PeriodicExecutor.create(); executor.stopExecutor(); - Thread.sleep(SLEEP_TIME_NO_DATA_MS); + + waitForExecutorState(executor, PeriodicExecutor.State.STOPPED); handler.clear(); executor.stopExecutor(); @@ -152,7 +185,7 @@ void testFineMessageOnStopWhenStopped() throws InterruptedException { PERIODIC_EXECUTOR_LOGGER.setLevel(Level.FINE); PeriodicExecutor executor = PeriodicExecutor.create(); executor.stopExecutor(); - Thread.sleep(SLEEP_TIME_NO_DATA_MS); + waitForExecutorState(executor, PeriodicExecutor.State.STOPPED); executor.stopExecutor(); handler.clear(); @@ -184,7 +217,7 @@ void testFineLoggingOnExpectedStop(PeriodicExecutor.State testState) throws Inte if (testState == PeriodicExecutor.State.STARTED) { executor.startExecutor(); - Thread.sleep(SLEEP_TIME_NO_DATA_MS); + waitForExecutorState(executor, PeriodicExecutor.State.STARTED); } handler.clear(); @@ -205,6 +238,15 @@ void testFineLoggingOnExpectedStop(PeriodicExecutor.State testState) throws Inte } } + private void waitForExecutorState(PeriodicExecutor executor, PeriodicExecutor.State expectedState) { + for (int i = MAX_TEST_WAIT_TIME_MS; i > 0; i -= 500) { + if (executor.executorState() == expectedState) { + return; + } + } + fail("Timed out waiting for executor in state " + executor.executorState() + " to enter state " + expectedState.name()); + } + private static class MyHandler extends Handler { private List logRecords = new ArrayList<>(); diff --git a/microprofile/metrics/src/test/java/io/helidon/microprofile/metrics/HelloWorldRestEndpointSimpleTimerDisabledTest.java b/microprofile/metrics/src/test/java/io/helidon/microprofile/metrics/HelloWorldRestEndpointSimpleTimerDisabledTest.java index 3a21ab3a3a9..f687e859a2b 100644 --- a/microprofile/metrics/src/test/java/io/helidon/microprofile/metrics/HelloWorldRestEndpointSimpleTimerDisabledTest.java +++ b/microprofile/metrics/src/test/java/io/helidon/microprofile/metrics/HelloWorldRestEndpointSimpleTimerDisabledTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2018, 2021 Oracle and/or its affiliates. + * Copyright (c) 2018, 2022 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,11 +16,13 @@ package io.helidon.microprofile.metrics; +import io.helidon.microprofile.tests.junit5.AddConfig; import io.helidon.microprofile.tests.junit5.HelidonTest; import jakarta.inject.Inject; import org.eclipse.microprofile.metrics.MetricRegistry; import org.eclipse.microprofile.metrics.annotation.RegistryType; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import static org.hamcrest.CoreMatchers.is; @@ -31,15 +33,21 @@ * the config disables that feature. */ @HelidonTest +@AddConfig(key = "metrics." + MetricsCdiExtension.REST_ENDPOINTS_METRIC_ENABLED_PROPERTY_NAME, value = "false") public class HelloWorldRestEndpointSimpleTimerDisabledTest { + @BeforeAll + static void init() { + MetricsMpServiceTest.cleanUpSyntheticSimpleTimerRegistry(); + } + @Inject @RegistryType(type = MetricRegistry.Type.BASE) MetricRegistry syntheticSimpleTimerRegistry; boolean isSyntheticSimpleTimerPresent() { return !syntheticSimpleTimerRegistry.getSimpleTimers((metricID, metric) -> - metricID.equals(MetricsCdiExtension.SYNTHETIC_SIMPLE_TIMER_METRIC_NAME)) + metricID.getName().equals(MetricsCdiExtension.SYNTHETIC_SIMPLE_TIMER_METRIC_NAME)) .isEmpty(); } diff --git a/microprofile/metrics/src/test/java/io/helidon/microprofile/metrics/HelloWorldTest.java b/microprofile/metrics/src/test/java/io/helidon/microprofile/metrics/HelloWorldTest.java index 4b2d29ff1ee..a01d6c1a59c 100644 --- a/microprofile/metrics/src/test/java/io/helidon/microprofile/metrics/HelloWorldTest.java +++ b/microprofile/metrics/src/test/java/io/helidon/microprofile/metrics/HelloWorldTest.java @@ -113,9 +113,9 @@ public void testMetrics() throws InterruptedException { assertThat("Value of explicitly-updated counter", registry.counter("helloCounter").getCount(), is((long) iterations)); - assertThat("Diff in value of interceptor-updated class-level counter for constructor", - classLevelCounterForConstructor.getCount() - classLevelCounterStart, - is((long) iterations)); + assertThatWithRetry("Diff in value of interceptor-updated class-level counter for constructor", + () -> classLevelCounterForConstructor.getCount() - classLevelCounterStart, + is((long) iterations)); Counter classLevelCounterForMethod = registry.getCounters().get(new MetricID(HelloWorldResource.class.getName() + ".message")); @@ -125,13 +125,13 @@ public void testMetrics() throws InterruptedException { SimpleTimer simpleTimer = getSyntheticSimpleTimer("message"); assertThat("Synthetic simple timer", simpleTimer, is(notNullValue())); - assertThat("Synthetic simple timer count value", simpleTimer.getCount(), is((long) iterations)); + assertThatWithRetry("Synthetic simple timer count value", simpleTimer::getCount, is((long) iterations)); checkMetricsUrl(iterations); } @Test - public void testSyntheticSimpleTimer() { + public void testSyntheticSimpleTimer() throws InterruptedException { testSyntheticSimpleTimer(1L); } @@ -181,7 +181,11 @@ void testUnmappedException() throws Exception { assertThat("Change in unsuccessful count", counter.getCount() - unsuccessfulBeforeRequest, is(1L)); } - void testSyntheticSimpleTimer(long expectedSyntheticSimpleTimerCount) { + void testSyntheticSimpleTimer(long expectedSyntheticSimpleTimerCount) throws InterruptedException { + SimpleTimer explicitSimpleTimer = registry.getSimpleTimer(new MetricID(MESSAGE_SIMPLE_TIMER)); + assertThat("SimpleTimer from explicit @SimplyTimed", explicitSimpleTimer, is(notNullValue())); + SimpleTimer syntheticSimpleTimer = getSyntheticSimpleTimer("messageWithArg", String.class); + assertThat("SimpleTimer from @SyntheticRestRequest", syntheticSimpleTimer, is(notNullValue())); IntStream.range(0, (int) expectedSyntheticSimpleTimerCount).forEach( i -> webTarget .path("helloworld/withArg/Joe") @@ -189,14 +193,13 @@ void testSyntheticSimpleTimer(long expectedSyntheticSimpleTimerCount) { .get(String.class)); pause(); - SimpleTimer explicitSimpleTimer = registry.simpleTimer(MESSAGE_SIMPLE_TIMER); - assertThat("SimpleTimer from explicit @SimplyTimed", explicitSimpleTimer, is(notNullValue())); - assertThat("SimpleTimer from explicit @SimpleTimed count", explicitSimpleTimer.getCount(), - is(expectedSyntheticSimpleTimerCount)); + assertThatWithRetry("SimpleTimer from explicit @SimpleTimed count", + explicitSimpleTimer::getCount, + is(expectedSyntheticSimpleTimerCount)); - SimpleTimer syntheticSimpleTimer = getSyntheticSimpleTimer("messageWithArg", String.class); - assertThat("SimpleTimer from @SyntheticRestRequest", syntheticSimpleTimer, is(notNullValue())); - assertThat("SimpleTimer from @SyntheticRestRequest count", syntheticSimpleTimer.getCount(), is(expectedSyntheticSimpleTimerCount)); + assertThatWithRetry("SimpleTimer from @SyntheticRestRequest count", + syntheticSimpleTimer::getCount, + is(expectedSyntheticSimpleTimerCount)); } SimpleTimer getSyntheticSimpleTimer(String methodName, Class... paramTypes) { @@ -217,13 +220,15 @@ SimpleTimer getSyntheticSimpleTimer(MetricID metricID) { return simpleTimers.get(metricID); } - void checkMetricsUrl(int iterations) { - JsonObject app = webTarget - .path("metrics") - .request() - .accept(MediaType.APPLICATION_JSON_TYPE) - .get(JsonObject.class) - .getJsonObject("application"); - assertThat(app.getJsonNumber("helloCounter").intValue(), is(iterations)); + void checkMetricsUrl(int iterations) throws InterruptedException { + assertThatWithRetry("helloCounter count", () -> { + JsonObject app = webTarget + .path("metrics") + .request() + .accept(MediaType.APPLICATION_JSON_TYPE) + .get(JsonObject.class) + .getJsonObject("application"); + return app.getJsonNumber("helloCounter").intValue(); + }, is(iterations)); } } diff --git a/openapi/pom.xml b/openapi/pom.xml index 5601d66c0c0..71cc8c3f8a4 100644 --- a/openapi/pom.xml +++ b/openapi/pom.xml @@ -150,6 +150,10 @@ jakarta.json jakarta.json-api + + io.helidon.common + helidon-common-service-loader + io.helidon.config helidon-config-metadata @@ -198,6 +202,11 @@ junit-jupiter-api test + + org.junit.jupiter + junit-jupiter-params + test + org.hamcrest hamcrest-all @@ -218,5 +227,10 @@ helidon-config-testing test + + io.helidon.webclient + helidon-webclient + test + diff --git a/openapi/src/main/java/io/helidon/openapi/OpenAPISupport.java b/openapi/src/main/java/io/helidon/openapi/OpenAPISupport.java index 975ca4d5765..42f8ea775f4 100644 --- a/openapi/src/main/java/io/helidon/openapi/OpenAPISupport.java +++ b/openapi/src/main/java/io/helidon/openapi/OpenAPISupport.java @@ -51,6 +51,7 @@ import io.helidon.media.common.MessageBodyWriterContext; import io.helidon.media.jsonp.JsonpSupport; import io.helidon.openapi.internal.OpenAPIConfigImpl; +import io.helidon.webserver.RequestHeaders; import io.helidon.webserver.Routing; import io.helidon.webserver.ServerRequest; import io.helidon.webserver.ServerResponse; @@ -111,7 +112,11 @@ public abstract class OpenAPISupport implements Service { */ public static final MediaType DEFAULT_RESPONSE_MEDIA_TYPE = MediaType.APPLICATION_OPENAPI_YAML; - private enum QueryParameterRequestedFormat { + /** + * Some logic related to the possible format values as requested in the query + * parameter {@value OPENAPI_ENDPOINT_FORMAT_QUERY_PARAMETER}. + */ + enum QueryParameterRequestedFormat { JSON(MediaType.APPLICATION_JSON), YAML(MediaType.APPLICATION_OPENAPI_YAML); static QueryParameterRequestedFormat chooseFormat(String format) { @@ -129,7 +134,10 @@ MediaType mediaType() { } } - private static final String OPENAPI_ENDPOINT_FORMAT_QUERY_PARAMETER = "format"; + /** + * URL query parameter for specifying the requested format when retrieving the OpenAPI document. + */ + static final String OPENAPI_ENDPOINT_FORMAT_QUERY_PARAMETER = "format"; private static final Logger LOGGER = Logger.getLogger(OpenAPISupport.class.getName()); @@ -165,6 +173,11 @@ MediaType mediaType() { private final Lock modelAccess = new ReentrantLock(true); + private final OpenApiUi ui; + + private final MediaType[] preferredMediaTypeOrdering; + private final MediaType[] mediaTypesSupportedByUi; + /** * Creates a new instance of {@code OpenAPISupport}. * @@ -178,6 +191,9 @@ protected OpenAPISupport(Builder builder) { openApiConfig = builder.openAPIConfig(); openApiStaticFile = builder.staticFile(); indexViewsSupplier = builder.indexViewsSupplier(); + ui = prepareUi(builder); + mediaTypesSupportedByUi = ui.supportedMediaTypes(); + preferredMediaTypeOrdering = preparePreferredMediaTypeOrdering(mediaTypesSupportedByUi); } @Override @@ -196,6 +212,15 @@ public void configureEndpoint(Routing.Rules rules) { rules.get(this::registerJsonpSupport) .any(webContext, corsEnabledServiceHelper.processor()) .get(webContext, this::prepareResponse); + ui.update(rules); + } + + /** + * + * @return the web context setting for this service + */ + public String webContext() { + return webContext; } /** @@ -205,6 +230,19 @@ protected void prepareModel() { model(); } + private OpenApiUi prepareUi(Builder builder) { + return builder.uiBuilder.build(this::prepareDocument, webContext); + } + + private static MediaType[] preparePreferredMediaTypeOrdering(MediaType[] uiTypesSupported) { + int nonTextLength = OpenAPIMediaType.NON_TEXT_PREFERRED_ORDERING.length; + + MediaType[] result = Arrays.copyOf(OpenAPIMediaType.NON_TEXT_PREFERRED_ORDERING, + nonTextLength + uiTypesSupported.length); + System.arraycopy(uiTypesSupported, 0, result, nonTextLength, uiTypesSupported.length); + return result; + } + private OpenAPI model() { return access(modelAccess, () -> { if (model == null) { @@ -396,7 +434,25 @@ private static String typeFromPath(Path path) { private void prepareResponse(ServerRequest req, ServerResponse resp) { try { - final MediaType resultMediaType = chooseResponseMediaType(req); + Optional requestedMediaType = chooseResponseMediaType(req); + + // Give the UI a chance to respond first if it claims to support the chosen media type. + if (requestedMediaType.isPresent() + && uiSupportsMediaType(requestedMediaType.get())) { + if (ui.prepareTextResponseFromMainEndpoint(req, resp)) { + return; + } + } + + if (requestedMediaType.isEmpty()) { + LOGGER.log(Level.FINER, + () -> String.format("Did not recognize requested media type %s; passing the request on", + req.headers().acceptedTypes())); + req.next(); + return; + } + + MediaType resultMediaType = requestedMediaType.get(); final String openAPIDocument = prepareDocument(resultMediaType); resp.status(Http.Status.OK_200); resp.headers().add(Http.Header.CONTENT_TYPE, resultMediaType.toString()); @@ -408,15 +464,24 @@ private void prepareResponse(ServerRequest req, ServerResponse resp) { } } + private boolean uiSupportsMediaType(MediaType mediaType) { + // The UI supports a very short list of media types, hence the sequential search. + for (MediaType uiSupportedMediaType : mediaTypesSupportedByUi) { + if (uiSupportedMediaType.test(mediaType)) { + return true; + } + } + return false; + } + /** * Returns the OpenAPI document in the requested format. * * @param resultMediaType requested media type * @return String containing the formatted OpenAPI document - * @throws IOException in case of errors serializing the OpenAPI document * from its underlying data */ - String prepareDocument(MediaType resultMediaType) throws IOException { + String prepareDocument(MediaType resultMediaType) { OpenAPIMediaType matchingOpenAPIMediaType = OpenAPIMediaType.byMediaType(resultMediaType) .orElseGet(() -> { @@ -452,7 +517,7 @@ private String formatDocument(Format fmt, OpenAPI model) { } - private MediaType chooseResponseMediaType(ServerRequest req) { + private Optional chooseResponseMediaType(ServerRequest req) { /* * Response media type default is application/vnd.oai.openapi (YAML) * unless otherwise specified. @@ -462,7 +527,7 @@ private MediaType chooseResponseMediaType(ServerRequest req) { if (queryParameterFormat.isPresent()) { String queryParameterFormatValue = queryParameterFormat.get(); try { - return QueryParameterRequestedFormat.chooseFormat(queryParameterFormatValue).mediaType(); + return Optional.of(QueryParameterRequestedFormat.chooseFormat(queryParameterFormatValue).mediaType()); } catch (IllegalArgumentException e) { throw new IllegalArgumentException( "Query parameter 'format' had value '" @@ -471,18 +536,12 @@ private MediaType chooseResponseMediaType(ServerRequest req) { } } - final Optional requestedMediaType = req.headers() - .bestAccepted(OpenAPIMediaType.preferredOrdering()); - - final MediaType resultMediaType = requestedMediaType - .orElseGet(() -> { - LOGGER.log(Level.FINER, - () -> String.format("Did not recognize requested media type %s; responding with default %s", - req.headers().acceptedTypes(), - DEFAULT_RESPONSE_MEDIA_TYPE.toString())); - return DEFAULT_RESPONSE_MEDIA_TYPE; - }); - return resultMediaType; + RequestHeaders headers = req.headers(); + if (headers.acceptedTypes().isEmpty()) { + headers.add(Http.Header.ACCEPT, DEFAULT_RESPONSE_MEDIA_TYPE.toString()); + } + return headers + .bestAccepted(preferredMediaTypeOrdering); } /** @@ -583,17 +642,17 @@ private static Object convertJsonValue(JsonValue jsonValue) { enum OpenAPIMediaType { JSON(Format.JSON, - new MediaType[]{MediaType.APPLICATION_OPENAPI_JSON, - MediaType.APPLICATION_JSON}, - "json"), + new MediaType[] {MediaType.APPLICATION_OPENAPI_JSON, + MediaType.APPLICATION_JSON}, + "json"), YAML(Format.YAML, - new MediaType[]{MediaType.APPLICATION_OPENAPI_YAML, - MediaType.APPLICATION_X_YAML, - MediaType.APPLICATION_YAML, - MediaType.TEXT_PLAIN, - MediaType.TEXT_X_YAML, - MediaType.TEXT_YAML}, - "yaml", "yml"); + new MediaType[] {MediaType.APPLICATION_OPENAPI_YAML, + MediaType.APPLICATION_X_YAML, + MediaType.APPLICATION_YAML, + MediaType.TEXT_PLAIN, + MediaType.TEXT_X_YAML, + MediaType.TEXT_YAML}, + "yaml", "yml"); private static final OpenAPIMediaType DEFAULT_TYPE = YAML; @@ -652,24 +711,17 @@ private static OpenAPIMediaType byFormat(Format format) { return null; } - /** - * Media types we recognize as OpenAPI, in order of preference. - * - * @return MediaTypes in order that we recognize them as OpenAPI - * content. - */ - private static MediaType[] preferredOrdering() { - return new MediaType[]{ - MediaType.APPLICATION_OPENAPI_YAML, - MediaType.APPLICATION_X_YAML, - MediaType.APPLICATION_YAML, - MediaType.APPLICATION_OPENAPI_JSON, - MediaType.APPLICATION_JSON, - MediaType.TEXT_X_YAML, - MediaType.TEXT_YAML, - MediaType.TEXT_PLAIN - }; - } + private static final MediaType[] NON_TEXT_PREFERRED_ORDERING = + new MediaType[] { + MediaType.APPLICATION_OPENAPI_YAML, + MediaType.APPLICATION_X_YAML, + MediaType.APPLICATION_YAML, + MediaType.APPLICATION_OPENAPI_JSON, + MediaType.APPLICATION_JSON, + MediaType.TEXT_X_YAML, + MediaType.TEXT_YAML + + }; } /** @@ -734,6 +786,7 @@ public abstract static class Builder> implements io.helidon private Optional staticFilePath = Optional.empty(); private CrossOriginConfig crossOriginConfig = null; + private OpenApiUi.Builder uiBuilder = OpenApiUi.builder(); /** * Set various builder attributes from the specified {@code Config} object. @@ -755,6 +808,8 @@ public B config(Config config) { config.get(CORS_CONFIG_KEY) .as(CrossOriginConfig::create) .ifPresent(this::crossOriginConfig); + config.get(OpenApiUi.Builder.OPENAPI_UI_CONFIG_KEY) + .ifExists(uiBuilder::config); return identity(); } @@ -847,6 +902,19 @@ public B crossOriginConfig(CrossOriginConfig crossOriginConfig) { return identity(); } + /** + * Assigns the OpenAPI UI builder the {@code OpenAPISupport} service should use in preparing the UI. + * + * @param uiBuilder the {@link OpenApiUi.Builder} + * @return updated builder instance + */ + @ConfiguredOption(type = OpenApiUi.class) + public B ui(OpenApiUi.Builder uiBuilder) { + Objects.requireNonNull(uiBuilder, "UI must be non-null"); + this.uiBuilder = uiBuilder; + return identity(); + } + /** * Returns the supplier of index views. * diff --git a/openapi/src/main/java/io/helidon/openapi/OpenApiUi.java b/openapi/src/main/java/io/helidon/openapi/OpenApiUi.java new file mode 100644 index 00000000000..f0a6d622fba --- /dev/null +++ b/openapi/src/main/java/io/helidon/openapi/OpenApiUi.java @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.openapi; + +import java.util.Map; +import java.util.function.Function; + +import io.helidon.common.http.MediaType; +import io.helidon.config.Config; +import io.helidon.config.metadata.Configured; +import io.helidon.config.metadata.ConfiguredOption; +import io.helidon.webserver.ServerRequest; +import io.helidon.webserver.ServerResponse; +import io.helidon.webserver.Service; + +/** + * Behavior for OpenAPI UI implementations. + */ +public interface OpenApiUi extends Service { + + /** + * Default subcontext within the {@link OpenAPISupport} instance's web context + * (which itself defaults to {@value OpenAPISupport#DEFAULT_WEB_CONTEXT}. + */ + String UI_WEB_SUBCONTEXT = "/ui"; + + /** + * Creates a builder for a new {@code OpenApiUi} instance. + * + * @return new builder + */ + static Builder builder() { + return OpenApiUiBase.builder(); + } + + /** + * Indicates the media types the UI implementation itself supports. + * + * @return the media types the + * {@link #prepareTextResponseFromMainEndpoint(io.helidon.webserver.ServerRequest, io.helidon.webserver.ServerResponse)} + * method responds to + */ + MediaType[] supportedMediaTypes(); + + /** + * Gives the UI an opportunity to respond to a request arriving at the {@code OpenAPISupport} endpoint for which the + * best-accepted {@link MediaType} was {@code text/html}. + *

+ * An implementation should return {@code true} if it is responsible for a particular media type + * whether it handled the request itself or delegated the request to the next handler. + * For example, even if the implementation is disabled it should still return {@code true} for the HTML media type. + *

+ * + * @param request the request for HTML content + * @param response the response which could be prepared and sent + * @return whether the UI did respond to the request + */ + boolean prepareTextResponseFromMainEndpoint(ServerRequest request, ServerResponse response); + + /** + * Builder for an {@code OpenApiUi}. + * + * @param type of the {@code OpenApiUi} to be build + * @param type of the builder for T + */ + @Configured(prefix = Builder.OPENAPI_UI_CONFIG_KEY) + interface Builder, T extends OpenApiUi> extends io.helidon.common.Builder { + + /** + * Config prefix within the {@value OpenAPISupport.Builder#CONFIG_KEY} section containing UI settings. + */ + String OPENAPI_UI_CONFIG_KEY = "ui"; + + /** + * Config key for the {@code enabled} setting. + */ + String ENABLED_CONFIG_KEY = "enabled"; + + /** + * Config key for implementation-dependent {@code options} settings. + */ + String OPTIONS_CONFIG_KEY = "options"; + + /** + * Config key for specifying the entire web context where the UI responds. + */ + String WEB_CONTEXT_CONFIG_KEY = "web-context"; + + /** + * Sets implementation-specific UI options. + * + * @param options the options to set for the UI + * @return updated builder + */ + @ConfiguredOption(kind = ConfiguredOption.Kind.MAP) + B options(Map options); + + /** + * Sets whether the UI should be enabled. + * + * @param isEnabled true/false + * @return updated builder + */ + @ConfiguredOption(key = "enabled", value = "true") + B isEnabled(boolean isEnabled); + + /** + * Sets the entire web context (not just the suffix) where the UI response. + * + * @param webContext entire web context (path) where the UI responds + * @return updated builder + */ + @ConfiguredOption(description = "web context (path) where the UI will respond") + B webContext(String webContext); + + /** + * Updates the builder using the specified config node at {@value OPENAPI_UI_CONFIG_KEY} within the + * {@value OpenAPISupport.Builder#CONFIG_KEY} config section. + * + * @param uiConfig config node containing the UI settings + * @return updated builder + */ + default B config(Config uiConfig) { + uiConfig.get(ENABLED_CONFIG_KEY).asBoolean().ifPresent(this::isEnabled); + uiConfig.get(WEB_CONTEXT_CONFIG_KEY).asString().ifPresent(this::webContext); + uiConfig.get(OPTIONS_CONFIG_KEY).asMap().ifPresent(this::options); + return identity(); + } + + /** + * + * @return correctly-typed self + */ + @SuppressWarnings("unchecked") + default B identity() { + return (B) this; + } + + /** + * Assigns how the OpenAPI UI can obtain a formatted document for a given media type. + *

+ * Developers typically do not invoke this method. Helidon invokes it internally. + *

+ * + * @param documentPreparer the function for obtaining the formatted document + * @return updated builder + */ + B documentPreparer(Function documentPreparer); + + /** + * Assigns the web context the {@code OpenAPISupport} instance uses. + *

+ * Developers typically do not invoke this method. Helidon invokes it internally. + *

+ * @param openApiWebContext the web context used by the {@code OpenAPISupport} service + * @return updated builder + */ + B openApiSupportWebContext(String openApiWebContext); + + /** + * Creates a new {@link OpenApiUi} from the builder. + * + * @param documentPreparer function which converts a {@link MediaType} into the corresponding expression of the OpenAPI + * document + * @param openAPIWebContext web context for the OpenAPI instance + * @return new {@code OpenApiUi} + */ + default OpenApiUi build(Function documentPreparer, String openAPIWebContext) { + documentPreparer(documentPreparer); + openApiSupportWebContext(openAPIWebContext); + return build(); + } + } +} diff --git a/openapi/src/main/java/io/helidon/openapi/OpenApiUiBase.java b/openapi/src/main/java/io/helidon/openapi/OpenApiUiBase.java new file mode 100644 index 00000000000..ea93e4a350a --- /dev/null +++ b/openapi/src/main/java/io/helidon/openapi/OpenApiUiBase.java @@ -0,0 +1,240 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.openapi; + +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.ServiceLoader; +import java.util.function.Function; +import java.util.logging.Level; +import java.util.logging.Logger; + +import io.helidon.common.LazyValue; +import io.helidon.common.http.Http; +import io.helidon.common.http.MediaType; +import io.helidon.common.http.Parameters; +import io.helidon.common.serviceloader.HelidonServiceLoader; +import io.helidon.webserver.ServerRequest; +import io.helidon.webserver.ServerResponse; + +/** + * Common base class for implementations of @link OpenApiUi}. + */ +public abstract class OpenApiUiBase implements OpenApiUi { + + private static final Logger LOGGER = Logger.getLogger(OpenApiUiBase.class.getName()); + + private static final LazyValue> UI_FACTORY = LazyValue.create(OpenApiUiBase::loadUiFactory); + + private static final String HTML_PREFIX = """ + + + + + OpenAPI Document + + +
+            """;
+    private static final String HTML_SUFFIX = """
+                    
+ + + """; + private final Map preparedDocuments = new HashMap<>(); + + /** + * + * @return a builder for the currently-available implementation of {@link OpenApiUi}. + */ + static OpenApiUi.Builder builder() { + return UI_FACTORY.get().builder(); + } + + private final boolean isEnabled; + private final Function documentPreparer; + private final String webContext; + private final Map options = new HashMap<>(); + + /** + * Creates a new UI implementation from the specified builder and document preparer. + * + * @param builder the builder containing relevant settings + * @param documentPreparer function returning an OpenAPI document represented as a specified {@link MediaType} + * @param openAPIWebContext final web context for the {@code OpenAPISupport} service + */ + protected OpenApiUiBase(Builder builder, Function documentPreparer, String openAPIWebContext) { + Objects.requireNonNull(builder.documentPreparer, "Builder's documentPreparer must be non-null"); + Objects.requireNonNull(builder.openApiSupportWebContext, + "Builder's OpenAPISupport web context must be non-null"); + this.documentPreparer = documentPreparer; + isEnabled = builder.isEnabled; + webContext = Objects.requireNonNullElse(builder.webContext, + openAPIWebContext + OpenApiUi.UI_WEB_SUBCONTEXT); + options.putAll(builder.options); + } + + /** + * + * @return whether the UI is enabled + */ + protected boolean isEnabled() { + return isEnabled; + } + + /** + * Prepares a representation of the OpenAPI document in the specified media type. + * + * @param mediaType media type in which to express the document + * @return representation of the OpenAPI document + */ + protected String prepareDocument(MediaType mediaType) { + return documentPreparer.apply(mediaType); + } + + /** + * + * @return web context this UI implementation responds at + */ + protected String webContext() { + return webContext; + } + + /** + * + * @return options set for this UI implementation (unmodifiable) + */ + protected Map options() { + return Collections.unmodifiableMap(options); + } + + /** + * Sends a static text response of the given media type. + * + * @param request the request to respond to + * @param response the response + * @param mediaType the {@code MediaType} with which to respond, if possible + * @return whether the implementation responded with a static text response + */ + protected boolean sendStaticText(ServerRequest request, ServerResponse response, MediaType mediaType) { + try { + response + .addHeader(Http.Header.CONTENT_TYPE, mediaType.toString()) + .send(prepareDocument(request.queryParams(), mediaType)); + } catch (IOException e) { + LOGGER.log(Level.WARNING, "Error formatting OpenAPI output as " + mediaType, e); + response.status(Http.Status.INTERNAL_SERVER_ERROR_500) + .send("Error formatting OpenAPI output. See server log."); + } + return true; + } + + private static OpenApiUiFactory loadUiFactory() { + return HelidonServiceLoader.builder(ServiceLoader.load(OpenApiUiFactory.class)) + .addService(OpenApiUiNoOpFactory.create(), Integer.MAX_VALUE) + .build() + .iterator() + .next(); + } + + private String prepareDocument(Parameters queryParameters, MediaType mediaType) throws IOException { + String result = null; + if (preparedDocuments.containsKey(mediaType)) { + return preparedDocuments.get(mediaType); + } + MediaType resultMediaType = queryParameters + .first(OpenAPISupport.OPENAPI_ENDPOINT_FORMAT_QUERY_PARAMETER) + .map(OpenAPISupport.QueryParameterRequestedFormat::chooseFormat) + .map(OpenAPISupport.QueryParameterRequestedFormat::mediaType) + .orElse(mediaType); + + result = prepareDocument(resultMediaType); + if (mediaType.test(MediaType.TEXT_HTML)) { + result = embedInHtml(result); + } + preparedDocuments.put(resultMediaType, result); + return result; + } + + private String embedInHtml(String text) { + return HTML_PREFIX + text + HTML_SUFFIX; + } + + /** + * Common base builder implementation for creating a new {@code OpenApiUi}. + * + * @param type of the {@code OpenApiUiBase} to be built + * @param type of the builder for T + */ + public abstract static class Builder, T extends OpenApiUi> implements OpenApiUi.Builder { + + private final Map options = new HashMap<>(); + private boolean isEnabled = true; + private String webContext; + private Function documentPreparer; + private String openApiSupportWebContext; + + @Override + public B options(Map options) { + this.options.clear(); + this.options.putAll(options); + return identity(); + } + + @Override + public B isEnabled(boolean isEnabled) { + this.isEnabled = isEnabled; + return identity(); + } + + @Override + public B webContext(String webContext) { + this.webContext = webContext; + return identity(); + } + + @Override + public B documentPreparer(Function documentPreparer) { + this.documentPreparer = documentPreparer; + return identity(); + } + + @Override + public B openApiSupportWebContext(String openApiWebContext) { + this.openApiSupportWebContext = openApiWebContext; + return identity(); + } + + /** + * + * @return OpenAPI web context + */ + public String openApiSupportWebContext() { + return openApiSupportWebContext; + } + + /** + * + * @return document preparer + */ + public Function documentPreparer() { + return documentPreparer; + } + } +} diff --git a/openapi/src/main/java/io/helidon/openapi/OpenApiUiFactory.java b/openapi/src/main/java/io/helidon/openapi/OpenApiUiFactory.java new file mode 100644 index 00000000000..28a37ddfc36 --- /dev/null +++ b/openapi/src/main/java/io/helidon/openapi/OpenApiUiFactory.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.openapi; + +/** + * Behavior for factories able to provide new builders of {@link OpenApiUi} instances. + * + * @param type of the {@link OpenApiUi} to be built + * @param type of the builder for T + */ +public interface OpenApiUiFactory, T extends OpenApiUi> { + + /** + * + * @return a builder for the selected type of concrete {@link OpenApiUi}. + */ + B builder(); +} diff --git a/openapi/src/main/java/io/helidon/openapi/OpenApiUiNoOp.java b/openapi/src/main/java/io/helidon/openapi/OpenApiUiNoOp.java new file mode 100644 index 00000000000..01b0927d257 --- /dev/null +++ b/openapi/src/main/java/io/helidon/openapi/OpenApiUiNoOp.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.openapi; + +import io.helidon.common.http.MediaType; +import io.helidon.webserver.Routing; +import io.helidon.webserver.ServerRequest; +import io.helidon.webserver.ServerResponse; + +/** + * Implementation of {@link OpenApiUi} which provides no UI support but simply honors the interface. + */ +class OpenApiUiNoOp implements OpenApiUi { + + private static final MediaType[] SUPPORTED_TEXT_MEDIA_TYPES_AT_OPENAPI_ENDPOINT = new MediaType[0]; + /** + * + * @return new builder for an {@code OpenApiUiNoOp} service + */ + static OpenApiUiNoOp.Builder builder() { + return new Builder(); + } + + private OpenApiUiNoOp(Builder builder) { + } + + @Override + public void update(Routing.Rules rules) { + } + + @Override + public MediaType[] supportedMediaTypes() { + return SUPPORTED_TEXT_MEDIA_TYPES_AT_OPENAPI_ENDPOINT; + } + + @Override + public boolean prepareTextResponseFromMainEndpoint(ServerRequest request, ServerResponse response) { + return false; + } + + static class Builder extends OpenApiUiBase.Builder { + + @Override + public OpenApiUiNoOp build() { + return new OpenApiUiNoOp(this); + } + } +} diff --git a/openapi/src/main/java/io/helidon/openapi/OpenApiUiNoOpFactory.java b/openapi/src/main/java/io/helidon/openapi/OpenApiUiNoOpFactory.java new file mode 100644 index 00000000000..152cd842fef --- /dev/null +++ b/openapi/src/main/java/io/helidon/openapi/OpenApiUiNoOpFactory.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.openapi; + +/** + * Factory providing builders for {@link OpenApiUiNoOp} implementations. + */ +public class OpenApiUiNoOpFactory implements OpenApiUiFactory { + + /** + * + * @return new instance of the factory for a minimal implementation of the UI + */ + static OpenApiUiNoOpFactory create() { + return new OpenApiUiNoOpFactory(); + } + + @Override + public OpenApiUiNoOp.Builder builder() { + return OpenApiUiNoOp.builder(); + } +} diff --git a/openapi/src/main/java/module-info.java b/openapi/src/main/java/module-info.java index 67af68f9778..d33ea797f1f 100644 --- a/openapi/src/main/java/module-info.java +++ b/openapi/src/main/java/module-info.java @@ -14,6 +14,8 @@ * limitations under the License. */ +import io.helidon.openapi.OpenApiUiNoOpFactory; + /** * Helidon SE OpenAPI Support. */ @@ -21,6 +23,7 @@ requires java.logging; requires io.helidon.common; + requires io.helidon.common.serviceloader; requires io.helidon.config; requires io.helidon.media.common; requires io.helidon.media.jsonp; @@ -40,4 +43,8 @@ exports io.helidon.openapi; exports io.helidon.openapi.internal to io.helidon.microprofile.openapi; + + uses io.helidon.openapi.OpenApiUiFactory; + + provides io.helidon.openapi.OpenApiUiFactory with OpenApiUiNoOpFactory; } diff --git a/openapi/src/test/java/io/helidon/openapi/MediaTypeMatcher.java b/openapi/src/test/java/io/helidon/openapi/MediaTypeMatcher.java new file mode 100644 index 00000000000..3405df453f0 --- /dev/null +++ b/openapi/src/test/java/io/helidon/openapi/MediaTypeMatcher.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.openapi; + +import io.helidon.common.http.MediaType; + +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeMatcher; + +/** + * Matcher for {@link io.helidon.common.http.MediaType}. + */ +public class MediaTypeMatcher { + + private MediaTypeMatcher() { + } + + /** + * Matcher for a {@link io.helidon.common.http.MediaType} checking if the {@code test} method returns true. + * + *

+ * Example: + *

+     *         assertThat("Returned media type", myMediaType, test(MediaType.TEXT_HTML));
+     *     
+ *

+ *

+ * Combine with {@link io.helidon.config.testing.OptionalMatcher}: + *

+     *         assertThat("Response media type",
+     *                    response.headers().contentType(),
+     *                    OptionalMatcher.value(test(MediaType.TEXT_HTML)));
+     *     
+ *

+ * @param expectedMediaType expected {@code MediaType} + * @return matcher checking if the {@code test} method for the {@code MediaType} returns true + */ + public static Matcher test(MediaType expectedMediaType) { + return new MediaTypeTest(expectedMediaType); + } + + private static class MediaTypeTest extends TypeSafeMatcher { + private final MediaType expectedMediaType; + + MediaTypeTest(MediaType expectedMediaType) { + this.expectedMediaType = expectedMediaType; + } + + @Override + protected boolean matchesSafely(MediaType item) { + return item.test(expectedMediaType); + } + + @Override + public void describeTo(Description description) { + description.appendText("a MediaType compatible with " + expectedMediaType.toString()); + } + + @Override + protected void describeMismatchSafely(MediaType item, Description mismatchDescription) { + mismatchDescription.appendText("was " + item.toString()); + } + } +} diff --git a/openapi/src/test/java/io/helidon/openapi/ServerTest.java b/openapi/src/test/java/io/helidon/openapi/ServerTest.java index 844c9ed7981..e16cf0ef4a0 100644 --- a/openapi/src/test/java/io/helidon/openapi/ServerTest.java +++ b/openapi/src/test/java/io/helidon/openapi/ServerTest.java @@ -19,6 +19,7 @@ import java.util.ArrayList; import java.util.Map; import java.util.function.Consumer; +import java.util.stream.Stream; import io.helidon.common.http.MediaType; import io.helidon.config.Config; @@ -28,6 +29,8 @@ import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; @@ -145,12 +148,17 @@ public void testGreetingAsConfig() throws Exception { * @throws Exception in case of errors sending the request or receiving the * response */ - @Test - public void checkExplicitResponseMediaTypeViaHeaders() throws Exception { - connectAndConsumePayload(MediaType.APPLICATION_OPENAPI_YAML); - connectAndConsumePayload(MediaType.APPLICATION_YAML); - connectAndConsumePayload(MediaType.APPLICATION_OPENAPI_JSON); - connectAndConsumePayload(MediaType.APPLICATION_JSON); + @ParameterizedTest + @MethodSource() + public void checkExplicitResponseMediaTypeViaHeaders(MediaType testMediaType) throws Exception { + connectAndConsumePayload(testMediaType); + } + + static Stream checkExplicitResponseMediaTypeViaHeaders() { + return Stream.of(MediaType.APPLICATION_OPENAPI_YAML, + MediaType.APPLICATION_YAML, + MediaType.APPLICATION_OPENAPI_JSON, + MediaType.APPLICATION_JSON); } @Test @@ -166,17 +174,6 @@ void checkExplicitResponseMediaTypeViaQueryParameter() throws Exception { MediaType.APPLICATION_OPENAPI_YAML); } - /** - * Makes sure that the response is correct if the request specified no - * explicit Accept. - * - * @throws Exception error sending the request or receiving the response - */ - @Test - public void checkDefaultResponseMediaType() throws Exception { - connectAndConsumePayload(null); - } - @Test public void testTimeAsConfig() throws Exception { commonTestTimeAsConfig(null); diff --git a/openapi/src/test/java/io/helidon/openapi/TestUi.java b/openapi/src/test/java/io/helidon/openapi/TestUi.java new file mode 100644 index 00000000000..204d9633adf --- /dev/null +++ b/openapi/src/test/java/io/helidon/openapi/TestUi.java @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2022 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.openapi; + +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +import io.helidon.common.http.Http; +import io.helidon.common.http.MediaType; +import io.helidon.config.Config; +import io.helidon.config.ConfigSources; +import io.helidon.config.testing.OptionalMatcher; +import io.helidon.webclient.WebClient; +import io.helidon.webclient.WebClientResponse; +import io.helidon.webserver.WebServer; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +public class TestUi { + + private static final String GREETING_OPENAPI_PATH = "/openapi-greeting"; + + private static final Config OPENAPI_CONFIG_DISABLED_CORS = Config.create( + ConfigSources.classpath("serverNoCORS.properties").build()).get(OpenAPISupport.Builder.CONFIG_KEY); + + private static final MediaType[] SIMULATED_BROWSER_ACCEPT = new MediaType[] { + MediaType.TEXT_HTML, + MediaType.APPLICATION_XHTML_XML, + MediaType.builder() + .type(MediaType.APPLICATION_XML.type()) + .subtype(MediaType.APPLICATION_XML.subtype()) + .parameters(Map.of("q", "0.9")) + .build(), + MediaType.builder() + .type("image") + .subtype("webp") + .build(), + MediaType.builder() + .type("image") + .subtype("apng") + .build(), + MediaType.builder() + .type(MediaType.WILDCARD_VALUE) + .subtype(MediaType.WILDCARD_VALUE) + .parameters(Map.of("q", "0.8")) + .build() + }; + + static final Supplier> GREETING_OPENAPI_SUPPORT_BUILDER_SUPPLIER + = () -> OpenAPISupport.builder() + .staticFile("src/test/resources/openapi-greeting.yml") + .webContext(GREETING_OPENAPI_PATH) + .config(OPENAPI_CONFIG_DISABLED_CORS); + + private static final String ALTERNATE_UI_WEB_CONTEXT = "/my-ui"; + static final Supplier> ALTERNATE_UI_WEB_CONTEXT_OPENAPI_SUPPORT_BUILDER_SUPPLIER + = () -> OpenAPISupport.builder() + .staticFile("src/test/resources/openapi-greeting.yml") + .config(OPENAPI_CONFIG_DISABLED_CORS) + .ui(OpenApiUi.builder() + .webContext(ALTERNATE_UI_WEB_CONTEXT)); + + private static WebServer sharedWebServer; + + private static WebClient sharedWebClient; + + @BeforeAll + public static void init() { + sharedWebServer = TestUtil.startServer(GREETING_OPENAPI_SUPPORT_BUILDER_SUPPLIER.get()); + sharedWebClient = WebClient.builder() + .baseUri("http://localhost:" + sharedWebServer.port()) + .build(); + } + + @AfterAll + public static void cleanup() { + sharedWebServer.shutdown(); + } + + @Test + void checkNoOpUi() { + + String path = GREETING_OPENAPI_PATH + OpenApiUi.UI_WEB_SUBCONTEXT; + WebClientResponse response = sharedWebClient.get() + .path(path) + .accept(MediaType.TEXT_HTML) + .submit() + .await(15, TimeUnit.SECONDS); + + assertThat("Request to " + path + " response status", + response.status().code(), + is(Http.Status.NOT_FOUND_404.code())); + } + + @Test + void checkSimulatedBrowserAccessToMainEndpoint() throws Exception { + WebClientResponse response = sharedWebClient.get() + .path(GREETING_OPENAPI_PATH) + .accept(SIMULATED_BROWSER_ACCEPT) + .submit() + .await(15, TimeUnit.SECONDS); + + assertThat("Status simulating browser to main endpoint", + response.status().code(), + is(Http.Status.OK_200.code())); + assertThat("Content-Type", + response.headers().contentType(), + OptionalMatcher.value(is(OpenAPISupport.DEFAULT_RESPONSE_MEDIA_TYPE))); + } + + @Test + void checkAlternateUiWebContext() throws ExecutionException, InterruptedException { + WebServer ws = TestUtil.startServer(ALTERNATE_UI_WEB_CONTEXT_OPENAPI_SUPPORT_BUILDER_SUPPLIER.get()); + try { + WebClient wc = WebClient.builder() + .baseUri("http://localhost:" + ws.port()) + .build(); + + WebClientResponse response = wc.get() + .path(ALTERNATE_UI_WEB_CONTEXT) + .accept(MediaType.TEXT_PLAIN) + .submit() + .await(Duration.ofSeconds(15)); + assertThat("Request to " + ALTERNATE_UI_WEB_CONTEXT + " status", + response.status().code(), + is(Http.Status.NOT_FOUND_404.code())); + + } finally { + ws.shutdown(); + } + } +}