diff --git a/bom/pom.xml b/bom/pom.xml index 88ddd25551d..9baf6465362 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -910,6 +910,11 @@ helidon-openapi ${helidon.version} + + io.helidon.openapi + helidon-openapi-ui + ${helidon.version} + io.helidon.microprofile.openapi helidon-microprofile-openapi diff --git a/common/testing/junit5/src/main/java/io/helidon/common/testing/junit5/MapMatcher.java b/common/testing/junit5/src/main/java/io/helidon/common/testing/junit5/MapMatcher.java new file mode 100644 index 00000000000..0358078ae48 --- /dev/null +++ b/common/testing/junit5/src/main/java/io/helidon/common/testing/junit5/MapMatcher.java @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2023 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.common.testing.junit5; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeMatcher; + +/** + * Hamcrest matchers for {@link java.util.Map}. + */ +public final class MapMatcher { + private MapMatcher() { + } + + /** + * A matcher that performs {@link java.util.Map} deep equality. + *

+ * Usage example: + *

+     *     assertThat(actualMap, mapEqualTo(expectedMap));
+     * 
+ * + * This method targets trees implemented using {@link java.util.Map} where values of type {@link java.util.Map} + * are considered tree nodes, and values of any other type are considered leaf nodes. + *

+ * The deep-equality is performed by diffing a flat string representation of each map. If the diff yields no differences, + * the maps are considered deeply equal. + *

+ * The entries are compared using strings, both keys and leaf nodes must implement {@link Object#toString()}. + * + * @param expected expected map + * @param type of the map keys + * @param type of the map values + * @return matcher validating the {@link java.util.Map} is deeply equal + */ + public static Matcher> mapEqualTo(Map expected) { + return new DiffMatcher<>(expected); + } + + private static final class DiffMatcher extends TypeSafeMatcher> { + + private final Map expected; + private volatile Map actual; + private volatile List diffs; + + private DiffMatcher(Map expected) { + this.expected = expected; + } + + @Override + protected boolean matchesSafely(Map actual) { + this.actual = actual; + this.diffs = diffs(expected, actual); + return diffs.isEmpty(); + } + + @Override + public void describeTo(Description description) { + description.appendText("deep map equality"); + } + + @Override + protected void describeMismatchSafely(Map item, Description mismatchDescription) { + List diffs = actual == item ? this.diffs : diffs(expected, item); + mismatchDescription.appendText("found differences" + System.lineSeparator()) + .appendText(String.join(System.lineSeparator(), diffs.stream().map(Diff::toString).toList())); + } + + private static List diffs(Map left, Map right) { + List diffs = new ArrayList<>(); + Iterator> leftEntries = flattenEntries(left, "").iterator(); + Iterator> rightEntries = flattenEntries(right, "").iterator(); + while (true) { + boolean hasLeft = leftEntries.hasNext(); + boolean hasRight = rightEntries.hasNext(); + if (hasLeft && hasRight) { + Map.Entry leftEntry = leftEntries.next(); + Map.Entry rightEntry = rightEntries.next(); + if (!leftEntry.equals(rightEntry)) { + diffs.add(new Diff(leftEntry, rightEntry)); + } + } else if (hasLeft) { + diffs.add(new Diff(leftEntries.next(), null)); + } else if (hasRight) { + diffs.add(new Diff(null, rightEntries.next())); + } else { + return diffs; + } + } + } + + private static List> flattenEntries(Map map, String prefix) { + List> result = new ArrayList<>(); + for (Map.Entry entry : map.entrySet()) { + if (entry.getValue() instanceof Map node) { + result.addAll(flattenEntries(node, prefix + entry.getKey() + ".")); + } else { + result.add(Map.entry(prefix + entry.getKey(), entry.getValue().toString())); + } + } + result.sort(Map.Entry.comparingByKey()); + return result; + } + + private record Diff(Map.Entry left, Map.Entry right) { + + @Override + public String toString() { + if (left == null && right != null) { + return "ADDED >> " + right; + } + if (left != null && right == null) { + return "REMOVED << " + left; + } + if (left != null) { + return "ADDED >> " + left + System.lineSeparator() + "REMOVED << " + right; + } + return "?"; + } + } + } +} diff --git a/common/testing/junit5/src/test/java/io/helidon/common/testing/MapMatcherTest.java b/common/testing/junit5/src/test/java/io/helidon/common/testing/MapMatcherTest.java new file mode 100644 index 00000000000..73e79168f4e --- /dev/null +++ b/common/testing/junit5/src/test/java/io/helidon/common/testing/MapMatcherTest.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2023 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.common.testing; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import static io.helidon.common.testing.junit5.MapMatcher.mapEqualTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.not; + +class MapMatcherTest { + + @Test + void testIsMapEqual() { + assertThat(Map.of("foo", "bar"), is(mapEqualTo(Map.of("foo", "bar")))); + assertThat(Map.of("bar", "foo"), is(not(mapEqualTo(Map.of("foo", "bar"))))); + + assertThat(Map.of("foo", Map.of("bar", Map.of("bob", "alice"))), + is(mapEqualTo(Map.of("foo", Map.of("bar", Map.of("bob", "alice")))))); + + assertThat(Map.of("foo", Map.of("bar", Map.of("bob", "alice"))), + is(not(mapEqualTo(Map.of("foo", Map.of("bar", Map.of("bob", "not-alice"))))))); + + assertThat(Map.of("foo", "bar", "bob", "alice"), is(mapEqualTo(Map.of("bob", "alice", "foo", "bar")))); + } +} diff --git a/docs/config/config_reference.adoc b/docs/config/config_reference.adoc index 08afecfb613..1a905d03238 100644 --- a/docs/config/config_reference.adoc +++ b/docs/config/config_reference.adoc @@ -28,7 +28,6 @@ The following section lists all configurable types in Helidon. - xref:{rootdir}/config/io_helidon_common_configurable_AllowList.adoc[AllowList (common.configurable)] - xref:{rootdir}/config/io_helidon_faulttolerance_Async.adoc[Async (faulttolerance)] - xref:{rootdir}/config/io_helidon_security_providers_oidc_common_BaseBuilder.adoc[BaseBuilder (security.providers.oidc.common)] -- xref:{rootdir}/config/io_helidon_openapi_OpenApiUi_Builder.adoc[Builder (openapi.OpenApiUi)] - xref:{rootdir}/config/io_helidon_security_providers_idcs_mapper_IdcsRoleMapperProviderBase_Builder.adoc[Builder (security.providers.idcs.mapper.IdcsRoleMapperProviderBase)] - xref:{rootdir}/config/io_helidon_webserver_servicecommon_HelidonFeatureSupport_Builder.adoc[Builder (webserver.servicecommon.HelidonFeatureSupport)] - xref:{rootdir}/config/io_helidon_faulttolerance_Bulkhead.adoc[Bulkhead (faulttolerance)] @@ -79,7 +78,8 @@ The following section lists all configurable types in Helidon. - xref:{rootdir}/config/io_helidon_common_configurable_ScheduledThreadPoolSupplier.adoc[ScheduledThreadPoolSupplier (common.configurable)] - xref:{rootdir}/config/io_helidon_metrics_api_ScopeConfig.adoc[ScopeConfig (metrics.api)] - xref:{rootdir}/config/io_helidon_metrics_api_ScopingConfig.adoc[ScopingConfig (metrics.api)] -- xref:{rootdir}/config/io_helidon_openapi_SeOpenApiFeature.adoc[SeOpenApiFeature (openapi)] +- xref:{rootdir}/config/io_helidon_openapi_OpenApiFeature.adoc[OpenApiFeature (openapi)] +- xref:{rootdir}/config/io_helidon_openapi_ui_OpenApiUi.adoc[OpenApiUi (openapi.ui)] - xref:{rootdir}/config/io_helidon_security_Security.adoc[Security (security)] - xref:{rootdir}/config/io_helidon_security_SecurityTime.adoc[SecurityTime (security)] - xref:{rootdir}/config/io_helidon_microprofile_server_Server.adoc[Server (microprofile.server)] @@ -99,3 +99,4 @@ The following section lists all configurable types in Helidon. - xref:{rootdir}/config/io_helidon_tracing_providers_zipkin_ZipkinTracerBuilder.adoc[ZipkinTracerBuilder (tracing.providers.zipkin)] - xref:{rootdir}/config/io_opentracing_Tracer.adoc[io_opentracing_Tracer] - xref:{rootdir}/config/org_eclipse_microprofile_config_Config.adoc[org_eclipse_microprofile_config_Config] +- xref:{rootdir}/config/io_helidon_microprofile_openapi_MpOpenApiManagerConfig.adoc[MpOpenApiManagerConfig (microprofile.openapi)] diff --git a/docs/config/io_helidon_microprofile_openapi_MpOpenApiManagerConfig.adoc b/docs/config/io_helidon_microprofile_openapi_MpOpenApiManagerConfig.adoc new file mode 100644 index 00000000000..236a5fdc271 --- /dev/null +++ b/docs/config/io_helidon_microprofile_openapi_MpOpenApiManagerConfig.adoc @@ -0,0 +1,52 @@ +/////////////////////////////////////////////////////////////////////////////// + + Copyright (c) 2023 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.microprofile.openapi.MpOpenApiManagerConfig +:keywords: helidon, config, io.helidon.microprofile.openapi.MpOpenApiManagerConfig +:basic-table-intro: The table below lists the configuration keys that configure io.helidon.microprofile.openapi.MpOpenApiManagerConfig +include::{rootdir}/includes/attributes.adoc[] + += MpOpenApiManagerConfig (microprofile.openapi) Configuration + +// tag::config[] + + +Type: link:{javadoc-base-url}/io.helidon.microprofile.openapi/io/helidon/microprofile/openapi/MpOpenApiManagerConfig.html[io.helidon.microprofile.openapi.MpOpenApiManagerConfig] + + + + +== Configuration options + + + +.Optional configuration options +[cols="3,3a,2,5a"] + +|=== +|key |type |default value |description + +|`mp.openapi.extensions.helidon.use-jaxrs-semantics` |boolean |{nbsp} |If `true` and the `jakarta.ws.rs.core.Application` class returns a non-empty set, endpoints defined by + other resources are not included in the OpenAPI document. + + @return `true` if enabled, `false` otherwise + +|=== + +// end::config[] \ No newline at end of file diff --git a/docs/config/io_helidon_openapi_OpenApiFeature.adoc b/docs/config/io_helidon_openapi_OpenApiFeature.adoc new file mode 100644 index 00000000000..2f1f044eb15 --- /dev/null +++ b/docs/config/io_helidon_openapi_OpenApiFeature.adoc @@ -0,0 +1,68 @@ +/////////////////////////////////////////////////////////////////////////////// + + Copyright (c) 2023 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.OpenApiFeature +:keywords: helidon, config, io.helidon.openapi.OpenApiFeature +:basic-table-intro: The table below lists the configuration keys that configure io.helidon.openapi.OpenApiFeature +include::{rootdir}/includes/attributes.adoc[] + += OpenApiFeature (openapi) Configuration + +// tag::config[] + + +Type: link:{javadoc-base-url}/io.helidon.openapi/io/helidon/openapi/OpenApiFeature.html[io.helidon.openapi.OpenApiFeature] + + +This is a standalone configuration type, prefix from configuration root: `openapi` + + + +== Configuration options + + + +.Optional configuration options +[cols="3,3a,2,5a"] + +|=== +|key |type |default value |description + +|`cors` |xref:{rootdir}/config/io_helidon_cors_CrossOriginConfig.adoc[CrossOriginConfig] |{nbsp} |CORS config. + + @return CORS config +|`enabled` |boolean |`true` |Sets whether the feature should be enabled. + + @return `true` if enabled, `false` otherwise +|`manager` |io.helidon.openapi.OpenApiManager (service provider interface) |{nbsp} |OpenAPI manager. + + @return the OpenAPI manager +|`services` |io.helidon.openapi.OpenApiService[] (service provider interface) |{nbsp} |OpenAPI services. + + @return the OpenAPI services +|`static-file` |string |{nbsp} |Path of the static OpenAPI document file. Default types are `json`, `yaml`, and `yml`. + + @return location of the static OpenAPI document file +|`web-context` |string |`/openapi` |Web context path for the OpenAPI endpoint. + + @return webContext to use + +|=== + +// end::config[] \ No newline at end of file diff --git a/docs/config/io_helidon_openapi_OpenApiUi_Builder.adoc b/docs/config/io_helidon_openapi_OpenApiUi_Builder.adoc index 659716b1275..e1319dbefaa 100644 --- a/docs/config/io_helidon_openapi_OpenApiUi_Builder.adoc +++ b/docs/config/io_helidon_openapi_OpenApiUi_Builder.adoc @@ -1,6 +1,6 @@ /////////////////////////////////////////////////////////////////////////////// - Copyright (c) 2023 Oracle and/or its affiliates. + Copyright (c) 2022, 2023 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,17 +17,17 @@ /////////////////////////////////////////////////////////////////////////////// ifndef::rootdir[:rootdir: {docdir}/..] -:description: Configuration of io.helidon.openapi.OpenApiUi.Builder -:keywords: helidon, config, io.helidon.openapi.OpenApiUi.Builder -:basic-table-intro: The table below lists the configuration keys that configure io.helidon.openapi.OpenApiUi.Builder +: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[] -= Builder (openapi.OpenApiUi) Configuration += OpenApiUi (openapi) Configuration // tag::config[] -Type: link:{javadoc-base-url}/io.helidon.openapi.OpenApiUi/io/helidon/openapi/OpenApiUi/Builder.html[io.helidon.openapi.OpenApiUi.Builder] +Type: link:{javadoc-base-url}/io.helidon.openapi/io/helidon/openapi/OpenApiUi.html[io.helidon.openapi.OpenApiUi] [source,text] @@ -49,7 +49,7 @@ ui |key |type |default value |description |`enabled` |boolean |`true` |Sets whether the UI should be enabled. -|`options` |Map<string, string> |{nbsp} |Merges implementation-specific UI options. +|`options` |Map<string, string> |{nbsp} |Sets implementation-specific UI options. |`web-context` |string |{nbsp} |web context (path) where the UI will respond |=== diff --git a/docs/config/io_helidon_openapi_SeOpenApiFeature.adoc b/docs/config/io_helidon_openapi_ui_OpenApiUi.adoc similarity index 61% rename from docs/config/io_helidon_openapi_SeOpenApiFeature.adoc rename to docs/config/io_helidon_openapi_ui_OpenApiUi.adoc index b85d8584d6b..9db0e32dd62 100644 --- a/docs/config/io_helidon_openapi_SeOpenApiFeature.adoc +++ b/docs/config/io_helidon_openapi_ui_OpenApiUi.adoc @@ -17,21 +17,19 @@ /////////////////////////////////////////////////////////////////////////////// ifndef::rootdir[:rootdir: {docdir}/..] -:description: Configuration of io.helidon.openapi.SeOpenApiFeature -:keywords: helidon, config, io.helidon.openapi.SeOpenApiFeature -:basic-table-intro: The table below lists the configuration keys that configure io.helidon.openapi.SeOpenApiFeature +:description: Configuration of io.helidon.openapi.ui.OpenApiUi +:keywords: helidon, config, io.helidon.openapi.ui.OpenApiUi +:basic-table-intro: The table below lists the configuration keys that configure io.helidon.openapi.ui.OpenApiUi include::{rootdir}/includes/attributes.adoc[] -= SeOpenApiFeature (openapi) Configuration += OpenApiUi (openapi.ui) Configuration // tag::config[] -Type: link:{javadoc-base-url}/io.helidon.openapi/io/helidon/openapi/SeOpenApiFeature.html[io.helidon.openapi.SeOpenApiFeature] +Type: link:{javadoc-base-url}/io.helidon.openapi.ui/io/helidon/openapi/ui/OpenApiUi.html[io.helidon.openapi.ui.OpenApiUi] -This is a standalone configuration type, prefix from configuration root: `openapi` - == Configuration options @@ -44,8 +42,15 @@ This is a standalone configuration type, prefix from configuration root: `openap |=== |key |type |default value |description -|`cross-origin-config` |xref:{rootdir}/config/io_helidon_cors_CrossOriginConfig.adoc[CrossOriginConfig] |{nbsp} |Set the CORS config from the specified `CrossOriginConfig` object. -|`web-context` |string |{nbsp} |Set the root context for the REST API of the service. +|`enabled` |boolean |`true` |Sets whether the service should be enabled. + + @return `true` if enabled, `false` otherwise +|`options` |Map<string, string> |{nbsp} |Merges implementation-specific UI options. + + @return options for the UI to merge +|`web-context` |string |{nbsp} |Full web context (not just the suffix). + + @return full web context path |=== diff --git a/docs/includes/openapi/openapi-ui.adoc b/docs/includes/openapi/openapi-ui.adoc index 571ad56610b..7153db99887 100644 --- a/docs/includes/openapi/openapi-ui.adoc +++ b/docs/includes/openapi/openapi-ui.adoc @@ -59,8 +59,8 @@ include::{rootdir}/includes/dependencies.adoc[] [source,xml,subs=+macros] ---- - io.helidon.integrations.openapi-ui - helidon-integrations-openapi-ui + io.helidon.openapi + helidon-openapi-ui ifdef::mp-flavor[ runtime] ---- diff --git a/docs/mp/openapi/openapi.adoc b/docs/mp/openapi/openapi.adoc index 78700f59f86..c63618fcd94 100644 --- a/docs/mp/openapi/openapi.adoc +++ b/docs/mp/openapi/openapi.adoc @@ -92,10 +92,10 @@ include::{incdir}/openapi.adoc[tag=api] == Examples -Helidon MP includes a link:{helidon-github-tree-url}/examples/microprofile/openapi-basic[complete OpenAPI example] +Helidon MP includes a link:{helidon-github-tree-url}/examples/microprofile/openapi[complete OpenAPI example] based on the MP quick-start sample app. The rest of this section shows, step-by-step, how one might change the original QuickStart service to adopt OpenAPI. -=== Helidon MP Basic OpenAPI Example +=== Helidon MP OpenAPI Example This example shows a simple greeting application, similar to the one from the Helidon MP QuickStart, enhanced with OpenAPI support. @@ -228,8 +228,8 @@ Having written the filter and model reader classes, identify them by adding conf [source,properties] ---- -mp.openapi.filter=io.helidon.microprofile.examples.openapi.basic.internal.SimpleAPIFilter -mp.openapi.model.reader=io.helidon.microprofile.examples.openapi.basic.internal.SimpleAPIModelReader +mp.openapi.filter=io.helidon.microprofile.examples.openapi.internal.SimpleAPIFilter +mp.openapi.model.reader=io.helidon.microprofile.examples.openapi.internal.SimpleAPIModelReader ---- @@ -238,7 +238,7 @@ Now just build and run: [source,bash] ---- mvn package -java -jar target/helidon-examples-microprofile-openapi-basic.jar +java -jar target/helidon-examples-microprofile-openapi.jar ---- Try the endpoints: @@ -255,7 +255,7 @@ curl -X GET http://localhost:8080/openapi The output describes not only then endpoints from `GreetResource` but also one contributed by the `SimpleAPIModelReader`. -Full example is available link:{helidon-github-tree-url}}/examples/microprofile/openapi-basic[in our official repository] +Full example is available link:{helidon-github-tree-url}}/examples/microprofile/openapi[in our official repository] == Additional Information diff --git a/docs/se/openapi.adoc b/docs/se/openapi.adoc index f4773b3339a..ee7811d00e9 100644 --- a/docs/se/openapi.adoc +++ b/docs/se/openapi.adoc @@ -89,7 +89,7 @@ Helidon {flavor-uc} provides an API for creating and setting up the REST endpoin Helidon SE OpenAPI configuration supports the following settings: -include::{rootdir}/config/io_helidon_openapi_SeOpenApiFeature.adoc[leveloffset=+1,tag=config] +include::{rootdir}/config/io_helidon_openapi_OpenApiFeature.adoc[leveloffset=+1,tag=config] diff --git a/docs/se/openapi/openapi.adoc b/docs/se/openapi/openapi.adoc index 891bb506635..6e8aeba2063 100644 --- a/docs/se/openapi/openapi.adoc +++ b/docs/se/openapi/openapi.adoc @@ -92,7 +92,7 @@ Helidon {flavor-uc} provides an API for creating and setting up the REST endpoin Helidon SE OpenAPI configuration supports the following settings: -include::{rootdir}/config/io_helidon_openapi_SeOpenApiFeature.adoc[leveloffset=+1,tag=config] +include::{rootdir}/config/io_helidon_openapi_OpenApiFeature.adoc[leveloffset=+1,tag=config] diff --git a/examples/microprofile/openapi-basic/src/main/java/io/helidon/microprofile/examples/openapi/basic/internal/SimpleAPIFilter.java b/examples/microprofile/openapi-basic/src/main/java/io/helidon/microprofile/examples/openapi/basic/internal/SimpleAPIFilter.java deleted file mode 100644 index e06f133bc20..00000000000 --- a/examples/microprofile/openapi-basic/src/main/java/io/helidon/microprofile/examples/openapi/basic/internal/SimpleAPIFilter.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright (c) 2019, 2021 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.microprofile.examples.openapi.basic.internal; - -import java.util.Map; - -import org.eclipse.microprofile.openapi.OASFilter; -import org.eclipse.microprofile.openapi.models.Operation; -import org.eclipse.microprofile.openapi.models.PathItem; - -/** - * Example OpenAPI filter which hides a single endpoint from the OpenAPI document. - */ -public class SimpleAPIFilter implements OASFilter { - - @Override - public PathItem filterPathItem(PathItem pathItem) { - for (Map.Entry methodOp - : pathItem.getOperations().entrySet()) { - if (SimpleAPIModelReader.DOOMED_OPERATION_ID - .equals(methodOp.getValue().getOperationId())) { - return null; - } - } - return OASFilter.super.filterPathItem(pathItem); - } -} diff --git a/examples/microprofile/openapi-basic/src/main/java/io/helidon/microprofile/examples/openapi/basic/internal/SimpleAPIModelReader.java b/examples/microprofile/openapi-basic/src/main/java/io/helidon/microprofile/examples/openapi/basic/internal/SimpleAPIModelReader.java deleted file mode 100644 index 59ccf9ca8dc..00000000000 --- a/examples/microprofile/openapi-basic/src/main/java/io/helidon/microprofile/examples/openapi/basic/internal/SimpleAPIModelReader.java +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright (c) 2019, 2021 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.microprofile.examples.openapi.basic.internal; - -import org.eclipse.microprofile.openapi.OASFactory; -import org.eclipse.microprofile.openapi.OASModelReader; -import org.eclipse.microprofile.openapi.models.OpenAPI; -import org.eclipse.microprofile.openapi.models.PathItem; -import org.eclipse.microprofile.openapi.models.Paths; - -/** - * Defines two paths using the OpenAPI model reader mechanism, one that should - * be suppressed by the filter class and one that should appear in the published - * OpenAPI document. - */ -public class SimpleAPIModelReader implements OASModelReader { - - /** - * Path for the example endpoint added by this model reader that should be visible. - */ - public static final String MODEL_READER_PATH = "/test/newpath"; - - /** - * Path for an endpoint that the filter should hide. - */ - public static final String DOOMED_PATH = "/test/doomed"; - - /** - * ID for an endpoint that the filter should hide. - */ - public static final String DOOMED_OPERATION_ID = "doomedPath"; - - /** - * Summary text for the endpoint. - */ - public static final String SUMMARY = "A sample test endpoint from ModelReader"; - - @Override - public OpenAPI buildModel() { - /* - * Add two path items, one of which we expect to be removed by - * the filter and a very simple one that will appear in the - * published OpenAPI document. - */ - PathItem newPathItem = OASFactory.createPathItem() - .GET(OASFactory.createOperation() - .operationId("newPath") - .summary(SUMMARY)); - PathItem doomedPathItem = OASFactory.createPathItem() - .GET(OASFactory.createOperation() - .operationId(DOOMED_OPERATION_ID) - .summary("This should become invisible")); - OpenAPI openAPI = OASFactory.createOpenAPI(); - Paths paths = OASFactory.createPaths() - .addPathItem(MODEL_READER_PATH, newPathItem) - .addPathItem(DOOMED_PATH, doomedPathItem); - openAPI.paths(paths); - - return openAPI; - } -} diff --git a/examples/microprofile/openapi-basic/README.md b/examples/microprofile/openapi/README.md similarity index 87% rename from examples/microprofile/openapi-basic/README.md rename to examples/microprofile/openapi/README.md index b44070fce1f..1fdefe9737a 100644 --- a/examples/microprofile/openapi-basic/README.md +++ b/examples/microprofile/openapi/README.md @@ -1,4 +1,4 @@ -# Helidon MP Basic OpenAPI Example +# Helidon MP OpenAPI Example This example shows a simple greeting application, similar to the one from the Helidon MP QuickStart, enhanced with OpenAPI support. @@ -7,7 +7,7 @@ Helidon MP QuickStart, enhanced with OpenAPI support. ```bash mvn package -java -jar target/helidon-examples-microprofile-openapi-basic.jar +java -jar target/helidon-examples-microprofile-openapi.jar ``` Try the endpoints: diff --git a/examples/microprofile/openapi-basic/pom.xml b/examples/microprofile/openapi/pom.xml similarity index 90% rename from examples/microprofile/openapi-basic/pom.xml rename to examples/microprofile/openapi/pom.xml index 797c9e6e82b..11c51673119 100644 --- a/examples/microprofile/openapi-basic/pom.xml +++ b/examples/microprofile/openapi/pom.xml @@ -28,12 +28,8 @@ ../../../applications/mp/pom.xml io.helidon.examples.microprofile - helidon-examples-microprofile-openapi-basic - Helidon Examples Microprofile Basic OpenAPI - - - Microprofile example showing basic OpenAPI support - + helidon-examples-microprofile-openapi + Helidon Examples Microprofile OpenAPI @@ -65,6 +61,11 @@ hamcrest-all test + + io.helidon.microprofile.testing + helidon-microprofile-testing-junit5 + test + diff --git a/examples/microprofile/openapi-basic/src/main/java/io/helidon/microprofile/examples/openapi/basic/GreetResource.java b/examples/microprofile/openapi/src/main/java/io/helidon/microprofile/examples/openapi/GreetResource.java similarity index 97% rename from examples/microprofile/openapi-basic/src/main/java/io/helidon/microprofile/examples/openapi/basic/GreetResource.java rename to examples/microprofile/openapi/src/main/java/io/helidon/microprofile/examples/openapi/GreetResource.java index ad2259d5142..fb9d82629a5 100644 --- a/examples/microprofile/openapi-basic/src/main/java/io/helidon/microprofile/examples/openapi/basic/GreetResource.java +++ b/examples/microprofile/openapi/src/main/java/io/helidon/microprofile/examples/openapi/GreetResource.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.helidon.microprofile.examples.openapi.basic; +package io.helidon.microprofile.examples.openapi; import jakarta.enterprise.context.RequestScoped; import jakarta.inject.Inject; @@ -50,7 +50,7 @@ * * Note that the output will include not only the annotated endpoints from this * class but also an endpoint added by the - * {@link io.helidon.microprofile.examples.openapi.basic.internal.SimpleAPIModelReader}. + * {@link io.helidon.microprofile.examples.openapi.internal.SimpleAPIModelReader}. * * The message is returned as a JSON object. */ diff --git a/examples/microprofile/openapi-basic/src/main/java/io/helidon/microprofile/examples/openapi/basic/GreetingMessage.java b/examples/microprofile/openapi/src/main/java/io/helidon/microprofile/examples/openapi/GreetingMessage.java similarity index 96% rename from examples/microprofile/openapi-basic/src/main/java/io/helidon/microprofile/examples/openapi/basic/GreetingMessage.java rename to examples/microprofile/openapi/src/main/java/io/helidon/microprofile/examples/openapi/GreetingMessage.java index 76c361b1e07..84d9e9e98c0 100644 --- a/examples/microprofile/openapi-basic/src/main/java/io/helidon/microprofile/examples/openapi/basic/GreetingMessage.java +++ b/examples/microprofile/openapi/src/main/java/io/helidon/microprofile/examples/openapi/GreetingMessage.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.helidon.microprofile.examples.openapi.basic; +package io.helidon.microprofile.examples.openapi; /** * POJO defining the greeting message content. diff --git a/examples/microprofile/openapi-basic/src/main/java/io/helidon/microprofile/examples/openapi/basic/GreetingProvider.java b/examples/microprofile/openapi/src/main/java/io/helidon/microprofile/examples/openapi/GreetingProvider.java similarity index 92% rename from examples/microprofile/openapi-basic/src/main/java/io/helidon/microprofile/examples/openapi/basic/GreetingProvider.java rename to examples/microprofile/openapi/src/main/java/io/helidon/microprofile/examples/openapi/GreetingProvider.java index 4f08e014349..de2f56e1e88 100644 --- a/examples/microprofile/openapi-basic/src/main/java/io/helidon/microprofile/examples/openapi/basic/GreetingProvider.java +++ b/examples/microprofile/openapi/src/main/java/io/helidon/microprofile/examples/openapi/GreetingProvider.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019, 2021 Oracle and/or its affiliates. + * Copyright (c) 2019, 2023 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. @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.helidon.microprofile.examples.openapi.basic; +package io.helidon.microprofile.examples.openapi; import java.util.concurrent.atomic.AtomicReference; diff --git a/examples/openapi/src/main/java/io/helidon/examples/openapi/internal/SimpleAPIFilter.java b/examples/microprofile/openapi/src/main/java/io/helidon/microprofile/examples/openapi/internal/SimpleAPIFilter.java similarity index 91% rename from examples/openapi/src/main/java/io/helidon/examples/openapi/internal/SimpleAPIFilter.java rename to examples/microprofile/openapi/src/main/java/io/helidon/microprofile/examples/openapi/internal/SimpleAPIFilter.java index 43b21000f1b..8b68da5c7cd 100644 --- a/examples/openapi/src/main/java/io/helidon/examples/openapi/internal/SimpleAPIFilter.java +++ b/examples/microprofile/openapi/src/main/java/io/helidon/microprofile/examples/openapi/internal/SimpleAPIFilter.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019, 2021 Oracle and/or its affiliates. + * Copyright (c) 2019, 2023 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. @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.helidon.examples.openapi.internal; +package io.helidon.microprofile.examples.openapi.internal; import java.util.Map; diff --git a/examples/openapi/src/main/java/io/helidon/examples/openapi/internal/SimpleAPIModelReader.java b/examples/microprofile/openapi/src/main/java/io/helidon/microprofile/examples/openapi/internal/SimpleAPIModelReader.java similarity index 95% rename from examples/openapi/src/main/java/io/helidon/examples/openapi/internal/SimpleAPIModelReader.java rename to examples/microprofile/openapi/src/main/java/io/helidon/microprofile/examples/openapi/internal/SimpleAPIModelReader.java index 856e5fb2948..b88717f377a 100644 --- a/examples/openapi/src/main/java/io/helidon/examples/openapi/internal/SimpleAPIModelReader.java +++ b/examples/microprofile/openapi/src/main/java/io/helidon/microprofile/examples/openapi/internal/SimpleAPIModelReader.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019, 2021 Oracle and/or its affiliates. + * Copyright (c) 2019, 2023 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. @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.helidon.examples.openapi.internal; +package io.helidon.microprofile.examples.openapi.internal; import org.eclipse.microprofile.openapi.OASFactory; import org.eclipse.microprofile.openapi.OASModelReader; diff --git a/examples/microprofile/openapi-basic/src/main/java/io/helidon/microprofile/examples/openapi/basic/internal/package-info.java b/examples/microprofile/openapi/src/main/java/io/helidon/microprofile/examples/openapi/internal/package-info.java similarity index 83% rename from examples/microprofile/openapi-basic/src/main/java/io/helidon/microprofile/examples/openapi/basic/internal/package-info.java rename to examples/microprofile/openapi/src/main/java/io/helidon/microprofile/examples/openapi/internal/package-info.java index 8949725e1ec..76e52456f8a 100644 --- a/examples/microprofile/openapi-basic/src/main/java/io/helidon/microprofile/examples/openapi/basic/internal/package-info.java +++ b/examples/microprofile/openapi/src/main/java/io/helidon/microprofile/examples/openapi/internal/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019, 2021 Oracle and/or its affiliates. + * Copyright (c) 2019, 2023 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,4 @@ /** * Internal classes supporting Helidon MP OpenAPI. */ -package io.helidon.microprofile.examples.openapi.basic.internal; +package io.helidon.microprofile.examples.openapi.internal; diff --git a/examples/microprofile/openapi-basic/src/main/java/io/helidon/microprofile/examples/openapi/basic/package-info.java b/examples/microprofile/openapi/src/main/java/io/helidon/microprofile/examples/openapi/package-info.java similarity index 78% rename from examples/microprofile/openapi-basic/src/main/java/io/helidon/microprofile/examples/openapi/basic/package-info.java rename to examples/microprofile/openapi/src/main/java/io/helidon/microprofile/examples/openapi/package-info.java index cf5f401b072..db5a831b38d 100644 --- a/examples/microprofile/openapi-basic/src/main/java/io/helidon/microprofile/examples/openapi/basic/package-info.java +++ b/examples/microprofile/openapi/src/main/java/io/helidon/microprofile/examples/openapi/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019, 2021 Oracle and/or its affiliates. + * Copyright (c) 2019, 2023 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. @@ -15,6 +15,6 @@ */ /** - * Helidon MicroProfile OpenAPI basic example. + * Helidon MicroProfile OpenAPI example. */ -package io.helidon.microprofile.examples.openapi.basic; +package io.helidon.microprofile.examples.openapi; diff --git a/examples/microprofile/openapi-basic/src/main/resources/META-INF/beans.xml b/examples/microprofile/openapi/src/main/resources/META-INF/beans.xml similarity index 94% rename from examples/microprofile/openapi-basic/src/main/resources/META-INF/beans.xml rename to examples/microprofile/openapi/src/main/resources/META-INF/beans.xml index f2f827007a8..7785b13b791 100644 --- a/examples/microprofile/openapi-basic/src/main/resources/META-INF/beans.xml +++ b/examples/microprofile/openapi/src/main/resources/META-INF/beans.xml @@ -1,7 +1,7 @@ - - org.eclipse.microprofile.openapi - microprofile-openapi-api - - io.helidon.webserver helidon-webserver @@ -120,5 +112,4 @@ - diff --git a/examples/openapi/src/main/java/io/helidon/examples/openapi/Main.java b/examples/openapi/src/main/java/io/helidon/examples/openapi/Main.java index 918844a889f..4627d7b0645 100644 --- a/examples/openapi/src/main/java/io/helidon/examples/openapi/Main.java +++ b/examples/openapi/src/main/java/io/helidon/examples/openapi/Main.java @@ -73,8 +73,7 @@ static void setup(WebServerConfig.Builder server) { static void routing(HttpRouting.Builder routing) { Config config = Config.global(); routing.addFeature(ObserveFeature.create()) - .addFeature(OpenApiFeature.builder() - .config(config.get(OpenApiFeature.Builder.CONFIG_KEY))) + .addFeature(OpenApiFeature.create(config.get("openapi"))) .register("/greet", new GreetService()); } diff --git a/examples/openapi/src/main/resources/META-INF/openapi.yml b/examples/openapi/src/main/resources/META-INF/openapi.yml index 8a7c77a20cf..b2785566ceb 100644 --- a/examples/openapi/src/main/resources/META-INF/openapi.yml +++ b/examples/openapi/src/main/resources/META-INF/openapi.yml @@ -1,5 +1,5 @@ # -# Copyright (c) 2019, 2021 Oracle and/or its affiliates. +# Copyright (c) 2019, 2023 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. @@ -49,12 +49,10 @@ paths: examples: greeting: summary: Example greeting message to update - value: New greeting message + value: { "greeting": "Hola" } responses: - "200": - description: OK - content: - application/json: {} + "204": + description: Updated /greet/{name}: get: summary: Returns a personalized greeting diff --git a/examples/openapi/src/main/resources/application.yaml b/examples/openapi/src/main/resources/application.yaml index 70b778ea690..ced90e2c47e 100644 --- a/examples/openapi/src/main/resources/application.yaml +++ b/examples/openapi/src/main/resources/application.yaml @@ -1,5 +1,5 @@ # -# Copyright (c) 2019, 2021 Oracle and/or its affiliates. +# Copyright (c) 2019, 2023 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. @@ -20,10 +20,3 @@ app: server: port: 8080 host: 0.0.0.0 - -openapi: - filter: io.helidon.examples.openapi.internal.SimpleAPIFilter - model: - reader: io.helidon.examples.openapi.internal.SimpleAPIModelReader -# The following would change the endpoint path for retrieving the OpenAPI document -# web-context: /myopenapi diff --git a/examples/openapi/src/test/java/io/helidon/examples/openapi/MainTest.java b/examples/openapi/src/test/java/io/helidon/examples/openapi/MainTest.java index 1cf620366ea..de2da0910ef 100644 --- a/examples/openapi/src/test/java/io/helidon/examples/openapi/MainTest.java +++ b/examples/openapi/src/test/java/io/helidon/examples/openapi/MainTest.java @@ -19,7 +19,6 @@ import java.util.Map; import io.helidon.common.media.type.MediaTypes; -import io.helidon.examples.openapi.internal.SimpleAPIModelReader; import io.helidon.http.Status; import io.helidon.webclient.http1.Http1Client; import io.helidon.webclient.http1.Http1ClientResponse; @@ -32,7 +31,6 @@ import jakarta.json.JsonObject; import jakarta.json.JsonPointer; import jakarta.json.JsonString; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import static org.hamcrest.CoreMatchers.is; @@ -88,12 +86,7 @@ public void testHelloWorld() { } @Test - @Disabled("https://github.com/helidon-io/helidon/issues/5411") public void testOpenAPI() { - /* - * If you change the OpenAPI endpoint path in application.yaml, then - * change the following path also. - */ JsonObject jsonObject = client.get("/openapi") .accept(MediaTypes.APPLICATION_JSON) .requestEntity(JsonObject.class); @@ -102,14 +95,6 @@ public void testOpenAPI() { JsonPointer jp = Json.createPointer("/" + escape("/greet/greeting") + "/put/summary"); JsonString js = (JsonString) jp.getValue(paths); assertThat("/greet/greeting.put.summary not as expected", js.getString(), is("Set the greeting prefix")); - - jp = Json.createPointer("/" + escape(SimpleAPIModelReader.MODEL_READER_PATH) + "/get/summary"); - js = (JsonString) jp.getValue(paths); - assertThat("summary added by model reader does not match", js.getString(), - is(SimpleAPIModelReader.SUMMARY)); - - jp = Json.createPointer("/" + escape(SimpleAPIModelReader.DOOMED_PATH)); - assertThat("/test/doomed should not appear but does", jp.containsValue(paths), is(false)); } private static String escape(String path) { diff --git a/http/http/src/main/java/io/helidon/http/PathMatchers.java b/http/http/src/main/java/io/helidon/http/PathMatchers.java index 205729f87c8..9aa0eff66d4 100644 --- a/http/http/src/main/java/io/helidon/http/PathMatchers.java +++ b/http/http/src/main/java/io/helidon/http/PathMatchers.java @@ -157,12 +157,10 @@ public static PathMatcher create(String pathPattern) { checkPattern = pathPattern.substring(0, pathPattern.length() - 2); } - if (checkPattern.contains("{")) { - // pattern with path parameter - return pattern(pathPattern); - } - - if (checkPattern.contains("*") || checkPattern.contains("\\")) { + if (checkPattern.contains("{") + || checkPattern.contains("[") + || checkPattern.contains("*") + || checkPattern.contains("\\")) { return pattern(pathPattern); } diff --git a/microprofile/openapi/etc/spotbugs/exclude.xml b/microprofile/openapi/etc/spotbugs/exclude.xml index 860b15bdb87..433cfe79dae 100644 --- a/microprofile/openapi/etc/spotbugs/exclude.xml +++ b/microprofile/openapi/etc/spotbugs/exclude.xml @@ -22,7 +22,7 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="https://github.com/spotbugs/filter/3.0.0 https://raw.githubusercontent.com/spotbugs/spotbugs/3.1.0/spotbugs/etc/findbugsfilter.xsd"> - @@ -36,12 +36,12 @@ https://github.com/spotbugs/spotbugs/issues/1877 (false positive with text blocks) --> - + - + diff --git a/microprofile/openapi/pom.xml b/microprofile/openapi/pom.xml index b0a10db0c9b..d2f127632b2 100644 --- a/microprofile/openapi/pom.xml +++ b/microprofile/openapi/pom.xml @@ -95,7 +95,6 @@ io.smallrye jandex - io.helidon.common.features helidon-common-features-api @@ -136,6 +135,11 @@ hamcrest-all test + + io.helidon.common.testing + helidon-common-testing-junit5 + test + @@ -196,8 +200,50 @@ helidon-common-features-processor ${helidon.version} + + io.helidon.config + helidon-config-metadata-processor + ${helidon.version} + + + io.helidon.builder + helidon-builder-processor + ${helidon.version} + + + io.helidon.common.processor + helidon-common-processor-helidon-copyright + ${helidon.version} + + + + io.helidon.common.features + helidon-common-features-processor + ${helidon.version} + + + io.helidon.builder + helidon-builder-processor + ${helidon.version} + + + io.helidon.config + helidon-config-metadata-processor + ${helidon.version} + + + io.helidon.inject.configdriven + helidon-inject-configdriven-processor + ${helidon.version} + + + io.helidon.common.processor + helidon-common-processor-helidon-copyright + ${helidon.version} + + org.apache.maven.plugins @@ -246,6 +292,7 @@ generate-sources + ${project.build.directory}/generated-sources/annotations io.helidon.microprofile.openapi.SnakeYAMLParserHelper ${openapi-interfaces-dir} diff --git a/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/ExpandedTypeDescription.java b/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/ExpandedTypeDescription.java index c985b7fc131..f0304ee8790 100644 --- a/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/ExpandedTypeDescription.java +++ b/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/ExpandedTypeDescription.java @@ -18,6 +18,7 @@ import java.beans.IntrospectionException; import java.beans.PropertyDescriptor; import java.lang.annotation.Annotation; +import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; @@ -55,12 +56,12 @@ * Some of the MP OpenAPI items are extensible, meaning they accept sub-item keys with the * "x-" prefix. This class supports extensions. For scalars it delegates to the normal * SnakeYAML processing to correctly type and parse the scalar. For sequences it - * creates {@code List}s. For mappings it creates {@code Map}s. The subnodes of the lists and + * creates {@code List}s. For mappings it creates {@code Map}s. The sub-nodes of the lists and * maps are handled by the normal SnakeYAML parsing, so the resulting elements in lists and * maps are of the SnakeYAML-inferred types. *

*

- * A subnode {@code $ref} maps to the {@code ref} property on the MP OpenAPI types. This type + * A sub-node {@code $ref} maps to the {@code ref} property on the MP OpenAPI types. This type * description simplifies defining the {@code $ref} property to those types that support it. *

*

@@ -158,9 +159,9 @@ public Object newInstance(String propertyName, Node node) { Property p = getProperty(propertyName); if (p.getType().isEnum()) { @SuppressWarnings("unchecked") - Class eClass = (Class) p.getType(); - String valueText = ScalarNode.class.cast(node).getValue(); - for (Enum e : eClass.getEnumConstants()) { + Class> eClass = (Class>) p.getType(); + String valueText = ((ScalarNode) node).getValue(); + for (Enum e : eClass.getEnumConstants()) { if (e.toString().equals(valueText)) { return e; } @@ -178,9 +179,7 @@ public void addExcludes(String... propNames) { if (excludes == Collections.emptySet()) { excludes = new HashSet<>(); } - for (String propName : propNames) { - excludes.add(propName); - } + excludes.addAll(Arrays.asList(propNames)); } /** @@ -198,13 +197,17 @@ public Class impl() { * @return {@code true} if default value property is defined */ public boolean hasDefaultProperty() { - return getPropertyNoEx("defaultValue") != null; + return defaultProperty() != null; } - Property getPropertyNoEx(String name) { + /** + * Returns the default property for the type. + * + * @return the 'default' property for this type; null if none + */ + Property defaultProperty() { try { - Property p = getProperty("defaultValue"); - return p; + return getProperty("defaultValue"); } catch (YAMLException ex) { if (ex.getMessage().startsWith("Unable to find property")) { return null; @@ -213,38 +216,22 @@ Property getPropertyNoEx(String name) { } } - /** - * Returns the default property for the type. - * - * @return the 'default' property for this type; null if none - */ - Property defaultProperty() { - return getPropertyNoEx("defaultValue"); - } - private static boolean setupExtensionType(String key, Node valueNode) { if (isExtension(key)) { - /* - * The nodeId in a node is more like node "category" in SnakeYAML. For those OpenAPI interfaces which implement - * Extensible we need to set the node's type if the extension is a List or Map. - */ + // The nodeId in a node is more like node "category" in SnakeYAML. For those OpenAPI interfaces which implement + // Extensible we need to set the node's type if the extension is a List or Map. switch (valueNode.getNodeId()) { - case sequence: - valueNode.setType(List.class); - return true; - - case anchor: - break; - - case mapping: - valueNode.setType(Map.class); - return true; - - case scalar: - break; - - default: - + case sequence -> { + valueNode.setType(List.class); + return true; + } + case mapping -> { + valueNode.setType(Map.class); + return true; + } + default -> { + return false; + } } } return false; @@ -261,13 +248,13 @@ private static boolean isRef(String name) { /** * Specific type description for {@code Schema}. *

- * The {@code Schema} node allows the {@code additionalProperties} subnode to be either + * The {@code Schema} node allows the {@code additionalProperties} sub-node to be either * {@code Boolean} or another {@code Schema}, and the {@code Schema} class exposes getters and setters for * {@code additionalPropertiesBoolean}, and {@code additionalPropertiesSchema}. * This type description customizes the handling of {@code additionalProperties} to account for all that. *

* - * @see Serializer (specifically doRepresentJavaBeanProperty) for output handling for + * @see OpenApiSerializer (specifically doRepresentJavaBeanProperty) for output handling for * additionalProperties */ static final class SchemaTypeDescription extends ExpandedTypeDescription { @@ -278,8 +265,8 @@ static final class SchemaTypeDescription extends ExpandedTypeDescription { new MethodProperty(ADDL_PROPS_PROP_DESCRIPTOR) { @Override - public void set(Object object, Object value) throws Exception { - Schema s = Schema.class.cast(object); + public void set(Object object, Object value) { + Schema s = (Schema) object; if (value instanceof Schema) { s.setAdditionalPropertiesSchema((Schema) value); } else { @@ -289,7 +276,7 @@ public void set(Object object, Object value) throws Exception { @Override public Object get(Object object) { - Schema s = Schema.class.cast(object); + Schema s = (Schema) object; Boolean b = s.getAdditionalPropertiesBoolean(); return b != null ? b : s.getAdditionalPropertiesSchema(); } @@ -394,7 +381,7 @@ static MapLikeTypeDescription create(Class

parentType, } @Override - public boolean setProperty(Object targetBean, String propertyName, Object value) throws Exception { + public boolean setProperty(Object targetBean, String propertyName, Object value) { P parent = parentType.cast(targetBean); C child = childType.cast(value); childAdder.addChild(parent, propertyName, child); @@ -441,7 +428,7 @@ static ListMapLikeTypeDescription create(Class

parentType, @Override @SuppressWarnings("unchecked") - public boolean setProperty(Object targetBean, String propertyName, Object value) throws Exception { + public boolean setProperty(Object targetBean, String propertyName, Object value) { P parent = parentType().cast(targetBean); if (value == null) { childNameAdder.addChild(parent, propertyName); @@ -455,11 +442,11 @@ public boolean setProperty(Object targetBean, String propertyName, Object value) } /** - * Property description for an extension subnode. + * Property description for an extension sub-node. */ static class ExtensionProperty extends Property { - private static final Class[] EXTENSION_TYPE_ARGS = new Class[0]; + private static final Class[] EXTENSION_TYPE_ARGS = new Class[0]; ExtensionProperty(String name) { super(name, Object.class); diff --git a/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/FilteredIndexViewsBuilder.java b/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/FilteredIndexViewsBuilder.java new file mode 100644 index 00000000000..8da7600ab4f --- /dev/null +++ b/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/FilteredIndexViewsBuilder.java @@ -0,0 +1,332 @@ +/* + * Copyright (c) 2019, 2023 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.microprofile.openapi; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.PrintStream; +import java.io.UncheckedIOException; +import java.lang.System.Logger.Level; +import java.lang.reflect.Modifier; +import java.net.URL; +import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import io.helidon.microprofile.server.JaxRsApplication; + +import io.smallrye.openapi.api.OpenApiConfigImpl; +import io.smallrye.openapi.runtime.scanner.FilteredIndexView; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Application; +import jakarta.ws.rs.core.Feature; +import jakarta.ws.rs.ext.Provider; +import org.eclipse.microprofile.config.Config; +import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.AnnotationTarget; +import org.jboss.jandex.ClassInfo; +import org.jboss.jandex.CompositeIndex; +import org.jboss.jandex.DotName; +import org.jboss.jandex.Index; +import org.jboss.jandex.IndexReader; +import org.jboss.jandex.IndexView; +import org.jboss.jandex.Indexer; + +/** + * Utility that computes the list of filtered index views, one for each JAX-RS application, + * sorted by the Application class name to help keep the list of endpoints in the OpenAPI document in a stable order. + */ +class FilteredIndexViewsBuilder { + + private static final System.Logger LOGGER = System.getLogger(FilteredIndexViewsBuilder.class.getName()); + + private final Config config; + private final FilteredIndexView view; + private final List apps; + private final Set requiredClasses; + private final boolean useJaxRsSemantics; + + FilteredIndexViewsBuilder(Config config, + List apps, + Set> types, + List indexPaths, + boolean useJaxRsSemantics) { + + this.config = config; + this.view = new FilteredIndexView(indexView(indexPaths, apps, types), new OpenApiConfigImpl(config)); + this.apps = apps; + this.requiredClasses = requiredClassNames(view); + this.useJaxRsSemantics = useJaxRsSemantics; + } + + /** + * Creates a {@link FilteredIndexView} tailored to each JAX-RS application. + * + * @return the list of filtered index views + */ + List buildViews() { + return apps.stream() + .filter(app -> app.applicationClass().isPresent()) + .sorted(Comparator.comparing(app -> app.applicationClass().get().getName())) + .map(this::map) + .toList(); + } + + private FilteredIndexView map(JaxRsApplication app) { + + Application application = app.resourceConfig().getApplication(); + + @SuppressWarnings("deprecation") + Set singletons = application.getSingletons() + .stream() + .map(Object::getClass) + .map(Class::getName) + .collect(Collectors.toSet()); + + Set classes = application.getClasses() + .stream() + .map(Class::getName) + .collect(Collectors.toSet()); + + String appClassName = className(app); + + Set explicitClassNames = new HashSet<>(classes); + explicitClassNames.addAll(singletons); + + if (explicitClassNames.isEmpty() && apps.size() == 1) { + // No need to do filtering at all. + if (LOGGER.isLoggable(Level.TRACE)) { + LOGGER.log(Level.TRACE, String.format( + "No filtering required for %s which reports no explicitly referenced classes and " + + "is the only JAX-RS application", + appClassName)); + } + return view; + } + + // Note that the MP OpenAPI TCK does not follow JAX-RS behavior wen getSingletons returns a non-empty set. + // The TCK incorrectly expects the endpoints defined by other resources as well to appear in the OpenAPI document. + if ((classes.isEmpty() && (singletons.isEmpty() || !useJaxRsSemantics)) && apps.size() == 1) { + if (LOGGER.isLoggable(Level.TRACE)) { + LOGGER.log(Level.TRACE, String.format( + "No filtering required for %s because JAX-RS semantics is disabled", + appClassName)); + } + // Perform no further filtering if all the following conditions are met: + // - there is exactly one application, + // - we found no classes from getClasses + // - we found no classes from getSingletons or the JAX-RS semantic is disabled. + return view; + } + + Set excludedClasses = excludedClasses(app, explicitClassNames); + FilteringOpenApiConfigImpl filteringOpenApiConfig = new FilteringOpenApiConfigImpl(config, excludedClasses); + + // Create a new filtered index view for this application which excludes the irrelevant classes we just identified. + // Its delegate is the previously-created view based only on the MP configuration. + FilteredIndexView result = new FilteredIndexView(view, filteringOpenApiConfig); + + if (LOGGER.isLoggable(Level.TRACE)) { + LOGGER.log(Level.TRACE, String.format( + "FilteredIndexView for %n" + + " application class %s%n" + + " with explicitly-referenced classes %s%n" + + " yields exclude list: %s%n and known classes: %n %s", + appClassName, + explicitClassNames, + excludedClasses, + String.join("," + System.lineSeparator() + " ", knownClassNames(result)))); + } + + return result; + } + + private Set excludedClasses(JaxRsApplication app, Set explicitClasses) { + + String appClass = className(app); + + // Start with all other JAX-RS app names. + Set result = apps.stream() + .map(FilteredIndexViewsBuilder::className) + .filter(name -> !name.equals("") && !name.equals(appClass)) + .collect(Collectors.toSet()); + + if (!explicitClasses.isEmpty()) { + // This class identified resource, provider, or feature classes it uses. + // Ignore all ancillary classes that this app does not explicitly reference. + result.addAll(requiredClasses); + result.removeAll(explicitClasses); + } + + return result; + } + + private static String className(JaxRsApplication app) { + return app.applicationClass().map(Class::getName).orElse(""); + } + + private static Set requiredClassNames(IndexView indexView) { + Set result = new HashSet<>(annotatedClassNames(indexView, Path.class)); + result.addAll(annotatedClassNames(indexView, Provider.class)); + result.addAll(annotatedClassNames(indexView, Feature.class)); + if (LOGGER.isLoggable(Level.DEBUG)) { + LOGGER.log(Level.DEBUG, "Ancillary classes: {0}", result); + } + return result; + } + + private static Set annotatedClassNames(IndexView indexView, Class annotationClass) { + return indexView + .getAnnotations(DotName.createSimple(annotationClass.getName())) + .stream() + .map(AnnotationInstance::target) + .filter(target -> target.kind() == AnnotationTarget.Kind.CLASS) + .map(AnnotationTarget::asClass) + .filter(classInfo -> hasImplementationOrIsIncluded(indexView, classInfo)) + .map(ClassInfo::toString) + .collect(Collectors.toSet()); + } + + private static boolean hasImplementationOrIsIncluded(IndexView indexView, ClassInfo classInfo) { + if (!Modifier.isInterface(classInfo.flags())) { + return true; + } + return indexView.getAllKnownImplementors(classInfo.name()).stream() + .anyMatch(info -> !Modifier.isAbstract(info.flags())); + } + + private static List knownClassNames(FilteredIndexView filteredIndexView) { + return filteredIndexView + .getKnownClasses() + .stream() + .map(ClassInfo::toString) + .sorted() + .toList(); + } + + private static IndexView indexView(List indexPaths, List apps, Set> types) { + try { + List urls = findIndexFiles(indexPaths); + if (urls.isEmpty()) { + LOGGER.log(Level.INFO, """ + Could not locate the Jandex index file META-INF/jandex.idx, building an in-memory index... + Consider using the Jandex maven plug-in during your build to add it to your app."""); + return buildIndex(apps, types); + } + return loadIndex(indexPaths); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static IndexView loadIndex(List indexPaths) throws IOException { + List indices = new ArrayList<>(); + for (URL url : findIndexFiles(indexPaths)) { + try (InputStream is = url.openStream()) { + LOGGER.log(Level.TRACE, "Adding Jandex index at {0}", url.toString()); + indices.add(new IndexReader(is).read()); + } catch (Exception ex) { + throw new IOException(String.format( + "Attempted to read from previously-located index file %s but the index cannot be read", + url), ex); + } + } + return indices.size() == 1 ? indices.get(0) : CompositeIndex.create(indices); + } + + private static IndexView buildIndex(List apps, Set> types) throws IOException { + Indexer indexer = new Indexer(); + for (Class c : types) { + indexClass(indexer, c); + } + + // Some apps might be added dynamically, not via annotation processing. + // Add those classes to the index if they are not already present. + apps.stream() + .map(JaxRsApplication::applicationClass) + .flatMap(Optional::stream) + .forEach(cls -> indexClass(indexer, cls)); + + LOGGER.log(Level.TRACE, "Using internal Jandex index created from CDI bean discovery"); + Index result = indexer.complete(); + dumpIndex(result); + return result; + } + + private static void indexClass(Indexer indexer, Class c) { + try { + indexer.indexClass(c); + } catch (IOException ex) { + throw new UncheckedIOException( + String.format("Cannot load bytecode from class %s for annotation processing", c), + ex); + } + } + + private static void dumpIndex(Index index) { + if (LOGGER.isLoggable(Level.DEBUG)) { + LOGGER.log(Level.DEBUG, "Dump of internal Jandex index:"); + PrintStream oldStdout = System.out; + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (PrintStream newPS = new PrintStream(baos, true, Charset.defaultCharset())) { + System.setOut(newPS); + index.printAnnotations(); + index.printSubclasses(); + LOGGER.log(Level.DEBUG, baos.toString(Charset.defaultCharset())); + } finally { + System.setOut(oldStdout); + } + } + } + + private static List findIndexFiles(List paths) { + List result = new ArrayList<>(); + for (String path : paths) { + Enumeration urls; + try { + urls = Thread.currentThread().getContextClassLoader().getResources(path); + while (urls.hasMoreElements()) { + result.add(urls.nextElement()); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } + return result; + } + + private static class FilteringOpenApiConfigImpl extends OpenApiConfigImpl { + + private final Set classesToExclude; + + FilteringOpenApiConfigImpl(org.eclipse.microprofile.config.Config config, Set classesToExclude) { + super(config); + this.classesToExclude = classesToExclude; + } + + @Override + public Set scanExcludeClasses() { + return classesToExclude; + } + } +} diff --git a/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/HelidonAnnotationScannerExtension.java b/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/JsonpAnnotationScannerExtension.java similarity index 71% rename from microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/HelidonAnnotationScannerExtension.java rename to microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/JsonpAnnotationScannerExtension.java index 62cc0c33290..802aee62af6 100644 --- a/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/HelidonAnnotationScannerExtension.java +++ b/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/JsonpAnnotationScannerExtension.java @@ -34,12 +34,10 @@ /** * Extension we want SmallRye's OpenAPI implementation to use for parsing the JSON content in Extension annotations. */ -class HelidonAnnotationScannerExtension implements AnnotationScannerExtension { - - private static final System.Logger LOGGER = System.getLogger(HelidonAnnotationScannerExtension.class.getName()); +class JsonpAnnotationScannerExtension implements AnnotationScannerExtension { + private static final System.Logger LOGGER = System.getLogger(JsonpAnnotationScannerExtension.class.getName()); private static final JsonReaderFactory JSON_READER_FACTORY = Json.createReaderFactory(Collections.emptyMap()); - private static final Representer MISSING_FIELD_TOLERANT_REPRESENTER; static { @@ -74,27 +72,27 @@ public Object parseValue(String value) { // See if we should parse the value fully. switch (value.charAt(0)) { - case '{', '[', '-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' -> { - try { - JsonReader reader = JSON_READER_FACTORY.createReader(new StringReader(value)); - JsonValue jsonValue = reader.readValue(); - // readValue will truncate the input to convert to a number if it can. Make sure the value is the same length - // as the original. - if (jsonValue.getValueType().equals(JsonValue.ValueType.NUMBER) - && value.length() != jsonValue.toString().length()) { - return value; + case '{', '[', '-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' -> { + try { + JsonReader reader = JSON_READER_FACTORY.createReader(new StringReader(value)); + JsonValue jsonValue = reader.readValue(); + // readValue will truncate the input to convert to a number if it can. Make sure the value is the same length + // as the original. + if (jsonValue.getValueType().equals(JsonValue.ValueType.NUMBER) + && value.length() != jsonValue.toString().length()) { + return value; + } + + return convertJsonValue(jsonValue); + } catch (Exception ex) { + LOGGER.log(System.Logger.Level.ERROR, + String.format("Error parsing JSON value: %s", value), + ex); + throw ex; } - - return convertJsonValue(jsonValue); - } catch (Exception ex) { - LOGGER.log(System.Logger.Level.ERROR, - String.format("Error parsing JSON value: %s", value), - ex); - throw ex; } - } - default -> { - } + default -> { + } } // Treat as JSON string. @@ -103,7 +101,7 @@ public Object parseValue(String value) { @Override public Schema parseSchema(String jsonSchema) { - return OpenApiParser.parse(MpOpenApiFeature.PARSER_HELPER.get().types(), + return OpenApiParser.parse(OpenApiHelper.types(), Schema.class, new StringReader(jsonSchema), MISSING_FIELD_TOLERANT_REPRESENTER); @@ -113,13 +111,13 @@ private static Object convertJsonValue(JsonValue jsonValue) { return switch (jsonValue.getValueType()) { case ARRAY -> jsonValue.asJsonArray() .stream() - .map(HelidonAnnotationScannerExtension::convertJsonValue) - .collect(Collectors.toList()); + .map(JsonpAnnotationScannerExtension::convertJsonValue) + .toList(); case FALSE -> Boolean.FALSE; case TRUE -> Boolean.TRUE; case NULL -> null; - case STRING -> JsonString.class.cast(jsonValue).getString(); - case NUMBER -> JsonNumber.class.cast(jsonValue).numberValue(); + case STRING -> ((JsonString) jsonValue).getString(); + case NUMBER -> ((JsonNumber) jsonValue).numberValue(); case OBJECT -> jsonValue.asJsonObject() .entrySet() .stream() diff --git a/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/MPOpenAPIBuilder.java b/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/MPOpenAPIBuilder.java deleted file mode 100644 index cfd41d7b7f1..00000000000 --- a/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/MPOpenAPIBuilder.java +++ /dev/null @@ -1,487 +0,0 @@ -/* - * Copyright (c) 2019, 2023 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.microprofile.openapi; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.PrintStream; -import java.lang.System.Logger.Level; -import java.lang.reflect.Modifier; -import java.net.URL; -import java.nio.charset.Charset; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.Enumeration; -import java.util.HashSet; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; - -import io.helidon.config.Config; -import io.helidon.microprofile.server.JaxRsApplication; -import io.helidon.openapi.OpenApiFeature; - -import io.smallrye.openapi.api.OpenApiConfig; -import io.smallrye.openapi.api.OpenApiConfigImpl; -import io.smallrye.openapi.runtime.scanner.FilteredIndexView; -import jakarta.enterprise.inject.spi.CDI; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.core.Application; -import jakarta.ws.rs.core.Feature; -import jakarta.ws.rs.ext.Provider; -import org.jboss.jandex.AnnotationInstance; -import org.jboss.jandex.AnnotationTarget; -import org.jboss.jandex.ClassInfo; -import org.jboss.jandex.CompositeIndex; -import org.jboss.jandex.DotName; -import org.jboss.jandex.Index; -import org.jboss.jandex.IndexReader; -import org.jboss.jandex.IndexView; -import org.jboss.jandex.Indexer; - -/** - * Builder for the MP OpenAPI feature. - */ -class MPOpenAPIBuilder extends OpenApiFeature.Builder { - - private static final System.Logger LOGGER = System.getLogger(MPOpenAPIBuilder.class.getName()); - - // This is the prefix users will use in the config file. - static final String MP_OPENAPI_CONFIG_PREFIX = "mp." + OpenApiFeature.Builder.CONFIG_KEY; - - private static final String USE_JAXRS_SEMANTICS_CONFIG_KEY = "use-jaxrs-semantics"; - - private static final String USE_JAXRS_SEMANTICS_FULL_CONFIG_KEY = - "mp.openapi.extensions.helidon." + USE_JAXRS_SEMANTICS_CONFIG_KEY; - private static final boolean USE_JAXRS_SEMANTICS_DEFAULT = true; - - private OpenApiConfig openApiConfig; - private org.eclipse.microprofile.config.Config mpConfig; - - private String[] indexPaths; - private int indexURLCount; - - private boolean useJaxRsSemantics = USE_JAXRS_SEMANTICS_DEFAULT; - - MPOpenAPIBuilder() { - super(); - } - - @Override - public MpOpenApiFeature build() { - List indexURLs = findIndexFiles(indexPaths); - indexURLCount = indexURLs.size(); - if (indexURLs.isEmpty()) { - LOGGER.log(Level.INFO, String.format(""" - OpenAPI feature could not locate the Jandex index file %s so will build an in-memory index. - This slows your app start-up and, depending on CDI configuration, might omit some type information \ - needed for a complete OpenAPI document. - Consider using the Jandex maven plug-in during your build to create the index and add it to your app.""", - OpenApiCdiExtension.INDEX_PATH)); - } - if (openApiConfig == null) { - openApiConfig = new OpenApiConfigImpl(mpConfig); - } - return new MpOpenApiFeature(this); - } - - @Override - public MPOpenAPIBuilder config(Config config) { - super.config(config); - return identity(); - } - - /** - * Sets the SmallRye OpenAPI configuration. - * - * @param openApiConfig the {@link io.smallrye.openapi.api.OpenApiConfig} settings - * @return updated builder - */ - public MPOpenAPIBuilder openApiConfig(OpenApiConfig openApiConfig) { - this.openApiConfig = openApiConfig; - return this; - } - - /** - * Returns an {@link org.jboss.jandex.IndexView} for the Jandex index that describes - * annotated classes for endpoints. - * - * @return {@code IndexView} describing discovered classes - */ - IndexView indexView() { - try { - return indexURLCount > 0 ? existingIndexFileReader() : indexFromHarvestedClasses(); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - /** - * Returns the {@link io.smallrye.openapi.api.OpenApiConfig} instance the builder uses. - * - * @return {@code OpenApiConfig} instance in use by the builder - */ - OpenApiConfig openApiConfig() { - return openApiConfig; - } - - @Override - protected System.Logger logger() { - return LOGGER; - } - - MPOpenAPIBuilder config(org.eclipse.microprofile.config.Config mpConfig) { - this.mpConfig = mpConfig; - // use-jaxrs-semantics is intended for Helidon's private use in running the TCKs to work around a problem there. - // We do not document its use. - useJaxRsSemantics = mpConfig - .getOptionalValue(USE_JAXRS_SEMANTICS_FULL_CONFIG_KEY, Boolean.class) - .orElse(USE_JAXRS_SEMANTICS_DEFAULT); - - return openApiConfig(new OpenApiConfigImpl(mpConfig)); - } - - MPOpenAPIBuilder indexPaths(String... indexPaths) { - this.indexPaths = indexPaths; - return identity(); - } - - /** - * Creates a {@link io.smallrye.openapi.runtime.scanner.FilteredIndexView} tailored to the specified JAX-RS application. - *

- * Use an {@link io.smallrye.openapi.api.OpenApiConfig} instance which (possibly) limits scanning for this application - * by excluding classes that are not "relevant" to the specified application. For our purposes, the classes "relevant" - * to an application are those: - *

    - *
  • returned by the application's {@code getClasses} method, and
  • - *
  • inferred from the objects returned from the application's {@code getSingletons} method.
  • - *
- * - * If both methods return empty sets (the default implementation in {@link jakarta.ws.rs.core.Application}), then all - * resources, providers, and features are considered relevant to the application. - *

- * In constructing the filtered index view for a JAX-RS application, we also exclude the other JAX-RS application classes. - *

- * - * @param viewFilteredByConfig filtered index view based only on MP config - * @param jaxRsApplications all JAX-RS applications discovered - * @param jaxRsApp the specific JAX-RS application of interest - * @param ancillaryClassNames names of resource, provider, and feature classes - * @return the filtered index view suitable for the specified JAX-RS application - */ - private FilteredIndexView filteredIndexView(FilteredIndexView viewFilteredByConfig, - List jaxRsApplications, - JaxRsApplication jaxRsApp, - Set ancillaryClassNames) { - Application app = jaxRsApp.resourceConfig().getApplication(); - - Set classesFromGetSingletons = app.getSingletons().stream() - .map(Object::getClass) - .map(Class::getName) - .collect(Collectors.toSet()); - - Set classesFromGetClasses = app.getClasses().stream() - .map(Class::getName) - .collect(Collectors.toSet()); - - String appClassName = toClassName(jaxRsApp); - - Set classesExplicitlyReferenced = new HashSet<>(classesFromGetClasses); - classesExplicitlyReferenced.addAll(classesFromGetSingletons); - - if (classesExplicitlyReferenced.isEmpty() && jaxRsApplications.size() == 1) { - // No need to do filtering at all. - if (LOGGER.isLoggable(Level.TRACE)) { - LOGGER.log(Level.TRACE, String.format( - "No filtering required for %s which reports no explicitly referenced classes and " - + "is the only JAX-RS application", - appClassName)); - } - return viewFilteredByConfig; - } - - // Also, perform no further filtering if there is exactly one application and we found no classes from getClasses and, - // although we found classes from getSingletons, the useJaxRsSemantics setting has been turned off. - // - // Note that the MP OpenAPI TCK does not follow JAX-RS behavior if the application class returns a non-empty set from - // getSingletons; in that case, the TCK incorrectly expects the endpoints defined by other resources as well to appear - // in the OpenAPI document. - if (( - classesFromGetClasses.isEmpty() - && (classesFromGetSingletons.isEmpty() || !useJaxRsSemantics)) - && jaxRsApplications.size() == 1) { - if (LOGGER.isLoggable(Level.TRACE)) { - LOGGER.log(Level.TRACE, String.format(""" - No filtering required for %s; although it returns a non-empty set from getSingletons, JAX-RS semantics \ - has been turned off for OpenAPI processing using %s""", - appClassName, MPOpenAPIBuilder.USE_JAXRS_SEMANTICS_FULL_CONFIG_KEY)); - } - return viewFilteredByConfig; - } - - Set excludedClasses = classNamesToIgnore(jaxRsApplications, - jaxRsApp, - ancillaryClassNames, - classesExplicitlyReferenced); - - // Create a new filtered index view for this application which excludes the irrelevant classes we just identified. Its - // delegate is the previously-created view based only on the MP configuration. - FilteredIndexView result = new FilteredIndexView(viewFilteredByConfig, - new FilteringOpenApiConfigImpl(mpConfig, excludedClasses)); - if (LOGGER.isLoggable(Level.TRACE)) { - String knownClassNames = result - .getKnownClasses() - .stream() - .map(ClassInfo::toString) - .sorted() - .collect(Collectors.joining("," + System.lineSeparator() + " ")); - LOGGER.log(Level.TRACE, - String.format("FilteredIndexView for %n" - + " application class %s%n" - + " with explicitly-referenced classes %s%n" - + " yields exclude list: %s%n" - + " and known classes: %n %s", - appClassName, - classesExplicitlyReferenced, - excludedClasses, - knownClassNames)); - } - - return result; - } - - private static String toClassName(JaxRsApplication jaxRsApplication) { - return jaxRsApplication.applicationClass() - .map(Class::getName) - .orElse(""); - } - - private static Set classNamesToIgnore(List jaxRsApplications, - JaxRsApplication jaxRsApp, - Set ancillaryClassNames, - Set classesExplicitlyReferenced) { - - String appClassName = toClassName(jaxRsApp); - - Set result = // Start with all other JAX-RS app names. - jaxRsApplications.stream() - .map(MPOpenAPIBuilder::toClassName) - .filter(candidateName -> !candidateName.equals("") && !candidateName.equals(appClassName)) - .collect(Collectors.toSet()); - - if (!classesExplicitlyReferenced.isEmpty()) { - // This class identified resource, provider, or feature classes it uses. Ignore all ancillary classes that this app - // does not explicitly reference. - result.addAll(ancillaryClassNames); - result.removeAll(classesExplicitlyReferenced); - } - - return result; - } - - private static boolean isConcrete(ClassInfo classInfo) { - return !Modifier.isAbstract(classInfo.flags()); - } - - private static class FilteringOpenApiConfigImpl extends OpenApiConfigImpl { - - private final Set classesToExclude; - - FilteringOpenApiConfigImpl(org.eclipse.microprofile.config.Config config, Set classesToExclude) { - super(config); - this.classesToExclude = classesToExclude; - } - - @Override - public Set scanExcludeClasses() { - return classesToExclude; - } - } - - /** - * Builds a list of filtered index views, one for each JAX-RS application, sorted by the Application class name to help - * keep the list of endpoints in the OpenAPI document in a stable order. - *

- * First, we find all resource, provider, and feature classes present in the index. This is the same for all - * applications. - *

- *

- * Each filtered index view is tuned to one JAX-RS application. - * - * @return list of {@code FilteredIndexView}s, one per JAX-RS application - */ - List buildPerAppFilteredIndexViews() { - - List jaxRsApplications = MpOpenApiFeature.jaxRsApplicationsToRun().stream() - .filter(jaxRsApp -> jaxRsApp.applicationClass().isPresent()) - .sorted(Comparator.comparing(jaxRsApplication -> jaxRsApplication.applicationClass() - .get() - .getName())) - .collect(Collectors.toList()); - - IndexView indexView = indexView(); - - FilteredIndexView viewFilteredByConfig = new FilteredIndexView(indexView, new OpenApiConfigImpl(mpConfig)); - Set ancillaryClassNames = ancillaryClassNames(viewFilteredByConfig); - - /* - * Filter even for a single-application class in case it implements getClasses or getSingletons. - */ - return jaxRsApplications.stream() - .map(jaxRsApp -> filteredIndexView(viewFilteredByConfig, - jaxRsApplications, - jaxRsApp, - ancillaryClassNames)) - .collect(Collectors.toList()); - } - - private static Set ancillaryClassNames(IndexView indexView) { - Set result = new HashSet<>(resourceClassNames(indexView)); - result.addAll(providerClassNames(indexView)); - result.addAll(featureClassNames(indexView)); - if (LOGGER.isLoggable(Level.DEBUG)) { - LOGGER.log(Level.DEBUG, "Ancillary classes: {0}", result); - } - return result; - } - - private static Set resourceClassNames(IndexView indexView) { - return annotatedClassNames(indexView, Path.class); - } - - private static Set providerClassNames(IndexView indexView) { - return annotatedClassNames(indexView, Provider.class); - } - - private static Set featureClassNames(IndexView indexView) { - return annotatedClassNames(indexView, Feature.class); - } - - private static Set annotatedClassNames(IndexView indexView, Class annotationClass) { - // Partially inspired by the SmallRye code. - return indexView - .getAnnotations(DotName.createSimple(annotationClass.getName())) - .stream() - .map(AnnotationInstance::target) - .filter(target -> target.kind() == AnnotationTarget.Kind.CLASS) - .map(AnnotationTarget::asClass) - .filter(classInfo -> hasImplementationOrIsIncluded(indexView, classInfo)) - .map(ClassInfo::toString) - .collect(Collectors.toSet()); - } - - private static boolean hasImplementationOrIsIncluded(IndexView indexView, ClassInfo classInfo) { - // Partially inspired by the SmallRye code. - return !Modifier.isInterface(classInfo.flags()) - || indexView.getAllKnownImplementors(classInfo.name()).stream() - .anyMatch(MPOpenAPIBuilder::isConcrete); - } - - /** - * Builds an {@code IndexView} from existing Jandex index file(s) on the classpath. - * - * @return IndexView from all index files - * @throws java.io.IOException in case of error attempting to open an index file - */ - private IndexView existingIndexFileReader() throws IOException { - List indices = new ArrayList<>(); - /* - * Do not reuse the previously-computed indexURLs; those values will be incorrect with native images. - */ - for (URL indexURL : findIndexFiles(indexPaths)) { - try (InputStream indexIS = indexURL.openStream()) { - LOGGER.log(Level.TRACE, "Adding Jandex index at {0}", indexURL.toString()); - indices.add(new IndexReader(indexIS).read()); - } catch (Exception ex) { - throw new IOException("Attempted to read from previously-located index file " - + indexURL + " but the index cannot be read", ex); - } - } - return indices.size() == 1 ? indices.get(0) : CompositeIndex.create(indices); - } - - private IndexView indexFromHarvestedClasses() throws IOException { - Indexer indexer = new Indexer(); - annotatedTypes().forEach(c -> addClassToIndexer(indexer, c)); - - /* - * Some apps might be added dynamically, not via annotation processing. Add those classes to the index if they are not - * already present. - */ - MpOpenApiFeature.jaxRsApplicationsToRun().stream() - .map(JaxRsApplication::applicationClass) - .filter(Optional::isPresent) - .forEach(appClassOpt -> addClassToIndexer(indexer, appClassOpt.get())); - - LOGGER.log(Level.TRACE, "Using internal Jandex index created from CDI bean discovery"); - Index result = indexer.complete(); - dumpIndex(Level.DEBUG, result); - return result; - } - - private void addClassToIndexer(Indexer indexer, Class c) { - try (InputStream is = MpOpenApiFeature.contextClassLoader().getResourceAsStream(resourceNameForClass(c))) { - if (is != null) { - indexer.index(is); - } - } catch (IOException ex) { - throw new RuntimeException(String.format("Cannot load bytecode from class %s at %s for annotation processing", - c.getName(), resourceNameForClass(c)), ex); - } - } - - private static void dumpIndex(Level level, Index index) { - if (LOGGER.isLoggable(level)) { - LOGGER.log(level, "Dump of internal Jandex index:"); - PrintStream oldStdout = System.out; - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - try (PrintStream newPS = new PrintStream(baos, true, Charset.defaultCharset())) { - System.setOut(newPS); - index.printAnnotations(); - index.printSubclasses(); - LOGGER.log(level, baos.toString(Charset.defaultCharset())); - } finally { - System.setOut(oldStdout); - } - } - } - - private static String resourceNameForClass(Class c) { - return c.getName().replace('.', '/') + ".class"; - } - - private List findIndexFiles(String... indexPaths) { - List result = new ArrayList<>(); - for (String indexPath : indexPaths) { - Enumeration urls = null; - try { - urls = MpOpenApiFeature.contextClassLoader().getResources(indexPath); - } catch (IOException e) { - throw new RuntimeException(e); - } - while (urls.hasMoreElements()) { - result.add(urls.nextElement()); - } - } - return result; - } - - private Set> annotatedTypes() { - return CDI.current().getBeanManager().getExtension(OpenApiCdiExtension.class).annotatedTypes(); - } -} diff --git a/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/MpOpenApiFeature.java b/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/MpOpenApiFeature.java deleted file mode 100644 index 8b3b43fc33d..00000000000 --- a/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/MpOpenApiFeature.java +++ /dev/null @@ -1,255 +0,0 @@ -/* - * Copyright (c) 2023 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.microprofile.openapi; - -import java.io.BufferedInputStream; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.StringWriter; -import java.nio.charset.Charset; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; -import java.util.concurrent.locks.Lock; -import java.util.concurrent.locks.ReentrantLock; -import java.util.function.Function; -import java.util.function.Supplier; -import java.util.stream.Collectors; - -import io.helidon.common.LazyValue; -import io.helidon.microprofile.server.JaxRsApplication; -import io.helidon.microprofile.server.JaxRsCdiExtension; -import io.helidon.openapi.OpenApiFeature; - -import io.smallrye.openapi.api.OpenApiConfig; -import io.smallrye.openapi.api.OpenApiDocument; -import io.smallrye.openapi.api.models.OpenAPIImpl; -import io.smallrye.openapi.api.util.MergeUtil; -import io.smallrye.openapi.runtime.OpenApiProcessor; -import io.smallrye.openapi.runtime.OpenApiStaticFile; -import io.smallrye.openapi.runtime.io.Format; -import io.smallrye.openapi.runtime.scanner.FilteredIndexView; -import io.smallrye.openapi.runtime.scanner.OpenApiAnnotationScanner; -import jakarta.enterprise.inject.spi.CDI; -import org.eclipse.microprofile.openapi.models.OpenAPI; -import org.jboss.jandex.IndexView; - -/** - * MP variant of OpenApiFeature. - */ -public class MpOpenApiFeature extends OpenApiFeature { - - /** - * Creates a new builder for the MP OpenAPI feature. - * - * @return new builder - */ - public static MPOpenAPIBuilder builder() { - return new MPOpenAPIBuilder(); - } - - /** - * Parser helper. - */ - static final LazyValue PARSER_HELPER = LazyValue.create(ParserHelper::create); - - /** - * Returns the {@code JaxRsApplication} instances that should be run, according to the JAX-RS CDI extension. - * - * @return List of JaxRsApplication instances that should be run - */ - static List jaxRsApplicationsToRun() { - JaxRsCdiExtension ext = CDI.current() - .getBeanManager() - .getExtension(JaxRsCdiExtension.class); - - return ext.applicationsToRun(); - } - - private static final System.Logger LOGGER = System.getLogger(MpOpenApiFeature.class.getName()); - - private final Supplier> filteredIndexViewsSupplier; - - private final Lock modelAccess = new ReentrantLock(true); - - private final OpenApiConfig openApiConfig; - private final io.helidon.openapi.OpenApiStaticFile openApiStaticFile; - - private final MPOpenAPIBuilder builder; - private OpenAPI model; - - private final Map, ExpandedTypeDescription> implsToTypes; - - protected MpOpenApiFeature(MPOpenAPIBuilder builder) { - super(LOGGER, builder); - this.builder = builder; - implsToTypes = buildImplsToTypes(); - openApiConfig = builder.openApiConfig(); - openApiStaticFile = builder.staticFile(); - filteredIndexViewsSupplier = builder::buildPerAppFilteredIndexViews; - } - - @Override - protected String openApiContent(OpenAPIMediaType openApiMediaType) { - - return openApiContent(openApiMediaType, model()); - } - - /** - * Triggers preparation of the model from external code. - */ - protected void prepareModel() { - model(); - } - - /** - * Returns the current thread's context class loader. - * - * @return class loader in use by the thread - */ - static ClassLoader contextClassLoader() { - return Thread.currentThread().getContextClassLoader(); - } - - // For testing - IndexView indexView() { - return builder.indexView(); - } - - Map, ExpandedTypeDescription> buildImplsToTypes() { - return Collections.unmodifiableMap(PARSER_HELPER.get().types() - .values() - .stream() - .collect(Collectors.toMap(ExpandedTypeDescription::impl, - Function.identity()))); - } - - - private String openApiContent(OpenAPIMediaType openAPIMediaType, OpenAPI model) { - StringWriter sw = new StringWriter(); - Serializer.serialize(PARSER_HELPER.get().types(), implsToTypes, model, openAPIMediaType, sw); - return sw.toString(); - } - - /** - * Prepares the OpenAPI model that later will be used to create the OpenAPI - * document for endpoints in this application. - * - * @param config {@code OpenApiConfig} object describing paths, servers, etc. - * @param staticFile the static file, if any, to be included in the resulting model - * @param filteredIndexViews possibly empty list of FilteredIndexViews to use in harvesting definitions from the code - * @return the OpenAPI model - * @throws RuntimeException in case of errors reading any existing static - * OpenAPI document - */ - private OpenAPI prepareModel(OpenApiConfig config, OpenApiStaticFile staticFile, - List filteredIndexViews) { - try { - // The write lock guarding the model has already been acquired. - OpenApiDocument.INSTANCE.reset(); - OpenApiDocument.INSTANCE.config(config); - OpenApiDocument.INSTANCE.modelFromReader(OpenApiProcessor.modelFromReader(config, contextClassLoader())); - if (staticFile != null) { - OpenApiDocument.INSTANCE.modelFromStaticFile(OpenApiParser.parse(PARSER_HELPER.get().types(), - staticFile.getContent())); - } - if (isAnnotationProcessingEnabled(config)) { - expandModelUsingAnnotations(config, filteredIndexViews); - } else { - LOGGER.log(System.Logger.Level.TRACE, "OpenAPI Annotation processing is disabled"); - } - OpenApiDocument.INSTANCE.filter(OpenApiProcessor.getFilter(config, contextClassLoader())); - OpenApiDocument.INSTANCE.initialize(); - OpenAPIImpl instance = OpenAPIImpl.class.cast(OpenApiDocument.INSTANCE.get()); - - // Create a copy, primarily to avoid problems during unit testing. - // The SmallRye MergeUtil omits the openapi value, so we need to set it explicitly. - return MergeUtil.merge(new OpenAPIImpl(), instance) - .openapi(instance.getOpenapi()); - } catch (IOException ex) { - throw new RuntimeException("Error initializing OpenAPI information", ex); - } - } - - - private static Format toFormat(OpenAPIMediaType openAPIMediaType) { - return openAPIMediaType.equals(OpenAPIMediaType.YAML) - ? Format.YAML - : Format.JSON; - } - - private boolean isAnnotationProcessingEnabled(OpenApiConfig config) { - return !config.scanDisable(); - } - - private void expandModelUsingAnnotations(OpenApiConfig config, List filteredIndexViews) { - if (filteredIndexViews.isEmpty() || config.scanDisable()) { - return; - } - - /* - * Conduct a SmallRye OpenAPI annotation scan for each filtered index view, merging the resulting OpenAPI models into one. - * The AtomicReference is effectively final so we can update the actual reference from inside the lambda. - */ - AtomicReference aggregateModelRef = new AtomicReference<>(new OpenAPIImpl()); // Start with skeletal model - filteredIndexViews.forEach(filteredIndexView -> { - OpenApiAnnotationScanner scanner = new OpenApiAnnotationScanner(config, filteredIndexView, - List.of(new HelidonAnnotationScannerExtension())); - OpenAPI modelForApp = scanner.scan(); - if (LOGGER.isLoggable(System.Logger.Level.DEBUG)) { - - LOGGER.log(System.Logger.Level.DEBUG, String.format("Intermediate model from filtered index view %s:%n%s", - filteredIndexView.getKnownClasses(), - openApiContent(OpenAPIMediaType.YAML, modelForApp))); - } - aggregateModelRef.set( - MergeUtil.merge(aggregateModelRef.get(), modelForApp) - .openapi(modelForApp.getOpenapi())); // SmallRye's merge skips openapi value. - - }); - OpenApiDocument.INSTANCE.modelFromAnnotations(aggregateModelRef.get()); - } - - private OpenAPI model() { - return access(() -> { - if (model == null) { - model = prepareModel(openApiConfig, toSmallRye(openApiStaticFile), filteredIndexViewsSupplier.get()); - } - return model; - }); - } - - private static OpenApiStaticFile toSmallRye(io.helidon.openapi.OpenApiStaticFile staticFile) { - - return staticFile == null - ? null - : new OpenApiStaticFile( - new BufferedInputStream( - new ByteArrayInputStream(staticFile.content() - .getBytes(Charset.defaultCharset()))), - toFormat(staticFile.openApiMediaType())); - } - - private T access(Supplier operation) { - modelAccess.lock(); - try { - return operation.get(); - } finally { - modelAccess.unlock(); - } - } -} diff --git a/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/MpOpenApiManager.java b/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/MpOpenApiManager.java new file mode 100644 index 00000000000..ee30391f86c --- /dev/null +++ b/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/MpOpenApiManager.java @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2023 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.microprofile.openapi; + +import java.io.StringReader; +import java.io.StringWriter; +import java.lang.System.Logger.Level; +import java.util.List; +import java.util.Set; + +import io.helidon.common.LazyValue; +import io.helidon.microprofile.server.JaxRsApplication; +import io.helidon.microprofile.server.JaxRsCdiExtension; +import io.helidon.openapi.OpenApiFormat; +import io.helidon.openapi.OpenApiManager; + +import io.smallrye.openapi.api.OpenApiConfig; +import io.smallrye.openapi.api.OpenApiConfigImpl; +import io.smallrye.openapi.api.OpenApiDocument; +import io.smallrye.openapi.api.models.OpenAPIImpl; +import io.smallrye.openapi.api.util.MergeUtil; +import io.smallrye.openapi.runtime.OpenApiProcessor; +import io.smallrye.openapi.runtime.scanner.AnnotationScannerExtension; +import io.smallrye.openapi.runtime.scanner.FilteredIndexView; +import io.smallrye.openapi.runtime.scanner.OpenApiAnnotationScanner; +import jakarta.enterprise.inject.spi.BeanManager; +import jakarta.enterprise.inject.spi.CDI; +import org.eclipse.microprofile.config.Config; +import org.eclipse.microprofile.openapi.models.OpenAPI; +import org.jboss.jandex.IndexView; + +/** + * A {@link OpenApiManager} for MicroProfile. + */ +final class MpOpenApiManager implements OpenApiManager { + + private static final System.Logger LOGGER = System.getLogger(MpOpenApiManager.class.getName()); + private static final String CONFIG_EXT_PREFIX = "mp.openapi.extensions.helidon."; + + /** + * Full config key for the {@code JAXRS_SEMANTICS} option. + */ + static final String USE_JAXRS_SEMANTICS_KEY = CONFIG_EXT_PREFIX + "use-jaxrs-semantics"; + + private final Config config; + private final MpOpenApiManagerConfig managerConfig; + private final OpenApiConfig openApiConfig; + private final List scannerExtensions = List.of(new JsonpAnnotationScannerExtension()); + private final LazyValue> filteredIndexViews = LazyValue.create(this::buildFilteredIndexViews); + + MpOpenApiManager(Config config) { + this.config = config; + this.managerConfig = MpOpenApiManagerConfig.builder() + .update(builder -> config.getOptionalValue(USE_JAXRS_SEMANTICS_KEY, Boolean.class) + .ifPresent(builder::useJaxRsSemantics)) + .build(); + this.openApiConfig = new OpenApiConfigImpl(config); + } + + @Override + public String name() { + return "manager"; + } + + @Override + public String type() { + return "mp"; + } + + @Override + public OpenAPI load(String content) { + ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader(); + OpenApiDocument.INSTANCE.reset(); + OpenApiDocument.INSTANCE.config(openApiConfig); + OpenApiDocument.INSTANCE.modelFromReader(OpenApiProcessor.modelFromReader(openApiConfig, contextClassLoader)); + if (!content.isBlank()) { + OpenAPI document = OpenApiParser.parse(OpenApiHelper.types(), OpenAPI.class, new StringReader(content)); + OpenApiDocument.INSTANCE.modelFromStaticFile(document); + } + if (!openApiConfig.scanDisable()) { + processAnnotations(); + } else { + LOGGER.log(Level.TRACE, "OpenAPI Annotation processing is disabled"); + } + OpenApiDocument.INSTANCE.filter(OpenApiProcessor.getFilter(openApiConfig, contextClassLoader)); + OpenApiDocument.INSTANCE.initialize(); + OpenAPIImpl instance = (OpenAPIImpl) OpenApiDocument.INSTANCE.get(); + + // MergeUtil omits the openapi value, so we need to set it explicitly. + return MergeUtil.merge(new OpenAPIImpl(), instance).openapi(instance.getOpenapi()); + } + + @Override + public String format(OpenAPI model, OpenApiFormat format) { + StringWriter sw = new StringWriter(); + OpenApiSerializer.serialize(OpenApiHelper.types(), model, format, sw); + return sw.toString(); + } + + /** + * Get the filtered index views. + * + * @return list of filter index views + */ + List filteredIndexViews() { + return filteredIndexViews.get(); + } + + private void processAnnotations() { + List indexViews = filteredIndexViews(); + if (openApiConfig.scanDisable() || indexViews.isEmpty()) { + return; + } + + // Conduct a SmallRye OpenAPI annotation scan for each filtered index view + // merging the resulting OpenAPI models into one. + OpenAPI model = new OpenAPIImpl(); // Start with skeletal model + for (IndexView indexView : indexViews) { + OpenApiAnnotationScanner scanner = new OpenApiAnnotationScanner(openApiConfig, indexView, scannerExtensions); + OpenAPI scanned = scanner.scan(); + if (LOGGER.isLoggable(Level.DEBUG)) { + LOGGER.log(Level.DEBUG, String.format( + "Intermediate scanned from filtered index view %s:%n%s", + indexView.getKnownClasses(), + format(scanned, OpenApiFormat.YAML))); + } + model = MergeUtil.merge(model, scanned).openapi(scanned.getOpenapi()); // SmallRye's merge skips openapi value. + } + OpenApiDocument.INSTANCE.modelFromAnnotations(model); + } + + private List buildFilteredIndexViews() { + BeanManager beanManager = CDI.current().getBeanManager(); + List jaxRsApps = beanManager.getExtension(JaxRsCdiExtension.class).applicationsToRun(); + Set> annotatedTypes = beanManager.getExtension(OpenApiCdiExtension.class).annotatedTypes(); + return new FilteredIndexViewsBuilder(config, + jaxRsApps, + annotatedTypes, + managerConfig.indexPaths(), + managerConfig.useJaxRsSemantics()).buildViews(); + } +} diff --git a/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/MpOpenApiManagerConfigBlueprint.java b/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/MpOpenApiManagerConfigBlueprint.java new file mode 100644 index 00000000000..62eb7aeb3ce --- /dev/null +++ b/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/MpOpenApiManagerConfigBlueprint.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2023 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.microprofile.openapi; + +import java.util.List; + +import io.helidon.builder.api.Prototype; +import io.helidon.config.metadata.Configured; +import io.helidon.config.metadata.ConfiguredOption; + +/** + * {@link MpOpenApiManager} prototype. + */ +@Prototype.Blueprint +@Configured +interface MpOpenApiManagerConfigBlueprint { + + /** + * If {@code true} and the {@code jakarta.ws.rs.core.Application} class returns a non-empty set, endpoints defined by + * other resources are not included in the OpenAPI document. + * + * @return {@code true} if enabled, {@code false} otherwise + */ + @ConfiguredOption(key = MpOpenApiManager.USE_JAXRS_SEMANTICS_KEY) + boolean useJaxRsSemantics(); + + /** + * Specify the set of Jandex index path. + * + * @return list of Jandex index path + */ + @ConfiguredOption(configured = false, value = "META-INF/jandex.idx") + List indexPaths(); +} diff --git a/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/OpenApiCdiExtension.java b/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/OpenApiCdiExtension.java index f930611e652..fd39bf1e385 100644 --- a/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/OpenApiCdiExtension.java +++ b/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/OpenApiCdiExtension.java @@ -42,36 +42,16 @@ public class OpenApiCdiExtension extends HelidonRestCdiExtension { private static final System.Logger LOGGER = System.getLogger(OpenApiCdiExtension.class.getName()); - /** - * Normal location of Jandex index files. - */ - static final String INDEX_PATH = "META-INF/jandex.idx"; - - private final String[] paths; - private final Set> annotatedTypes = new HashSet<>(); - - private volatile MpOpenApiFeature openApiFeature; + private volatile OpenApiFeature feature; /** - * Creates a new instance of the index builder. - * + * Creates a new instance. */ public OpenApiCdiExtension() { - this(INDEX_PATH); - } - - OpenApiCdiExtension(String... indexPaths) { - super(LOGGER, OpenApiFeature.Builder.CONFIG_KEY); - this.paths = indexPaths; + super(LOGGER, "openapi", "mp.openapi"); } - @Override - protected void processManagedBean(ProcessManagedBean processManagedBean) { - // SmallRye handles annotation processing. We have this method because the abstract superclass requires it. - } - - /** * Register the Health observer with server observer feature. * This is a CDI observer method invoked by CDI machinery. @@ -83,41 +63,34 @@ public void registerService(@Observes @Priority(LIBRARY_BEFORE + 10) @Initialize Object event, ServerCdiExtension server) { - org.eclipse.microprofile.config.Config mpConfig = ConfigProvider.getConfig(); - - this.openApiFeature = MpOpenApiFeature.builder() + feature = OpenApiFeature.builder() .config(componentConfig()) - .indexPaths(paths) - .config(mpConfig) + .manager(new MpOpenApiManager(ConfigProvider.getConfig())) .build(); - - this.openApiFeature.setup(server.serverRoutingBuilder(), super.routingBuilder(server)); + feature.setup(server.serverRoutingBuilder(), routingBuilder(server)); } - // Must run after the server has created the Application instances. - void buildModel(@Observes @Priority(PLATFORM_AFTER + 100 + 10) @Initialized(ApplicationScoped.class) Object event) { - this.openApiFeature.prepareModel(); - } - - // For testing - MpOpenApiFeature feature() { - return openApiFeature; + @Override + protected void processManagedBean(ProcessManagedBean processManagedBean) { + // SmallRye handles annotation processing. We have this method because the abstract superclass requires it. } - + /** + * Get the annotated types. + * + * @return annotated types + */ Set> annotatedTypes() { return annotatedTypes; } - /** - * Records each type that is annotated. - * - * @param annotated type - * @param event {@code ProcessAnnotatedType} event - */ + // Must run after the server has created the Application instances. + private void buildModel(@Observes @Priority(PLATFORM_AFTER + 100 + 10) @Initialized(ApplicationScoped.class) Object event) { + feature.initialize(); + } + + // Records each type that is annotated private void processAnnotatedType(@Observes ProcessAnnotatedType event) { - Class c = event.getAnnotatedType() - .getJavaClass(); - annotatedTypes.add(c); + annotatedTypes.add(event.getAnnotatedType().getJavaClass()); } } diff --git a/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/ParserHelper.java b/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/OpenApiHelper.java similarity index 71% rename from microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/ParserHelper.java rename to microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/OpenApiHelper.java index cd3107c76ee..95fa26f607d 100644 --- a/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/ParserHelper.java +++ b/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/OpenApiHelper.java @@ -20,6 +20,8 @@ import java.util.Map; import java.util.Set; +import io.helidon.common.LazyValue; + import org.eclipse.microprofile.openapi.models.Extensible; import org.eclipse.microprofile.openapi.models.Operation; import org.eclipse.microprofile.openapi.models.PathItem; @@ -27,11 +29,12 @@ import org.eclipse.microprofile.openapi.models.media.Schema; import org.eclipse.microprofile.openapi.models.servers.ServerVariable; import org.yaml.snakeyaml.TypeDescription; +import org.yaml.snakeyaml.introspector.Property; /** * Wraps generated parser and uses {@link ExpandedTypeDescription} as its type. */ -class ParserHelper { +final class OpenApiHelper { // Temporary to suppress SnakeYAML warnings. // As a static we keep a reference to the logger, thereby making sure any changes we make are persistent. (JUL holds @@ -39,28 +42,18 @@ class ParserHelper { private static final java.util.logging.Logger SNAKE_YAML_INTROSPECTOR_LOGGER = java.util.logging.Logger.getLogger(org.yaml.snakeyaml.introspector.PropertySubstitute.class.getPackage().getName()); - /** - * The SnakeYAMLParserHelper is generated by a maven plug-in. - */ - private final SnakeYAMLParserHelper generatedHelper; + private static final LazyValue INSTANCE = LazyValue.create(OpenApiHelper::new); - private ParserHelper(SnakeYAMLParserHelper generatedHelper) { - this.generatedHelper = generatedHelper; - adjustTypeDescriptions(generatedHelper.types()); - } + // The SnakeYAMLParserHelper is generated by a maven plug-in. + private final SnakeYAMLParserHelper generatedHelper; - /** - * Create a new parser helper. - * - * @return a new parser helper - */ - static ParserHelper create() { + private OpenApiHelper() { boolean warningsEnabled = Boolean.getBoolean("openapi.parsing.warnings.enabled"); if (SNAKE_YAML_INTROSPECTOR_LOGGER.isLoggable(java.util.logging.Level.WARNING) && !warningsEnabled) { SNAKE_YAML_INTROSPECTOR_LOGGER.setLevel(java.util.logging.Level.SEVERE); } - ParserHelper helper = new ParserHelper(SnakeYAMLParserHelper.create(ExpandedTypeDescription::create)); - return helper; + this.generatedHelper = SnakeYAMLParserHelper.create(ExpandedTypeDescription::create); + adjustTypeDescriptions(generatedHelper.types()); } /** @@ -68,41 +61,26 @@ static ParserHelper create() { * * @return types of this helper */ - public Map, ExpandedTypeDescription> types() { - return generatedHelper.types(); - } - - /** - * Entries of this helper. - * - * @return entry set - */ - public Set, ExpandedTypeDescription>> entrySet() { - return generatedHelper.entrySet(); + static Map, ExpandedTypeDescription> types() { + return INSTANCE.get().generatedHelper.types(); } private static void adjustTypeDescriptions(Map, ExpandedTypeDescription> types) { - /* - * We need to adjust the {@code TypeDescription} objects set up by the generated {@code SnakeYAMLParserHelper} class - * because there are some OpenAPI-specific issues that the general-purpose helper generator cannot know about. - */ - - /* - * In the OpenAPI document, HTTP methods are expressed in lower-case. But the associated Java methods on the PathItem - * class use the HTTP method names in upper-case. So for each HTTP method, "add" a property to PathItem's type - * description using the lower-case name but upper-case Java methods and exclude the upper-case property that - * SnakeYAML's automatic analysis of the class already created. - */ + // We need to adjust the {@code TypeDescription} objects set up by the generated {@code SnakeYAMLParserHelper} class + // because there are some OpenAPI-specific issues that the general-purpose helper generator cannot know about. + + // In the OpenAPI document, HTTP methods are expressed in lower-case. But the associated Java methods on the PathItem + // class use the HTTP method names in upper-case. So for each HTTP method, "add" a property to PathItem's type + // description using the lower-case name but upper-case Java methods and exclude the upper-case property that + // SnakeYAML's automatic analysis of the class already created. ExpandedTypeDescription pathItemTD = types.get(PathItem.class); for (PathItem.HttpMethod m : PathItem.HttpMethod.values()) { pathItemTD.substituteProperty(m.name().toLowerCase(), Operation.class, getter(m), setter(m)); pathItemTD.addExcludes(m.name()); } - /* - * An OpenAPI document can contain a property named "enum" for Schema and ServerVariable, but the related Java methods - * use "enumeration". - */ + // An OpenAPI document can contain a property named "enum" for Schema and ServerVariable, but the related Java methods + // use "enumeration". Set.>of(Schema.class, ServerVariable.class).forEach(c -> { ExpandedTypeDescription tdWithEnumeration = types.get(c); tdWithEnumeration.substituteProperty("enum", List.class, "getEnumeration", "setEnumeration"); @@ -110,16 +88,15 @@ private static void adjustTypeDescriptions(Map, ExpandedTypeDescription tdWithEnumeration.addExcludes("enumeration"); }); - /* - * SnakeYAML derives properties only from methods declared directly by each OpenAPI interface, not from methods defined - * on other interfaces which the original one extends. Those we have to handle explicitly. - */ + // SnakeYAML derives properties only from methods declared directly by each OpenAPI interface, not from methods defined + // on other interfaces which the original one extends. Those we have to handle explicitly. for (ExpandedTypeDescription td : types.values()) { if (Extensible.class.isAssignableFrom(td.getType())) { td.addExtensions(); } - if (td.hasDefaultProperty()) { - td.substituteProperty("default", Object.class, "getDefaultValue", "setDefaultValue"); + Property defaultProperty = td.defaultProperty(); + if (defaultProperty != null) { + td.substituteProperty("default", defaultProperty.getType(), "getDefaultValue", "setDefaultValue"); td.addExcludes("defaultValue"); } if (isRef(td)) { diff --git a/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/OpenApiParser.java b/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/OpenApiParser.java index 98fc7c03d0f..ed2d470f199 100644 --- a/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/OpenApiParser.java +++ b/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/OpenApiParser.java @@ -15,14 +15,9 @@ */ package io.helidon.microprofile.openapi; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; import java.io.Reader; -import java.nio.charset.StandardCharsets; import java.util.Map; -import org.eclipse.microprofile.openapi.models.OpenAPI; import org.yaml.snakeyaml.DumperOptions; import org.yaml.snakeyaml.TypeDescription; import org.yaml.snakeyaml.Yaml; @@ -37,20 +32,6 @@ final class OpenApiParser { private OpenApiParser() { } - /** - * Parse open API. - * - * @param types types - * @param inputStream input stream to parse from - * @return parsed document - * @throws IOException in case of I/O problems - */ - static OpenAPI parse(Map, ExpandedTypeDescription> types, InputStream inputStream) throws IOException { - try (Reader reader = new InputStreamReader(inputStream, StandardCharsets.UTF_8)) { - return parse(types, OpenAPI.class, reader); - } - } - /** * Parse YAML or JSON using the specified types, returning the specified type with input taken from the indicated reader. * diff --git a/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/Serializer.java b/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/OpenApiSerializer.java similarity index 74% rename from microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/Serializer.java rename to microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/OpenApiSerializer.java index 051500bfedb..8d76326634c 100644 --- a/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/Serializer.java +++ b/microprofile/openapi/src/main/java/io/helidon/microprofile/openapi/OpenApiSerializer.java @@ -26,7 +26,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; -import io.helidon.openapi.OpenApiFeature; +import io.helidon.openapi.OpenApiFormat; import io.smallrye.openapi.api.models.OpenAPIImpl; import org.eclipse.microprofile.openapi.models.Extensible; @@ -35,6 +35,7 @@ import org.eclipse.microprofile.openapi.models.media.Schema; import org.eclipse.microprofile.openapi.models.parameters.Parameter; import org.yaml.snakeyaml.DumperOptions; +import org.yaml.snakeyaml.DumperOptions.ScalarStyle; import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.introspector.Property; import org.yaml.snakeyaml.nodes.MappingNode; @@ -51,14 +52,14 @@ * while suppressing tags that would indicate the SmallRye classes -- we don't want to * suggest that the output can only be read into the SmallRye implementation. */ -public class Serializer { +final class OpenApiSerializer { private static final DumperOptions YAML_DUMPER_OPTIONS = new DumperOptions(); private static final DumperOptions JSON_DUMPER_OPTIONS = new DumperOptions(); - private static final System.Logger LOGGER = System.getLogger(Serializer.class.getName()); + private static final System.Logger LOGGER = System.getLogger(OpenApiSerializer.class.getName()); - private Serializer() { + private OpenApiSerializer() { } static { @@ -67,7 +68,7 @@ private Serializer() { JSON_DUMPER_OPTIONS.setDefaultFlowStyle(DumperOptions.FlowStyle.FLOW); JSON_DUMPER_OPTIONS.setPrettyFlow(true); - JSON_DUMPER_OPTIONS.setDefaultScalarStyle(DumperOptions.ScalarStyle.DOUBLE_QUOTED); + JSON_DUMPER_OPTIONS.setDefaultScalarStyle(ScalarStyle.DOUBLE_QUOTED); JSON_DUMPER_OPTIONS.setSplitLines(false); } @@ -75,29 +76,26 @@ private Serializer() { * Serialize using the selected format. * * @param types types - * @param implsToTypes implementations to types * @param openAPI Open API document to serialize - * @param openAPIMediaType OpenAPI media type to use + * @param format OpenAPI media type to use * @param writer writer to serialize to */ - public static void serialize(Map, ExpandedTypeDescription> types, - Map, ExpandedTypeDescription> implsToTypes, - OpenAPI openAPI, - OpenApiFeature.OpenAPIMediaType openAPIMediaType, - Writer writer) { - if (openAPIMediaType.equals(OpenApiFeature.OpenAPIMediaType.JSON)) { - serialize(types, implsToTypes, openAPI, writer, JSON_DUMPER_OPTIONS, DumperOptions.ScalarStyle.DOUBLE_QUOTED); + static void serialize(Map, ExpandedTypeDescription> types, + OpenAPI openAPI, + OpenApiFormat format, + Writer writer) { + if (format.equals(OpenApiFormat.JSON)) { + serialize(types, openAPI, writer, JSON_DUMPER_OPTIONS, ScalarStyle.DOUBLE_QUOTED); } else { - serialize(types, implsToTypes, openAPI, writer, YAML_DUMPER_OPTIONS, DumperOptions.ScalarStyle.PLAIN); + serialize(types, openAPI, writer, YAML_DUMPER_OPTIONS, ScalarStyle.PLAIN); } } - private static void serialize(Map, ExpandedTypeDescription> types, - Map, ExpandedTypeDescription> implsToTypes, OpenAPI openAPI, Writer writer, + private static void serialize(Map, ExpandedTypeDescription> types, OpenAPI openAPI, Writer writer, DumperOptions dumperOptions, - DumperOptions.ScalarStyle stringStyle) { + ScalarStyle stringStyle) { - Yaml yaml = new Yaml(new CustomRepresenter(types, implsToTypes, dumperOptions, stringStyle), dumperOptions); + Yaml yaml = new Yaml(new CustomRepresenter(types, dumperOptions, stringStyle), dumperOptions); yaml.dump(openAPI, new TagSuppressingWriter(writer)); } @@ -115,15 +113,11 @@ static class CustomRepresenter extends Representer { private static final String EXTENSIONS = "extensions"; - private final DumperOptions.ScalarStyle stringStyle; + private final ScalarStyle stringStyle; - private final Map, ExpandedTypeDescription> implsToTypes; - CustomRepresenter(Map, ExpandedTypeDescription> types, - Map, ExpandedTypeDescription> implsToTypes, DumperOptions dumperOptions, - DumperOptions.ScalarStyle stringStyle) { + CustomRepresenter(Map, ExpandedTypeDescription> types, DumperOptions dumperOptions, ScalarStyle stringStyle) { super(dumperOptions); - this.implsToTypes = implsToTypes; this.stringStyle = stringStyle; types.values().stream() .map(ImplTypeDescription::new) @@ -131,8 +125,8 @@ static class CustomRepresenter extends Representer { } @Override - protected Node representScalar(Tag tag, String value, DumperOptions.ScalarStyle style) { - return super.representScalar(tag, value, isExemptedFromQuotes(tag) ? DumperOptions.ScalarStyle.PLAIN : style); + protected Node representScalar(Tag tag, String value, ScalarStyle style) { + return super.representScalar(tag, value, isExemptedFromQuotes(tag) ? ScalarStyle.PLAIN : style); } @Override @@ -170,15 +164,12 @@ protected NodeTuple representJavaBeanProperty(Object javaBean, Property property return null; } - Property p = property; Object v = adjustPropertyValue(propertyValue); - if (propertyValue instanceof Enum) { - Enum e = (Enum) propertyValue; + if (propertyValue instanceof Enum e) { v = e.toString(); } - NodeTuple result = okToProcess(javaBean, property) - ? doRepresentJavaBeanProperty(javaBean, p, v, customTag) : null; - return result; + return okToProcess(javaBean, property) + ? doRepresentJavaBeanProperty(javaBean, property, v, customTag) : null; } private NodeTuple doRepresentJavaBeanProperty(Object javaBean, Property property, Object propertyValue, Tag customTag) { @@ -187,11 +178,9 @@ private NodeTuple doRepresentJavaBeanProperty(Object javaBean, Property property return new NodeTuple(representData("$ref"), defaultTuple.getValueNode()); } if (javaBean instanceof Schema) { - /* - * At most one of additionalPropertiesBoolean and additionalPropertiesSchema will return a non-null value. - * Whichever one does (if either), replace the name with "additionalProperties" for output. Skip whatever is - * returned from the deprecated additionalProperties method itself. - */ + // At most one of additionalPropertiesBoolean and additionalPropertiesSchema will return a non-null value. + // Whichever one does (if either), replace the name with "additionalProperties" for output. Skip whatever is + // returned from the deprecated additionalProperties method itself. String propertyName = property.getName(); if (propertyName.equals("additionalProperties")) { return null; @@ -203,19 +192,18 @@ private NodeTuple doRepresentJavaBeanProperty(Object javaBean, Property property } private Object adjustPropertyValue(Object propertyValue) { - /* Some MP OpenAPI TCK tests expect an integer-style format, even for BigDecimal types, if the - * value is an integer. Because the formatting is done in SnakeYAML code based on the type of the value, - * we need to replace a, for example BigDecimal that happen to be an integer value, with an Integer. - * See https://github.com/eclipse/microprofile-open-api/issues/412 - */ - if (Number.class.isInstance(propertyValue) && !Boolean.getBoolean("io.helidon.openapi.skipTCKWorkaround")) { - Number n = (Number) propertyValue; + // Some MP OpenAPI TCK tests expect an integer-style format, even for BigDecimal types, if the + // value is an integer. Because the formatting is done in SnakeYAML code based on the type of the value, + // we need to replace a for example BigDecimal that happen to be an integer value, with an Integer. + // See https://github.com/eclipse/microprofile-open-api/issues/412 + if (propertyValue instanceof Number n && !Boolean.getBoolean("io.helidon.openapi.skipTCKWorkaround")) { float diff = n.floatValue() - n.intValue(); if (diff == 0) { - propertyValue = Integer.valueOf(n.intValue()); + propertyValue = n.intValue(); } else if (Math.abs(diff) < 0.1) { - LOGGER.log(Level.WARNING, - String.format("Integer approximation of %f did not match but the difference was only %e", n, diff)); + LOGGER.log(Level.WARNING, String.format( + "Integer approximation of %f did not match but the difference was only %e", + n.floatValue(), diff)); } } return propertyValue; @@ -223,23 +211,17 @@ private Object adjustPropertyValue(Object propertyValue) { @Override protected MappingNode representJavaBean(Set properties, Object javaBean) { - /* - * First, let SnakeYAML prepare the node normally. If the JavaBean is Extensible and has extension properties, the - * will contain a subnode called "extensions" which itself has one or more subnodes, one for each extension - * property assigned. - */ + // First, let SnakeYAML prepare the node normally. If the JavaBean is Extensible and has extension properties, it + // will contain a sub-node called "extensions" which itself has one or more sub-nodes, one for each extension + // property assigned. MappingNode result = super.representJavaBean(properties, javaBean); - /* - * Now promote the individual subnodes for each extension property (if any) up one level so that they are peers of the - * other properties. Also remove the "extensions" node. - */ + // Now promote the individual sub-nodes for each extension property (if any) up one level so that they are peers of + // the other properties. Also remove the "extensions" node. processExtensions(result, javaBean); - /* - * Clearing representedObjects is an awkward but effective way of preventing SnakeYAML from using anchors and - * aliases, which apparently the Jackson parser used in the TCK (as of this writing) does not handle properly. - */ + // Clearing representedObjects is an awkward but effective way of preventing SnakeYAML from using anchors and + // aliases, which apparently the Jackson parser used in the TCK (as of this writing) does not handle properly. representedObjects.clear(); return result; } @@ -298,13 +280,12 @@ private List processExtensions(NodeTuple tuple) { * @param property the property being serialized * @return true if the property should be processes; false otherwise */ + @SuppressWarnings("ConstantValue") private boolean okToProcess(Object javaBean, Property property) { - /* - * The following construct might look awkward - and it is. But if SmallRye adds additional properties to its - * implementation classes that are not in the corresponding interfaces - and therefore we want to skip processing - * them - then we can just add additional lines like the "reject |= ..." one, testing for the new case, without - * having to change any other lines in the method. - */ + // The following construct might look awkward - and it is. But if SmallRye adds additional properties to its + // implementation classes that are not in the corresponding interfaces - and therefore we want to skip processing + // them - then we can just add additional lines like the "reject |= ..." one, testing for the new case, without + // having to change any other lines in the method. boolean reject = false; reject |= Parameter.class.isAssignableFrom(javaBean.getClass()) && property.getName().equals("hidden"); return !reject; diff --git a/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/AdditionalPropertiesTest.java b/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/AdditionalPropertiesTest.java new file mode 100644 index 00000000000..35c5a9d8e50 --- /dev/null +++ b/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/AdditionalPropertiesTest.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2021, 2023 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.microprofile.openapi; + +import java.io.StringReader; +import java.io.StringWriter; +import java.util.Map; + +import io.helidon.openapi.OpenApiFormat; + +import org.eclipse.microprofile.openapi.models.OpenAPI; +import org.eclipse.microprofile.openapi.models.media.Schema; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +import static io.helidon.microprofile.openapi.TestUtil.query; +import static io.helidon.microprofile.openapi.TestUtil.resource; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasKey; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; + +class AdditionalPropertiesTest { + + @Test + void checkParsingBooleanAdditionalProperties() { + OpenAPI openAPI = parse("/withBooleanAddlProps.yml"); + Schema itemSchema = openAPI.getComponents().getSchemas().get("item"); + + Schema additionalPropertiesSchema = itemSchema.getAdditionalPropertiesSchema(); + Boolean additionalPropertiesBoolean = itemSchema.getAdditionalPropertiesBoolean(); + + assertThat(additionalPropertiesSchema, is(nullValue())); + assertThat(additionalPropertiesBoolean, is(notNullValue())); + assertThat(additionalPropertiesBoolean, is(false)); + } + + @Test + void checkParsingSchemaAdditionalProperties() { + OpenAPI openAPI = parse("/withSchemaAddlProps.yml"); + Schema itemSchema = openAPI.getComponents().getSchemas().get("item"); + + Schema additionalPropertiesSchema = itemSchema.getAdditionalPropertiesSchema(); + Boolean additionalPropertiesBoolean = itemSchema.getAdditionalPropertiesBoolean(); + + assertThat(additionalPropertiesBoolean, is(nullValue())); + assertThat(additionalPropertiesSchema, is(notNullValue())); + + Map additionalProperties = additionalPropertiesSchema.getProperties(); + assertThat(additionalProperties, hasKey("code")); + assertThat(additionalProperties, hasKey("text")); + } + + @Test + void checkWritingSchemaAdditionalProperties() { + OpenAPI openAPI = parse("/withSchemaAddlProps.yml"); + String document = format(openAPI); + + // Expected output: + // additionalProperties: + // type: object + // properties: + // code: + // type: integer + // text: + // type: string + Yaml yaml = new Yaml(); + Map model = yaml.load(document); + Object additionalProperties = query(model, "components.schemas.item.additionalProperties", Object.class); + + assertThat(additionalProperties, is(instanceOf(Map.class))); + } + + @Test + void checkWritingBooleanAdditionalProperties() { + OpenAPI openAPI = parse("/withBooleanAddlProps.yml"); + String document = format(openAPI); + + assertThat(document, containsString("additionalProperties: false")); + } + + private static String format(OpenAPI model) { + StringWriter sw = new StringWriter(); + OpenApiSerializer.serialize(OpenApiHelper.types(), model, OpenApiFormat.YAML, sw); + return sw.toString(); + } + + private static OpenAPI parse(String path) { + String document = resource(path); + return OpenApiParser.parse(OpenApiHelper.types(), OpenAPI.class, new StringReader(document)); + } +} diff --git a/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/BasicServerTest.java b/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/BasicServerTest.java index 274ff47fef5..3b1dee079df 100644 --- a/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/BasicServerTest.java +++ b/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/BasicServerTest.java @@ -17,10 +17,9 @@ import java.util.Map; -import io.helidon.http.Status; +import io.helidon.common.media.type.MediaTypes; import io.helidon.microprofile.testing.junit5.AddBean; import io.helidon.microprofile.testing.junit5.HelidonTest; -import io.helidon.openapi.OpenApiFeature; import jakarta.inject.Inject; import jakarta.ws.rs.client.WebTarget; @@ -33,63 +32,37 @@ import static org.hamcrest.Matchers.is; /** - * Test that MP OpenAPI support works when retrieving the OpenAPI document - * from the server's /openapi endpoint. + * Test model from annotations. */ @HelidonTest @AddBean(TestApp.class) @AddBean(TestApp3.class) -public class BasicServerTest { +class BasicServerTest { - private static Map yaml; + private static final String APPLICATION_OPENAPI_YAML = MediaTypes.APPLICATION_OPENAPI_YAML.text(); @Inject - WebTarget webTarget; + private WebTarget webTarget; - private static Map retrieveYaml(WebTarget webTarget) { - try (Response response = webTarget - .path(OpenApiFeature.DEFAULT_CONTEXT) - .request(OpenApiFeature.DEFAULT_RESPONSE_MEDIA_TYPE.text()) - .get()) { - assertThat("Fetch of OpenAPI document from server status", response.getStatus(), - is(equalTo(Status.OK_200.code()))); - String yamlText = response.readEntity(String.class); - return new Yaml().load(yamlText); - } - } - - private static Map yaml(WebTarget webTarget) { - if (yaml == null) { - yaml = retrieveYaml(webTarget); - } - return yaml; - } - - private Map yaml() { - return yaml(webTarget); - } - - public BasicServerTest() { - } - - /** - * Make sure that the annotations in the test app were found and properly - * incorporated into the OpenAPI document. - * - * @throws Exception in case of errors reading the HTTP response - */ @Test - public void simpleTest() throws Exception { - checkPathValue("paths./testapp/go.get.summary", TestApp.GO_SUMMARY); + public void simpleTest() { + Map document = document(); + String summary = TestUtil.query(document, "paths./testapp/go.get.summary", String.class); + assertThat(summary, is(equalTo(TestApp.GO_SUMMARY))); } @Test public void testMultipleApps() { - checkPathValue("paths./testapp3/go3.get.summary", TestApp3.GO_SUMMARY); + Map document = document(); + String summary = TestUtil.query(document, "paths./testapp3/go3.get.summary", String.class); + assertThat(summary, is(equalTo(TestApp3.GO_SUMMARY))); } - private void checkPathValue(String pathExpression, String expected) { - String result = TestUtil.fromYaml(yaml(), pathExpression, String.class); - assertThat(pathExpression, result, is(equalTo(expected))); + private Map document() { + try (Response response = webTarget.path("/openapi").request(APPLICATION_OPENAPI_YAML).get()) { + assertThat(response.getStatus(), is(200)); + String yamlText = response.readEntity(String.class); + return new Yaml().load(yamlText); + } } } diff --git a/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/FilteredIndexViewsBuilderTest.java b/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/FilteredIndexViewsBuilderTest.java new file mode 100644 index 00000000000..47c5e105ee1 --- /dev/null +++ b/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/FilteredIndexViewsBuilderTest.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2020, 2023 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.microprofile.openapi; + +import java.util.List; +import java.util.Set; + +import io.helidon.microprofile.openapi.other.TestApp2; +import io.helidon.microprofile.server.JaxRsApplication; + +import io.smallrye.openapi.runtime.scanner.FilteredIndexView; +import org.jboss.jandex.ClassInfo; +import org.jboss.jandex.DotName; +import org.junit.jupiter.api.Test; + +import static io.helidon.microprofile.openapi.TestUtil.config; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; + +/** + * Tests {@link FilteredIndexViewsBuilder}. + */ +class FilteredIndexViewsBuilderTest { + + @Test + void testMultipleIndexFiles() { + + // The pom builds two differently-named test Jandex files, as an approximation + // to handling multiple same-named index files in the class path. + + List indexPaths = List.of("META-INF/jandex.idx", "META-INF/other.idx"); + + List apps = List.of( + JaxRsApplication.create(new TestApp()), + JaxRsApplication.create(new TestApp2())); + + List indexViews = new FilteredIndexViewsBuilder( + config(), apps, Set.of(), indexPaths, false).buildViews(); + + List filteredIndexViews = indexViews.stream() + .flatMap(view -> view.getKnownClasses().stream()) + .toList(); + + DotName testAppName = DotName.createSimple(TestApp.class.getName()); + DotName testApp2Name = DotName.createSimple(TestApp2.class.getName()); + + ClassInfo testAppInfo = filteredIndexViews.stream() + .filter(classInfo -> classInfo.name().equals(testAppName)) + .findFirst() + .orElse(null); + assertThat(testAppInfo, notNullValue()); + + ClassInfo testApp2Info = filteredIndexViews.stream() + .filter(classInfo -> classInfo.name().equals(testApp2Name)) + .findFirst() + .orElse(null); + assertThat(testApp2Info, notNullValue()); + } +} diff --git a/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/OpenApiConfigTest.java b/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/OpenApiConfigTest.java new file mode 100644 index 00000000000..ff8a70918a2 --- /dev/null +++ b/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/OpenApiConfigTest.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2019, 2023 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.microprofile.openapi; + +import java.util.Map; +import java.util.StringJoiner; + +import io.smallrye.openapi.api.OpenApiConfig; +import io.smallrye.openapi.api.OpenApiConfigImpl; +import org.junit.jupiter.api.Test; + +import static io.helidon.microprofile.openapi.TestUtil.config; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.hasKey; +import static org.hamcrest.Matchers.is; + +/** + * Tests {@link io.smallrye.openapi.api.OpenApiConfig}. + */ +class OpenApiConfigTest { + + private static final Map SCHEMA_OVERRIDE_VALUES = Map.of( + "name", "EpochMillis", + "type", "number", + "format", "int64", + "description", "Milliseconds since January 1, 1970, 00:00:00 GMT"); + + private static final String SCHEMA_OVERRIDE_JSON = prepareSchemaOverrideJSON(); + + private static final String SCHEMA_OVERRIDE_CONFIG_FQCN = "java.util.Date"; + + private static final Map SIMPLE_CONFIG = Map.of( + "mp.openapi.model.reader", "io.helidon.microprofile.openapi.test.MyModelReader", + "mp.openapi.filter", "io.helidon.microprofile.openapi.test.MySimpleFilter", + "mp.openapi.servers", "s1,s2", + "mp.openapi.servers.path.path1", "p1s1,p1s2", + "mp.openapi.servers.path.path2", "p2s1,p2s2", + "mp.openapi.servers.operation.op1", "o1s1,o1s2", + "mp.openapi.servers.operation.op2", "o2s1,o2s2", + "mp.openapi.scan.disable", "true" + ); + + private static final Map SCHEMA_OVERRIDE_CONFIG = Map.of( + "mp.openapi.schema." + SCHEMA_OVERRIDE_CONFIG_FQCN, SCHEMA_OVERRIDE_JSON + ); + + private static String prepareSchemaOverrideJSON() { + StringJoiner sj = new StringJoiner(",\n", "{\n", "\n}"); + SCHEMA_OVERRIDE_VALUES.forEach((key, value) -> sj.add("\"" + key + "\": \"" + value + "\"")); + return sj.toString(); + } + + @Test + public void simpleConfigTest() { + OpenApiConfig openApiConfig = openApiConfig(SIMPLE_CONFIG); + + assertThat(openApiConfig.modelReader(), is("io.helidon.microprofile.openapi.test.MyModelReader")); + assertThat(openApiConfig.filter(), is("io.helidon.microprofile.openapi.test.MySimpleFilter")); + assertThat(openApiConfig.scanDisable(), is(true)); + assertThat(openApiConfig.servers(), containsInAnyOrder("s1", "s2")); + assertThat(openApiConfig.pathServers("path1"), containsInAnyOrder("p1s1", "p1s2")); + assertThat(openApiConfig.pathServers("path2"), containsInAnyOrder("p2s1", "p2s2")); + } + + @Test + void checkSchemaConfig() { + OpenApiConfig openApiConfig = openApiConfig(SIMPLE_CONFIG, SCHEMA_OVERRIDE_CONFIG); + Map schemas = openApiConfig.getSchemas(); + + assertThat(schemas, hasKey(SCHEMA_OVERRIDE_CONFIG_FQCN)); + assertThat(schemas.get(SCHEMA_OVERRIDE_CONFIG_FQCN), is(SCHEMA_OVERRIDE_JSON)); + } + + @SafeVarargs + private static OpenApiConfig openApiConfig(Map... configSources) { + return new OpenApiConfigImpl(config(configSources)); + } +} diff --git a/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/OpenApiParserTest.java b/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/OpenApiParserTest.java new file mode 100644 index 00000000000..31f89d32154 --- /dev/null +++ b/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/OpenApiParserTest.java @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2020, 2023 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.microprofile.openapi; + +import java.io.StringReader; +import java.util.List; +import java.util.Map; + +import org.eclipse.microprofile.openapi.models.OpenAPI; +import org.eclipse.microprofile.openapi.models.Paths; +import org.eclipse.microprofile.openapi.models.parameters.Parameter; +import org.eclipse.microprofile.openapi.models.servers.Server; +import org.eclipse.microprofile.openapi.models.servers.ServerVariable; +import org.junit.jupiter.api.Test; + +import static io.helidon.common.testing.junit5.OptionalMatcher.optionalValue; +import static io.helidon.microprofile.openapi.TestUtil.resource; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +/** + * Tests {@link OpenApiParser}. + */ +class OpenApiParserTest { + + @Test + void testParserUsingYAML() { + OpenAPI openAPI = parse("/petstore.yaml"); + assertThat(openAPI.getOpenapi(), is("3.0.0")); + assertThat(openAPI.getPaths().getPathItem("/pets").getGET().getParameters().get(0).getIn(), + is(Parameter.In.QUERY)); + } + + @Test + void testExtensions() { + OpenAPI openAPI = parse("/openapi-greeting.yml"); + Object xMyPersonalMap = openAPI.getExtensions().get("x-my-personal-map"); + assertThat(xMyPersonalMap, is(instanceOf(Map.class))); + Map map = (Map) xMyPersonalMap; + Object owner = map.get("owner"); + Object value1 = map.get("value-1"); + assertThat(value1, is(instanceOf(Double.class))); + Double d = (Double) value1; + assertThat(d, equalTo(2.3)); + + assertThat(owner, is(instanceOf(Map.class))); + map = (Map) owner; + assertThat(map.get("first"), equalTo("Me")); + assertThat(map.get("last"), equalTo("Myself")); + + Object xBoolean = openAPI.getExtensions().get("x-boolean"); + assertThat(xBoolean, is(instanceOf(Boolean.class))); + Boolean b = (Boolean) xBoolean; + assertThat(b, is(true)); + + Object xInt = openAPI.getExtensions().get("x-int"); + assertThat(xInt, is(instanceOf(Integer.class))); + Integer i = (Integer) xInt; + assertThat(i, is(117)); + + Object xStrings = openAPI.getExtensions().get("x-string-array"); + assertThat(xStrings, is(instanceOf(List.class))); + List list = (List) xStrings; + Object first = list.get(0); + assertThat(first, is(instanceOf(String.class))); + String f = (String) first; + assertThat(f, is(equalTo("one"))); + } + + + @Test + void testYamlRef() { + OpenAPI openAPI = parse("/petstore.yaml"); + Paths paths = openAPI.getPaths(); + String ref = paths.getPathItem("/pets") + .getGET() + .getResponses() + .getAPIResponse("200") + .getContent() + .getMediaType("application/json") + .getSchema() + .getRef(); + + assertThat("ref value", ref, is(equalTo("#/components/schemas/Pets"))); + } + + @Test + void testJsonRef() { + OpenAPI openAPI = parse("/petstore.json"); + Paths paths = openAPI.getPaths(); + String ref = paths.getPathItem("/user") + .getPOST() + .getRequestBody() + .getContent() + .getMediaType("application/json") + .getSchema() + .getRef(); + + assertThat("ref value", ref, is(equalTo("#/components/schemas/User"))); + } + + @Test + void testParserUsingJSON() { + OpenAPI openAPI = parse("/petstore.json"); + assertThat(openAPI.getOpenapi(), is("3.0.0")); + + // TODO - uncomment the following once full $ref support is in place + // assertThat(openAPI.getPaths().getPathItem("/pet").getPUT().getRequestBody().getDescription(), + // containsString("needs to be added to the store")); + } + + @Test + @SuppressWarnings("HttpUrlsUsage") + void testComplicatedPetstoreDocument() { + OpenAPI openAPI = parse("/petstore-with-fake-endpoints-models.yaml"); + assertThat(openAPI.getOpenapi(), is("3.0.0")); + assertThat("Default for server variable 'port'", + openAPI.getPaths() + .getPathItem("/pet") + .getServers() + .stream() + .filter(server -> server.getUrl().equals("http://{server}.swagger.io:{port}/v2")) + .map(Server::getVariables) + .map(map -> map.get("server")) + .map(ServerVariable::getDefaultValue) + .findFirst(), + optionalValue(is("petstore"))); + } + + private static OpenAPI parse(String path) { + String document = resource(path); + return OpenApiParser.parse(OpenApiHelper.types(), OpenAPI.class, new StringReader(document)); + } +} diff --git a/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/OpenApiSerializerTest.java b/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/OpenApiSerializerTest.java new file mode 100644 index 00000000000..624bb70af15 --- /dev/null +++ b/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/OpenApiSerializerTest.java @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2020, 2023 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.microprofile.openapi; + +import java.io.IOException; +import java.io.LineNumberReader; +import java.io.Reader; +import java.io.StringReader; +import java.io.StringWriter; +import java.io.Writer; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.helidon.openapi.OpenApiFormat; + +import jakarta.json.Json; +import jakarta.json.JsonArray; +import jakarta.json.JsonObject; +import jakarta.json.JsonReader; +import jakarta.json.JsonStructure; +import jakarta.json.JsonValue; +import org.eclipse.microprofile.openapi.models.OpenAPI; +import org.junit.jupiter.api.Test; + +import static io.helidon.microprofile.openapi.TestUtil.resource; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.CoreMatchers.hasItem; +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.startsWith; +import static org.hamcrest.MatcherAssert.assertThat; + +class OpenApiSerializerTest { + + @Test + public void testJSONSerialization() { + OpenAPI openAPI = parse("/openapi-greeting.yml"); + Writer writer = new StringWriter(); + OpenApiSerializer.serialize(OpenApiHelper.types(), openAPI, OpenApiFormat.JSON, writer); + + JsonStructure json = readJson(writer.toString()); + + assertThat(json.getValue("/x-my-personal-map/owner/last").toString(), is("\"Myself\"")); + JsonValue otherItem = json.getValue("/x-other-item"); + assertThat(otherItem.getValueType(), is(JsonValue.ValueType.NUMBER)); + assertThat(Double.valueOf(otherItem.toString()), is(10.0)); + + JsonValue seq = json.getValue("/info/x-my-personal-seq"); + assertThat(seq.getValueType(), is(JsonValue.ValueType.ARRAY)); + JsonArray seqArray = seq.asJsonArray(); + JsonValue first = seqArray.get(0); + assertThat(first.getValueType(), is(JsonValue.ValueType.OBJECT)); + JsonObject firstObj = first.asJsonObject(); + checkJsonPathStringValue(firstObj, "/who", "Prof. Plum"); + checkJsonPathStringValue(firstObj, "/why", "felt like it"); + + JsonValue second = seqArray.get(1); + assertThat(second.getValueType(), is(JsonValue.ValueType.OBJECT)); + JsonObject secondObj = second.asJsonObject(); + checkJsonPathStringValue(secondObj, "/when", "yesterday"); + checkJsonPathStringValue(secondObj, "/how", "with the lead pipe"); + + JsonValue xInt = json.getValue("/x-int"); + assertThat(xInt.getValueType(), is(JsonValue.ValueType.NUMBER)); + assertThat(Integer.valueOf(xInt.toString()), is(117)); + + JsonValue xBoolean = json.getValue("/x-boolean"); + assertThat(xBoolean.getValueType(), is(JsonValue.ValueType.TRUE)); + + JsonValue xStrings = json.getValue("/x-string-array"); + assertThat(xStrings.getValueType(), is(JsonValue.ValueType.ARRAY)); + JsonArray xStringArray = xStrings.asJsonArray(); + assertThat(xStringArray.size(), is(2)); + checkJsonStringValue(xStringArray.get(0), "one"); + checkJsonStringValue(xStringArray.get(1), "two"); + + JsonValue xObjects = json.getValue("/x-object-array"); + assertThat(xObjects.getValueType(), is(JsonValue.ValueType.ARRAY)); + JsonArray xObjectArray = xObjects.asJsonArray(); + assertThat(xObjectArray.size(), is(2)); + first = xObjectArray.get(0); + assertThat(first.getValueType(), is(JsonValue.ValueType.OBJECT)); + firstObj = first.asJsonObject(); + checkJsonPathStringValue(firstObj, "/name", "item-1"); + checkJsonPathIntValue(firstObj, "/value", 16); + second = xObjectArray.get(1); + assertThat(second.getValueType(), is(JsonValue.ValueType.OBJECT)); + secondObj = second.asJsonObject(); + checkJsonPathStringValue(secondObj, "/name", "item-2"); + checkJsonPathIntValue(secondObj, "/value", 18); + + } + + @Test + public void testYAMLSerialization() throws IOException { + OpenAPI openAPI = parse("/openapi-greeting.yml"); + Writer writer = new StringWriter(); + OpenApiSerializer.serialize(OpenApiHelper.types(), openAPI, OpenApiFormat.YAML, writer); + try (Reader reader = new StringReader(writer.toString())) { + openAPI = OpenApiParser.parse(OpenApiHelper.types(), OpenAPI.class, reader); + } + Object candidateMap = openAPI.getExtensions() + .get("x-my-personal-map"); + assertThat(candidateMap, is(instanceOf(Map.class))); + + Map map = (Map) candidateMap; + Object candidateOwnerMap = map.get("owner"); + assertThat(candidateOwnerMap, is(instanceOf(Map.class))); + + Map ownerMap = (Map) candidateOwnerMap; + assertThat(ownerMap.get("last"), is("Myself")); + + List required = openAPI.getPaths().getPathItem("/greet/greeting") + .getPUT() + .getRequestBody() + .getContent() + .getMediaType("application/json") + .getSchema() + .getRequired(); + assertThat(required, hasItem("greeting")); + } + + @Test + void testRefSerializationAsOpenAPI() throws IOException { + OpenAPI openAPI = parse("/petstore.yaml"); + Writer writer = new StringWriter(); + OpenApiSerializer.serialize(OpenApiHelper.types(), openAPI, OpenApiFormat.YAML, writer); + + try (Reader reader = new StringReader(writer.toString())) { + openAPI = OpenApiParser.parse(OpenApiHelper.types(), OpenAPI.class, reader); + } + + String ref = openAPI.getPaths() + .getPathItem("/pets") + .getGET() + .getResponses() + .getDefaultValue() + .getContent() + .getMediaType("application/json") + .getSchema() + .getRef(); + + assertThat(ref, is(equalTo("#/components/schemas/Error"))); + } + + @Test + void testRefSerializationAsText() throws IOException { + // This test basically replicates the other ref test but without parsing again, just in case there might be + // compensating bugs in the parsing and the serialization. + Pattern refPattern = Pattern.compile("\\s\\$ref: '([^']+)"); + + OpenAPI openAPI = parse("/petstore.yaml"); + Writer writer = new StringWriter(); + OpenApiSerializer.serialize(OpenApiHelper.types(), openAPI, OpenApiFormat.YAML, writer); + + try (LineNumberReader reader = new LineNumberReader(new StringReader(writer.toString()))) { + String line; + while ((line = reader.readLine()) != null) { + Matcher refMatcher = refPattern.matcher(line); + if (refMatcher.matches()) { + assertThat(refMatcher.group(1), startsWith("#/components")); + } + } + } + } + + private static void checkJsonPathStringValue(JsonObject jsonObject, String pointer, String expected) { + checkJsonStringValue(jsonObject.getValue(pointer), expected); + } + + private static void checkJsonStringValue(JsonValue jsonValue, String expected) { + assertThat(jsonValue.getValueType(), is(JsonValue.ValueType.STRING)); + assertThat(jsonValue.toString(), is("\"" + expected + "\"")); + } + + @SuppressWarnings("SameParameterValue") + private static void checkJsonPathIntValue(JsonObject jsonObject, String pointer, int expected) { + checkJsonIntValue(jsonObject.getValue(pointer), expected); + } + + private static void checkJsonIntValue(JsonValue val, int expected) { + assertThat(val.getValueType(), is(JsonValue.ValueType.NUMBER)); + assertThat(Integer.valueOf(val.toString()), is(expected)); + } + + private static JsonStructure readJson(String str) { + try (JsonReader jsonReader = Json.createReader(new StringReader(str))) { + return jsonReader.read(); + } + } + + private static OpenAPI parse(String path) { + String document = resource(path); + return OpenApiParser.parse(OpenApiHelper.types(), OpenAPI.class, new StringReader(document)); + } +} diff --git a/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/ServerConfigTest.java b/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/ServerConfigTest.java new file mode 100644 index 00000000000..00582a363f6 --- /dev/null +++ b/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/ServerConfigTest.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2020, 2023 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.microprofile.openapi; + +import java.util.Map; + +import io.helidon.common.media.type.MediaTypes; +import io.helidon.microprofile.testing.junit5.AddBean; +import io.helidon.microprofile.testing.junit5.AddConfig; +import io.helidon.microprofile.testing.junit5.HelidonTest; + +import jakarta.inject.Inject; +import jakarta.ws.rs.client.WebTarget; +import jakarta.ws.rs.core.Response; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +@HelidonTest +@AddConfig(key = "openapi.web-context", value = "/alt-openapi") +@AddBean(TestApp.class) +class ServerConfigTest { + + private static final String APPLICATION_OPENAPI_YAML = MediaTypes.APPLICATION_OPENAPI_YAML.text(); + + @Inject + private WebTarget webTarget; + + @Test + public void testAlternatePath() { + Map document = document(); + String summary = TestUtil.query(document, "paths./testapp/go.get.summary", String.class); + assertThat(summary, is(TestApp.GO_SUMMARY)); + } + + private Map document() { + try (Response response = webTarget.path("/alt-openapi").request(APPLICATION_OPENAPI_YAML).get()) { + assertThat(response.getStatus(), is(200)); + String yamlText = response.readEntity(String.class); + return new Yaml().load(yamlText); + } + } +} diff --git a/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/ServerModelReaderTest.java b/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/ServerModelReaderTest.java new file mode 100644 index 00000000000..21bbea1ebe0 --- /dev/null +++ b/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/ServerModelReaderTest.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2019, 2023 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.microprofile.openapi; + +import java.io.StringReader; + +import io.helidon.common.media.type.MediaTypes; +import io.helidon.microprofile.testing.junit5.AddBean; +import io.helidon.microprofile.testing.junit5.AddConfig; +import io.helidon.microprofile.testing.junit5.HelidonTest; +import io.helidon.microprofile.openapi.test.MyModelReader; + +import jakarta.inject.Inject; +import jakarta.json.Json; +import jakarta.json.JsonException; +import jakarta.json.JsonReader; +import jakarta.json.JsonString; +import jakarta.json.JsonStructure; +import jakarta.json.JsonValue; +import jakarta.ws.rs.client.WebTarget; +import jakarta.ws.rs.core.Response; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +/** + * Makes sure that the app-supplied model reader participates in constructing + * the OpenAPI model. + */ +@HelidonTest +@AddConfig(key = "mp.openapi.model.reader", value = "io.helidon.microprofile.openapi.test.MyModelReader") +@AddConfig(key = "mp.openapi.filter", value = "io.helidon.microprofile.openapi.test.MySimpleFilter") +@AddBean(TestApp.class) +class ServerModelReaderTest { + + private static final String APPLICATION_OPENAPI_JSON = MediaTypes.APPLICATION_OPENAPI_JSON.text(); + + @Inject + private WebTarget webTarget; + + @Test + void checkCustomModelReader() { + try (Response response = webTarget.path("/openapi").request(APPLICATION_OPENAPI_JSON).get()) { + assertThat(response.getStatus(), is(200)); + assertThat(response.getMediaType().toString(), is(APPLICATION_OPENAPI_JSON)); + String text = response.readEntity(String.class); + JsonStructure json = readJson(text); + + // The model reader adds the following key/value (among others) to the model. + JsonValue v = json.getValue(String.format("/paths/%s/get/summary", + escapeJsonPointer(MyModelReader.MODEL_READER_PATH))); + assertThat(v.getValueType(), is(JsonValue.ValueType.STRING)); + assertThat(((JsonString) v).getString(), is(MyModelReader.SUMMARY)); + } + } + + @Test + void makeSureFilteredPathIsMissing() { + try (Response response = webTarget.path("/openapi").request(APPLICATION_OPENAPI_JSON).get()) { + assertThat(response.getStatus(), is(200)); + assertThat(response.getMediaType().toString(), is(APPLICATION_OPENAPI_JSON)); + String text = response.readEntity(String.class); + JsonStructure json = readJson(text); + + // Although the model reader adds this path, the filter should have removed it. + JsonException ex = assertThrows(JsonException.class, () -> + json.getValue(String.format("/paths/%s/get/summary", escapeJsonPointer(MyModelReader.DOOMED_PATH)))); + + assertThat(ex.getMessage(), containsString( + String.format("contains no mapping for the name '%s'", MyModelReader.DOOMED_PATH))); + } + } + + private static JsonStructure readJson(String str) { + try (JsonReader jsonReader = Json.createReader(new StringReader(str))) { + return jsonReader.read(); + } + } + + private static String escapeJsonPointer(String pointer) { + return pointer.replaceAll("~", "~0").replaceAll("/", "~1"); + } +} diff --git a/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/TestApp.java b/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/TestApp.java index e61dc798cdb..7f1896a8494 100644 --- a/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/TestApp.java +++ b/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/TestApp.java @@ -30,13 +30,10 @@ import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; -/** - * Test JAX-RS app for MP OpenAPI testing. - */ - -public class TestApp extends Application { +class TestApp extends Application { static final String GO_SUMMARY = "Returns a fixed string"; + @Override public Set> getClasses() { return Set.of(TestResources.class); @@ -48,10 +45,9 @@ public static class TestResources { @Path("/go") @GET @Operation(summary = GO_SUMMARY, - description = "Provides a single, fixed string as the response") + description = "Provides a single, fixed string as the response") @APIResponse(description = "Simple text string", - content = @Content(mediaType = "text/plain") - ) + content = @Content(mediaType = "text/plain")) @Produces(MediaType.TEXT_PLAIN) public Response go() { return Response.ok("Test").build(); @@ -60,15 +56,12 @@ public Response go() { @Path("/send") @PUT @Operation(summary = "Sends a simple string", - description = "Permits the client to send a string to the server" - ) + description = "Permits the client to send a string to the server") @RequestBody( - name = "message", - description = "Conveys the simple string message", - content = @Content( - mediaType = "text/plain" - ) - ) + name = "message", + description = "Conveys the simple string message", + content = @Content(mediaType = "text/plain") + ) @Consumes(MediaType.TEXT_PLAIN) @Produces(MediaType.TEXT_PLAIN) public Response send(String message) { diff --git a/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/TestApp3.java b/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/TestApp3.java index b6a5ef2c0f1..f518b6435e3 100644 --- a/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/TestApp3.java +++ b/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/TestApp3.java @@ -27,9 +27,10 @@ import org.eclipse.microprofile.openapi.annotations.media.Content; import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; -public class TestApp3 extends Application { +class TestApp3 extends Application { static final String GO_SUMMARY = "Returns a fixed string 3"; + @Override public Set> getClasses() { return Set.of(TestResources.class); @@ -41,10 +42,9 @@ public static class TestResources { @Path("/go3") @GET @Operation(summary = GO_SUMMARY, - description = "Provides a single, fixed string as the response") + description = "Provides a single, fixed string as the response") @APIResponse(description = "Simple text string", - content = @Content(mediaType = "text/plain") - ) + content = @Content(mediaType = "text/plain")) @Produces(MediaType.TEXT_PLAIN) public Response go() { return Response.ok("Test").build(); diff --git a/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/TestMultiJandex.java b/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/TestMultiJandex.java deleted file mode 100644 index 1c92aaf8621..00000000000 --- a/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/TestMultiJandex.java +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright (c) 2020, 2023 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.microprofile.openapi; - -import java.io.IOException; - -import io.helidon.microprofile.openapi.other.TestApp2; - -import org.jboss.jandex.ClassInfo; -import org.jboss.jandex.DotName; -import org.jboss.jandex.IndexView; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; - -import static org.hamcrest.CoreMatchers.notNullValue; -import static org.hamcrest.MatcherAssert.assertThat; - -@Disabled -public class TestMultiJandex { - - @Test - public void testMultipleIndexFiles() throws IOException { - - /* - * The pom builds two differently-named test Jandex files, as an approximation - * to handling multiple same-named index files in the class path. - */ - OpenApiCdiExtension ext = new OpenApiCdiExtension("META-INF/jandex.idx", "META-INF/other.idx"); - IndexView indexView = ext.feature().indexView(); - - - DotName testAppName = DotName.createSimple(TestApp.class.getName()); - DotName testApp2Name = DotName.createSimple(TestApp2.class.getName()); - - ClassInfo testAppInfo = indexView.getClassByName(testAppName); - assertThat("Expected index entry for TestApp not found", testAppInfo, notNullValue()); - - ClassInfo testApp2Info = indexView.getClassByName(testApp2Name); - assertThat("Expected index entry for TestApp2 not found", testApp2Info, notNullValue()); - } -} \ No newline at end of file diff --git a/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/TestServerWithConfig.java b/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/TestServerWithConfig.java deleted file mode 100644 index be666aa1e16..00000000000 --- a/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/TestServerWithConfig.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright (c) 2020, 2023 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.microprofile.openapi; - -import java.net.HttpURLConnection; -import java.util.Map; - -import io.helidon.http.HttpMediaType; -import io.helidon.common.media.type.MediaTypes; -import io.helidon.config.ClasspathConfigSource; -import io.helidon.config.Config; -import io.helidon.microprofile.server.Server; - -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.CoreMatchers.is; - -class TestServerWithConfig { - - private static final String ALTERNATE_OPENAPI_PATH = "/otheropenapi"; - - private static Server server; - - private static HttpURLConnection cnx; - - private static Map yaml; - - public TestServerWithConfig() { - } - - @BeforeAll - public static void startServer() throws Exception { - Config helidonConfig = Config.builder().addSource(ClasspathConfigSource.create("/serverConfig.yml")).build(); - server = TestUtil.startServer(helidonConfig, TestApp.class); - cnx = TestUtil.getURLConnection( - server.port(), - "GET", - ALTERNATE_OPENAPI_PATH, - HttpMediaType.create(MediaTypes.APPLICATION_OPENAPI_YAML)); - yaml = TestUtil.yamlFromResponse(cnx); - } - - @AfterAll - public static void stopServer() { - TestUtil.cleanup(server, cnx); - } - - @Test - public void testAlternatePath() throws Exception { - String goSummary = TestUtil.fromYaml(yaml, "paths./testapp/go.get.summary", String.class); - assertThat(goSummary, is(TestApp.GO_SUMMARY)); - } -} diff --git a/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/TestUtil.java b/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/TestUtil.java index 103dd4889e4..e204d3f09c5 100644 --- a/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/TestUtil.java +++ b/microprofile/openapi/src/test/java/io/helidon/microprofile/openapi/TestUtil.java @@ -17,191 +17,85 @@ import java.io.IOException; import java.io.InputStream; -import java.io.InputStreamReader; -import java.net.HttpURLConnection; -import java.net.URL; -import java.nio.CharBuffer; -import java.nio.charset.Charset; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; import java.util.Map; -import io.helidon.http.HttpMediaType; -import io.helidon.common.media.type.MediaTypes; -import io.helidon.config.Config; -import io.helidon.microprofile.server.Server; +import io.helidon.config.mp.MpConfigSources; -import jakarta.ws.rs.core.Application; -import org.yaml.snakeyaml.Yaml; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.CoreMatchers.is; -import static org.junit.jupiter.api.Assertions.fail; +import org.eclipse.microprofile.config.Config; +import org.eclipse.microprofile.config.spi.ConfigProviderResolver; +import org.eclipse.microprofile.config.spi.ConfigSource; /** - * Useful utility methods during testing. + * Utility to query a tree structure using a dotted path notation. */ -public class TestUtil { +class TestUtil { - /** - * Starts the MP server running the specific application. - *

- * The server will use the default MP config. - * - * @param helidonConfig the Helidon configuration to use in preparing the server - * @param appClasses application classes to serve - * @return the started MP {@code Server} instance - */ - public static Server startServer(Config helidonConfig, Class... appClasses) { - Server.Builder builder = Server.builder() - .port(0) - .config(helidonConfig); - for (Class appClass : appClasses) { - builder.addApplication(appClass); - } - return builder - .build() - .start(); + private TestUtil() { } /** - * Cleans up, stopping the server and disconnecting the connection. - * - * @param server the {@code Server} to stop - * @param cnx the connection to disconnect + * Get a class-path resource. + * @param path resource path + * @return resource content as a string */ - public static void cleanup(Server server, HttpURLConnection cnx) { - if (cnx != null) { - cnx.disconnect(); - } - if (server != null) { - server.stop(); - } - } - - /** - * Returns a {@code HttpURLConnection} for the requested method and path and - * {code @MediaType} from the specified location. - * - * @param port port to connect to - * @param method HTTP method to use in building the connection - * @param path path to the resource in the web server - * @param mediaType {@code MediaType} to be Accepted - * @return the connection to the server and path - * @throws Exception in case of errors creating the connection - */ - public static HttpURLConnection getURLConnection( - int port, - String method, - String path, - HttpMediaType mediaType) throws Exception { - URL url = new URL("http://localhost:" + port + path); - HttpURLConnection conn = (HttpURLConnection) url.openConnection(); - conn.setRequestMethod(method); - if (mediaType != null) { - conn.setRequestProperty("Accept", mediaType.text()); - } - System.out.println("Connecting: " + method + " " + url); - return conn; - } - - /** - * Returns the {@code MediaType} instance conforming to the HTTP response - * content type. - * - * @param cnx the HttpURLConnection from which to get the content type - * @return the MediaType corresponding to the content type in the response - */ - public static HttpMediaType mediaTypeFromResponse(HttpURLConnection cnx) { - HttpMediaType returnedMediaType = HttpMediaType.create(cnx.getContentType()); - if (returnedMediaType.charset().isEmpty()) { - return returnedMediaType.withCharset(Charset.defaultCharset().name()); + static String resource(String path) { + try (InputStream is = TestUtil.class.getResourceAsStream(path)) { + if (is == null) { + throw new IllegalArgumentException("Resource not found: " + path); + } + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException ex) { + throw new UncheckedIOException(ex); } - return returnedMediaType; - } - - /** - * Represents the HTTP response payload as a String. - * - * @param cnx the HttpURLConnection from which to get the response payload - * @return String representation of the OpenAPI document as a String - * @throws IOException in case of errors reading the HTTP response payload - */ - public static String stringYAMLFromResponse(HttpURLConnection cnx) throws IOException { - HttpMediaType returnedMediaType = mediaTypeFromResponse(cnx); - assertThat("Unexpected returned media type", - HttpMediaType.create(MediaTypes.APPLICATION_OPENAPI_YAML).test(returnedMediaType), is(true)); - return stringFromResponse(cnx, returnedMediaType); } /** - * Returns a {@code String} resulting from interpreting the response payload - * in the specified connection according to the expected {@code MediaType}. + * Create a new instance of {@link Config} with the given maps as config sources. * - * @param cnx {@code HttpURLConnection} with the response - * @param mediaType {@code MediaType} to use in interpreting the response - * payload - * @return {@code String} of the payload interpreted according to the - * specified {@code MediaType} - * @throws IOException in case of errors reading the response payload + * @param configSources config sources + * @return config */ - public static String stringFromResponse(HttpURLConnection cnx, HttpMediaType mediaType) throws IOException { - try (final InputStreamReader isr = new InputStreamReader( - cnx.getInputStream(), mediaType.charset().get())) { - StringBuilder sb = new StringBuilder(); - CharBuffer cb = CharBuffer.allocate(1024); - while (isr.read(cb) != -1) { - cb.flip(); - sb.append(cb); - } - return sb.toString(); - } + @SafeVarargs + static Config config(Map... configSources) { + return ConfigProviderResolver.instance() + .getBuilder() + .withSources(configSources(configSources)) + .build(); } - /** - * Returns the response payload from the specified connection as a snakeyaml - * {@code Yaml} object. - * - * @param cnx the {@code HttpURLConnection} containing the response - * @return the YAML {@code Map} (created by snakeyaml) from - * the HTTP response payload - * @throws IOException in case of errors reading the response - */ - @SuppressWarnings(value = "unchecked") - public static Map yamlFromResponse(HttpURLConnection cnx) throws IOException { - HttpMediaType returnedMediaType = mediaTypeFromResponse(cnx); - Yaml yaml = new Yaml(); - Charset cs = Charset.defaultCharset(); - if (returnedMediaType.charset().isPresent()) { - cs = Charset.forName(returnedMediaType.charset().get()); - } - try (InputStream is = cnx.getInputStream(); InputStreamReader isr = new InputStreamReader(is, cs)) { - return (Map) yaml.load(isr); - } + @SafeVarargs + private static ConfigSource[] configSources(Map... configSources) { + return Arrays.stream(configSources) + .map(MpConfigSources::create) + .toArray(ConfigSource[]::new); } /** - * Treats the provided {@code Map} as a YAML map and navigates through it + * Treats the provided {@code Map} as a tree and navigates through it * using the dotted-name convention as expressed in the {@code dottedPath} * argument, finally casting the value retrieved from the last segment of * the path as the specified type and returning that cast value. * - * @param type to which the final value will be cast - * @param map the YAML-inspired map - * @param dottedPath navigation path to the item of interest in the YAML - * maps-of-maps; note that the {@code dottedPath} must not use dots except - * as path segment separators - * @param cl {@code Class} for the return type {@code } - * @return value from the lowest-level map retrieved using the last path - * segment, cast to the specified type + * @param type to which the final value will be cast + * @param map the tree + * @param dottedPath navigation path to the item of interest ; + * note that the {@code dottedPath} must not use dots except as path segment separators + * @param cl {@code Class} for the return type {@code } + * @return value from the lowest-level map retrieved using the last path segment, cast to the specified type */ @SuppressWarnings(value = "unchecked") - public static T fromYaml(Map map, String dottedPath, Class cl) { + public static T query(Map map, String dottedPath, Class cl) { Map originalMap = map; String[] segments = dottedPath.split("\\."); for (int i = 0; i < segments.length - 1; i++) { map = (Map) map.get(segments[i]); if (map == null) { - fail("Traversing dotted path " + dottedPath + " segment " + segments[i] + " not found in parsed map " - + originalMap); + throw new AssertionError(String.format( + "Traversing dotted path %s segment %s not found in parsed map %s", + dottedPath, segments[i], originalMap)); } } return cl.cast(map.get(segments[segments.length - 1])); diff --git a/microprofile/openapi/src/test/resources/petstore-with-fake-endpoints-models.yaml b/microprofile/openapi/src/test/resources/petstore-with-fake-endpoints-models.yaml new file mode 100644 index 00000000000..b786185e654 --- /dev/null +++ b/microprofile/openapi/src/test/resources/petstore-with-fake-endpoints-models.yaml @@ -0,0 +1,1956 @@ +# +# Copyright (c) 2022, 2023 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. +# + +# Inspired by the corresponding file in the OpenAPITools generator. +openapi: 3.0.0 +info: + description: >- + This spec is mainly for testing Petstore server and contains fake endpoints, + models. Please do not use this for any other purpose. Special characters: " + \ + version: 1.0.0 + title: OpenAPI Petstore + license: + name: Apache-2.0 + url: 'https://www.apache.org/licenses/LICENSE-2.0.html' +tags: + - name: pet + description: Everything about your Pets + - name: store + description: Access to Petstore orders + - name: user + description: Operations about user +paths: + /foo: + get: + responses: + default: + description: response + content: + application/json: + schema: + type: object + properties: + string: + $ref: '#/components/schemas/Foo' + /pet: + servers: + - url: 'http://petstore.swagger.io/v2' + - url: 'http://path-server-test.petstore.local/v2' + - url: 'http://{server}.swagger.io:{port}/v2' + description: test server with variables + variables: + server: + description: target server + enum: + - 'petstore' + - 'qa-petstore' + - 'dev-petstore' + default: 'petstore' + port: + enum: + - 80 + - 8080 + default: 80 + post: + tags: + - pet + summary: Add a new pet to the store + description: '' + operationId: addPet + responses: + '200': + description: Successful operation + '405': + description: Invalid input + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + requestBody: + $ref: '#/components/requestBodies/Pet' + put: + tags: + - pet + summary: Update an existing pet + description: '' + operationId: updatePet + x-webclient-blocking: true + responses: + '200': + description: Successful operation + '400': + description: Invalid ID supplied + '404': + description: Pet not found + '405': + description: Validation exception + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + requestBody: + $ref: '#/components/requestBodies/Pet' + /pet/findByStatus: + get: + tags: + - pet + summary: Finds Pets by status + description: Multiple status values can be provided with comma separated strings + operationId: findPetsByStatus + x-webclient-blocking: true + parameters: + - name: status + in: query + description: Status values that need to be considered for filter + required: true + style: form + explode: false + deprecated: true + schema: + type: array + items: + type: string + enum: + - available + - pending + - sold + default: available + responses: + '200': + description: successful operation + content: + application/xml: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid status value + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + /pet/findByTags: + get: + tags: + - pet + summary: Finds Pets by tags + description: >- + Multiple tags can be provided with comma separated strings. Use tag1, + tag2, tag3 for testing. + operationId: findPetsByTags + x-webclient-blocking: true + parameters: + - name: tags + in: query + description: Tags to filter by + required: true + style: form + explode: false + schema: + type: array + items: + type: string + uniqueItems: true + responses: + '200': + description: successful operation + content: + application/xml: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + uniqueItems: true + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + uniqueItems: true + '400': + description: Invalid tag value + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + deprecated: true + '/pet/{petId}': + get: + tags: + - pet + summary: Find pet by ID + description: Returns a single pet + operationId: getPetById + x-webclient-blocking: true + parameters: + - name: petId + in: path + description: ID of pet to return + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/json: + schema: + $ref: '#/components/schemas/Pet' + '400': + description: Invalid ID supplied + '404': + description: Pet not found + security: + - api_key: [] + post: + tags: + - pet + summary: Updates a pet in the store with form data + description: '' + operationId: updatePetWithForm + parameters: + - name: petId + in: path + description: ID of pet that needs to be updated + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: Successful operation + '405': + description: Invalid input + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + name: + description: Updated name of the pet + type: string + status: + description: Updated status of the pet + type: string + delete: + tags: + - pet + summary: Deletes a pet + description: '' + operationId: deletePet + parameters: + - name: api_key + in: header + required: false + schema: + type: string + - name: petId + in: path + description: Pet id to delete + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: Successful operation + '400': + description: Invalid pet value + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + '/pet/{petId}/uploadImage': + post: + tags: + - pet + summary: uploads an image + description: '' + operationId: uploadFile + parameters: + - name: petId + in: path + description: ID of pet to update + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + additionalMetadata: + description: Additional data to pass to server + type: string + file: + description: file to upload + type: string + format: binary + /store/inventory: + get: + tags: + - store + summary: Returns pet inventories by status + description: Returns a map of status codes to quantities + operationId: getInventory + responses: + '200': + description: successful operation + content: + application/json: + schema: + type: object + additionalProperties: + type: integer + format: int32 + security: + - api_key: [] + /store/order: + post: + tags: + - store + summary: Place an order for a pet + description: '' + operationId: placeOrder + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Order' + application/json: + schema: + $ref: '#/components/schemas/Order' + '400': + description: Invalid Order + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + description: order placed for purchasing the pet + required: true + '/store/order/{order_id}': + get: + tags: + - store + summary: Find purchase order by ID + description: >- + For valid response try integer IDs with value <= 5 or > 10. Other values + will generated exceptions + operationId: getOrderById + parameters: + - name: order_id + in: path + description: ID of pet that needs to be fetched + required: true + schema: + type: integer + format: int64 + minimum: 1 + maximum: 5 + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Order' + application/json: + schema: + $ref: '#/components/schemas/Order' + '400': + description: Invalid ID supplied + '404': + description: Order not found + delete: + tags: + - store + summary: Delete purchase order by ID + description: >- + For valid response try integer IDs with value < 1000. Anything above + 1000 or nonintegers will generate API errors + operationId: deleteOrder + parameters: + - name: order_id + in: path + description: ID of the order that needs to be deleted + required: true + schema: + type: string + responses: + '400': + description: Invalid ID supplied + '404': + description: Order not found + /user: + post: + tags: + - user + summary: Create user + description: This can only be done by the logged in user. + operationId: createUser + responses: + default: + description: successful operation + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User' + description: Created user object + required: true + /user/createWithArray: + post: + tags: + - user + summary: Creates list of users with given input array + description: '' + operationId: createUsersWithArrayInput + responses: + default: + description: successful operation + requestBody: + $ref: '#/components/requestBodies/UserArray' + /user/createWithList: + post: + tags: + - user + summary: Creates list of users with given input array + description: '' + operationId: createUsersWithListInput + responses: + default: + description: successful operation + requestBody: + $ref: '#/components/requestBodies/UserArray' + /user/login: + get: + tags: + - user + summary: Logs user into the system + description: '' + operationId: loginUser + parameters: + - name: username + in: query + description: The user name for login + required: true + schema: + type: string + - name: password + in: query + description: The password for login in clear text + required: true + schema: + type: string + responses: + '200': + description: successful operation + headers: + X-Rate-Limit: + description: calls per hour allowed by the user + schema: + type: integer + format: int32 + X-Expires-After: + description: date in UTC when token expires + schema: + type: string + format: date-time + content: + application/xml: + schema: + type: string + application/json: + schema: + type: string + '400': + description: Invalid username/password supplied + /user/logout: + get: + tags: + - user + summary: Logs out current logged in user session + description: '' + operationId: logoutUser + responses: + default: + description: successful operation + '/user/{username}': + get: + tags: + - user + summary: Get user by user name + description: '' + operationId: getUserByName + parameters: + - name: username + in: path + description: The name that needs to be fetched. Use user1 for testing. + required: true + schema: + type: string + responses: + '200': + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/User' + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + description: Invalid username supplied + '404': + description: User not found + put: + tags: + - user + summary: Updated user + description: This can only be done by the logged in user. + operationId: updateUser + parameters: + - name: username + in: path + description: name that need to be deleted + required: true + schema: + type: string + responses: + '400': + description: Invalid user supplied + '404': + description: User not found + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User' + description: Updated user object + required: true + delete: + tags: + - user + summary: Delete user + description: This can only be done by the logged in user. + operationId: deleteUser + parameters: + - name: username + in: path + description: The name that needs to be deleted + required: true + schema: + type: string + responses: + '400': + description: Invalid username supplied + '404': + description: User not found + /fake_classname_test: + patch: + tags: + - 'fake_classname_tags 123#$%^' + summary: To test class name in snake case + description: To test class name in snake case + operationId: testClassname + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Client' + security: + - api_key_query: [] + requestBody: + $ref: '#/components/requestBodies/Client' + /fake: + patch: + tags: + - fake + summary: To test "client" model + description: To test "client" model + operationId: testClientModel + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Client' + requestBody: + $ref: '#/components/requestBodies/Client' + get: + tags: + - fake + summary: To test enum parameters + description: To test enum parameters + operationId: testEnumParameters + parameters: + - name: enum_header_string_array + in: header + description: Header parameter enum test (string array) + schema: + type: array + items: + type: string + default: $ + enum: + - '>' + - $ + - name: enum_header_string + in: header + description: Header parameter enum test (string) + schema: + type: string + enum: + - _abc + - '-efg' + - (xyz) + default: '-efg' + - name: enum_query_string_array + in: query + description: Query parameter enum test (string array) + schema: + type: array + items: + type: string + default: $ + enum: + - '>' + - $ + - name: enum_query_string + in: query + description: Query parameter enum test (string) + schema: + type: string + enum: + - _abc + - '-efg' + - (xyz) + default: '-efg' + - name: enum_query_integer + in: query + description: Query parameter enum test (double) + schema: + type: integer + format: int32 + enum: + - 1 + - -2 + - name: enum_query_double + in: query + description: Query parameter enum test (double) + schema: + type: number + format: double + enum: + - 1.1 + - -1.2 + - name: enum_query_model_array + in: query + schema: + type: array + items: + $ref: '#/components/schemas/EnumClass' + responses: + '400': + description: Invalid request + '404': + description: Not found + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + enum_form_string_array: + description: Form parameter enum test (string array) + type: array + items: + type: string + default: $ + enum: + - '>' + - $ + enum_form_string: + description: Form parameter enum test (string) + type: string + enum: + - _abc + - '-efg' + - (xyz) + default: '-efg' + post: + tags: + - fake + summary: | + Fake endpoint for testing various parameters + 假端點 + 偽のエンドポイント + 가짜 엔드 포인트 + description: | + Fake endpoint for testing various parameters + 假端點 + 偽のエンドポイント + 가짜 엔드 포인트 + operationId: testEndpointParameters + responses: + '400': + description: Invalid username supplied + '404': + description: User not found + security: + - http_basic_test: [] + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + integer: + description: None + type: integer + minimum: 10 + maximum: 100 + int32: + description: None + type: integer + format: int32 + minimum: 20 + maximum: 200 + int64: + description: None + type: integer + format: int64 + number: + description: None + type: number + minimum: 32.1 + maximum: 543.2 + float: + description: None + type: number + format: float + maximum: 987.6 + double: + description: None + type: number + format: double + minimum: 67.8 + maximum: 123.4 + string: + description: None + type: string + pattern: '/[a-z]/i' + pattern_without_delimiter: + description: None + type: string + pattern: '^[A-Z].*' + byte: + description: None + type: string + format: byte + binary: + description: None + type: string + format: binary + date: + description: None + type: string + format: date + dateTime: + description: None + type: string + format: date-time + password: + description: None + type: string + format: password + minLength: 10 + maxLength: 64 + callback: + description: None + type: string + required: + - number + - double + - pattern_without_delimiter + - byte + delete: + tags: + - fake + security: + - bearer_test: [] + summary: Fake endpoint to test group parameters (optional) + description: Fake endpoint to test group parameters (optional) + operationId: testGroupParameters + x-group-parameters: true + parameters: + - name: required_string_group + in: query + description: Required String in group parameters + required: true + schema: + type: integer + - name: required_boolean_group + in: header + description: Required Boolean in group parameters + required: true + schema: + type: boolean + - name: required_int64_group + in: query + description: Required Integer in group parameters + required: true + schema: + type: integer + format: int64 + - name: string_group + in: query + description: String in group parameters + schema: + type: integer + - name: boolean_group + in: header + description: Boolean in group parameters + schema: + type: boolean + - name: int64_group + in: query + description: Integer in group parameters + schema: + type: integer + format: int64 + responses: + '400': + description: Someting wrong + /fake/outer/number: + post: + tags: + - fake + description: Test serialization of outer number types + operationId: fakeOuterNumberSerialize + responses: + '200': + description: Output number + content: + '*/*': + schema: + $ref: '#/components/schemas/OuterNumber' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/OuterNumber' + description: Input number as post body + /fake/property/enum-int: + post: + tags: + - fake + description: Test serialization of enum (int) properties with examples + operationId: fakePropertyEnumIntegerSerialize + responses: + '200': + description: Output enum (int) + content: + '*/*': + schema: + $ref: '#/components/schemas/OuterObjectWithEnumProperty' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OuterObjectWithEnumProperty' + description: Input enum (int) as post body + /fake/outer/string: + post: + tags: + - fake + description: Test serialization of outer string types + operationId: fakeOuterStringSerialize + responses: + '200': + description: Output string + content: + '*/*': + schema: + $ref: '#/components/schemas/OuterString' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/OuterString' + description: Input string as post body + /fake/outer/boolean: + post: + tags: + - fake + description: Test serialization of outer boolean types + operationId: fakeOuterBooleanSerialize + responses: + '200': + description: Output boolean + content: + '*/*': + schema: + $ref: '#/components/schemas/OuterBoolean' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/OuterBoolean' + description: Input boolean as post body + /fake/outer/composite: + post: + tags: + - fake + description: Test serialization of object with outer number type + operationId: fakeOuterCompositeSerialize + responses: + '200': + description: Output composite + content: + '*/*': + schema: + $ref: '#/components/schemas/OuterComposite' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/OuterComposite' + description: Input composite as post body + /fake/jsonFormData: + get: + tags: + - fake + summary: test json serialization of form data + description: '' + operationId: testJsonFormData + responses: + '200': + description: successful operation + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + param: + description: field1 + type: string + param2: + description: field2 + type: string + required: + - param + - param2 + /fake/inline-additionalProperties: + post: + tags: + - fake + summary: test inline additionalProperties + description: '' + operationId: testInlineAdditionalProperties + responses: + '200': + description: successful operation + requestBody: + content: + application/json: + schema: + type: object + additionalProperties: + type: string + description: request body + required: true + /fake/body-with-query-params: + put: + tags: + - fake + operationId: testBodyWithQueryParams + parameters: + - name: query + in: query + required: true + schema: + type: string + responses: + '200': + description: Success + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/User' + required: true + /another-fake/dummy: + patch: + tags: + - $another-fake? + summary: To test special tags + description: To test special tags and operation ID starting with number + operationId: '123_test_@#$%_special_tags' + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Client' + requestBody: + $ref: '#/components/requestBodies/Client' + /fake/body-with-file-schema: + put: + tags: + - fake + description: >- + For this test, the body for this request must reference a schema named + `File`. + operationId: testBodyWithFileSchema + responses: + '200': + description: Success + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/FileSchemaTestClass' + required: true + /fake/body-with-binary: + put: + tags: + - fake + description: >- + For this test, the body has to be a binary file. + operationId: testBodyWithBinary + responses: + '200': + description: Success + requestBody: + content: + image/png: + schema: + type: string + nullable: true + format: binary + description: image to upload + required: true + /fake/test-query-parameters: + put: + tags: + - fake + description: To test the collection format in query parameters + operationId: testQueryParameterCollectionFormat + parameters: + - name: pipe + in: query + required: true + style: pipeDelimited + schema: + type: array + items: + type: string + - name: ioutil + in: query + required: true + style: form + explode: false + schema: + type: array + items: + type: string + - name: http + in: query + required: true + style: spaceDelimited + schema: + type: array + items: + type: string + - name: url + in: query + required: true + style: form + explode: false + schema: + type: array + items: + type: string + - name: context + in: query + required: true + explode: true + schema: + type: array + items: + type: string + - name: language + in: query + required: false + schema: + type: object + additionalProperties: + type: string + format: string + - name: allowEmpty + in: query + required: true + allowEmptyValue: true + schema: + type: string + responses: + "200": + description: Success + '/fake/{petId}/uploadImageWithRequiredFile': + post: + tags: + - pet + summary: uploads an image (required) + description: '' + operationId: uploadFileWithRequiredFile + parameters: + - name: petId + in: path + description: ID of pet to update + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + security: + - petstore_auth: + - 'write:pets' + - 'read:pets' + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + additionalMetadata: + description: Additional data to pass to server + type: string + requiredFile: + description: file to upload + type: string + format: binary + required: + - requiredFile + /fake/health: + get: + tags: + - fake + summary: Health check endpoint + responses: + 200: + description: The instance started successfully + content: + application/json: + schema: + $ref: '#/components/schemas/HealthCheckResult' + /fake/http-signature-test: + get: + tags: + - fake + summary: test http signature authentication + operationId: fake-http-signature-test + parameters: + - name: query_1 + in: query + description: query parameter + required: optional + schema: + type: string + - name: header_1 + in: header + description: header parameter + required: optional + schema: + type: string + security: + - http_signature_test: [] + requestBody: + $ref: '#/components/requestBodies/Pet' + responses: + 200: + description: The instance started successfully +servers: + - url: 'http://{server}.swagger.io:{port}/v2' + description: petstore server + variables: + server: + enum: + - 'petstore' + - 'qa-petstore' + - 'dev-petstore' + default: 'petstore' + port: + enum: + - 80 + - 8080 + default: 80 + - url: https://localhost:8080/{version} + description: The local server + variables: + version: + enum: + - 'v1' + - 'v2' + default: 'v2' + - url: https://127.0.0.1/no_varaible + description: The local server without variables +components: + requestBodies: + UserArray: + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + description: List of user object + required: true + Client: + content: + application/json: + schema: + $ref: '#/components/schemas/Client' + description: client model + required: true + Pet: + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + $ref: '#/components/schemas/Pet' + description: Pet object that needs to be added to the store + required: true + securitySchemes: + petstore_auth: + type: oauth2 + flows: + implicit: + authorizationUrl: 'http://petstore.swagger.io/api/oauth/dialog' + scopes: + 'write:pets': modify pets in your account + 'read:pets': read your pets + api_key: + type: apiKey + name: api_key + in: header + api_key_query: + type: apiKey + name: api_key_query + in: query + http_basic_test: + type: http + scheme: basic + bearer_test: + type: http + scheme: bearer + bearerFormat: JWT + http_signature_test: + type: http + scheme: signature + schemas: + Foo: + type: object + properties: + bar: + $ref: '#/components/schemas/Bar' + Bar: + type: string + default: bar + Order: + type: object + properties: + id: + type: integer + format: int64 + petId: + type: integer + format: int64 + quantity: + type: integer + format: int32 + shipDate: + type: string + format: date-time + status: + type: string + description: Order Status + enum: + - placed + - approved + - delivered + complete: + type: boolean + default: false + xml: + name: Order + Category: + type: object + required: + - name + properties: + id: + type: integer + format: int64 + name: + type: string + default: default-name + xml: + name: Category + User: + type: object + properties: + id: + type: integer + format: int64 + x-is-unique: true + username: + type: string + firstName: + type: string + lastName: + type: string + email: + type: string + password: + type: string + phone: + type: string + userStatus: + type: integer + format: int32 + description: User Status + xml: + name: User + Tag: + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + xml: + name: Tag + Pet: + type: object + required: + - name + - photoUrls + properties: + id: + type: integer + format: int64 + x-is-unique: true + category: + $ref: '#/components/schemas/Category' + name: + type: string + example: doggie + photoUrls: + type: array + xml: + name: photoUrl + wrapped: true + items: + type: string + uniqueItems: true + tags: + type: array + xml: + name: tag + wrapped: true + items: + $ref: '#/components/schemas/Tag' + status: + type: string + description: pet status in the store + enum: + - available + - pending + - sold + xml: + name: Pet + ApiResponse: + type: object + properties: + code: + type: integer + format: int32 + type: + type: string + message: + type: string + Return: + description: Model for testing reserved words + properties: + return: + type: integer + format: int32 + xml: + name: Return + Name: + description: Model for testing model name same as property name + required: + - name + properties: + name: + type: integer + format: int32 + snake_case: + readOnly: true + type: integer + format: int32 + property: + type: string + 123Number: + type: integer + readOnly: true + xml: + name: Name + 200_response: + description: Model for testing model name starting with number + properties: + name: + type: integer + format: int32 + class: + type: string + xml: + name: Name + ClassModel: + description: Model for testing model with "_class" property + properties: + _class: + type: string + Dog: + allOf: + - $ref: '#/components/schemas/Animal' + - type: object + properties: + breed: + type: string + Cat: + allOf: + - $ref: '#/components/schemas/Animal' + - type: object + properties: + declawed: + type: boolean + Animal: + type: object + discriminator: + propertyName: className + required: + - className + properties: + className: + type: string + color: + type: string + default: red + AnimalFarm: + type: array + items: + $ref: '#/components/schemas/Animal' + format_test: + type: object + required: + - number + - byte + - date + - password + properties: + integer: + type: integer + maximum: 100 + minimum: 10 + int32: + type: integer + format: int32 + maximum: 200 + minimum: 20 + int64: + type: integer + format: int64 + number: + maximum: 543.2 + minimum: 32.1 + type: number + float: + type: number + format: float + maximum: 987.6 + minimum: 54.3 + double: + type: number + format: double + maximum: 123.4 + minimum: 67.8 + decimal: + type: string + format: number + string: + type: string + pattern: '/[a-z]/i' + byte: + type: string + format: byte + binary: + type: string + format: binary + date: + type: string + format: date + dateTime: + type: string + format: date-time + uuid: + type: string + format: uuid + example: 72f98069-206d-4f12-9f12-3d1e525a8e84 + password: + type: string + format: password + maxLength: 64 + minLength: 10 + pattern_with_digits: + description: A string that is a 10 digit number. Can have leading zeros. + type: string + pattern: '^\d{10}$' + pattern_with_digits_and_delimiter: + description: A string starting with 'image_' (case insensitive) and one to three digits following i.e. Image_01. + type: string + pattern: '/^image_\d{1,3}$/i' + EnumClass: + type: string + default: '-efg' + enum: + - _abc + - '-efg' + - (xyz) + Enum_Test: + type: object + required: + - enum_string_required + properties: + enum_string: + type: string + enum: + - UPPER + - lower + - '' + enum_string_required: + type: string + enum: + - UPPER + - lower + - '' + enum_integer: + type: integer + format: int32 + enum: + - 1 + - -1 + enum_number: + type: number + format: double + enum: + - 1.1 + - -1.2 + outerEnum: + $ref: '#/components/schemas/OuterEnum' + outerEnumInteger: + $ref: '#/components/schemas/OuterEnumInteger' + outerEnumDefaultValue: + $ref: '#/components/schemas/OuterEnumDefaultValue' + outerEnumIntegerDefaultValue: + $ref: '#/components/schemas/OuterEnumIntegerDefaultValue' + AdditionalPropertiesClass: + type: object + properties: + map_property: + type: object + additionalProperties: + type: string + map_of_map_property: + type: object + additionalProperties: + type: object + additionalProperties: + type: string + MixedPropertiesAndAdditionalPropertiesClass: + type: object + properties: + uuid: + type: string + format: uuid + dateTime: + type: string + format: date-time + map: + type: object + additionalProperties: + $ref: '#/components/schemas/Animal' + List: + type: object + properties: + 123-list: + type: string + Client: + type: object + properties: + client: + type: string + ReadOnlyFirst: + type: object + properties: + bar: + type: string + readOnly: true + baz: + type: string + hasOnlyReadOnly: + type: object + properties: + bar: + type: string + readOnly: true + foo: + type: string + readOnly: true + Capitalization: + type: object + properties: + smallCamel: + type: string + CapitalCamel: + type: string + small_Snake: + type: string + Capital_Snake: + type: string + SCA_ETH_Flow_Points: + type: string + ATT_NAME: + description: | + Name of the pet + type: string + MapTest: + type: object + properties: + map_map_of_string: + type: object + additionalProperties: + type: object + additionalProperties: + type: string + map_of_enum_string: + type: object + additionalProperties: + type: string + enum: + - UPPER + - lower + direct_map: + type: object + additionalProperties: + type: boolean + indirect_map: + $ref: '#/components/schemas/StringBooleanMap' + ArrayTest: + type: object + properties: + array_of_string: + type: array + items: + type: string + minItems: 0 + maxItems: 3 + array_array_of_integer: + type: array + items: + type: array + items: + type: integer + format: int64 + array_array_of_model: + type: array + items: + type: array + items: + $ref: '#/components/schemas/ReadOnlyFirst' + NumberOnly: + type: object + properties: + JustNumber: + type: number + ArrayOfNumberOnly: + type: object + properties: + ArrayNumber: + type: array + items: + type: number + ArrayOfArrayOfNumberOnly: + type: object + properties: + ArrayArrayNumber: + type: array + items: + type: array + items: + type: number + EnumArrays: + type: object + properties: + just_symbol: + type: string + enum: + - '>=' + - $ + array_enum: + type: array + items: + type: string + enum: + - fish + - crab + OuterEnum: + nullable: true + type: string + enum: + - placed + - approved + - delivered + OuterEnumInteger: + type: integer + enum: + - 0 + - 1 + - 2 + example: 2 + OuterEnumDefaultValue: + type: string + enum: + - placed + - approved + - delivered + default: placed + OuterEnumIntegerDefaultValue: + type: integer + enum: + - 0 + - 1 + - 2 + default: 0 + OuterComposite: + type: object + properties: + my_number: + $ref: '#/components/schemas/OuterNumber' + my_string: + $ref: '#/components/schemas/OuterString' + my_boolean: + $ref: '#/components/schemas/OuterBoolean' + OuterNumber: + type: number + OuterString: + type: string + OuterBoolean: + type: boolean + x-codegen-body-parameter-name: boolean_post_body + StringBooleanMap: + additionalProperties: + type: boolean + FileSchemaTestClass: + type: object + properties: + file: + $ref: '#/components/schemas/File' + files: + type: array + items: + $ref: '#/components/schemas/File' + File: + type: object + description: Must be named `File` for test. + properties: + sourceURI: + description: Test capitalization + type: string + _special_model.name_: + properties: + '$special[property.name]': + type: integer + format: int64 + xml: + name: '$special[model.name]' + HealthCheckResult: + type: object + properties: + NullableMessage: + nullable: true + type: string + description: Just a string to inform instance is up and running. Make it nullable in hope to get it as pointer in generated model. + NullableClass: + type: object + properties: + integer_prop: + type: integer + nullable: true + number_prop: + type: number + nullable: true + boolean_prop: + type: boolean + nullable: true + string_prop: + type: string + nullable: true + date_prop: + type: string + format: date + nullable: true + datetime_prop: + type: string + format: date-time + nullable: true + array_nullable_prop: + type: array + nullable: true + items: + type: object + array_and_items_nullable_prop: + type: array + nullable: true + items: + type: object + nullable: true + array_items_nullable: + type: array + items: + type: object + nullable: true + object_nullable_prop: + type: object + nullable: true + additionalProperties: + type: object + object_and_items_nullable_prop: + type: object + nullable: true + additionalProperties: + type: object + nullable: true + object_items_nullable: + type: object + additionalProperties: + type: object + nullable: true + additionalProperties: + type: object + nullable: true + OuterObjectWithEnumProperty: + type: object + example: + value: 2 + required: + - value + properties: + value: + $ref: '#/components/schemas/OuterEnumInteger' + DeprecatedObject: + type: object + deprecated: true + properties: + name: + type: string + ObjectWithDeprecatedFields: + type: object + properties: + uuid: + type: string + id: + type: number + deprecated: true + deprecatedRef: + $ref: '#/components/schemas/DeprecatedObject' + bars: + type: array + deprecated: true + items: + $ref: '#/components/schemas/Bar' + AllOfWithSingleRef: + type: object + properties: + username: + type: string + SingleRefType: + allOf: + - $ref: '#/components/schemas/SingleRefType' + SingleRefType: + type: string + title: SingleRefType + enum: + - admin + - user diff --git a/microprofile/openapi/src/test/resources/serverCORSRestricted.yaml b/microprofile/openapi/src/test/resources/serverCORSRestricted.yaml deleted file mode 100644 index 30700d1fadf..00000000000 --- a/microprofile/openapi/src/test/resources/serverCORSRestricted.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# -# Copyright (c) 2020, 2023 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: - cors: - allow-origins: ["http://foo.bar", "http://bar.foo"] diff --git a/microprofile/openapi/src/test/resources/serverConfig.yml b/microprofile/openapi/src/test/resources/serverConfig.yml deleted file mode 100644 index f924a73fa7f..00000000000 --- a/microprofile/openapi/src/test/resources/serverConfig.yml +++ /dev/null @@ -1,19 +0,0 @@ -# -# Copyright (c) 2020, 2023 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: - web-context: /otheropenapi - port: 0 diff --git a/microprofile/openapi/src/test/resources/serverNoCORS.properties b/microprofile/openapi/src/test/resources/serverNoCORS.properties deleted file mode 100644 index 7295615d45a..00000000000 --- a/microprofile/openapi/src/test/resources/serverNoCORS.properties +++ /dev/null @@ -1,16 +0,0 @@ -# -# Copyright (c) 2020, 2023 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.cors.enabled: false diff --git a/microprofile/openapi/src/test/resources/serverTest.properties b/microprofile/openapi/src/test/resources/serverTest.properties deleted file mode 100644 index 6fb062921a0..00000000000 --- a/microprofile/openapi/src/test/resources/serverTest.properties +++ /dev/null @@ -1,24 +0,0 @@ -# -# Copyright (c) 2019, 2023 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.model.reader: io.helidon.microprofile.openapi.test.MyModelReader -openapi.filter: io.helidon.openapi.microprofile.MySimpleFilter -openapi.servers: s1,s2 -openapi.servers.path.path1: p1s1,p1s2 -openapi.servers.path.path2: p2s1,p2s2 -openapi.servers.operation.op1: o1s1,o1s2 -openapi.servers.operation.op2: o2s1,o2s2 -openapi.scan.disable: false - diff --git a/microprofile/openapi/src/test/resources/simple.properties b/microprofile/openapi/src/test/resources/simple.properties deleted file mode 100644 index cdf682ad3e4..00000000000 --- a/microprofile/openapi/src/test/resources/simple.properties +++ /dev/null @@ -1,23 +0,0 @@ -# -# Copyright (c) 2019, 2023 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.model.reader: io.helidon.microprofile.openapi.test.MyModelReader -openapi.filter: io.helidon.microprofile.openapi.test.MySimpleFilter -openapi.servers: s1,s2 -openapi.servers.path.path1: p1s1,p1s2 -openapi.servers.path.path2: p2s1,p2s2 -openapi.servers.operation.op1: o1s1,o1s2 -openapi.servers.operation.op2: o2s1,o2s2 -openapi.scan.disable: false diff --git a/openapi/openapi-ui/pom.xml b/openapi/openapi-ui/pom.xml new file mode 100644 index 00000000000..8511bbeccf2 --- /dev/null +++ b/openapi/openapi-ui/pom.xml @@ -0,0 +1,134 @@ + + + + 4.0.0 + + io.helidon.openapi + helidon-openapi-project + 4.0.0-SNAPSHOT + + + helidon-openapi-ui + Helidon OpenAPI + + + Helidon OpenAPI UI implementation + + + + + io.helidon.openapi + helidon-openapi + + + io.helidon.common.features + helidon-common-features-api + true + + + io.helidon.config + helidon-config-metadata + true + + + io.smallrye + smallrye-open-api-ui + ${version.lib.smallrye-openapi} + + + io.helidon.webserver + helidon-webserver-static-content + + + org.junit.jupiter + junit-jupiter-api + test + + + org.hamcrest + hamcrest-all + test + + + io.helidon.common.testing + helidon-common-testing-junit5 + test + + + io.helidon.webserver.testing.junit5 + helidon-webserver-testing-junit5 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + io.helidon.common.features + helidon-common-features-processor + ${helidon.version} + + + io.helidon.config + helidon-config-metadata-processor + ${helidon.version} + + + io.helidon.builder + helidon-builder-processor + ${helidon.version} + + + io.helidon.common.processor + helidon-common-processor-helidon-copyright + ${helidon.version} + + + + + + io.helidon.common.features + helidon-common-features-processor + ${helidon.version} + + + io.helidon.builder + helidon-builder-processor + ${helidon.version} + + + io.helidon.config + helidon-config-metadata-processor + ${helidon.version} + + + io.helidon.common.processor + helidon-common-processor-helidon-copyright + ${helidon.version} + + + + + + diff --git a/openapi/openapi-ui/src/main/java/io/helidon/openapi/ui/OpenApiUi.java b/openapi/openapi-ui/src/main/java/io/helidon/openapi/ui/OpenApiUi.java new file mode 100644 index 00000000000..775b15843e7 --- /dev/null +++ b/openapi/openapi-ui/src/main/java/io/helidon/openapi/ui/OpenApiUi.java @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2023 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.ui; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; + +import io.helidon.builder.api.RuntimeType; +import io.helidon.common.LazyValue; +import io.helidon.common.media.type.MediaType; +import io.helidon.common.media.type.MediaTypes; +import io.helidon.http.HeaderNames; +import io.helidon.http.HttpMediaType; +import io.helidon.http.ServerRequestHeaders; +import io.helidon.http.Status; +import io.helidon.openapi.OpenApiService; +import io.helidon.webserver.http.HttpRules; +import io.helidon.webserver.http.ServerRequest; +import io.helidon.webserver.http.ServerResponse; +import io.helidon.webserver.staticcontent.StaticContentService; + +import io.smallrye.openapi.ui.IndexHtmlCreator; +import io.smallrye.openapi.ui.Option; + +/** + * An {@link OpenApiService} that serves OpenApi UI. + */ +@RuntimeType.PrototypedBy(OpenApiUiConfig.class) +public final class OpenApiUi implements OpenApiService, RuntimeType.Api { + + /** + * Returns a new builder. + * + * @return new builder + */ + public static OpenApiUiConfig.Builder builder() { + return OpenApiUiConfig.builder(); + } + + /** + * Create a new instance with default configuration. + * + * @return new instance + */ + public static OpenApiUi create() { + return builder().build(); + } + + /** + * Create a new instance from typed configuration. + * + * @param config typed configuration + * @return new instance + */ + static OpenApiUi create(OpenApiUiConfig config) { + return new OpenApiUi(config); + } + + /** + * Create a new instance with custom configuration. + * + * @param builderConsumer consumer of configuration builder + * @return new instance + */ + public static OpenApiUi create(Consumer builderConsumer) { + OpenApiUiConfig.Builder b = OpenApiUiConfig.builder(); + builderConsumer.accept(b); + return b.build(); + } + + private static final String LOGO_RESOURCE = "logo.svg"; + private static final String HELIDON_IO_LINK = "https://helidon.io"; + + private static final MediaType[] ACCEPTED_MEDIA_TYPES = new MediaType[] { + MediaTypes.APPLICATION_JSON, + MediaTypes.TEXT_YAML, + MediaTypes.TEXT_PLAIN, + MediaTypes.TEXT_HTML, + }; + + private static final HttpMediaType TEXT_HTML = HttpMediaType.create(MediaTypes.TEXT_HTML); + + private static final Map HELIDON_OPTIONS = Map.of( + Option.title, "Helidon OpenAPI UI", + Option.logoHref, LOGO_RESOURCE, + // workaround for a bug in IndexHtmlCreator + Option.oauth2RedirectUrl, "-", + // link applied to the rendered logo image + Option.backHref, HELIDON_IO_LINK, + // link applied to the title if there is no logo (but there is; set this anyway) + Option.selfHref, HELIDON_IO_LINK); + + private final LazyValue indexHtml = LazyValue.create(this::createIndexHtml); + private final OpenApiUiConfig config; + private volatile String docPath = "/openapi"; + private volatile String uiPath = "/openapi/ui"; + + OpenApiUi(OpenApiUiConfig config) { + this.config = config; + } + + @Override + public OpenApiUiConfig prototype() { + return config; + } + + @Override + public String name() { + return "openapi-ui"; + } + + @Override + public String type() { + return "openapi-ui"; + } + + @Override + public boolean supports(ServerRequestHeaders headers) { + return headers.bestAccepted(ACCEPTED_MEDIA_TYPES) + .map(TEXT_HTML::test) + .orElse(false); + } + + @Override + public void setup(HttpRules rules, String docPath, Function content) { + if (!config.isEnabled()) { + return; + } + this.docPath = docPath; + this.uiPath = config.webContext().orElseGet(() -> docPath + "/ui"); + rules.get(docPath + "[/]", (req, res) -> handle(req, res, content)) + .get(uiPath + "[/]", this::redirectIndex) + .get(uiPath + "/index.html", this::index) + .register(uiPath, StaticContentService.create("helidon-openapi-ui")) + .register(uiPath, StaticContentService.create("META-INF/resources/openapi-ui")); + } + + private void index(ServerRequest req, ServerResponse res) { + req.headers() + .bestAccepted(ACCEPTED_MEDIA_TYPES) + .filter(TEXT_HTML::test) + .ifPresentOrElse(ct -> { + res.headers().contentType(ct); + res.send(indexHtml.get()); + }, res::next); + } + + private void redirectIndex(ServerRequest req, ServerResponse res) { + res.status(Status.TEMPORARY_REDIRECT_307); + res.header(HeaderNames.LOCATION, uiPath + "/index.html"); + res.send(); + } + + private void handle(ServerRequest req, ServerResponse res, Function content) { + req.headers() + .bestAccepted(ACCEPTED_MEDIA_TYPES) + .ifPresentOrElse(ct -> { + if (TEXT_HTML.test(ct)) { + redirectIndex(req, res); + } else { + res.headers().contentType(ct); + res.send(content.apply(ct)); + } + }, res::next); + } + + private byte[] createIndexHtml() { + Map options = new HashMap<>(HELIDON_OPTIONS); + options.put(Option.url, docPath); // location of the OpenAPI document + config.options().forEach((k, v) -> options.put(Option.valueOf(k), v)); // user options + try { + return IndexHtmlCreator.createIndexHtml(options); + } catch (IOException e) { + throw new UncheckedIOException("Unable to initialize the index.html content for the OpenAPI UI", e); + } + } +} diff --git a/openapi/openapi-ui/src/main/java/io/helidon/openapi/ui/OpenApiUiConfigBlueprint.java b/openapi/openapi-ui/src/main/java/io/helidon/openapi/ui/OpenApiUiConfigBlueprint.java new file mode 100644 index 00000000000..73ce8b11db6 --- /dev/null +++ b/openapi/openapi-ui/src/main/java/io/helidon/openapi/ui/OpenApiUiConfigBlueprint.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2023 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.ui; + +import java.util.Map; +import java.util.Optional; + +import io.helidon.builder.api.Prototype; +import io.helidon.config.metadata.Configured; +import io.helidon.config.metadata.ConfiguredOption; + +/** + * {@link OpenApiUi} prototype. + */ +@Prototype.Blueprint +@Configured +interface OpenApiUiConfigBlueprint extends Prototype.Factory { + /** + * Merges implementation-specific UI options. + * + * @return options for the UI to merge + */ + @ConfiguredOption(kind = ConfiguredOption.Kind.MAP) + Map options(); + + /** + * Sets whether the service should be enabled. + * + * @return {@code true} if enabled, {@code false} otherwise + */ + @ConfiguredOption(key = "enabled", value = "true") + boolean isEnabled(); + + /** + * Full web context (not just the suffix). + * + * @return full web context path + */ + @ConfiguredOption + Optional webContext(); +} diff --git a/openapi/openapi-ui/src/main/java/io/helidon/openapi/ui/OpenApiUiProvider.java b/openapi/openapi-ui/src/main/java/io/helidon/openapi/ui/OpenApiUiProvider.java new file mode 100644 index 00000000000..43a3c127986 --- /dev/null +++ b/openapi/openapi-ui/src/main/java/io/helidon/openapi/ui/OpenApiUiProvider.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 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.ui; + +import io.helidon.common.config.Config; +import io.helidon.openapi.spi.OpenApiServiceProvider; + +/** + * A {@link OpenApiServiceProvider} that provides {@link OpenApiUi}. + */ +public final class OpenApiUiProvider implements OpenApiServiceProvider { + + /** + * Create a new instance. + * + * @deprecated to be used solely by {@link java.util.ServiceLoader} + */ + @Deprecated + public OpenApiUiProvider() { + } + + @Override + public String configKey() { + return "ui"; + } + + @Override + public OpenApiUi create(Config config, String name) { + return OpenApiUi.builder().config(config).build(); + } +} diff --git a/examples/openapi/src/main/java/io/helidon/examples/openapi/internal/package-info.java b/openapi/openapi-ui/src/main/java/io/helidon/openapi/ui/package-info.java similarity index 77% rename from examples/openapi/src/main/java/io/helidon/examples/openapi/internal/package-info.java rename to openapi/openapi-ui/src/main/java/io/helidon/openapi/ui/package-info.java index 19c4a5d1780..cd9b011798a 100644 --- a/examples/openapi/src/main/java/io/helidon/examples/openapi/internal/package-info.java +++ b/openapi/openapi-ui/src/main/java/io/helidon/openapi/ui/package-info.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019, 2021 Oracle and/or its affiliates. + * Copyright (c) 2019, 2023 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. @@ -15,6 +15,6 @@ */ /** - * Internal classes supporting the Helidon OpenAPI example. + * Helidon OpenAPI UI support. */ -package io.helidon.examples.openapi.internal; +package io.helidon.openapi.ui; diff --git a/openapi/openapi-ui/src/main/java/module-info.java b/openapi/openapi-ui/src/main/java/module-info.java new file mode 100644 index 00000000000..38713373ca1 --- /dev/null +++ b/openapi/openapi-ui/src/main/java/module-info.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2022, 2023 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. + */ +import io.helidon.common.features.api.Feature; +import io.helidon.common.features.api.HelidonFlavor; + +@Feature(value = "OpenAPI UI", + description = "OpenAPI UI support", + in = HelidonFlavor.SE +) +module io.helidon.openapi.ui { + requires io.helidon.common.features.api; + requires io.helidon.common.media.type; + requires io.helidon.openapi; + requires io.helidon.webserver; + requires io.helidon.webserver.staticcontent; + requires io.helidon.config.metadata; + + requires smallrye.open.api.ui; + + provides io.helidon.openapi.spi.OpenApiServiceProvider with + io.helidon.openapi.ui.OpenApiUiProvider; +} diff --git a/openapi/openapi-ui/src/main/resources/helidon-openapi-ui/favicon.ico b/openapi/openapi-ui/src/main/resources/helidon-openapi-ui/favicon.ico new file mode 100644 index 00000000000..902ff2f811b Binary files /dev/null and b/openapi/openapi-ui/src/main/resources/helidon-openapi-ui/favicon.ico differ diff --git a/openapi/openapi-ui/src/main/resources/helidon-openapi-ui/logo.svg b/openapi/openapi-ui/src/main/resources/helidon-openapi-ui/logo.svg new file mode 100644 index 00000000000..32f107b0f2a --- /dev/null +++ b/openapi/openapi-ui/src/main/resources/helidon-openapi-ui/logo.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + diff --git a/openapi/openapi-ui/src/test/java/io/helidon/openapi/ui/OpenApiUiTest.java b/openapi/openapi-ui/src/test/java/io/helidon/openapi/ui/OpenApiUiTest.java new file mode 100644 index 00000000000..1efb1960bd4 --- /dev/null +++ b/openapi/openapi-ui/src/test/java/io/helidon/openapi/ui/OpenApiUiTest.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2022, 2023 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.ui; + +import java.util.Map; + +import io.helidon.common.media.type.MediaType; +import io.helidon.common.media.type.MediaTypes; +import io.helidon.http.HttpMediaType; +import io.helidon.http.Status; +import io.helidon.openapi.OpenApiFeature; +import io.helidon.webclient.api.ClientResponseTyped; +import io.helidon.webclient.api.WebClient; +import io.helidon.webserver.http.HttpRouting; +import io.helidon.webserver.testing.junit5.ServerTest; +import io.helidon.webserver.testing.junit5.SetUpRoute; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.startsWith; + +/** + * Tests {@link OpenApiUi}. + */ +@ServerTest +class OpenApiUiTest { + + private static final MediaType[] SIMULATED_BROWSER_ACCEPT = new MediaType[] { + MediaTypes.TEXT_HTML, + MediaTypes.APPLICATION_XHTML_XML, + HttpMediaType.builder() + .mediaType(MediaTypes.APPLICATION_XML) + .parameters(Map.of("q", "0.9")) + .build(), + MediaTypes.create("image", "webp"), + MediaTypes.create("image", "apng"), + HttpMediaType.builder() + .mediaType(MediaTypes.WILDCARD) + .parameters(Map.of("q", "0.8")) + .build() + }; + + private final WebClient client; + + OpenApiUiTest(WebClient client) { + this.client = client; + } + + @SetUpRoute + public static void setup(HttpRouting.Builder routing) { + routing.addFeature(OpenApiFeature.builder() + .servicesDiscoverServices(false) + .staticFile("src/test/resources/greeting.yml") + .cors(cors -> cors.enabled(false)) + .addService(OpenApiUi.create())) + .addFeature(OpenApiFeature.builder() + .servicesDiscoverServices(false) + .staticFile("src/test/resources/greeting.yml") + .webContext("/openapi-greeting") + .cors(cors -> cors.enabled(false)) + .addService(OpenApiUi.create())) + .addFeature(OpenApiFeature.builder() + .servicesDiscoverServices(false) + .staticFile("src/test/resources/greeting.yml") + .cors(cors -> cors.enabled(false)) + .addService(OpenApiUi.builder() + .webContext("/my-ui") + .build())); + } + + @Test + void testDefault() { + ClientResponseTyped response = client.get("/openapi/ui") + .accept(MediaTypes.TEXT_HTML) + .request(String.class); + + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.entity(), startsWith("")); + } + + @Test + void testDefaultWithTrailingSlash() { + ClientResponseTyped response = client.get("/openapi/ui/") + .accept(MediaTypes.TEXT_HTML) + .request(String.class); + + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.entity(), startsWith("")); + } + + @Test + void testAlternateOpenApiWebContext() { + ClientResponseTyped response = client.get("/openapi-greeting/ui") + .accept(MediaTypes.TEXT_HTML) + .request(String.class); + + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.entity(), startsWith("")); + } + + @Test + void testMainEndpoint() { + ClientResponseTyped response = client.get("/openapi") + .accept(SIMULATED_BROWSER_ACCEPT) + .request(String.class); + + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.entity(), startsWith("")); + } + + @Test + void testMainEndpointWithTrailingSlash() { + ClientResponseTyped response = client.get("/openapi/") + .accept(SIMULATED_BROWSER_ACCEPT) + .request(String.class); + + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.entity(), startsWith("")); + } + + @Test + void testAlternateUiWebContext() { + ClientResponseTyped response = client.get("/my-ui") + .accept(MediaTypes.TEXT_HTML) + .request(String.class); + + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.entity(), startsWith("")); + } +} diff --git a/openapi/src/test/resources/openapi-greeting.yml b/openapi/openapi-ui/src/test/resources/greeting.yml similarity index 97% rename from openapi/src/test/resources/openapi-greeting.yml rename to openapi/openapi-ui/src/test/resources/greeting.yml index fc36ebd11b7..dc367eb57a3 100644 --- a/openapi/src/test/resources/openapi-greeting.yml +++ b/openapi/openapi-ui/src/test/resources/greeting.yml @@ -1,5 +1,5 @@ # -# Copyright (c) 2019, 2021 Oracle and/or its affiliates. +# Copyright (c) 2019, 2023 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. diff --git a/openapi/etc/spotbugs/exclude.xml b/openapi/openapi/etc/spotbugs/exclude.xml similarity index 100% rename from openapi/etc/spotbugs/exclude.xml rename to openapi/openapi/etc/spotbugs/exclude.xml diff --git a/openapi/openapi/pom.xml b/openapi/openapi/pom.xml new file mode 100644 index 00000000000..696330ba42a --- /dev/null +++ b/openapi/openapi/pom.xml @@ -0,0 +1,159 @@ + + + + 4.0.0 + + io.helidon.openapi + helidon-openapi-project + 4.0.0-SNAPSHOT + + + helidon-openapi + Helidon OpenAPI + + + Helidon OpenAPI implementation + + + + etc/spotbugs/exclude.xml + + + + + io.helidon.webserver + helidon-webserver-service-common + + + io.helidon.common.features + helidon-common-features-api + true + + + io.helidon.common + helidon-common + + + io.helidon.common + helidon-common-config + + + io.helidon.common + helidon-common-media-type + + + io.helidon.webserver + helidon-webserver + + + io.helidon.config + helidon-config-metadata + true + + + org.yaml + snakeyaml + + + org.junit.jupiter + junit-jupiter-api + test + + + org.hamcrest + hamcrest-all + test + + + org.junit.jupiter + junit-jupiter-params + test + + + io.helidon.webserver.testing.junit5 + helidon-webserver-testing-junit5 + test + + + jakarta.json + jakarta.json-api + test + + + org.eclipse.parsson + parsson + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + io.helidon.common.features + helidon-common-features-processor + ${helidon.version} + + + io.helidon.config + helidon-config-metadata-processor + ${helidon.version} + + + io.helidon.builder + helidon-builder-processor + ${helidon.version} + + + io.helidon.common.processor + helidon-common-processor-helidon-copyright + ${helidon.version} + + + + + + io.helidon.common.features + helidon-common-features-processor + ${helidon.version} + + + io.helidon.builder + helidon-builder-processor + ${helidon.version} + + + io.helidon.config + helidon-config-metadata-processor + ${helidon.version} + + + io.helidon.common.processor + helidon-common-processor-helidon-copyright + ${helidon.version} + + + + + + diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiFeature.java b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiFeature.java new file mode 100644 index 00000000000..a92f97441c3 --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiFeature.java @@ -0,0 +1,259 @@ +/* + * Copyright (c) 2023 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.io.InputStream; +import java.io.UncheckedIOException; +import java.lang.System.Logger.Level; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.function.Consumer; + +import io.helidon.builder.api.RuntimeType; +import io.helidon.common.LazyValue; +import io.helidon.common.config.Config; +import io.helidon.common.media.type.MediaType; +import io.helidon.common.media.type.MediaTypes; +import io.helidon.http.BadRequestException; +import io.helidon.http.HttpMediaType; +import io.helidon.http.Status; +import io.helidon.webserver.cors.CorsEnabledServiceHelper; +import io.helidon.webserver.http.HttpRouting; +import io.helidon.webserver.http.ServerRequest; +import io.helidon.webserver.http.ServerResponse; +import io.helidon.webserver.servicecommon.FeatureSupport; + +/** + * Helidon Support for OpenAPI. + */ +@RuntimeType.PrototypedBy(OpenApiFeatureConfig.class) +public final class OpenApiFeature implements FeatureSupport, RuntimeType.Api { + + private static final System.Logger LOGGER = System.getLogger(OpenApiFeature.class.getName()); + + /** + * Returns a new builder. + * + * @return new builder` + */ + public static OpenApiFeatureConfig.Builder builder() { + return OpenApiFeatureConfig.builder(); + } + + /** + * Create a new instance with default configuration. + * + * @return new instance + */ + public static OpenApiFeature create() { + return builder().build(); + } + + /** + * Create a new instance from typed configuration. + * + * @param config typed configuration + * @return new instance + */ + public static OpenApiFeature create(Config config) { + return new OpenApiFeature(OpenApiFeatureConfig.create(config)); + } + + /** + * Create a new instance from typed configuration. + * + * @param config typed configuration + * @return new instance + */ + static OpenApiFeature create(OpenApiFeatureConfig config) { + return new OpenApiFeature(config); + } + + /** + * Create a new instance with custom configuration. + * + * @param builderConsumer consumer of configuration builder + * @return new instance + */ + public static OpenApiFeature create(Consumer builderConsumer) { + return OpenApiFeatureConfig.builder().update(builderConsumer).build(); + } + + private static final String DEFAULT_STATIC_FILE_PATH_PREFIX = "META-INF/openapi."; + private static final Map SUPPORTED_FORMATS = Map.of( + "json", MediaTypes.APPLICATION_JSON, + "yaml", MediaTypes.APPLICATION_OPENAPI_YAML, + "yml", MediaTypes.APPLICATION_OPENAPI_YAML); + private static final List DEFAULT_FILE_PATHS = SUPPORTED_FORMATS.keySet() + .stream() + .map(fileType -> DEFAULT_STATIC_FILE_PATH_PREFIX + fileType) + .toList(); + private static final MediaType[] PREFERRED_MEDIA_TYPES = new MediaType[] { + MediaTypes.APPLICATION_OPENAPI_YAML, + MediaTypes.APPLICATION_X_YAML, + MediaTypes.APPLICATION_YAML, + MediaTypes.APPLICATION_OPENAPI_JSON, + MediaTypes.APPLICATION_JSON, + MediaTypes.TEXT_X_YAML, + MediaTypes.TEXT_YAML + }; + + private final String content; + private final OpenApiFeatureConfig config; + private final CorsEnabledServiceHelper corsService; + private final OpenApiManager manager; + private final LazyValue model; + private final ConcurrentMap cachedDocuments = new ConcurrentHashMap<>(); + + OpenApiFeature(OpenApiFeatureConfig config) { + this.config = config; + String staticFile = config.staticFile().orElse(null); + String defaultContent = null; + if (staticFile != null) { + defaultContent = readContent(staticFile); + if (defaultContent == null) { + defaultContent = ""; + LOGGER.log(Level.WARNING, "Static OpenAPI file not found: {0}", staticFile); + } + } else { + for (String path : DEFAULT_FILE_PATHS) { + defaultContent = readContent(path); + if (defaultContent != null) { + break; + } + } + if (defaultContent == null) { + defaultContent = ""; + LOGGER.log(Level.WARNING, "Static OpenAPI file not found, checked: {0}", DEFAULT_FILE_PATHS); + } + } + content = defaultContent; + manager = config.manager().orElseGet(SimpleOpenApiManager::new); + corsService = CorsEnabledServiceHelper.create("openapi", config.cors().orElse(null)); + model = LazyValue.create(() -> manager.load(content)); + } + + @Override + public OpenApiFeatureConfig prototype() { + return config; + } + + @Override + public void setup(HttpRouting.Builder routing, HttpRouting.Builder featureRouting) { + String path = prototype().webContext(); + routing.any(path, corsService.processor()) + .get(path, this::handle); + config.services().forEach(service -> service.setup(routing, path, this::content)); + } + + @Override + public String context() { + return config.webContext(); + } + + @Override + public String configuredContext() { + return config.webContext(); + } + + @Override + public boolean enabled() { + return config.isEnabled(); + } + + /** + * Initialize the model. + */ + public void initialize() { + model.get(); + } + + private void handle(ServerRequest req, ServerResponse res) { + String format = req.query().first("format").map(String::toLowerCase).orElse(null); + if (format != null) { + MediaType contentType = SUPPORTED_FORMATS.get(format.toLowerCase()); + if (contentType == null) { + throw new BadRequestException(String.format( + "Unsupported format: %s, supported formats: %s", + format, SUPPORTED_FORMATS.keySet())); + } + res.status(Status.OK_200); + res.headers().contentType(contentType); + res.send(content(contentType)); + } else { + // check if we should delegate to a service + for (OpenApiService service : config.services()) { + if (service.supports(req.headers())) { + res.next(); + return; + } + } + + HttpMediaType contentType = req.headers() + .bestAccepted(PREFERRED_MEDIA_TYPES) + .map(HttpMediaType::create) + .orElse(null); + + if (contentType == null) { + if (LOGGER.isLoggable(Level.TRACE)) { + LOGGER.log(Level.TRACE, "Accepted types not supported: {0}", req.headers().acceptedTypes()); + } + res.next(); + return; + } + + res.status(Status.OK_200); + res.headers().contentType(contentType); + res.send(content(contentType)); + } + } + + private String content(MediaType mediaType) { + OpenApiFormat format = OpenApiFormat.valueOf(mediaType); + if (format == OpenApiFormat.UNSUPPORTED) { + if (LOGGER.isLoggable(Level.TRACE)) { + LOGGER.log(Level.TRACE, "Requested format {0} not supported", mediaType); + } + } + return cachedDocuments.computeIfAbsent(format, fmt -> format(manager, fmt, model.get())); + } + + @SuppressWarnings("unchecked") + private static String format(OpenApiManager manager, OpenApiFormat format, Object model) { + return manager.format((T) model, format); + } + + private static String readContent(String path) { + try { + Path file = Path.of(path); + if (Files.exists(file)) { + return Files.readString(file); + } else { + try (InputStream is = Thread.currentThread().getContextClassLoader().getResourceAsStream(path)) { + return is != null ? new String(is.readAllBytes(), StandardCharsets.UTF_8) : null; + } + } + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } +} diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiFeatureConfigBlueprint.java b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiFeatureConfigBlueprint.java new file mode 100644 index 00000000000..21e61f67317 --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiFeatureConfigBlueprint.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2023 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.List; +import java.util.Optional; + +import io.helidon.builder.api.Option; +import io.helidon.builder.api.Prototype; +import io.helidon.config.metadata.Configured; +import io.helidon.config.metadata.ConfiguredOption; +import io.helidon.cors.CrossOriginConfig; +import io.helidon.openapi.spi.OpenApiManagerProvider; +import io.helidon.openapi.spi.OpenApiServiceProvider; + +/** + * {@link OpenApiFeature} prototype. + */ +@Prototype.Blueprint +@Configured(root = true, prefix = "openapi") +interface OpenApiFeatureConfigBlueprint extends Prototype.Factory { + + /** + * Sets whether the feature should be enabled. + * + * @return {@code true} if enabled, {@code false} otherwise + */ + @ConfiguredOption(key = "enabled", value = "true") + boolean isEnabled(); + + /** + * Web context path for the OpenAPI endpoint. + * + * @return webContext to use + */ + @ConfiguredOption("/openapi") + String webContext(); + + /** + * Path of the static OpenAPI document file. Default types are `json`, `yaml`, and `yml`. + * + * @return location of the static OpenAPI document file + */ + @ConfiguredOption + Optional staticFile(); + + /** + * CORS config. + * + * @return CORS config + */ + @ConfiguredOption + Optional cors(); + + /** + * OpenAPI services. + * + * @return the OpenAPI services + */ + @ConfiguredOption(provider = true, providerType = OpenApiServiceProvider.class) + @Option.Singular + List services(); + + /** + * OpenAPI manager. + * + * @return the OpenAPI manager + */ + @ConfiguredOption(provider = true, providerType = OpenApiManagerProvider.class, providerDiscoverServices = false) + Optional> manager(); +} diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiFormat.java b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiFormat.java new file mode 100644 index 00000000000..c67fc3068d9 --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiFormat.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2023 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.ArrayList; +import java.util.Arrays; +import java.util.List; + +import io.helidon.common.media.type.MediaType; +import io.helidon.common.media.type.MediaTypes; + +/** + * Supported OpenApi formats. + */ +public enum OpenApiFormat { + /** + * JSON. + */ + JSON(new MediaType[] { + MediaTypes.APPLICATION_OPENAPI_JSON, + MediaTypes.APPLICATION_JSON + }, "json"), + + /** + * YAML. + */ + YAML(new MediaType[] { + MediaTypes.APPLICATION_OPENAPI_YAML, + MediaTypes.APPLICATION_X_YAML, + MediaTypes.APPLICATION_YAML, + MediaTypes.TEXT_PLAIN, + MediaTypes.TEXT_X_YAML, + MediaTypes.TEXT_YAML + }, "yaml", "yml"), + + /** + * Unsupported format. + */ + UNSUPPORTED(new MediaType[0]); + + private final List fileTypes; + private final List mediaTypes; + + OpenApiFormat(MediaType[] mediaTypes, String... fileTypes) { + this.mediaTypes = Arrays.asList(mediaTypes); + this.fileTypes = new ArrayList<>(Arrays.asList(fileTypes)); + } + + /** + * File types usable with this format. + * + * @return file types + */ + public List fileTypes() { + return fileTypes; + } + + private boolean supports(MediaType mediaType) { + for (MediaType mt : mediaTypes) { + if (mediaType.type().equals(mt.type()) + && mediaType.subtype().equals(mt.subtype())) { + return true; + } + } + return false; + } + + /** + * Find OpenAPI media type by media type. + * + * @param mediaType media type + * @return OpenAPI media type + */ + public static OpenApiFormat valueOf(MediaType mediaType) { + for (OpenApiFormat candidateType : values()) { + if (candidateType.supports(mediaType)) { + return candidateType; + } + } + return OpenApiFormat.UNSUPPORTED; + } +} diff --git a/openapi/src/main/java/io/helidon/openapi/OpenApiUiFactory.java b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiManager.java similarity index 53% rename from openapi/src/main/java/io/helidon/openapi/OpenApiUiFactory.java rename to openapi/openapi/src/main/java/io/helidon/openapi/OpenApiManager.java index 94e7d34fd3e..041c9a6319a 100644 --- a/openapi/src/main/java/io/helidon/openapi/OpenApiUiFactory.java +++ b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiManager.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2023 Oracle and/or its affiliates. + * Copyright (c) 2023 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. @@ -15,18 +15,29 @@ */ package io.helidon.openapi; +import io.helidon.common.config.NamedService; + /** - * Behavior for factories able to provide new builders of {@link io.helidon.openapi.OpenApiUi} instances. + * OpenApi manager. * - * @param type of the {@link io.helidon.openapi.OpenApiUi} to be built - * @param type of the builder for T + * @param model type */ -public interface OpenApiUiFactory, T extends OpenApiUi> { +public interface OpenApiManager extends NamedService { + + /** + * Load the model. + * + * @param content initial static content, may be empty + * @return in-memory model + */ + T load(String content); /** - * Returns a builder for the UI. + * Format the model. * - * @return a builder for the selected type of concrete {@link io.helidon.openapi.OpenApiUi}. + * @param model model + * @param format desired format + * @return formatted content */ - B builder(); + String format(T model, OpenApiFormat format); } diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiService.java b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiService.java new file mode 100644 index 00000000000..0f1a87eec3d --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiService.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2023 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.function.Function; + +import io.helidon.common.config.NamedService; +import io.helidon.common.media.type.MediaType; +import io.helidon.http.ServerRequestHeaders; +import io.helidon.webserver.http.HttpRules; + +/** + * OpenAPI service. + */ +public interface OpenApiService extends NamedService { + + /** + * Test if the service should handle a request. + * + * @param headers headers + * @return {@code true} if the service should handle the request + */ + boolean supports(ServerRequestHeaders headers); + + /** + * Set up the service. + * + * @param routing routing + * @param docPath document context path + * @param content content function + */ + void setup(HttpRules routing, String docPath, Function content); +} diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/SimpleOpenApiManager.java b/openapi/openapi/src/main/java/io/helidon/openapi/SimpleOpenApiManager.java new file mode 100644 index 00000000000..83ab29a7bf4 --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/SimpleOpenApiManager.java @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2023 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 org.yaml.snakeyaml.DumperOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.nodes.Node; +import org.yaml.snakeyaml.nodes.Tag; +import org.yaml.snakeyaml.representer.Representer; + +/** + * Simple implementation of {@link OpenApiManager}. + */ +final class SimpleOpenApiManager implements OpenApiManager { + + private static final System.Logger LOGGER = System.getLogger(SimpleOpenApiManager.class.getName()); + private static final DumperOptions JSON_DUMPER_OPTIONS = jsonDumperOptions(); + private static final DumperOptions YAML_DUMPER_OPTIONS = yamlDumperOptions(); + + @Override + public String name() { + return "default"; + } + + @Override + public String type() { + return "default"; + } + + @Override + public String load(String content) { + return content; + } + + @Override + public String format(String content, OpenApiFormat format) { + return switch (format) { + case UNSUPPORTED, YAML -> toYaml(content); + case JSON -> toJson(content); + }; + } + + private String toYaml(String rawData) { + if (LOGGER.isLoggable(System.Logger.Level.TRACE)) { + LOGGER.log(System.Logger.Level.TRACE, "Converting OpenAPI document in YAML format"); + } + Yaml yaml = new Yaml(YAML_DUMPER_OPTIONS); + Object loadedData = yaml.load(rawData); + return yaml.dump(loadedData); + } + + private String toJson(String data) { + if (LOGGER.isLoggable(System.Logger.Level.TRACE)) { + LOGGER.log(System.Logger.Level.TRACE, "Converting OpenAPI document in JSON format"); + } + Representer representer = new Representer(JSON_DUMPER_OPTIONS) { + + @Override + protected Node representScalar(Tag tag, String value, DumperOptions.ScalarStyle style) { + if (tag.equals(Tag.BINARY)) { + // base64 string + return super.representScalar(Tag.STR, value, DumperOptions.ScalarStyle.DOUBLE_QUOTED); + } + if (tag.equals(Tag.BOOL) + || tag.equals(Tag.FLOAT) + || tag.equals(Tag.INT)) { + return super.representScalar(tag, value, DumperOptions.ScalarStyle.PLAIN); + } + return super.representScalar(tag, value, style); + } + }; + Yaml yaml = new Yaml(representer, JSON_DUMPER_OPTIONS); + Object loadedData = yaml.load(data); + return yaml.dump(loadedData); + } + + private static DumperOptions yamlDumperOptions() { + DumperOptions dumperOptions = new DumperOptions(); + dumperOptions.setIndent(2); + dumperOptions.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK); + return dumperOptions; + } + + private static DumperOptions jsonDumperOptions() { + DumperOptions dumperOptions = new DumperOptions(); + dumperOptions.setDefaultFlowStyle(DumperOptions.FlowStyle.FLOW); + dumperOptions.setPrettyFlow(true); + dumperOptions.setDefaultScalarStyle(DumperOptions.ScalarStyle.DOUBLE_QUOTED); + dumperOptions.setSplitLines(false); + return dumperOptions; + } +} diff --git a/openapi/src/main/java/io/helidon/openapi/package-info.java b/openapi/openapi/src/main/java/io/helidon/openapi/package-info.java similarity index 94% rename from openapi/src/main/java/io/helidon/openapi/package-info.java rename to openapi/openapi/src/main/java/io/helidon/openapi/package-info.java index 28a455c548a..f6282bf94c1 100644 --- a/openapi/src/main/java/io/helidon/openapi/package-info.java +++ b/openapi/openapi/src/main/java/io/helidon/openapi/package-info.java @@ -15,6 +15,6 @@ */ /** - * Helidon common OpenAPI classes. + * Helidon OpenAPI support. */ package io.helidon.openapi; diff --git a/tests/integration/gh-5792/src/test/java/io/helidon/tests/integration/yamlparsing/MainIT.java b/openapi/openapi/src/main/java/io/helidon/openapi/spi/OpenApiManagerProvider.java similarity index 66% rename from tests/integration/gh-5792/src/test/java/io/helidon/tests/integration/yamlparsing/MainIT.java rename to openapi/openapi/src/main/java/io/helidon/openapi/spi/OpenApiManagerProvider.java index 8d601809b59..21522082f7a 100644 --- a/tests/integration/gh-5792/src/test/java/io/helidon/tests/integration/yamlparsing/MainIT.java +++ b/openapi/openapi/src/main/java/io/helidon/openapi/spi/OpenApiManagerProvider.java @@ -13,16 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.helidon.tests.integration.yamlparsing; +package io.helidon.openapi.spi; -import io.helidon.webserver.testing.junit5.ServerTest; -import io.helidon.webclient.http1.Http1Client; -import org.junit.jupiter.api.Disabled; +import io.helidon.common.config.ConfiguredProvider; +import io.helidon.openapi.OpenApiManager; -@ServerTest -@Disabled -class MainIT extends AbstractMainTest { - MainIT(Http1Client client) { - super(client); - } -} \ No newline at end of file +/** + * {@link io.helidon.openapi.OpenApiManager} provider. + */ +public interface OpenApiManagerProvider extends ConfiguredProvider { +} diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/spi/OpenApiServiceProvider.java b/openapi/openapi/src/main/java/io/helidon/openapi/spi/OpenApiServiceProvider.java new file mode 100644 index 00000000000..820ab2c3919 --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/spi/OpenApiServiceProvider.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2023 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.spi; + +import io.helidon.common.config.ConfiguredProvider; +import io.helidon.openapi.OpenApiService; + +/** + * {@link OpenApiService} provider. + */ +public interface OpenApiServiceProvider extends ConfiguredProvider { +} diff --git a/tests/integration/gh-5792/src/main/java/io/helidon/tests/integration/yamlparsing/package-info.java b/openapi/openapi/src/main/java/io/helidon/openapi/spi/package-info.java similarity index 81% rename from tests/integration/gh-5792/src/main/java/io/helidon/tests/integration/yamlparsing/package-info.java rename to openapi/openapi/src/main/java/io/helidon/openapi/spi/package-info.java index b81dd6a2525..d851804835c 100644 --- a/tests/integration/gh-5792/src/main/java/io/helidon/tests/integration/yamlparsing/package-info.java +++ b/openapi/openapi/src/main/java/io/helidon/openapi/spi/package-info.java @@ -15,6 +15,6 @@ */ /** - * Integration test to make sure apps can use the older release of SnakeYAML if needed. + * OpenAPI SPI. */ -package io.helidon.tests.integration.yamlparsing; +package io.helidon.openapi.spi; diff --git a/openapi/src/main/java/module-info.java b/openapi/openapi/src/main/java/module-info.java similarity index 65% rename from openapi/src/main/java/module-info.java rename to openapi/openapi/src/main/java/module-info.java index c2572e9df2f..1eafb672456 100644 --- a/openapi/src/main/java/module-info.java +++ b/openapi/openapi/src/main/java/module-info.java @@ -14,28 +14,31 @@ * limitations under the License. */ -import io.helidon.openapi.OpenApiUiNoOpFactory; +import io.helidon.common.features.api.Feature; +import io.helidon.common.features.api.HelidonFlavor; /** * Helidon common OpenAPI behavior. */ +@Feature(value = "OpenAPI", + description = "OpenAPI support", + in = HelidonFlavor.SE +) module io.helidon.openapi { + requires static io.helidon.common.features.api; + requires io.helidon.common; requires io.helidon.common.config; requires io.helidon.common.media.type; - requires jakarta.json; - - requires static io.helidon.common.features.api; + requires io.helidon.servicecommon; requires static io.helidon.config.metadata; - requires transitive io.helidon.common; - requires transitive io.helidon.servicecommon; - requires transitive io.helidon.webserver; + requires org.yaml.snakeyaml; + requires io.helidon.inject.api; exports io.helidon.openapi; + exports io.helidon.openapi.spi; - uses io.helidon.openapi.OpenApiUiFactory; - - provides io.helidon.openapi.OpenApiUiFactory with OpenApiUiNoOpFactory; - + uses io.helidon.openapi.spi.OpenApiServiceProvider; + uses io.helidon.openapi.spi.OpenApiManagerProvider; } diff --git a/openapi/src/main/resources/META-INF/helidon/native-image/reflection-config.json b/openapi/openapi/src/main/resources/META-INF/helidon/native-image/reflection-config.json similarity index 100% rename from openapi/src/main/resources/META-INF/helidon/native-image/reflection-config.json rename to openapi/openapi/src/main/resources/META-INF/helidon/native-image/reflection-config.json diff --git a/openapi/src/main/resources/META-INF/native-image/io.helidon.openapi/helidon-openapi/native-image.properties b/openapi/openapi/src/main/resources/META-INF/native-image/io.helidon.openapi/helidon-openapi/native-image.properties similarity index 92% rename from openapi/src/main/resources/META-INF/native-image/io.helidon.openapi/helidon-openapi/native-image.properties rename to openapi/openapi/src/main/resources/META-INF/native-image/io.helidon.openapi/helidon-openapi/native-image.properties index d4458ccb1ee..c247eb32a4f 100644 --- a/openapi/src/main/resources/META-INF/native-image/io.helidon.openapi/helidon-openapi/native-image.properties +++ b/openapi/openapi/src/main/resources/META-INF/native-image/io.helidon.openapi/helidon-openapi/native-image.properties @@ -1,5 +1,5 @@ # -# Copyright (c) 2020, 2022 Oracle and/or its affiliates. +# Copyright (c) 2020, 2023 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. diff --git a/openapi/openapi/src/test/java/io/helidon/openapi/OpenApiFeatureTest.java b/openapi/openapi/src/test/java/io/helidon/openapi/OpenApiFeatureTest.java new file mode 100644 index 00000000000..1b9a2f5a9eb --- /dev/null +++ b/openapi/openapi/src/test/java/io/helidon/openapi/OpenApiFeatureTest.java @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2019, 2023 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.io.InputStream; +import java.io.UncheckedIOException; +import java.net.URL; +import java.util.Map; +import java.util.stream.Stream; + +import io.helidon.common.media.type.MediaType; +import io.helidon.common.media.type.MediaTypes; +import io.helidon.http.HeaderNames; +import io.helidon.http.HttpMediaType; +import io.helidon.http.Status; +import io.helidon.webclient.api.ClientResponseTyped; +import io.helidon.webclient.api.WebClient; +import io.helidon.webserver.http.HttpRouting; +import io.helidon.webserver.testing.junit5.RoutingTest; +import io.helidon.webserver.testing.junit5.SetUpRoute; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.yaml.snakeyaml.Yaml; + +import static io.helidon.common.testing.junit5.MapMatcher.mapEqualTo; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +/** + * Tests {@link io.helidon.openapi.OpenApiFeature}. + */ +@RoutingTest +@SuppressWarnings("HttpUrlsUsage") +class OpenApiFeatureTest { + + private final WebClient client; + + OpenApiFeatureTest(WebClient client) { + this.client = client; + } + + @SetUpRoute + static void setup(HttpRouting.Builder routing) { + routing.addFeature(OpenApiFeature.builder() + .servicesDiscoverServices(false) + .staticFile("src/test/resources/greeting.yml") + .webContext("/openapi-greeting") + .cors(cors -> cors.enabled(false))) + .addFeature(OpenApiFeature.builder() + .servicesDiscoverServices(false) + .staticFile("src/test/resources/time-server.yml") + .webContext("/openapi-time") + .cors(cors -> cors.allowOrigins("http://foo.bar", "http://bar.foo"))) + .addFeature(OpenApiFeature.builder() + .servicesDiscoverServices(false) + .staticFile("src/test/resources/petstore.yaml") + .webContext("/openapi-petstore") + .cors(cors -> cors.enabled(false))); + } + + @Test + void testGreetingAsYAML() { + ClientResponseTyped response = client.get("/openapi-greeting") + .accept(MediaTypes.APPLICATION_OPENAPI_YAML) + .request(String.class); + assertThat(response.status(), is(Status.OK_200)); + assertThat(parse(response.entity()), mapEqualTo(parse(resource("/greeting.yml")))); + } + + static Stream checkExplicitResponseMediaTypeViaHeaders() { + return Stream.of(MediaTypes.APPLICATION_OPENAPI_YAML, + MediaTypes.APPLICATION_YAML, + MediaTypes.APPLICATION_OPENAPI_JSON, + MediaTypes.APPLICATION_JSON); + } + + @ParameterizedTest + @MethodSource() + void checkExplicitResponseMediaTypeViaHeaders(MediaType testMediaType) { + ClientResponseTyped response = client.get("/openapi-petstore") + .accept(testMediaType) + .request(String.class); + assertThat(response.status(), is(Status.OK_200)); + + HttpMediaType contentType = response.headers().contentType().orElseThrow(); + + if (contentType.test(MediaTypes.APPLICATION_OPENAPI_YAML) + || contentType.test(MediaTypes.APPLICATION_YAML)) { + + assertThat(parse(response.entity()), mapEqualTo(parse(resource("/petstore.yaml")))); + } else if (contentType.test(MediaTypes.APPLICATION_OPENAPI_JSON) + || contentType.test(MediaTypes.APPLICATION_JSON)) { + + // parsing normalizes the entity, so we can compare the entity to the original YAML + assertThat(parse(response.entity()), mapEqualTo(parse(resource("/petstore.yaml")))); + } else { + throw new AssertionError("Expected either JSON or YAML response but received " + contentType); + } + } + + @ParameterizedTest + @ValueSource(strings = {"JSON", "YAML"}) + void checkExplicitResponseMediaTypeViaQueryParam(String format) { + ClientResponseTyped response = client.get("/openapi-petstore") + .queryParam("format", format) + .accept(MediaTypes.APPLICATION_JSON) + .request(String.class); + assertThat(response.status(), is(Status.OK_200)); + + switch (format) { + // parsing normalizes the entity, so we can compare the entity to the original YAML + case "YAML", "JSON" -> assertThat(parse(response.entity()), mapEqualTo(parse(resource("/petstore.yaml")))); + default -> throw new AssertionError("Format not supported: " + format); + } + } + + @Test + void testUnrestrictedCorsAsIs() { + ClientResponseTyped response = client.get("/openapi-time") + .accept(MediaTypes.APPLICATION_OPENAPI_YAML) + .request(String.class); + assertThat(response.status(), is(Status.OK_200)); + assertThat(parse(response.entity()), mapEqualTo(parse(resource("/time-server.yml")))); + } + + @Test + void testUnrestrictedCorsWithHeaders() { + ClientResponseTyped response = client.get("/openapi-time") + .accept(MediaTypes.APPLICATION_OPENAPI_YAML) + .header(HeaderNames.ORIGIN, "http://foo.bar") + .header(HeaderNames.HOST, "localhost") + .request(String.class); + assertThat(response.status(), is(Status.OK_200)); + assertThat(parse(response.entity()), mapEqualTo(parse(resource("/time-server.yml")))); + } + + private static Map parse(String content) { + return new Yaml().load(content); + } + + private static String resource(String path) { + try { + URL resource = OpenApiFeature.class.getResource(path); + if (resource != null) { + try (InputStream is = resource.openStream()) { + return new String(is.readAllBytes()); + } + } + throw new IllegalArgumentException("Resource not found: " + path); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } +} diff --git a/openapi/openapi/src/test/java/io/helidon/openapi/OpenApiFormatTest.java b/openapi/openapi/src/test/java/io/helidon/openapi/OpenApiFormatTest.java new file mode 100644 index 00000000000..fab4dc5bbc2 --- /dev/null +++ b/openapi/openapi/src/test/java/io/helidon/openapi/OpenApiFormatTest.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2022, 2023 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.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +import io.helidon.openapi.OpenApiFormat; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; + +/** + * Test consistency of the file types defined in {@link io.helidon.openapi.OpenApiFormat}. + */ +class OpenApiFormatTest { + + @Test + void makeSureAllTypesAreDescribed() { + Set fileTypes = Arrays.stream(OpenApiFormat.values()) + .flatMap(mediaType -> mediaType.fileTypes().stream()) + .collect(Collectors.toSet()); + assertThat(fileTypes, containsInAnyOrder("json", "yaml", "yml")); + } +} diff --git a/openapi/openapi/src/test/java/io/helidon/openapi/SimpleOpenApiManagerTest.java b/openapi/openapi/src/test/java/io/helidon/openapi/SimpleOpenApiManagerTest.java new file mode 100644 index 00000000000..c83daf95063 --- /dev/null +++ b/openapi/openapi/src/test/java/io/helidon/openapi/SimpleOpenApiManagerTest.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2023 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.StringReader; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; + +import jakarta.json.Json; +import jakarta.json.JsonObject; +import jakarta.json.JsonReader; +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +import static org.hamcrest.Matchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +class SimpleOpenApiManagerTest { + + private static final String HELLO_BASE64 = Base64.getEncoder().encodeToString("Hello".getBytes(StandardCharsets.UTF_8)); + + @Test + void testJsonFormatting() { + SimpleOpenApiManager manager = new SimpleOpenApiManager(); + String raw = "plain-boolean: true\n" + + "quoted-boolean: \"true\"\n" + + "integer: 100\n" + + "float: 1.1\n" + + "binary: !!binary \"" + HELLO_BASE64 + "\"\n"; + String formatted = manager.format(raw, OpenApiFormat.JSON); + JsonReader reader = Json.createReader(new StringReader(formatted)); + JsonObject jsonObject = reader.readObject(); + assertThat(jsonObject.getBoolean("plain-boolean"), is(true)); + assertThat(jsonObject.getString("quoted-boolean"), is("true")); + assertThat(jsonObject.getInt("integer"), is(100)); + assertThat(jsonObject.getJsonNumber("float").doubleValue(), is(1.1D)); + assertThat(jsonObject.getString("binary"), is(HELLO_BASE64)); + } + + @Test + void testYamlFormatting() { + SimpleOpenApiManager manager = new SimpleOpenApiManager(); + String raw = "{" + + "\"plain-boolean\": true," + + "\"quoted-boolean\": \"true\"," + + "\"integer\": 100," + + "\"float\": 1.1," + + "\"binary\": \"" + HELLO_BASE64 + "\"" + + "}"; + String formatted = manager.format(raw, OpenApiFormat.YAML); + Yaml yaml = new Yaml(); + Map yamlObject = yaml.load(formatted); + assertThat(yamlObject.get("plain-boolean"), is(true)); + assertThat(yamlObject.get("quoted-boolean"), is("true")); + assertThat(yamlObject.get("integer"), is(100)); + assertThat(yamlObject.get("float"), is(1.1D)); + assertThat(yamlObject.get("binary"), is(HELLO_BASE64)); + } +} diff --git a/openapi/openapi/src/test/resources/greeting.yml b/openapi/openapi/src/test/resources/greeting.yml new file mode 100644 index 00000000000..dc367eb57a3 --- /dev/null +++ b/openapi/openapi/src/test/resources/greeting.yml @@ -0,0 +1,106 @@ +# +# Copyright (c) 2019, 2023 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 +x-my-personal-map: + owner: + first: Me + last: Myself + value-1: 2.3 +x-other-item: 10 +x-boolean: true +x-int: 117 +x-string-array: + - one + - two +x-object-array: + - name: item-1 + value: 16 + - name: item-2 + value: 18 +info: + title: Helidon SE OpenAPI test + description: OpenAPI document for testing + + version: 1.0.0 + x-my-personal-seq: + - who: Prof. Plum + why: felt like it + - when: yesterday + how: with the lead pipe + +servers: + - url: http://localhost:8000 + description: Local test server + +paths: + /greet/greeting: + put: + summary: Sets 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 + required: true + content: + application/json: + schema: + type: object + required: + - greeting + properties: + greeting: + type: string + + responses: + '204': + description: Greeting set + + /greet/: + get: + summary: Returns a generic greeting + description: Greets the user generically + responses: + '200': + description: Simple JSON containing the greeting + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Hello World! + /greet/{userID}: + get: + summary: Returns a personalized greeting + parameters: + - name: userID + in: path + required: true + description: Name of the user to be used in the returned greeting + schema: + type: string + responses: + '200': + description: Simple JSON containing the greeting + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Hello Joe! diff --git a/openapi/src/test/resources/petstore.yaml b/openapi/openapi/src/test/resources/petstore.yaml similarity index 100% rename from openapi/src/test/resources/petstore.yaml rename to openapi/openapi/src/test/resources/petstore.yaml diff --git a/microprofile/openapi/src/test/resources/openapi-time-server.yml b/openapi/openapi/src/test/resources/time-server.yml similarity index 100% rename from microprofile/openapi/src/test/resources/openapi-time-server.yml rename to openapi/openapi/src/test/resources/time-server.yml diff --git a/openapi/pom.xml b/openapi/pom.xml index 86e5bd0ce22..5cbf41aa88e 100644 --- a/openapi/pom.xml +++ b/openapi/pom.xml @@ -1,6 +1,6 @@ + + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 io.helidon helidon-project 4.0.0-SNAPSHOT - + pom io.helidon.openapi - helidon-openapi - Helidon OpenAPI - - - Helidon OpenAPI implementation - - - - etc/spotbugs/exclude.xml - - - - - io.helidon.webserver - helidon-webserver-service-common - - - jakarta.json - jakarta.json-api - - - io.helidon.common.features - helidon-common-features-api - true - - - io.helidon.common - helidon-common - - - io.helidon.common - helidon-common-config - - - io.helidon.common - helidon-common-media-type - - - io.helidon.webserver - helidon-webserver - - - io.helidon.config - helidon-config-metadata - true - - - org.eclipse - yasson - runtime - - - org.eclipse.parsson - parsson - runtime - - - org.junit.jupiter - junit-jupiter-api - test - - - org.hamcrest - hamcrest-all - test - - - io.helidon.config - helidon-config-yaml - test - - - io.helidon.common.testing - helidon-common-testing-junit5 - test - - - org.junit.jupiter - junit-jupiter-params - test - - - - - - - org.apache.maven.plugins - maven-surefire-plugin - - - - true - - - - - org.apache.maven.plugins - maven-compiler-plugin - - - - io.helidon.common.features - helidon-common-features-processor - ${helidon.version} - - - io.helidon.config - helidon-config-metadata-processor - ${helidon.version} - - - - - - io.helidon.common.features - helidon-common-features-processor - ${helidon.version} - - - io.helidon.config - helidon-config-metadata-processor - ${helidon.version} - - - - - + helidon-openapi-project + Helidon OpenAPI Project + + + openapi + openapi-ui + + + + + tests + + tests + + + diff --git a/openapi/src/main/java/io/helidon/openapi/OpenApiFeature.java b/openapi/src/main/java/io/helidon/openapi/OpenApiFeature.java deleted file mode 100644 index 6c0d89f1cd8..00000000000 --- a/openapi/src/main/java/io/helidon/openapi/OpenApiFeature.java +++ /dev/null @@ -1,558 +0,0 @@ -/* - * Copyright (c) 2023 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.BufferedReader; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.Reader; -import java.nio.CharBuffer; -import java.nio.charset.Charset; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; -import java.util.stream.Collectors; - -import io.helidon.common.mapper.OptionalValue; -import io.helidon.common.media.type.MediaType; -import io.helidon.common.media.type.MediaTypes; -import io.helidon.config.Config; -import io.helidon.config.metadata.ConfiguredOption; -import io.helidon.http.HeaderNames; -import io.helidon.http.HttpMediaType; -import io.helidon.http.ServerRequestHeaders; -import io.helidon.http.Status; -import io.helidon.http.WritableHeaders; -import io.helidon.webserver.http.HttpRules; -import io.helidon.webserver.http.HttpService; -import io.helidon.webserver.http.ServerRequest; -import io.helidon.webserver.http.ServerResponse; -import io.helidon.webserver.servicecommon.HelidonFeatureSupport; - -/** - * Behavior shared between the SE and MP OpenAPI feature implementations. - */ -public abstract class OpenApiFeature extends HelidonFeatureSupport { - - /** - * Default media type used in responses in absence of incoming Accept - * header. - */ - public static final MediaType DEFAULT_RESPONSE_MEDIA_TYPE = MediaTypes.APPLICATION_OPENAPI_YAML; - - /** - * Feature name for OpenAPI. - */ - public static final String FEATURE_NAME = "OpenAPI"; - - /** - * Default web context for the endpoint. - */ - public static final String DEFAULT_CONTEXT = "/openapi"; - /** - * 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 String DEFAULT_STATIC_FILE_PATH_PREFIX = "META-INF/openapi."; - private static final String OPENAPI_EXPLICIT_STATIC_FILE_LOG_MESSAGE_FORMAT = "Using specified OpenAPI static file %s"; - private static final String OPENAPI_DEFAULTED_STATIC_FILE_LOG_MESSAGE_FORMAT = "Using default OpenAPI static file %s"; - private final OpenApiStaticFile openApiStaticFile; - private final OpenApiUi ui; - private final MediaType[] preferredMediaTypeOrdering; - private final MediaType[] mediaTypesSupportedByUi; - private final ConcurrentMap cachedDocuments = new ConcurrentHashMap<>(); - /** - * Constructor for the feature. - * - * @param logger logger to use for the feature - * @param builder builder to use for initializing the feature - */ - protected OpenApiFeature(System.Logger logger, Builder builder) { - super(logger, builder, FEATURE_NAME); - openApiStaticFile = builder.staticFile(); - ui = prepareUi(builder); - mediaTypesSupportedByUi = ui.supportedMediaTypes(); - preferredMediaTypeOrdering = preparePreferredMediaTypeOrdering(mediaTypesSupportedByUi); - } - - /** - * Returns a new builder for preparing an SE variant of {@code OpenApiFeature}. - * - * @return new builder - */ - public static Builder builder() { - return new SeOpenApiFeature.Builder(); - } - - /** - * Create a new instance of an Open API feature from configuration. - * - * @param config configuration to use - * @return a new Open API feature - */ - public static OpenApiFeature create(io.helidon.common.config.Config config) { - return builder().config(config).build(); - } - - @Override - public Optional service() { - return enabled() - ? Optional.of(this::configureRoutes) - : Optional.empty(); - } - - /** - * Returns the OpenAPI document content in {@code String} form given the requested media type. - * - * @param openApiMediaType which OpenAPI media type to use for formatting - * @return {@code String} containing the formatted OpenAPI document - */ - protected abstract String openApiContent(OpenAPIMediaType openApiMediaType); - - /** - * Returns the explicitly-assigned or default static content (if any). - *

- * Most likely invoked by the concrete implementations of {@link #openApiContent(OpenAPIMediaType)} as needed - * to find static content as needed. - *

- * - * @return an {@code Optional} of the static content - */ - protected Optional staticContent() { - return Optional.ofNullable(openApiStaticFile); - } - - private static MediaType[] preparePreferredMediaTypeOrdering(MediaType[] uiTypesSupported) { - int nonTextLength = OpenAPIMediaType.preferredOrdering().length; - - MediaType[] result = Arrays.copyOf(OpenAPIMediaType.preferredOrdering(), - nonTextLength + uiTypesSupported.length); - System.arraycopy(uiTypesSupported, 0, result, nonTextLength, uiTypesSupported.length); - return result; - } - - private static ClassLoader getContextClassLoader() { - return Thread.currentThread().getContextClassLoader(); - } - - private static String typeFromPath(String staticFileNamePath) { - if (staticFileNamePath == null) { - throw new IllegalArgumentException("File path does not seem to have a file name value but one is expected"); - } - return staticFileNamePath.substring(staticFileNamePath.lastIndexOf(".") + 1); - } - - private OpenApiUi prepareUi(Builder builder) { - return builder.uiBuilder.build(this::prepareDocument, context()); - } - - private void configureRoutes(HttpRules rules) { - rules.get("/", this::prepareResponse); - } - - private void prepareResponse(ServerRequest req, ServerResponse resp) { - - try { - 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(System.Logger.Level.TRACE, - () -> String.format("Did not recognize requested media type %s; passing the request on", - req.headers().acceptedTypes())); - return; - } - - MediaType resultMediaType = requestedMediaType.get(); - final String openAPIDocument = prepareDocument(resultMediaType); - resp.status(Status.OK_200); - resp.headers().contentType(resultMediaType); - resp.send(openAPIDocument); - } catch (Exception ex) { - resp.status(Status.INTERNAL_SERVER_ERROR_500); - resp.send("Error serializing OpenAPI document; " + ex.getMessage()); - logger().log(System.Logger.Level.ERROR, "Error serializing OpenAPI document", ex); - } - } - - private boolean uiSupportsMediaType(MediaType mediaType) { - HttpMediaType httpMediaType = HttpMediaType.create(mediaType); - // The UI supports a very short list of media types, hence the sequential search. - for (MediaType uiSupportedMediaType : mediaTypesSupportedByUi) { - if (httpMediaType.test(uiSupportedMediaType)) { - return true; - } - } - return false; - } - - /** - * Returns the OpenAPI document in the requested format. - * - * @param resultMediaType requested media type - * @return String containing the formatted OpenAPI document - * from its underlying data - */ - private String prepareDocument(MediaType resultMediaType) { - OpenAPIMediaType matchingOpenApiMediaType - = OpenAPIMediaType.byMediaType(resultMediaType) - .orElseGet(() -> { - logger().log(System.Logger.Level.TRACE, - () -> String.format( - "Requested media type %s not supported; using default", - resultMediaType.toString())); - return OpenAPIMediaType.DEFAULT_TYPE; - }); - - return cachedDocuments.computeIfAbsent(matchingOpenApiMediaType, - fmt -> { - String r = openApiContent(fmt); - logger().log(System.Logger.Level.TRACE, - "Created and cached OpenAPI document in {0} format", - fmt.toString()); - return r; - }); - } - - private Optional chooseResponseMediaType(ServerRequest req) { - /* - * Response media type default is application/vnd.oai.openapi (YAML) - * unless otherwise specified. - */ - OptionalValue queryParameterFormat = req.query() - .first(OPENAPI_ENDPOINT_FORMAT_QUERY_PARAMETER); - if (queryParameterFormat.isPresent()) { - String queryParameterFormatValue = queryParameterFormat.get(); - try { - return Optional.of(QueryParameterRequestedFormat.chooseFormat(queryParameterFormatValue).mediaType()); - } catch (IllegalArgumentException e) { - throw new IllegalArgumentException( - "Query parameter 'format' had value '" - + queryParameterFormatValue - + "' but expected " + Arrays.toString(QueryParameterRequestedFormat.values())); - } - } - - ServerRequestHeaders headersToCheck = req.headers(); - if (headersToCheck.acceptedTypes().isEmpty()) { - WritableHeaders writableHeaders = WritableHeaders.create(headersToCheck); - writableHeaders.add(HeaderNames.ACCEPT, DEFAULT_RESPONSE_MEDIA_TYPE.toString()); - headersToCheck = ServerRequestHeaders.create(writableHeaders); - } - return headersToCheck - .bestAccepted(preferredMediaTypeOrdering); - } - - /** - * Abstraction of the different representations of a static OpenAPI document - * file and the file type(s) they correspond to. - *

- * Each {@code OpenAPIMediaType} stands for a single format (e.g., yaml, - * json). That said, each can map to multiple file types (e.g., yml and - * yaml) and multiple actual media types (the proposed OpenAPI media type - * vnd.oai.openapi and various other YAML types proposed or in use). - */ - public enum OpenAPIMediaType { - /** - * JSON media type. - */ - JSON(new MediaType[] {MediaTypes.APPLICATION_OPENAPI_JSON, - MediaTypes.APPLICATION_JSON}, - "json"), - /** - * YAML media type. - */ - YAML(new MediaType[] {MediaTypes.APPLICATION_OPENAPI_YAML, - MediaTypes.APPLICATION_X_YAML, - MediaTypes.APPLICATION_YAML, - MediaTypes.TEXT_PLAIN, - MediaTypes.TEXT_X_YAML, - MediaTypes.TEXT_YAML}, - "yaml", "yml"); - - /** - * Default media type (YAML). - */ - public static final OpenAPIMediaType DEFAULT_TYPE = YAML; - - static final String TYPE_LIST = "json|yaml|yml"; // must be a true constant so it can be used in an annotation - - private final List fileTypes; - private final List mediaTypes; - - OpenAPIMediaType(MediaType[] mediaTypes, String... fileTypes) { - this.mediaTypes = Arrays.asList(mediaTypes); - this.fileTypes = new ArrayList<>(Arrays.asList(fileTypes)); - } - - /** - * Find media type by file suffix. - * - * @param fileType file suffix - * @return media type or empty if not supported - */ - public static Optional byFileType(String fileType) { - for (OpenAPIMediaType candidateType : values()) { - if (candidateType.matchingTypes().contains(fileType)) { - return Optional.of(candidateType); - } - } - return Optional.empty(); - } - - /** - * Find OpenAPI media type by media type. - * - * @param mt media type - * @return OpenAPI media type or empty if not supported - */ - public static Optional byMediaType(MediaType mt) { - for (OpenAPIMediaType candidateType : values()) { - if (candidateType.mediaTypes.contains(mt)) { - return Optional.of(candidateType); - } - } - return Optional.empty(); - } - - /** - * List of all supported file types. - * - * @return file types - */ - public static List recognizedFileTypes() { - final List result = new ArrayList<>(); - for (OpenAPIMediaType type : values()) { - result.addAll(type.fileTypes); - } - return result; - } - - /** - * Media types we recognize as OpenAPI, in order of preference. - * - * @return MediaTypes in order that we recognize them as OpenAPI - * content. - */ - public static MediaType[] preferredOrdering() { - return new MediaType[] { - MediaTypes.APPLICATION_OPENAPI_YAML, - MediaTypes.APPLICATION_X_YAML, - MediaTypes.APPLICATION_YAML, - MediaTypes.APPLICATION_OPENAPI_JSON, - MediaTypes.APPLICATION_JSON, - MediaTypes.TEXT_X_YAML, - MediaTypes.TEXT_YAML, - MediaTypes.TEXT_PLAIN - }; - } - - /** - * File types matching this media type. - * - * @return file types - */ - public List matchingTypes() { - return fileTypes; - } - } - - /** - * Some logic related to the possible format values as requested in the query - * parameter {@value OPENAPI_ENDPOINT_FORMAT_QUERY_PARAMETER}. - */ - enum QueryParameterRequestedFormat { - JSON(MediaTypes.APPLICATION_JSON), YAML(MediaTypes.APPLICATION_OPENAPI_YAML); - - private final MediaType mt; - - QueryParameterRequestedFormat(MediaType mt) { - this.mt = mt; - } - - static QueryParameterRequestedFormat chooseFormat(String format) { - return QueryParameterRequestedFormat.valueOf(format); - } - - MediaType mediaType() { - return mt; - } - } - - /** - * Behavior shared between the SE and MP OpenAPI feature builders. - * - * @param specific concrete type of the builder - * @param specific concrete type of {@link OpenApiFeature} the builder creates - */ - public abstract static class Builder, T extends OpenApiFeature> - extends HelidonFeatureSupport.Builder { - - /** - * Config key for the OpenAPI section. - */ - public static final String CONFIG_KEY = "openapi"; - - private OpenApiStaticFile staticFile; - - private OpenApiUi.Builder uiBuilder = OpenApiUi.builder(); - - /** - * Constructor for the builder. - */ - protected Builder() { - super(DEFAULT_CONTEXT); - } - - /** - * Apply configuration settings to the builder. - * - * @param config the Helidon config instance - * @return updated builder - */ - public B config(Config config) { - super.config(config); - config.get("static-file") - .asString() - .ifPresent(this::staticFile); - config.get(OpenApiUi.Builder.OPENAPI_UI_CONFIG_KEY) - .ifExists(uiBuilder::config); - return identity(); - } - - /** - * Sets the path of the static OpenAPI document file. Default types are `json`, `yaml`, and `yml`. - * - * @param path non-null location of the static OpenAPI document file - * @return updated builder instance - */ - @ConfiguredOption(value = DEFAULT_STATIC_FILE_PATH_PREFIX + "*") - public B staticFile(String path) { - Objects.requireNonNull(path, "path to static file must be non-null"); - OpenAPIMediaType openApiMediaType = OpenAPIMediaType.byFileType(typeFromPath(path)) - .orElseThrow(() -> new IllegalArgumentException("Static file " + path + " not recognized as YAML or JSON")); - - staticFile = OpenApiStaticFile.create(openApiMediaType, explicitStaticFileContentFromPath(path)); - - 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 path to a static OpenAPI document file (if any exists), - * either as explicitly set using {@link #staticFile(java.lang.String) } - * or one of the default files. - * - * @return the OpenAPI static file instance for the static file if such - * a file exists, null otherwise - */ - public OpenApiStaticFile staticFile() { - return staticFile == null - ? getDefaultStaticFile() - : staticFile; - } - - /** - * Returns the logger for the OpenAPI feature instance. - * - * @return logger - */ - protected abstract System.Logger logger(); - - private OpenApiStaticFile getDefaultStaticFile() { - OpenApiStaticFile result = null; - final List candidatePaths = logger().isLoggable(System.Logger.Level.TRACE) ? new ArrayList<>() : null; - for (OpenAPIMediaType candidate : OpenAPIMediaType.values()) { - for (String type : candidate.matchingTypes()) { - String candidatePath = DEFAULT_STATIC_FILE_PATH_PREFIX + type; - if (candidatePaths != null) { - candidatePaths.add(candidatePath); - } - String content = defaultStaticFileContentFromPath(candidatePath); - if (content != null) { - result = OpenApiStaticFile.create(candidate, content); - } - } - } - if (candidatePaths != null) { - logger().log(System.Logger.Level.TRACE, - candidatePaths.stream() - .collect(Collectors.joining( - ",", - "No default static OpenAPI description file found; checked [", - "]"))); - } - return result; - } - - private String defaultStaticFileContentFromPath(String candidatePath) { - return staticFileContentFromPath(candidatePath, OPENAPI_DEFAULTED_STATIC_FILE_LOG_MESSAGE_FORMAT); - } - - private String explicitStaticFileContentFromPath(String candidatePath) { - return staticFileContentFromPath(candidatePath, OPENAPI_EXPLICIT_STATIC_FILE_LOG_MESSAGE_FORMAT); - } - - private String staticFileContentFromPath(String candidatePath, String logMessage) { - InputStream is = getContextClassLoader().getResourceAsStream(candidatePath); - if (is != null) { - try (Reader reader = new BufferedReader(new InputStreamReader(is, Charset.defaultCharset()))) { - Path path = Paths.get(candidatePath); - logger().log(System.Logger.Level.TRACE, () -> String.format( - logMessage, - path.toAbsolutePath())); - StringBuilder result = new StringBuilder(); - CharBuffer charBuffer = CharBuffer.allocate(512); - while (reader.read(charBuffer) != -1) { - charBuffer.flip(); - result.append(charBuffer); - } - return result.toString(); - } catch (IOException ex) { - throw new IllegalArgumentException("Error preparing to read from path " + candidatePath, ex); - } - } else { - return null; - } - } - } -} diff --git a/openapi/src/main/java/io/helidon/openapi/OpenApiStaticFile.java b/openapi/src/main/java/io/helidon/openapi/OpenApiStaticFile.java deleted file mode 100644 index 318d8da2db1..00000000000 --- a/openapi/src/main/java/io/helidon/openapi/OpenApiStaticFile.java +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright (c) 2023 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.Objects; - -/** - * Information about a static OpenAPI file bundled with the application. - *

- * There can be up to one of these for each {@link io.helidon.openapi.OpenApiFeature.OpenAPIMediaType} (YAML and JSON). - *

- */ -public class OpenApiStaticFile { - - /** - * Creates a new static file instance using the given OpenAPI media type and content. - * - * @param openApiMediaType OpenAPI media type - * @param content text content - * @return static file instance - */ - static OpenApiStaticFile create(OpenApiFeature.OpenAPIMediaType openApiMediaType, String content) { - return new Builder().openApiMediaType(openApiMediaType).content(content).build(); - } - - private OpenApiFeature.OpenAPIMediaType openApiMediaType; - private String content; - - private OpenApiStaticFile(Builder builder) { - this.content = builder.content; - this.openApiMediaType = builder.openApiMediaType; - } - - /** - * Returns the OpenAPI media type of the static content. - * - * @return the OpenAPI media type of the static content - */ - public OpenApiFeature.OpenAPIMediaType openApiMediaType() { - return openApiMediaType; - } - - /** - * Returns the text content of the static file. - * - * @return text static content - */ - public String content() { - return content; - } - - static class Builder implements io.helidon.common.Builder { - - private OpenApiFeature.OpenAPIMediaType openApiMediaType; - private String content; - - @Override - public OpenApiStaticFile build() { - Objects.requireNonNull(openApiMediaType, "openApiMediaType"); - Objects.requireNonNull(content, "content"); - return new OpenApiStaticFile(this); - } - - Builder openApiMediaType(OpenApiFeature.OpenAPIMediaType openApiMediaType) { - this.openApiMediaType = openApiMediaType; - return this; - } - - Builder content(String content) { - this.content = content; - return this; - } - } - - void content(String content) { - this.content = content; - } -} diff --git a/openapi/src/main/java/io/helidon/openapi/OpenApiUi.java b/openapi/src/main/java/io/helidon/openapi/OpenApiUi.java deleted file mode 100644 index a5018a3ced8..00000000000 --- a/openapi/src/main/java/io/helidon/openapi/OpenApiUi.java +++ /dev/null @@ -1,188 +0,0 @@ -/* - * Copyright (c) 2022, 2023 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.media.type.MediaType; -import io.helidon.config.Config; -import io.helidon.config.metadata.Configured; -import io.helidon.config.metadata.ConfiguredOption; -import io.helidon.http.HttpMediaType; -import io.helidon.webserver.http.HttpService; -import io.helidon.webserver.http.ServerRequest; -import io.helidon.webserver.http.ServerResponse; - -/** - * Behavior for OpenAPI UI implementations. - */ -public interface OpenApiUi extends HttpService { - - /** - * Default subcontext within the {@link OpenApiFeature} instance's web context - * (which itself defaults to {@value OpenApiFeature#DEFAULT_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.http.ServerRequest, io.helidon.webserver.http.ServerResponse)} - * method responds to - */ - HttpMediaType[] 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 OpenApiFeature.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"; - - /** - * Merges implementation-specific UI options. - * - * @param options the options to for the UI to merge - * @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 io.helidon.openapi.OpenApiFeature.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).detach().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 deleted file mode 100644 index 637f8b56bf7..00000000000 --- a/openapi/src/main/java/io/helidon/openapi/OpenApiUiBase.java +++ /dev/null @@ -1,260 +0,0 @@ -/* - * Copyright (c) 2022, 2023 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 io.helidon.common.HelidonServiceLoader; -import io.helidon.common.LazyValue; -import io.helidon.common.media.type.MediaType; -import io.helidon.common.media.type.MediaTypes; -import io.helidon.common.uri.UriQuery; -import io.helidon.http.HeaderNames; -import io.helidon.http.HttpMediaType; -import io.helidon.http.Status; -import io.helidon.webserver.http.ServerRequest; -import io.helidon.webserver.http.ServerResponse; - -/** - * Common base class for implementations of @link OpenApiUi}. - */ -public abstract class OpenApiUiBase implements OpenApiUi { - - private static final System.Logger LOGGER = System.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<>(); - - /** - * Returns a builder for the UI. - * - * @return a builder for the currently-available implementation of {@link io.helidon.openapi.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); - } - - /** - * Returns whether the UI is enabled. - * - * @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); - } - - /** - * Returns the web context for the UI. - * - * @return web context this UI implementation responds at - */ - protected String webContext() { - return webContext; - } - - /** - * Returns the options set for the UI. - * - * @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, HttpMediaType mediaType) { - try { - response - .header(HeaderNames.CONTENT_TYPE, mediaType.toString()) - .send(prepareDocument(request.query(), mediaType)); - } catch (IOException e) { - LOGGER.log(System.Logger.Level.WARNING, "Error formatting OpenAPI output as " + mediaType, e); - response.status(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(UriQuery queryParameters, HttpMediaType mediaType) throws IOException { - String result = null; - if (preparedDocuments.containsKey(mediaType)) { - return preparedDocuments.get(mediaType); - } - MediaType resultMediaType = queryParameters - .first(OpenApiFeature.OPENAPI_ENDPOINT_FORMAT_QUERY_PARAMETER) - .map(OpenApiFeature.QueryParameterRequestedFormat::chooseFormat) - .map(OpenApiFeature.QueryParameterRequestedFormat::mediaType) - .orElse(mediaType); - - result = prepareDocument(resultMediaType); - if (mediaType.test(MediaTypes.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; - - /** - * Creates a new instance. - */ - protected Builder() { - } - @Override - public B options(Map options) { - 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(); - } - - /** - * Returns the web context for OpenAPI support. - * - * @return OpenAPI web context - */ - public String openApiSupportWebContext() { - return openApiSupportWebContext; - } - - /** - * Returns the document preparer for the UI. - * - * @return document preparer - */ - public Function documentPreparer() { - return documentPreparer; - } - - /** - * Returns options settings for the UI. - * - * @return options for the UI - */ - protected Map options() { - return options; - } - } -} diff --git a/openapi/src/main/java/io/helidon/openapi/OpenApiUiNoOp.java b/openapi/src/main/java/io/helidon/openapi/OpenApiUiNoOp.java deleted file mode 100644 index 08b9b0253a8..00000000000 --- a/openapi/src/main/java/io/helidon/openapi/OpenApiUiNoOp.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright (c) 2022, 2023 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.http.HttpMediaType; -import io.helidon.webserver.http.HttpRules; -import io.helidon.webserver.http.ServerRequest; -import io.helidon.webserver.http.ServerResponse; - -/** - * Implementation of {@link io.helidon.openapi.OpenApiUi} which provides no UI support but simply honors the interface. - */ -class OpenApiUiNoOp implements OpenApiUi { - - private static final HttpMediaType[] SUPPORTED_TEXT_MEDIA_TYPES_AT_OPENAPI_ENDPOINT = new HttpMediaType[0]; - /** - * - * @return new builder for an {@code OpenApiUiNoOp} service - */ - static Builder builder() { - return new Builder(); - } - - private OpenApiUiNoOp(Builder builder) { - } - - @Override - public void routing(HttpRules rules) { - } - - @Override - public HttpMediaType[] 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 deleted file mode 100644 index 49e8bc9b4ca..00000000000 --- a/openapi/src/main/java/io/helidon/openapi/OpenApiUiNoOpFactory.java +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright (c) 2022, 2023 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 io.helidon.openapi.OpenApiUiNoOp} implementations. - */ -public class OpenApiUiNoOpFactory implements OpenApiUiFactory { - - /** - * Returns a new no-op UI factory. - * - * @return new instance of the factory for a minimal implementation of the UI - */ - static OpenApiUiNoOpFactory create() { - return new OpenApiUiNoOpFactory(); - } - - /** - * Creates a new instance of the no-op factory. - */ - public OpenApiUiNoOpFactory() { - } - - @Override - public OpenApiUiNoOp.Builder builder() { - return OpenApiUiNoOp.builder(); - } -} diff --git a/openapi/src/main/java/io/helidon/openapi/SeOpenApiFeature.java b/openapi/src/main/java/io/helidon/openapi/SeOpenApiFeature.java deleted file mode 100644 index 1f785c4427e..00000000000 --- a/openapi/src/main/java/io/helidon/openapi/SeOpenApiFeature.java +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright (c) 2023 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.config.metadata.Configured; - -/** - * SE implementation of {@link OpenApiFeature}. - */ -class SeOpenApiFeature extends OpenApiFeature { - - private static final System.Logger LOGGER = System.getLogger(SeOpenApiFeature.class.getName()); - - /** - * Creates a new instance. - * - * @param builder builder for the SE OpenAPI feature - */ - protected SeOpenApiFeature(Builder builder) { - super(LOGGER, builder); - } - - @Override - protected String openApiContent(io.helidon.openapi.OpenApiFeature.OpenAPIMediaType openApiMediaType) { - // TODO temporarily supports only static files - if (staticContent().isPresent()) { - return staticContent().get().content(); - } - return null; - } - - /** - * Builder class for the SE OpenAPI feature. - */ - @Configured(root = true, prefix = "openapi") - public static class Builder extends OpenApiFeature.Builder { - - private static final System.Logger LOGGER = System.getLogger(Builder.class.getName()); - - @Override - public SeOpenApiFeature build() { - return new SeOpenApiFeature(this); - } - - @Override - protected System.Logger logger() { - return LOGGER; - } - } -} diff --git a/openapi/src/test/java/io/helidon/openapi/ServerTest.java b/openapi/src/test/java/io/helidon/openapi/ServerTest.java deleted file mode 100644 index 012b9b5043d..00000000000 --- a/openapi/src/test/java/io/helidon/openapi/ServerTest.java +++ /dev/null @@ -1,234 +0,0 @@ -/* - * Copyright (c) 2019, 2023 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.net.HttpURLConnection; -import java.util.ArrayList; -import java.util.Map; -import java.util.function.Consumer; -import java.util.stream.Stream; - -import io.helidon.common.media.type.MediaType; -import io.helidon.common.media.type.MediaTypes; -import io.helidon.config.Config; -import io.helidon.config.ConfigSources; -import io.helidon.webserver.WebServer; - -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; - -/** - * Starts a server with the default OpenAPI endpoint to test a static OpenAPI - * document file in various ways. - */ -public class ServerTest { - - private static WebServer greetingWebServer; - private static WebServer timeWebServer; - - private static final String GREETING_PATH = "/openapi-greeting"; - private static final String TIME_PATH = "/openapi-time"; - - private static final Config OPENAPI_CONFIG_DISABLED_CORS = Config.create( - ConfigSources.classpath("serverNoCORS.properties").build()).get(OpenApiFeature.Builder.CONFIG_KEY); - - private static final Config OPENAPI_CONFIG_RESTRICTED_CORS = Config.create( - ConfigSources.classpath("serverCORSRestricted.yaml").build()).get(OpenApiFeature.Builder.CONFIG_KEY); - - static final OpenApiFeature.Builder GREETING_OPENAPI_SUPPORT_BUILDER - = StaticFileOnlyOpenApiFeatureImpl.builder() - .staticFile("openapi-greeting.yml") - .webContext(GREETING_PATH) - .config(OPENAPI_CONFIG_DISABLED_CORS); - - static final OpenApiFeature.Builder TIME_OPENAPI_SUPPORT_BUILDER - = StaticFileOnlyOpenApiFeatureImpl.builder() - .staticFile("openapi-time-server.yml") - .webContext(TIME_PATH) - .config(OPENAPI_CONFIG_RESTRICTED_CORS); - - public ServerTest() { - } - - @BeforeAll - public static void startup() { - greetingWebServer = TestUtil.startServer(GREETING_OPENAPI_SUPPORT_BUILDER); - timeWebServer = TestUtil.startServer(TIME_OPENAPI_SUPPORT_BUILDER); - } - - @AfterAll - public static void shutdown() { - TestUtil.shutdownServer(greetingWebServer); - TestUtil.shutdownServer(timeWebServer); - } - - - /** - * Accesses the OpenAPI endpoint, requesting a YAML response payload, and - * makes sure that navigating among the YAML yields what we expect. - * - * @throws Exception in case of errors sending the request or reading the - * response - */ - @SuppressWarnings("unchecked") - @Test - public void testGreetingAsYAML() throws Exception { - HttpURLConnection cnx = TestUtil.getURLConnection( - greetingWebServer.port(), - "GET", - GREETING_PATH, - MediaTypes.APPLICATION_OPENAPI_YAML); - Map openAPIDocument = TestUtil.yamlFromResponse(cnx); - - ArrayList> servers = TestUtil.as( - ArrayList.class, openAPIDocument.get("servers")); - Map server = servers.get(0); - assertThat("unexpected URL", server.get("url"), is("http://localhost:8000")); - assertThat("unexpected description", server.get("description"), is("Local test server")); - - Map paths = TestUtil.as(Map.class, openAPIDocument.get("paths")); - Map setGreetingPath = TestUtil.as(Map.class, paths.get("/greet/greeting")); - Map put = TestUtil.as(Map.class, setGreetingPath.get("put")); - assertThat(put.get("summary"), is("Sets the greeting prefix")); - Map requestBody = TestUtil.as(Map.class, put.get("requestBody")); - assertThat(Boolean.class.cast(requestBody.get("required")), is(true)); - Map content = TestUtil.as(Map.class, requestBody.get("content")); - Map applicationJson = TestUtil.as(Map.class, content.get("application/json")); - Map schema = TestUtil.as(Map.class, applicationJson.get("schema")); - - assertThat(schema.get("type"), is("object")); - } - - /** - * Tests the OpenAPI support by converting the response payload as YAML and - * then creating a {@code Config} instance from that YAML for ease of - * accessing its values in the test. - * - * @throws Exception in case of errors sending the request or receiving the - * response - */ - @Test - public void testGreetingAsConfig() throws Exception { - HttpURLConnection cnx = TestUtil.getURLConnection( - greetingWebServer.port(), - "GET", - GREETING_PATH, - MediaTypes.APPLICATION_OPENAPI_YAML); - Config c = TestUtil.configFromResponse(cnx); - assertThat(TestUtil.fromConfig(c, "paths./greet/greeting.put.summary"), - is("Sets the greeting prefix")); - assertThat(TestUtil.fromConfig(c, - "paths./greet/greeting.put.requestBody.content." - + "application/json.schema.properties.greeting.type"), - is("string")); - } - - /** - * Makes sure that the response content type is consistent with the Accept - * media type. - * - * @throws Exception in case of errors sending the request or receiving the - * response - */ - @ParameterizedTest - @MethodSource() - public void checkExplicitResponseMediaTypeViaHeaders(MediaType testMediaType) throws Exception { - connectAndConsumePayload(testMediaType); - } - - static Stream checkExplicitResponseMediaTypeViaHeaders() { - return Stream.of(MediaTypes.APPLICATION_OPENAPI_YAML, - MediaTypes.APPLICATION_YAML, - MediaTypes.APPLICATION_OPENAPI_JSON, - MediaTypes.APPLICATION_JSON); - } - - @Test - void checkExplicitResponseMediaTypeViaQueryParameter() throws Exception { - TestUtil.connectAndConsumePayload(greetingWebServer.port(), - GREETING_PATH, - "format=JSON", - MediaTypes.APPLICATION_JSON); - - TestUtil.connectAndConsumePayload(greetingWebServer.port(), - GREETING_PATH, - "format=YAML", - MediaTypes.APPLICATION_OPENAPI_YAML); - } - - @Test - public void testTimeAsConfig() throws Exception { - commonTestTimeAsConfig(null); - } - - @Test - public void testTimeUnrestrictedCors() throws Exception { - commonTestTimeAsConfig(cnx -> { - - cnx.setRequestProperty("Origin", "http://foo.bar"); - cnx.setRequestProperty("Host", "localhost"); - }); - - } - - private void commonTestTimeAsConfig(Consumer headerSetter) throws Exception { - HttpURLConnection cnx = TestUtil.getURLConnection( - timeWebServer.port(), - "GET", - TIME_PATH, - MediaTypes.APPLICATION_OPENAPI_YAML); - if (headerSetter != null) { - headerSetter.accept(cnx); - } - Config c = TestUtil.configFromResponse(cnx); - assertThat(TestUtil.fromConfig(c, "paths./timecheck.get.summary"), - is("Returns the current time")); - assertThat(TestUtil.fromConfig(c, - "paths./timecheck.get.responses.200.content." - + "application/json.schema.properties.message.type"), - is("string")); - } - - @Test - public void ensureNoCrosstalkAmongPorts() throws Exception { - HttpURLConnection timeCnx = TestUtil.getURLConnection( - timeWebServer.port(), - "GET", - TIME_PATH, - MediaTypes.APPLICATION_OPENAPI_YAML); - HttpURLConnection greetingCnx = TestUtil.getURLConnection( - greetingWebServer.port(), - "GET", - GREETING_PATH, - MediaTypes.APPLICATION_OPENAPI_YAML); - Config greetingConfig = TestUtil.configFromResponse(greetingCnx); - Config timeConfig = TestUtil.configFromResponse(timeCnx); - assertThat("Incorrectly found greeting-related item in time OpenAPI document", - timeConfig.get("paths./greet/greeting.put.summary").exists(), is(false)); - assertThat("Incorrectly found time-related item in greeting OpenAPI document", - greetingConfig.get("paths./timecheck.get.summary").exists(), is(false)); - } - - private static void connectAndConsumePayload(MediaType mt) throws Exception { - TestUtil.connectAndConsumePayload(greetingWebServer.port(), GREETING_PATH, mt); - } -} diff --git a/openapi/src/test/java/io/helidon/openapi/StaticFileOnlyOpenApiFeatureImpl.java b/openapi/src/test/java/io/helidon/openapi/StaticFileOnlyOpenApiFeatureImpl.java deleted file mode 100644 index 2487487e47e..00000000000 --- a/openapi/src/test/java/io/helidon/openapi/StaticFileOnlyOpenApiFeatureImpl.java +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright (c) 2023 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.Date; -import java.util.HashMap; -import java.util.Map; - -import jakarta.json.Json; -import jakarta.json.JsonBuilderFactory; -import jakarta.json.JsonObject; -import org.yaml.snakeyaml.Yaml; - -class StaticFileOnlyOpenApiFeatureImpl extends OpenApiFeature { - - private static final System.Logger LOGGER = System.getLogger(StaticFileOnlyOpenApiFeatureImpl.class.getName()); - - private static final JsonBuilderFactory JSON = Json.createBuilderFactory(Map.of()); - - public static Builder builder() { - return new Builder(); - } - - private final Map staticContent = new HashMap<>(); - - /** - * Constructor for the feature. - * - * @param builder builder to use for initializing the feature - */ - protected StaticFileOnlyOpenApiFeatureImpl(Builder builder) { - super(LOGGER, builder); - // We should have a static file containing either YAML or JSON. Create a static file of the other type so we have it. - if (staticContent().isEmpty()) { - throw new IllegalArgumentException("Static-only OpenAPI feature does not have static content!"); - } - OpenApiStaticFile staticFile = staticContent().get(); - staticContent.put(staticFile.openApiMediaType(), staticFile.content()); - if (staticFile.openApiMediaType().equals(OpenAPIMediaType.YAML)) { - Yaml yaml = new Yaml(); - Map map = yaml.load(staticFile.content()); - // Simplistic - change Date to String because Json does not know how to handle Date - map = clean(map); - JsonObject json = JSON.createObjectBuilder(map).build(); - staticContent.put(OpenAPIMediaType.JSON, json.toString()); - } else { - Yaml yaml = new Yaml(); - yaml.load(staticFile.content()); - staticContent.put(OpenAPIMediaType.YAML, yaml.toString()); - } - } - - @Override - protected String openApiContent(OpenAPIMediaType openApiMediaType) { - // A real implemention would have only one static content instance. This test implementation - // has two, one each for JSON and YAML. - return staticContent.get(openApiMediaType); - } - - private static Map clean(Map map) { - Map result = new HashMap<>(); - map.forEach((k, v) -> { - if (v instanceof Map vMap) { - result.put(k, clean(vMap)); - } else if (v instanceof Date date) { - result.put(k, date.toString()); - } else { - result.put(k, v); - } - }); - return result; - } - - static class Builder extends OpenApiFeature.Builder { - - - private static final System.Logger LOGGER = System.getLogger(Builder.class.getName()); - - @Override - public StaticFileOnlyOpenApiFeatureImpl build() { - return new StaticFileOnlyOpenApiFeatureImpl(this); - } - - @Override - protected System.Logger logger() { - return LOGGER; - } - } -} diff --git a/openapi/src/test/java/io/helidon/openapi/TestOpenAPIMediaTypesDescribedCorrectly.java b/openapi/src/test/java/io/helidon/openapi/TestOpenAPIMediaTypesDescribedCorrectly.java deleted file mode 100644 index 8f28371acdb..00000000000 --- a/openapi/src/test/java/io/helidon/openapi/TestOpenAPIMediaTypesDescribedCorrectly.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright (c) 2022, 2023 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.Arrays; -import java.util.HashSet; -import java.util.Set; -import java.util.stream.Collectors; - -import io.helidon.openapi.OpenApiFeature.OpenAPIMediaType; - -import org.junit.jupiter.api.Test; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.empty; - -/** - * Makes sure that the file types which the OpenAPIMediaType enums report are correctly captured in a hard-coded - * constant used in an annotation. - */ -class TestOpenAPIMediaTypesDescribedCorrectly { - - private static final Set FILE_TYPES_DEFINED_BY_ENUM = Arrays.stream(OpenAPIMediaType.values()) - .flatMap(mediaType -> mediaType.matchingTypes().stream()) - .collect(Collectors.toSet()); - - private static final Set FILE_TYPES_DESCRIBED = Arrays.stream( - OpenAPIMediaType.TYPE_LIST.split("\\|")) - .collect(Collectors.toSet()); - - @Test - void makeSureAllTypesAreDescribed() { - Set reportedNotDescribed = new HashSet<>(FILE_TYPES_DEFINED_BY_ENUM); - reportedNotDescribed.removeAll(FILE_TYPES_DESCRIBED); - assertThat("File types defined by the enum values but not described in the hard-coded constant", - reportedNotDescribed, - empty()); - } - - @Test - void makeSureOnlyTypesAreDescribed() { - Set describedTypesNotReported = new HashSet<>(FILE_TYPES_DESCRIBED); - describedTypesNotReported.removeAll(FILE_TYPES_DEFINED_BY_ENUM); - assertThat("File types described in the hard-coded constant but not reported by any enum value", - describedTypesNotReported, - empty()); - } -} diff --git a/openapi/src/test/java/io/helidon/openapi/TestStaticContent.java b/openapi/src/test/java/io/helidon/openapi/TestStaticContent.java deleted file mode 100644 index b774a1545e8..00000000000 --- a/openapi/src/test/java/io/helidon/openapi/TestStaticContent.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright (c) 2023 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.stream.Stream; - -import io.helidon.common.testing.junit5.OptionalMatcher; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.containsString; -import static org.hamcrest.Matchers.nullValue; - -class TestStaticContent { - - record StaticContentTestValue(String path, - OpenApiFeature.OpenAPIMediaType openAPIMediaType, - String expectedContent) {} - - - @ParameterizedTest - @MethodSource("staticContentTestValues") - void testStaticContent(StaticContentTestValue testValue) { - OpenApiFeature feature = StaticFileOnlyOpenApiFeatureImpl.builder() - .staticFile(testValue.path) - .build(); - - assertThat("YAML static content", - feature.staticContent(), - OptionalMatcher.optionalPresent()); - - assertThat("YAML static content value", - feature.staticContent().get().content(), - containsString(testValue.expectedContent)); - - assertThat("Content", feature.openApiContent(testValue.openAPIMediaType), - containsString(testValue.expectedContent)); - } - - static Stream staticContentTestValues() { - return Stream.of(new StaticContentTestValue("openapi-greeting.yml", - OpenApiFeature.OpenAPIMediaType.YAML, - "Sets the greeting prefix"), - new StaticContentTestValue("petstore.json", - OpenApiFeature.OpenAPIMediaType.JSON, - "This is a sample server Petstore server."), - new StaticContentTestValue("petstore.yaml", - OpenApiFeature.OpenAPIMediaType.YAML, - "A link to the next page of responses")); - } -} diff --git a/openapi/src/test/java/io/helidon/openapi/TestUtil.java b/openapi/src/test/java/io/helidon/openapi/TestUtil.java deleted file mode 100644 index c9d7965cfd7..00000000000 --- a/openapi/src/test/java/io/helidon/openapi/TestUtil.java +++ /dev/null @@ -1,393 +0,0 @@ -/* - * Copyright (c) 2019, 2023 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.io.InputStreamReader; -import java.net.HttpURLConnection; -import java.net.URL; -import java.nio.CharBuffer; -import java.nio.charset.Charset; -import java.util.Collections; -import java.util.Map; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeoutException; -import java.util.logging.Level; -import java.util.logging.Logger; - -import io.helidon.common.media.type.MediaType; -import io.helidon.common.media.type.MediaTypes; -import io.helidon.config.Config; -import io.helidon.config.ConfigSources; -import io.helidon.http.HttpMediaType; -import io.helidon.http.Status; -import io.helidon.webserver.WebServer; -import io.helidon.webserver.http.HttpRouting; - -import jakarta.json.Json; -import jakarta.json.JsonReader; -import jakarta.json.JsonReaderFactory; -import jakarta.json.JsonStructure; -import org.yaml.snakeyaml.Yaml; - -import static org.hamcrest.CoreMatchers.is; -import static org.hamcrest.MatcherAssert.assertThat; - -/** - * Various utility methods used by OpenAPI tests. - */ -public class TestUtil { - - private static final JsonReaderFactory JSON_READER_FACTORY - = Json.createReaderFactory(Collections.emptyMap()); - - private static final Logger LOGGER = Logger.getLogger(TestUtil.class.getName()); - - /** - * Starts the web server at an available port and sets up OpenAPI using the - * supplied builder. - * - * @param builder the {@code OpenAPISupport.Builder} to set up for the - * server. - * @return the {@code WebServer} set up with OpenAPI support - */ - public static WebServer startServer(OpenApiFeature.Builder builder) { - try { - return startServer(0, builder); - } catch (InterruptedException | ExecutionException | TimeoutException ex) { - throw new RuntimeException("Error starting server for test", ex); - } - } - - /** - * Represents the HTTP response payload as a String. - * - * @param cnx the HttpURLConnection from which to get the response payload - * @return String representation of the OpenAPI document as a String - * @throws java.io.IOException in case of errors reading the HTTP response payload - */ - public static String stringYAMLFromResponse(HttpURLConnection cnx) throws IOException { - HttpMediaType returnedMediaType = mediaTypeFromResponse(cnx); - assertThat("Unexpected returned media type", - returnedMediaType.test(MediaTypes.APPLICATION_OPENAPI_YAML), is(true)); - return stringFromResponse(cnx, returnedMediaType); - } - - /** - * Connects to localhost at the specified port, sends a request using the - * specified method, and consumes the response payload as the indicated - * media type, returning the actual media type reported in the response. - * - * @param port port with which to create the connection - * @param path URL path to access on the web server - * @param expectedMediaType the {@code MediaType} with which the response - * must be consistent - * @return actual {@code MediaType} - * @throws Exception in case of errors sending the request or receiving the - * response - */ - public static MediaType connectAndConsumePayload( - int port, String path, MediaType expectedMediaType) throws Exception { - HttpURLConnection cnx = getURLConnection(port, "GET", path, expectedMediaType); - HttpMediaType actualMT = validateResponseMediaType(cnx, expectedMediaType); - if (actualMT.test(MediaTypes.APPLICATION_OPENAPI_YAML) || actualMT.test(MediaTypes.APPLICATION_YAML)) { - yamlFromResponse(cnx); - } else if (actualMT.test(MediaTypes.APPLICATION_OPENAPI_JSON) - || actualMT.test(MediaTypes.APPLICATION_JSON)) { - jsonFromResponse(cnx); - } else { - throw new IllegalArgumentException( - "Expected either JSON or YAML response but received " + actualMT.toString()); - } - return actualMT; - } - - static MediaType connectAndConsumePayload( - int port, String path, String queryParameter, MediaType expectedMediaType) throws Exception { - HttpURLConnection cnx = getURLConnection(port, "GET", path, queryParameter); - HttpMediaType actualMT = validateResponseMediaType(cnx, expectedMediaType); - if (actualMT.test(MediaTypes.APPLICATION_OPENAPI_YAML) || actualMT.test(MediaTypes.APPLICATION_YAML)) { - yamlFromResponse(cnx); - } else if (actualMT.test(MediaTypes.APPLICATION_OPENAPI_JSON) - || actualMT.test(MediaTypes.APPLICATION_JSON)) { - jsonFromResponse(cnx); - } else { - throw new IllegalArgumentException( - "Expected either JSON or YAML response but received " + actualMT.toString()); - } - return actualMT; - } - - /** - * Returns the {@code MediaType} instance conforming to the HTTP response - * content type. - * - * @param cnx the HttpURLConnection from which to get the content type - * @return the MediaType corresponding to the content type in the response - */ - public static HttpMediaType mediaTypeFromResponse(HttpURLConnection cnx) { - HttpMediaType returnedMediaType = HttpMediaType.create(cnx.getContentType()); - if (!returnedMediaType.charset().isPresent()) { - returnedMediaType = HttpMediaType.builder() - .mediaType(returnedMediaType) - .charset(Charset.defaultCharset().name()) - .build(); - } - return returnedMediaType; - } - - /** - * Represents an OpenAPI document HTTP response as a {@code Config} instance - * to simplify access to deeply-nested values. - * - * @param cnx the HttpURLConnection which already has the response to - * process - * @return Config representing the OpenAPI document content - * @throws java.io.IOException in case of errors reading the returned payload as - * config - */ - public static Config configFromResponse(HttpURLConnection cnx) throws IOException { - HttpMediaType mt = mediaTypeFromResponse(cnx); - MediaType configMT = HttpMediaType.create(MediaTypes.APPLICATION_OPENAPI_YAML).test(mt) - ? MediaTypes.APPLICATION_YAML - : MediaTypes.APPLICATION_JSON; - String yaml = stringYAMLFromResponse(cnx); - return Config.create(ConfigSources.create(yaml, configMT)); - } - - /** - * Returns the response payload from the specified connection as a snakeyaml - * {@code Yaml} object. - * - * @param cnx the {@code HttpURLConnection} containing the response - * @return the YAML {@code Map} (created by snakeyaml) from - * the HTTP response payload - * @throws java.io.IOException in case of errors reading the response - */ - @SuppressWarnings(value = "unchecked") - public static Map yamlFromResponse(HttpURLConnection cnx) throws IOException { - HttpMediaType returnedMediaType = mediaTypeFromResponse(cnx); - Yaml yaml = new Yaml(); - Charset cs = Charset.defaultCharset(); - if (returnedMediaType.charset().isPresent()) { - cs = Charset.forName(returnedMediaType.charset().get()); - } - return (Map) yaml.load(new InputStreamReader(cnx.getInputStream(), cs)); - } - - /** - * Shuts down the specified web server. - * - * @param ws the {@code WebServer} instance to stop - */ - public static void shutdownServer(WebServer ws) { - if (ws != null) { - try { - stopServer(ws); - } catch (InterruptedException | ExecutionException | TimeoutException ex) { - throw new RuntimeException("Error shutting down server for test", ex); - } - } - } - - /** - * Returns the string values from the specified key in the {@code Config}, - * ensuring that the key exists first. - * - * @param c the {@code Config} object to query - * @param key the key to access in the {@code Config} object - * @return the {@code String} value from the {@code Config} value - */ - public static String fromConfig(Config c, String key) { - Config v = c.get(key); - if (!v.exists()) { - throw new IllegalArgumentException("Requested key not found: " + key); - } - return v.asString().get(); - } - - /** - * Returns the response payload in the specified connection as a - * {@code JsonStructure} instance. - * - * @param cnx the {@code HttpURLConnection} containing the response - * @return {@code JsonStructure} representing the response payload - * @throws java.io.IOException in case of errors reading the response - */ - public static JsonStructure jsonFromResponse(HttpURLConnection cnx) throws IOException { - JsonReader reader = JSON_READER_FACTORY.createReader(cnx.getInputStream()); - JsonStructure result = reader.read(); - reader.close(); - return result; - } - - /** - * Converts a JSON pointer possibly containing slashes and tildes into a - * JSON pointer with such characters properly escaped. - * - * @param pointer original JSON pointer expression - * @return escaped (if needed) JSON pointer - */ - public static String escapeForJsonPointer(String pointer) { - return pointer.replaceAll("\\~", "~0").replaceAll("\\/", "~1"); - } - - /** - * Makes sure that the response is 200 and that the content type MediaType - * is consistent with the expected one, returning the actual MediaType from - * the response and leaving the payload ready for consumption. - * - * @param cnx {@code HttpURLConnection} with the response to validate - * @param expectedMediaType {@code MediaType} with which the actual one - * should be consistent - * @return actual media type - * @throws Exception in case of errors reading the content type from the - * response - */ - public static HttpMediaType validateResponseMediaType( - HttpURLConnection cnx, - MediaType expectedMediaType) throws Exception { - assertThat("Unexpected response code", cnx.getResponseCode(), - is(Status.OK_200.code())); - HttpMediaType expectedMT = expectedMediaType != null - ? HttpMediaType.create(expectedMediaType) - : HttpMediaType.create(OpenApiFeature.DEFAULT_RESPONSE_MEDIA_TYPE); - HttpMediaType actualMT = mediaTypeFromResponse(cnx); - assertThat("Expected response media type " - + expectedMT.toString() - + " but received " - + actualMT.toString(), - expectedMT.test(actualMT), is(true)); - return actualMT; - } - - /** - * Returns a {@code HttpURLConnection} for the requested method and path and - * {code @MediaType} from the specified {@link WebServer}. - * - * @param port port to connect to - * @param method HTTP method to use in building the connection - * @param path path to the resource in the web server - * @param mediaType {@code MediaType} to be Accepted - * @return the connection to the server and path - * @throws Exception in case of errors creating the connection - */ - public static HttpURLConnection getURLConnection( - int port, - String method, - String path, - MediaType mediaType) throws Exception { - URL url = new URL("http://localhost:" + port + path); - HttpURLConnection conn = (HttpURLConnection) url.openConnection(); - conn.setRequestMethod(method); - if (mediaType != null) { - conn.setRequestProperty("Accept", mediaType.text()); - } - System.out.println("Connecting: " + method + " " + url); - return conn; - } - - static HttpURLConnection getURLConnection( - int port, - String method, - String path, - String queryParameter) throws Exception { - URL url = new URL("http://localhost:" + port + path + "?" + queryParameter); - HttpURLConnection conn = (HttpURLConnection) url.openConnection(); - conn.setRequestMethod(method); - return conn; - } - - /** - * Stop the web server. - * - * @param server the {@code WebServer} to stop - * @throws InterruptedException if the stop operation was interrupted - * @throws java.util.concurrent.ExecutionException if the stop operation failed as it ran - * @throws java.util.concurrent.TimeoutException if the stop operation timed out - */ - public static void stopServer(WebServer server) throws - InterruptedException, ExecutionException, TimeoutException { - if (server != null) { - server.stop(); - } - } - - /** - * Start the Web Server - * - * @param port the port on which to start the server; if less than 1, the - * port is dynamically selected - * @param openApiBuilders OpenAPISupport.Builder instances to use in - * starting the server - * @return {@code WebServer} that has been started - * @throws InterruptedException if the start was interrupted - * @throws java.util.concurrent.ExecutionException if the start failed - * @throws java.util.concurrent.TimeoutException if the start timed out - */ - public static WebServer startServer( - int port, - OpenApiFeature.Builder... openApiBuilders) throws - InterruptedException, ExecutionException, TimeoutException { - HttpRouting.Builder routingBuilder = HttpRouting.builder(); - for (OpenApiFeature.Builder openApiBuilder : openApiBuilders) { - routingBuilder.addFeature(openApiBuilder); - } - WebServer result = WebServer.builder() - .routing(routingBuilder.build()) - .port(port) - .build() - .start(); - LOGGER.log(Level.INFO, "Started server at: https://localhost:{0}", result.port()); - return result; - } - - /** - * Returns a {@code String} resulting from interpreting the response payload - * in the specified connection according to the expected {@code MediaType}. - * - * @param cnx {@code HttpURLConnection} with the response - * @param mediaType {@code MediaType} to use in interpreting the response - * payload - * @return {@code String} of the payload interpreted according to the - * specified {@code MediaType} - * @throws java.io.IOException in case of errors reading the response payload - */ - public static String stringFromResponse(HttpURLConnection cnx, HttpMediaType mediaType) throws IOException { - try (final InputStreamReader isr = new InputStreamReader( - cnx.getInputStream(), mediaType.charset().get())) { - StringBuilder sb = new StringBuilder(); - CharBuffer cb = CharBuffer.allocate(1024); - while (isr.read(cb) != -1) { - cb.flip(); - sb.append(cb); - } - return sb.toString(); - } - } - - /** - * Returns an instance of the requested type given the input object. - * - * @param expected type - * @param c the {@code Class} for the expected type - * @param o the {@code Object} to be cast to the expected type - * @return the object, cast to {@code T} - */ - public static T as(Class c, Object o) { - return c.cast(o); - } -} diff --git a/openapi/src/test/resources/configWithSchemasWithRef.yaml b/openapi/src/test/resources/configWithSchemasWithRef.yaml deleted file mode 100644 index f7f515319d0..00000000000 --- a/openapi/src/test/resources/configWithSchemasWithRef.yaml +++ /dev/null @@ -1,56 +0,0 @@ -# -# Copyright (c) 2023 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: - schema: - Pet: - content: - application/json: - schema: - $ref: '#/components/schemas/Pet' - application/xml: - schema: - $ref: '#/components/schemas/Pet' - description: Pet object that needs to be added to the store - required: true - Order: - title: Pet Order - description: An order for a pets from the pet store - type: object - properties: - id: - type: integer - format: int64 - petId: - type: integer - format: int64 - quantity: - type: integer - format: int32 - shipDate: - type: string - format: date-time - status: - type: string - description: Order Status - enum: - - placed - - approved - - delivered - complete: - type: boolean - default: false - xml: - name: Order \ No newline at end of file diff --git a/openapi/src/test/resources/openapi-time-server.yml b/openapi/src/test/resources/openapi-time-server.yml deleted file mode 100644 index b6bbf906f8c..00000000000 --- a/openapi/src/test/resources/openapi-time-server.yml +++ /dev/null @@ -1,44 +0,0 @@ -# -# Copyright (c) 2019, 2021 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 OpenAPI second server - description: OpenAPI document for testing the second of two servers in an app - - version: 1.0.0 - -servers: - - url: http://localhost:8001 - description: Local test server for time - -paths: - /timecheck: - get: - summary: Returns the current time - description: Reports the time-of-day - responses: - '200': - description: Simple JSON containing the time - content: - application/json: - schema: - type: object - properties: - message: - type: string - example: 2019-08-01T12:34:56.987 - diff --git a/openapi/src/test/resources/petstore.json b/openapi/src/test/resources/petstore.json deleted file mode 100644 index 0a6d79ac60c..00000000000 --- a/openapi/src/test/resources/petstore.json +++ /dev/null @@ -1,1055 +0,0 @@ -{ - "openapi": "3.0.0", - "servers": [ - { - "url": "https://petstore.swagger.io/v2" - }, - { - "url": "http://petstore.swagger.io/v2" - } - ], - "info": { - "description": "This is a sample server Petstore server. You can find out more about Swagger at [http://swagger.io](http://swagger.io) or on [irc.freenode.net, #swagger](http://swagger.io/irc/). For this sample, you can use the api key `special-key` to test the authorization filters.", - "version": "1.0.0", - "title": "Swagger Petstore", - "termsOfService": "http://swagger.io/terms/", - "contact": { - "email": "apiteam@swagger.io" - }, - "license": { - "name": "Apache 2.0", - "url": "http://www.apache.org/licenses/LICENSE-2.0.html" - } - }, - "tags": [ - { - "name": "pet", - "description": "Everything about your Pets", - "externalDocs": { - "description": "Find out more", - "url": "http://swagger.io" - } - }, - { - "name": "store", - "description": "Access to Petstore orders" - }, - { - "name": "user", - "description": "Operations about user", - "externalDocs": { - "description": "Find out more about our store", - "url": "http://swagger.io" - } - } - ], - "paths": { - "/pet": { - "post": { - "tags": [ - "pet" - ], - "summary": "Add a new pet to the store", - "description": "", - "operationId": "addPet", - "responses": { - "405": { - "description": "Invalid input" - } - }, - "security": [ - { - "petstore_auth": [ - "write:pets", - "read:pets" - ] - } - ], - "requestBody": { - "$ref": "#/components/requestBodies/Pet" - } - }, - "put": { - "tags": [ - "pet" - ], - "summary": "Update an existing pet", - "description": "", - "operationId": "updatePet", - "responses": { - "400": { - "description": "Invalid ID supplied" - }, - "404": { - "description": "Pet not found" - }, - "405": { - "description": "Validation exception" - } - }, - "security": [ - { - "petstore_auth": [ - "write:pets", - "read:pets" - ] - } - ], - "requestBody": { - "$ref": "#/components/requestBodies/Pet" - } - } - }, - "/pet/findByStatus": { - "get": { - "tags": [ - "pet" - ], - "summary": "Finds Pets by status", - "description": "Multiple status values can be provided with comma separated strings", - "operationId": "findPetsByStatus", - "parameters": [ - { - "name": "status", - "in": "query", - "description": "Status values that need to be considered for filter", - "required": true, - "explode": true, - "schema": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "available", - "pending", - "sold" - ], - "default": "available" - } - } - } - ], - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/xml": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Pet" - } - } - }, - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Pet" - } - } - } - } - }, - "400": { - "description": "Invalid status value" - } - }, - "security": [ - { - "petstore_auth": [ - "write:pets", - "read:pets" - ] - } - ] - } - }, - "/pet/findByTags": { - "get": { - "tags": [ - "pet" - ], - "summary": "Finds Pets by tags", - "description": "Muliple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.", - "operationId": "findPetsByTags", - "parameters": [ - { - "name": "tags", - "in": "query", - "description": "Tags to filter by", - "required": true, - "explode": true, - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - } - ], - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/xml": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Pet" - } - } - }, - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Pet" - } - } - } - } - }, - "400": { - "description": "Invalid tag value" - } - }, - "security": [ - { - "petstore_auth": [ - "write:pets", - "read:pets" - ] - } - ], - "deprecated": true - } - }, - "/pet/{petId}": { - "get": { - "tags": [ - "pet" - ], - "summary": "Find pet by ID", - "description": "Returns a single pet", - "operationId": "getPetById", - "parameters": [ - { - "name": "petId", - "in": "path", - "description": "ID of pet to return", - "required": true, - "schema": { - "type": "integer", - "format": "int64" - } - } - ], - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/xml": { - "schema": { - "$ref": "#/components/schemas/Pet" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Pet" - } - } - } - }, - "400": { - "description": "Invalid ID supplied" - }, - "404": { - "description": "Pet not found" - } - }, - "security": [ - { - "api_key": [] - } - ] - }, - "post": { - "tags": [ - "pet" - ], - "summary": "Updates a pet in the store with form data", - "description": "", - "operationId": "updatePetWithForm", - "parameters": [ - { - "name": "petId", - "in": "path", - "description": "ID of pet that needs to be updated", - "required": true, - "schema": { - "type": "integer", - "format": "int64" - } - } - ], - "responses": { - "405": { - "description": "Invalid input" - } - }, - "security": [ - { - "petstore_auth": [ - "write:pets", - "read:pets" - ] - } - ], - "requestBody": { - "content": { - "application/x-www-form-urlencoded": { - "schema": { - "type": "object", - "properties": { - "name": { - "description": "Updated name of the pet", - "type": "string" - }, - "status": { - "description": "Updated status of the pet", - "type": "string" - } - } - } - } - } - } - }, - "delete": { - "tags": [ - "pet" - ], - "summary": "Deletes a pet", - "description": "", - "operationId": "deletePet", - "parameters": [ - { - "name": "api_key", - "in": "header", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "petId", - "in": "path", - "description": "Pet id to delete", - "required": true, - "schema": { - "type": "integer", - "format": "int64" - } - } - ], - "responses": { - "400": { - "description": "Invalid ID supplied" - }, - "404": { - "description": "Pet not found" - } - }, - "security": [ - { - "petstore_auth": [ - "write:pets", - "read:pets" - ] - } - ] - } - }, - "/pet/{petId}/uploadImage": { - "post": { - "tags": [ - "pet" - ], - "summary": "uploads an image", - "description": "", - "operationId": "uploadFile", - "parameters": [ - { - "name": "petId", - "in": "path", - "description": "ID of pet to update", - "required": true, - "schema": { - "type": "integer", - "format": "int64" - } - } - ], - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiResponse" - } - } - } - } - }, - "security": [ - { - "petstore_auth": [ - "write:pets", - "read:pets" - ] - } - ], - "requestBody": { - "content": { - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - } - } - }, - "/store/inventory": { - "get": { - "tags": [ - "store" - ], - "summary": "Returns pet inventories by status", - "description": "Returns a map of status codes to quantities", - "operationId": "getInventory", - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "type": "integer", - "format": "int32" - } - } - } - } - } - }, - "security": [ - { - "api_key": [] - } - ] - } - }, - "/store/order": { - "post": { - "tags": [ - "store" - ], - "summary": "Place an order for a pet", - "description": "", - "operationId": "placeOrder", - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/xml": { - "schema": { - "$ref": "#/components/schemas/Order" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Order" - } - } - } - }, - "400": { - "description": "Invalid Order" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Order" - } - } - }, - "description": "order placed for purchasing the pet", - "required": true - } - } - }, - "/store/order/{orderId}": { - "get": { - "tags": [ - "store" - ], - "summary": "Find purchase order by ID", - "description": "For valid response try integer IDs with value >= 1 and <= 10. Other values will generated exceptions", - "operationId": "getOrderById", - "parameters": [ - { - "name": "orderId", - "in": "path", - "description": "ID of pet that needs to be fetched", - "required": true, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 1, - "maximum": 10 - } - } - ], - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/xml": { - "schema": { - "$ref": "#/components/schemas/Order" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Order" - } - } - } - }, - "400": { - "description": "Invalid ID supplied" - }, - "404": { - "description": "Order not found" - } - } - }, - "delete": { - "tags": [ - "store" - ], - "summary": "Delete purchase order by ID", - "description": "For valid response try integer IDs with positive integer value. Negative or non-integer values will generate API errors", - "operationId": "deleteOrder", - "parameters": [ - { - "name": "orderId", - "in": "path", - "description": "ID of the order that needs to be deleted", - "required": true, - "schema": { - "type": "integer", - "format": "int64", - "minimum": 1 - } - } - ], - "responses": { - "400": { - "description": "Invalid ID supplied" - }, - "404": { - "description": "Order not found" - } - } - } - }, - "/user": { - "post": { - "tags": [ - "user" - ], - "summary": "Create user", - "description": "This can only be done by the logged in user.", - "operationId": "createUser", - "responses": { - "default": { - "description": "successful operation" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/User" - } - } - }, - "description": "Created user object", - "required": true - } - } - }, - "/user/createWithArray": { - "post": { - "tags": [ - "user" - ], - "summary": "Creates list of users with given input array", - "description": "", - "operationId": "createUsersWithArrayInput", - "responses": { - "default": { - "description": "successful operation" - } - }, - "requestBody": { - "$ref": "#/components/requestBodies/UserArray" - } - } - }, - "/user/createWithList": { - "post": { - "tags": [ - "user" - ], - "summary": "Creates list of users with given input array", - "description": "", - "operationId": "createUsersWithListInput", - "responses": { - "default": { - "description": "successful operation" - } - }, - "requestBody": { - "$ref": "#/components/requestBodies/UserArray" - } - } - }, - "/user/login": { - "get": { - "tags": [ - "user" - ], - "summary": "Logs user into the system", - "description": "", - "operationId": "loginUser", - "parameters": [ - { - "name": "username", - "in": "query", - "description": "The user name for login", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "password", - "in": "query", - "description": "The password for login in clear text", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "successful operation", - "headers": { - "X-Rate-Limit": { - "description": "calls per hour allowed by the user", - "schema": { - "type": "integer", - "format": "int32" - } - }, - "X-Expires-After": { - "description": "date in UTC when token expires", - "schema": { - "type": "string", - "format": "date-time" - } - } - }, - "content": { - "application/xml": { - "schema": { - "type": "string" - } - }, - "application/json": { - "schema": { - "type": "string" - } - } - } - }, - "400": { - "description": "Invalid username/password supplied" - } - } - } - }, - "/user/logout": { - "get": { - "tags": [ - "user" - ], - "summary": "Logs out current logged in user session", - "description": "", - "operationId": "logoutUser", - "responses": { - "default": { - "description": "successful operation" - } - } - } - }, - "/user/{username}": { - "get": { - "tags": [ - "user" - ], - "summary": "Get user by user name", - "description": "", - "operationId": "getUserByName", - "parameters": [ - { - "name": "username", - "in": "path", - "description": "The name that needs to be fetched. Use user1 for testing. ", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "successful operation", - "content": { - "application/xml": { - "schema": { - "$ref": "#/components/schemas/User" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/User" - } - } - } - }, - "400": { - "description": "Invalid username supplied" - }, - "404": { - "description": "User not found" - } - } - }, - "put": { - "tags": [ - "user" - ], - "summary": "Updated user", - "description": "This can only be done by the logged in user.", - "operationId": "updateUser", - "parameters": [ - { - "name": "username", - "in": "path", - "description": "name that need to be updated", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "400": { - "description": "Invalid user supplied" - }, - "404": { - "description": "User not found" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/User" - } - } - }, - "description": "Updated user object", - "required": true - } - }, - "delete": { - "tags": [ - "user" - ], - "summary": "Delete user", - "description": "This can only be done by the logged in user.", - "operationId": "deleteUser", - "parameters": [ - { - "name": "username", - "in": "path", - "description": "The name that needs to be deleted", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "400": { - "description": "Invalid username supplied" - }, - "404": { - "description": "User not found" - } - } - } - } - }, - "externalDocs": { - "description": "Find out more about Swagger", - "url": "http://swagger.io" - }, - "components": { - "schemas": { - "Order": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int64" - }, - "petId": { - "type": "integer", - "format": "int64" - }, - "quantity": { - "type": "integer", - "format": "int32" - }, - "shipDate": { - "type": "string", - "format": "date-time" - }, - "status": { - "type": "string", - "description": "Order Status", - "enum": [ - "placed", - "approved", - "delivered" - ] - }, - "complete": { - "type": "boolean", - "default": false - } - }, - "xml": { - "name": "Order" - } - }, - "User": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int64" - }, - "username": { - "type": "string" - }, - "firstName": { - "type": "string" - }, - "lastName": { - "type": "string" - }, - "email": { - "type": "string" - }, - "password": { - "type": "string" - }, - "phone": { - "type": "string" - }, - "userStatus": { - "type": "integer", - "format": "int32", - "description": "User Status" - } - }, - "xml": { - "name": "User" - } - }, - "Category": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int64" - }, - "name": { - "type": "string" - } - }, - "xml": { - "name": "Category" - } - }, - "Tag": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "format": "int64" - }, - "name": { - "type": "string" - } - }, - "xml": { - "name": "Tag" - } - }, - "ApiResponse": { - "type": "object", - "properties": { - "code": { - "type": "integer", - "format": "int32" - }, - "type": { - "type": "string" - }, - "message": { - "type": "string" - } - } - }, - "Pet": { - "type": "object", - "required": [ - "name", - "photoUrls" - ], - "properties": { - "id": { - "type": "integer", - "format": "int64" - }, - "category": { - "$ref": "#/components/schemas/Category" - }, - "name": { - "type": "string", - "example": "doggie" - }, - "photoUrls": { - "type": "array", - "xml": { - "name": "photoUrl", - "wrapped": true - }, - "items": { - "type": "string" - } - }, - "tags": { - "type": "array", - "xml": { - "name": "tag", - "wrapped": true - }, - "items": { - "$ref": "#/components/schemas/Tag" - } - }, - "status": { - "type": "string", - "description": "pet status in the store", - "enum": [ - "available", - "pending", - "sold" - ] - } - }, - "xml": { - "name": "Pet" - } - } - }, - "requestBodies": { - "Pet": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Pet" - } - }, - "application/xml": { - "schema": { - "$ref": "#/components/schemas/Pet" - } - } - }, - "description": "Pet object that needs to be added to the store", - "required": true - }, - "UserArray": { - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/User" - } - } - } - }, - "description": "List of user object", - "required": true - } - }, - "securitySchemes": { - "petstore_auth": { - "type": "oauth2", - "flows": { - "implicit": { - "authorizationUrl": "https://petstore.swagger.io/oauth/dialog", - "scopes": { - "write:pets": "modify pets in your account", - "read:pets": "read your pets" - } - } - } - }, - "api_key": { - "type": "apiKey", - "name": "api_key", - "in": "header" - } - } - } -} diff --git a/openapi/src/test/resources/serverCORSRestricted.yaml b/openapi/src/test/resources/serverCORSRestricted.yaml deleted file mode 100644 index 2002982ac45..00000000000 --- a/openapi/src/test/resources/serverCORSRestricted.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# -# Copyright (c) 2020, 2021 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: - cors: - allow-origins: ["http://foo.bar", "http://bar.foo"] diff --git a/openapi/src/test/resources/serverNoCORS.properties b/openapi/src/test/resources/serverNoCORS.properties deleted file mode 100644 index ddc4e030003..00000000000 --- a/openapi/src/test/resources/serverNoCORS.properties +++ /dev/null @@ -1,16 +0,0 @@ -# -# Copyright (c) 2020, 2021 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.cors.enabled: false diff --git a/openapi/src/test/resources/serverTest.properties b/openapi/src/test/resources/serverTest.properties deleted file mode 100644 index bd36be8c025..00000000000 --- a/openapi/src/test/resources/serverTest.properties +++ /dev/null @@ -1,24 +0,0 @@ -# -# Copyright (c) 2019, 2021 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.model.reader: io.helidon.openapi.test.MyModelReader -openapi.filter: io.helidon.openapi.test.MySimpleFilter -openapi.servers: s1,s2 -openapi.servers.path.path1: p1s1,p1s2 -openapi.servers.path.path2: p2s1,p2s2 -openapi.servers.operation.op1: o1s1,o1s2 -openapi.servers.operation.op2: o2s1,o2s2 -openapi.scan.disable: false - diff --git a/openapi/src/test/resources/simple.properties b/openapi/src/test/resources/simple.properties deleted file mode 100644 index 5852a8aaa29..00000000000 --- a/openapi/src/test/resources/simple.properties +++ /dev/null @@ -1,23 +0,0 @@ -# -# Copyright (c) 2019, 2021 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.model.reader: io.helidon.openapi.test.MyModelReader -openapi.filter: io.helidon.openapi.test.MySimpleFilter -openapi.servers: s1,s2 -openapi.servers.path.path1: p1s1,p1s2 -openapi.servers.path.path2: p2s1,p2s2 -openapi.servers.operation.op1: o1s1,o1s2 -openapi.servers.operation.op2: o2s1,o2s2 -openapi.scan.disable: false diff --git a/openapi/src/test/resources/withBooleanAddlProps.yml b/openapi/src/test/resources/withBooleanAddlProps.yml deleted file mode 100644 index bb47d220b4f..00000000000 --- a/openapi/src/test/resources/withBooleanAddlProps.yml +++ /dev/null @@ -1,45 +0,0 @@ -# -# Copyright (c) 2021 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.1.0 - -info: - title: Some service - version: 0.1.0 - -components: - schemas: - item: - type: object - additionalProperties: false - properties: - id: - type: string - title: - type: string - -paths: - /items: - get: - responses: - '200': - description: Get items - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/item' diff --git a/openapi/src/test/resources/withSchemaAddlProps.yml b/openapi/src/test/resources/withSchemaAddlProps.yml deleted file mode 100644 index 232d598c87e..00000000000 --- a/openapi/src/test/resources/withSchemaAddlProps.yml +++ /dev/null @@ -1,51 +0,0 @@ -# -# Copyright (c) 2021 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.1.0 - -info: - title: Some service - version: 0.1.0 - -components: - schemas: - item: - type: object - additionalProperties: - type: object - properties: - code: - type: integer - text: - type: string - properties: - id: - type: string - title: - type: string - -paths: - /items: - get: - responses: - '200': - description: Get items - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/item' diff --git a/openapi/tests/gh-5792/pom.xml b/openapi/tests/gh-5792/pom.xml new file mode 100644 index 00000000000..183cb5c9a9d --- /dev/null +++ b/openapi/tests/gh-5792/pom.xml @@ -0,0 +1,67 @@ + + + + + 4.0.0 + + io.helidon.openapi.tests + helidon-openapi-tests-project + 4.0.0-SNAPSHOT + + helidon-openapi-tests-yaml-parsing + + Helidon OpenAPI Tests GH-5792 + + + + 1.32 + + + + + io.helidon.webserver + helidon-webserver + + + io.helidon.webclient + helidon-webclient-http1 + + + io.helidon.openapi + helidon-openapi + + + io.helidon.webserver.testing.junit5 + helidon-webserver-testing-junit5 + test + + + org.junit.jupiter + junit-jupiter-api + test + + + org.hamcrest + hamcrest-all + test + + + diff --git a/tests/integration/gh-5792/src/test/java/io/helidon/tests/integration/yamlparsing/AbstractMainTest.java b/openapi/tests/gh-5792/src/test/java/io/helidon/openapi/tests/yamlparsing/SnakeYAMLV1Test.java similarity index 71% rename from tests/integration/gh-5792/src/test/java/io/helidon/tests/integration/yamlparsing/AbstractMainTest.java rename to openapi/tests/gh-5792/src/test/java/io/helidon/openapi/tests/yamlparsing/SnakeYAMLV1Test.java index ca154f3ba42..4de0ee77097 100644 --- a/tests/integration/gh-5792/src/test/java/io/helidon/tests/integration/yamlparsing/AbstractMainTest.java +++ b/openapi/tests/gh-5792/src/test/java/io/helidon/openapi/tests/yamlparsing/SnakeYAMLV1Test.java @@ -13,40 +13,36 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package io.helidon.tests.integration.yamlparsing; +package io.helidon.openapi.tests.yamlparsing; import io.helidon.http.Status; +import io.helidon.openapi.OpenApiFeature; import io.helidon.webclient.http1.Http1Client; import io.helidon.webclient.http1.Http1ClientResponse; import io.helidon.webserver.http.HttpRouting; +import io.helidon.webserver.testing.junit5.DirectClient; +import io.helidon.webserver.testing.junit5.RoutingTest; import io.helidon.webserver.testing.junit5.SetUpRoute; -import jakarta.json.JsonObject; import org.junit.jupiter.api.Test; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.is; -abstract class AbstractMainTest { +@RoutingTest +class SnakeYAMLV1Test { + private final Http1Client client; - protected AbstractMainTest(Http1Client client) { + SnakeYAMLV1Test(DirectClient client) { this.client = client; } @SetUpRoute - static void routing(HttpRouting.Builder builder) { - Main.routing(builder); - } - - @Test - void testRootRoute() { - try (Http1ClientResponse response = client.get("/greet").request()) { - assertThat(response.status(), is(Status.OK_200)); - JsonObject json = response.as(JsonObject.class); - assertThat(json.getString("message"), is("Hello World!")); - } + static void routing(HttpRouting.Builder routing) { + routing.addFeature(OpenApiFeature.builder() + .staticFile("target/test-classes/petstore.yaml")); } @Test @@ -56,5 +52,4 @@ void testOpenApi() { assertThat("/openapi content", response.as(String.class), containsString("title: Swagger Petstore")); } } - } diff --git a/tests/integration/gh-5792/src/main/resources/META-INF/openapi/petstore.yaml b/openapi/tests/gh-5792/src/test/resources/petstore.yaml similarity index 99% rename from tests/integration/gh-5792/src/main/resources/META-INF/openapi/petstore.yaml rename to openapi/tests/gh-5792/src/test/resources/petstore.yaml index aefd6ea97e0..b15eb29dac1 100644 --- a/tests/integration/gh-5792/src/main/resources/META-INF/openapi/petstore.yaml +++ b/openapi/tests/gh-5792/src/test/resources/petstore.yaml @@ -121,4 +121,4 @@ components: type: integer format: int32 message: - type: string \ No newline at end of file + type: string diff --git a/openapi/tests/pom.xml b/openapi/tests/pom.xml new file mode 100644 index 00000000000..168b7fc571e --- /dev/null +++ b/openapi/tests/pom.xml @@ -0,0 +1,49 @@ + + + + + 4.0.0 + + io.helidon.openapi + helidon-openapi-project + 4.0.0-SNAPSHOT + + + io.helidon.openapi.tests + helidon-openapi-tests-project + Helidon OpenAPI Tests Project + + pom + + + gh-5792 + + + + true + true + true + true + true + true + true + + diff --git a/tests/integration/gh-5792/README.md b/tests/integration/gh-5792/README.md deleted file mode 100644 index 5c31899c843..00000000000 --- a/tests/integration/gh-5792/README.md +++ /dev/null @@ -1,21 +0,0 @@ - -# helidon-tests-integration-yaml-parsing - -Sample Helidon WebServer project to make sure that we can build and run using an older release of SnakeYAML in case users need to fall back. - -Note that the static OpenAPI document packaged into the application JAR file intentionally _does not_ describe the API for this service. -It contains a much richer definition to exercise YAML parsing a bit more. - -## Build and run - -With JDK19+ - ```bash - mvn package -java -jar target/helidon-tests-integration-yaml-parsing.jar - ``` - -## Try OpenAPI - - ``` - curl -s -X GET http://localhost:8080/openapi -``` diff --git a/tests/integration/gh-5792/pom.xml b/tests/integration/gh-5792/pom.xml deleted file mode 100644 index f5126343e62..00000000000 --- a/tests/integration/gh-5792/pom.xml +++ /dev/null @@ -1,117 +0,0 @@ - - - - - 4.0.0 - - io.helidon.applications - helidon-se - 4.0.0-SNAPSHOT - ../../../applications/se/pom.xml - - io.helidon.tests.integration - helidon-tests-integration-yaml-parsing - 4.0.0-SNAPSHOT - - Helidon Tests Integration GH-5792 - - - io.helidon.tests.integration.yamlparsing.Main - 1.32 - - - - - - - org.yaml - snakeyaml - ${selectedSnakeYamlVersion} - - - - - - - io.helidon.webserver - helidon-webserver - - - io.helidon.webclient - helidon-webclient - - - io.helidon.http.media - helidon-http-media-jsonp - - - io.helidon.openapi - helidon-openapi - - - io.helidon.config - helidon-config-yaml - - - jakarta.json - jakarta.json-api - - - io.helidon.webserver.testing.junit5 - helidon-webserver-testing-junit5 - test - - - org.junit.jupiter - junit-jupiter-api - test - - - org.hamcrest - hamcrest-all - test - - - - - - - org.apache.maven.plugins - maven-surefire-plugin - - - ${selectedSnakeYamlVersion} - - - - - org.apache.maven.plugins - maven-dependency-plugin - - - copy-libs - - - - - - diff --git a/tests/integration/gh-5792/src/main/java/io/helidon/tests/integration/yamlparsing/GreetClientHttp.java b/tests/integration/gh-5792/src/main/java/io/helidon/tests/integration/yamlparsing/GreetClientHttp.java deleted file mode 100644 index 92811597bfe..00000000000 --- a/tests/integration/gh-5792/src/main/java/io/helidon/tests/integration/yamlparsing/GreetClientHttp.java +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright (c) 2023 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.tests.integration.yamlparsing; - -import io.helidon.http.Method; -import io.helidon.webclient.api.WebClient; - -/** - * Executable class that invokes HTTP/1 requests against the server. - */ -public class GreetClientHttp { - private GreetClientHttp() { - } - - /** - * Main method. - * - * @param args ignored - */ - public static void main(String[] args) { - WebClient client = WebClient.builder() - .baseUri("http://localhost:8080/greet") - .build(); - - String response = client.method(Method.GET) - .requestEntity(String.class); - - System.out.println(response); - - response = client.get("Frank") - .requestEntity(String.class); - - System.out.println(response); - } -} diff --git a/tests/integration/gh-5792/src/main/java/io/helidon/tests/integration/yamlparsing/GreetService.java b/tests/integration/gh-5792/src/main/java/io/helidon/tests/integration/yamlparsing/GreetService.java deleted file mode 100644 index b5a9acaf120..00000000000 --- a/tests/integration/gh-5792/src/main/java/io/helidon/tests/integration/yamlparsing/GreetService.java +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright (c) 2023 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.tests.integration.yamlparsing; - -import java.util.Collections; -import java.util.concurrent.atomic.AtomicReference; - -import io.helidon.http.Status; -import io.helidon.webserver.http.HttpRules; -import io.helidon.webserver.http.HttpService; -import io.helidon.webserver.http.ServerRequest; -import io.helidon.webserver.http.ServerResponse; - -import jakarta.json.Json; -import jakarta.json.JsonBuilderFactory; -import jakarta.json.JsonObject; - -/** - * A simple service to greet you. Examples: - *

- * Get default greeting message: - * {@code curl -X GET http://localhost:8080/greet} - *

- * Get greeting message for Joe: - * {@code curl -X GET http://localhost:8080/greet/Joe} - *

- * Change greeting - * {@code curl -X PUT -H "Content-Type: application/json" -d '{"greeting" : "Howdy"}' http://localhost:8080/greet/greeting} - *

- * The message is returned as a JSON object - */ -class GreetService implements HttpService { - - private static final JsonBuilderFactory JSON = Json.createBuilderFactory(Collections.emptyMap()); - - /** - * The config value for the key {@code greeting}. - */ - private final AtomicReference greeting = new AtomicReference<>(); - - GreetService() { - greeting.set("Hello"); - } - - /** - * A service registers itself by updating the routing rules. - * - * @param rules the routing rules. - */ - @Override - public void routing(HttpRules 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().pathParameters().get("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 void updateGreetingFromJson(JsonObject jo, ServerResponse response) { - - if (!jo.containsKey("greeting")) { - JsonObject jsonErrorObject = JSON.createObjectBuilder() - .add("error", "No greeting provided") - .build(); - response.status(Status.BAD_REQUEST_400) - .send(jsonErrorObject); - return; - } - - greeting.set(jo.getString("greeting")); - response.status(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) { - updateGreetingFromJson(request.content().as(JsonObject.class), response); - } - -} diff --git a/tests/integration/gh-5792/src/main/java/io/helidon/tests/integration/yamlparsing/Main.java b/tests/integration/gh-5792/src/main/java/io/helidon/tests/integration/yamlparsing/Main.java deleted file mode 100644 index 69b333b18ba..00000000000 --- a/tests/integration/gh-5792/src/main/java/io/helidon/tests/integration/yamlparsing/Main.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright (c) 2023 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.tests.integration.yamlparsing; - -import io.helidon.logging.common.LogConfig; -import io.helidon.webserver.WebServer; -import io.helidon.webserver.http.HttpRouting; -import io.helidon.openapi.OpenApiFeature; - -/** - * The application main class. - */ -public final class Main { - - /** - * Cannot be instantiated. - */ - private Main() { - } - - /** - * Application main entry point. - * @param args command line arguments. - */ - public static void main(final String[] args) { - // load logging configuration - LogConfig.configureRuntime(); - - WebServer server = WebServer.builder() - .routing(Main::routing) - .build() - .start(); - - System.out.println("WEB server is up! http://localhost:" + server.port() + "/greet"); - } - - /** - * Updates HTTP Routing. - */ - static void routing(HttpRouting.Builder routing) { - OpenApiFeature openApiService = OpenApiFeature.builder() - .build(); - - GreetService greetService = new GreetService(); - - routing.register("/greet", greetService) - .addFeature(openApiService); - } -} diff --git a/tests/integration/gh-5792/src/main/resources/application.yaml b/tests/integration/gh-5792/src/main/resources/application.yaml deleted file mode 100644 index 63c5f08a061..00000000000 --- a/tests/integration/gh-5792/src/main/resources/application.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# -# Copyright (c) 2023 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. -server: - port: 8080 - host: 0.0.0.0 - diff --git a/tests/integration/gh-5792/src/main/resources/logging.properties b/tests/integration/gh-5792/src/main/resources/logging.properties deleted file mode 100644 index 0db74b78313..00000000000 --- a/tests/integration/gh-5792/src/main/resources/logging.properties +++ /dev/null @@ -1,19 +0,0 @@ -# -# Copyright (c) 2023 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. -handlers=java.util.logging.ConsoleHandler -java.util.logging.SimpleFormatter.format=%1$tY.%1$tm.%1$td %1$tH:%1$tM:%1$tS.%1$tL %5$s%6$s%n -# Global logging level. Can be overridden by specific loggers -.level=INFO -io.helidon.webserver.level=INFO diff --git a/tests/integration/gh-5792/src/test/java/io/helidon/tests/integration/yamlparsing/MainTest.java b/tests/integration/gh-5792/src/test/java/io/helidon/tests/integration/yamlparsing/MainTest.java deleted file mode 100644 index 0395ecdfae8..00000000000 --- a/tests/integration/gh-5792/src/test/java/io/helidon/tests/integration/yamlparsing/MainTest.java +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (c) 2023 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.tests.integration.yamlparsing; - -import io.helidon.webserver.testing.junit5.DirectClient; -import io.helidon.webserver.testing.junit5.RoutingTest; -import org.junit.jupiter.api.Disabled; - -@RoutingTest -@Disabled -class MainTest extends AbstractMainTest { - MainTest(DirectClient client) { - super(client); - } -} \ No newline at end of file diff --git a/tests/integration/gh-5792/src/test/resources/application.yaml b/tests/integration/gh-5792/src/test/resources/application.yaml deleted file mode 100644 index 850e85592c4..00000000000 --- a/tests/integration/gh-5792/src/test/resources/application.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# -# Copyright (c) 2023 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. -server: - port: 0 - host: 0.0.0.0 - -app: - greeting: "Hello" \ No newline at end of file diff --git a/tests/integration/pom.xml b/tests/integration/pom.xml index 0f1a4c28078..1946b55eb2f 100644 --- a/tests/integration/pom.xml +++ b/tests/integration/pom.xml @@ -40,7 +40,6 @@ config dbclient - gh-5792 harness health jep290 diff --git a/webserver/webserver/src/test/java/io/helidon/webserver/http/PathMatchersTest.java b/webserver/webserver/src/test/java/io/helidon/webserver/http/PathMatchersTest.java index 9b98497186d..b37c7d42514 100644 --- a/webserver/webserver/src/test/java/io/helidon/webserver/http/PathMatchersTest.java +++ b/webserver/webserver/src/test/java/io/helidon/webserver/http/PathMatchersTest.java @@ -35,7 +35,7 @@ class PathMatchersTest { @Test - public void testNormalization() throws Exception { + public void testNormalization() { assertThat("/a/./b", pathMatcherMatches("/a/b")); assertThat("/a/b/../c", pathMatcherMatches("/a/c")); } @@ -88,6 +88,18 @@ void testMultipliedSlashes() { assertThat("/a//b", not(pathMatcherMatches("/a//*"))); } + @Test + public void testOptionals() { + assertThat("/foo/bar", pathMatcherMatches("/foo[/bar]")); + assertThat("/foo", pathMatcherMatches("/foo[/bar]")); + assertThat("/foo/ba", not(pathMatcherMatches("/foo[/bar]"))); + assertThat("/foo/bar", pathMatcherMatches("/foo[/{var}]")); + assertThat("/foo", pathMatcherMatches("/foo[/{var}]")); + assertThat("/foo/bar/baz", not(pathMatcherMatches("/foo[/{var}]"))); + assertThat("/foo/bar/baz", pathMatcherMatches("/foo[/{var}]/baz")); + assertThat("/foo/baz", pathMatcherMatches("/foo[/{var}]/baz")); + } + private static org.hamcrest.Matcher pathMatcherMatches(String pattern) { PathMatcher matcher = PathMatchers.create(pattern); return new TypeSafeMatcher<>() {