diff --git a/pom.xml b/pom.xml index 4db0dd398d..d0ca988bde 100644 --- a/pom.xml +++ b/pom.xml @@ -90,6 +90,7 @@ 2.2.0 1.0.4 1.0.4 + 1.6.6 spring-cloud-dataflow-container-registry @@ -245,6 +246,11 @@ aws-java-sdk-ecr ${aws-java-sdk-ecr.version} + + org.springdoc + springdoc-openapi-ui + ${springdoc-openapi-ui.version} + com.wavefront diff --git a/spring-cloud-dataflow-server-core/pom.xml b/spring-cloud-dataflow-server-core/pom.xml index fba56bd290..43748dee58 100644 --- a/spring-cloud-dataflow-server-core/pom.xml +++ b/spring-cloud-dataflow-server-core/pom.xml @@ -39,6 +39,10 @@ com.zaxxer HikariCP + + org.springdoc + springdoc-openapi-ui + org.springframework.boot spring-boot-starter-data-jpa diff --git a/spring-cloud-dataflow-server-core/src/main/java/org/springframework/cloud/dataflow/server/config/DataflowOAuthSecurityConfiguration.java b/spring-cloud-dataflow-server-core/src/main/java/org/springframework/cloud/dataflow/server/config/DataflowOAuthSecurityConfiguration.java index cf54311073..8ecd12990b 100644 --- a/spring-cloud-dataflow-server-core/src/main/java/org/springframework/cloud/dataflow/server/config/DataflowOAuthSecurityConfiguration.java +++ b/spring-cloud-dataflow-server-core/src/main/java/org/springframework/cloud/dataflow/server/config/DataflowOAuthSecurityConfiguration.java @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + package org.springframework.cloud.dataflow.server.config; import org.springframework.cloud.common.security.OAuthSecurityConfiguration; diff --git a/spring-cloud-dataflow-server-core/src/main/java/org/springframework/cloud/dataflow/server/config/SpringDocAutoConfiguration.java b/spring-cloud-dataflow-server-core/src/main/java/org/springframework/cloud/dataflow/server/config/SpringDocAutoConfiguration.java new file mode 100644 index 0000000000..1261b6e0da --- /dev/null +++ b/spring-cloud-dataflow-server-core/src/main/java/org/springframework/cloud/dataflow/server/config/SpringDocAutoConfiguration.java @@ -0,0 +1,117 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.dataflow.server.config; + +import org.springdoc.core.SpringDocConfigProperties; +import org.springdoc.core.SwaggerUiConfigProperties; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.cloud.dataflow.server.support.SpringDocJsonDecodeFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; + +/** + * Makes SpringDoc public available without any authentication required by initializing a {@link WebSecurityCustomizer} and + * applying all path of SpringDoc to be ignored. Also applies a filter registration bean to unescape JSON content for the + * SpringDoc frontend. + * + * @author Tobias Soloschenko + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnBean({ SpringDocConfigProperties.class, SwaggerUiConfigProperties.class }) +public class SpringDocAutoConfiguration { + + public static final String SWAGGER_UI_CONTEXT = "/swagger-ui/**"; + + private final SpringDocConfigProperties springDocConfigProperties; + + private final SwaggerUiConfigProperties swaggerUiConfigProperties; + + /** + * Creates the SpringDocConfiguration with the given properties. + * + * @param springDocConfigProperties the spring doc config properties + * @param swaggerUiConfigProperties the swagger ui config properties + */ + public SpringDocAutoConfiguration(SpringDocConfigProperties springDocConfigProperties, SwaggerUiConfigProperties swaggerUiConfigProperties) { + this.springDocConfigProperties = springDocConfigProperties; + this.swaggerUiConfigProperties = swaggerUiConfigProperties; + } + + /** + * Creates a web security customizer for the spring security which makes the SpringDoc frontend public available. + * + * @return a web security customizer with security settings for SpringDoc + */ + @Bean("springDocWebSecurityCustomizer") + public WebSecurityCustomizer springDocCustomizer() { + return (webSecurity -> webSecurity.ignoring().antMatchers( + SWAGGER_UI_CONTEXT, + getApiDocsPathContext() + "/**", + swaggerUiConfigProperties.getPath(), + swaggerUiConfigProperties.getConfigUrl(), + swaggerUiConfigProperties.getValidatorUrl(), + swaggerUiConfigProperties.getOauth2RedirectUrl(), + springDocConfigProperties.getWebjars().getPrefix(), + springDocConfigProperties.getWebjars().getPrefix() + "/**")); + } + + /** + * Applies {@link SpringDocJsonDecodeFilter} to the filter chain which decodes the JSON of ApiDocs and SwaggerUi so that the SpringDoc frontend is able + * to read it. Spring Cloud Data Flow however requires the JSON to be escaped and wrapped into quotes, because the + * Angular Ui frontend is using it that way. + * + * @return a filter registration bean which unescapes the content of the JSON endpoints of SpringDoc before it is returned. + */ + @Bean + public FilterRegistrationBean springDocJsonDecodeFilterRegistration() { + String apiDocsPathContext = getApiDocsPathContext(); + String swaggerUiConfigContext = getSwaggerUiConfigContext(); + + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new SpringDocJsonDecodeFilter()); + registrationBean.addUrlPatterns(apiDocsPathContext, apiDocsPathContext + "/*", swaggerUiConfigContext, + swaggerUiConfigContext + "/*"); + + return registrationBean; + } + + /** + * Gets the SwaggerUi config context. For example the default configuration for the SwaggerUi config is /v3/api-docs/swagger-config + * which results in a context of /v3/api-docs. + * + * @return the SwaggerUi config path context + */ + private String getSwaggerUiConfigContext() { + String swaggerUiConfigUrl = swaggerUiConfigProperties.getConfigUrl(); + return swaggerUiConfigUrl.substring(0, swaggerUiConfigUrl.lastIndexOf("/")); + } + + /** + * Gets the ApiDocs context path. For example the default configuration for the ApiDocs path is /v3/api-docs + * which results in a context of /v3. + * + * @return the api docs path context + */ + private String getApiDocsPathContext() { + String apiDocsPath = springDocConfigProperties.getApiDocs().getPath(); + return apiDocsPath.substring(0, apiDocsPath.lastIndexOf("/")); + } +} diff --git a/spring-cloud-dataflow-server-core/src/main/java/org/springframework/cloud/dataflow/server/support/SpringDocJsonDecodeFilter.java b/spring-cloud-dataflow-server-core/src/main/java/org/springframework/cloud/dataflow/server/support/SpringDocJsonDecodeFilter.java new file mode 100644 index 0000000000..4afa63fa3c --- /dev/null +++ b/spring-cloud-dataflow-server-core/src/main/java/org/springframework/cloud/dataflow/server/support/SpringDocJsonDecodeFilter.java @@ -0,0 +1,70 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.dataflow.server.support; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletOutputStream; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletRequestWrapper; +import javax.servlet.http.HttpServletResponse; + +import org.apache.commons.text.StringEscapeUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.springframework.web.util.ContentCachingResponseWrapper; + +/** + * Sets up a filter that unescapes the JSON content of the API endpoints of OpenApi and Swagger. + * This is similar to the issue mentioned here: https://github.com/springdoc/springdoc-openapi/issues/624 + * Spring Cloud Data Flow however needs the escaped JSON to show task logs in the UI. + * + * @author Tobias Soloschenko + */ +public class SpringDocJsonDecodeFilter implements Filter { + + private static final Logger LOG = LoggerFactory.getLogger(SpringDocJsonDecodeFilter.class); + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + final HttpServletRequestWrapper httpServletRequestWrapper = new HttpServletRequestWrapper((HttpServletRequest) request); + final ContentCachingResponseWrapper httpServletResponseWrapper = new ContentCachingResponseWrapper((HttpServletResponse) response); + + chain.doFilter(httpServletRequestWrapper, httpServletResponseWrapper); + + ServletOutputStream outputStream = httpServletResponseWrapper.getResponse().getOutputStream(); + + LOG.debug("Request for Swagger api-docs detected - unescaping json content."); + String content = new String(httpServletResponseWrapper.getContentAsByteArray(), StandardCharsets.UTF_8); + // Replaces all escaped quotes + content = StringEscapeUtils.unescapeJson(content); + // Replaces first and last quote + content = content.substring(1, content.length() - 1); + if (LOG.isTraceEnabled()) { + LOG.trace("Using decoded JSON for serving api-docs: {}", content); + } + outputStream.write(content.getBytes(StandardCharsets.UTF_8)); + + } +} diff --git a/spring-cloud-dataflow-server-core/src/main/resources/META-INF/dataflow-server-defaults.yml b/spring-cloud-dataflow-server-core/src/main/resources/META-INF/dataflow-server-defaults.yml index 0fac72e80c..6e879e1cd4 100644 --- a/spring-cloud-dataflow-server-core/src/main/resources/META-INF/dataflow-server-defaults.yml +++ b/spring-cloud-dataflow-server-core/src/main/resources/META-INF/dataflow-server-defaults.yml @@ -243,3 +243,8 @@ spring: # Tools - POST /tools/** => hasRole('ROLE_VIEW') +springdoc: + api-docs: + enabled: false + swagger-ui: + enabled: false diff --git a/spring-cloud-dataflow-server-core/src/main/resources/META-INF/spring.factories b/spring-cloud-dataflow-server-core/src/main/resources/META-INF/spring.factories index 3ddae42b73..1a69328580 100644 --- a/spring-cloud-dataflow-server-core/src/main/resources/META-INF/spring.factories +++ b/spring-cloud-dataflow-server-core/src/main/resources/META-INF/spring.factories @@ -3,4 +3,5 @@ org.springframework.boot.env.EnvironmentPostProcessor=\ org.springframework.cloud.dataflow.server.config.MetricsReplicationEnvironmentPostProcessor org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ org.springframework.cloud.dataflow.server.config.DataFlowServerAutoConfiguration,\ - org.springframework.cloud.dataflow.server.config.DataFlowControllerAutoConfiguration + org.springframework.cloud.dataflow.server.config.DataFlowControllerAutoConfiguration, \ + org.springframework.cloud.dataflow.server.config.SpringDocAutoConfiguration diff --git a/spring-cloud-dataflow-server-core/src/test/java/org/springframework/cloud/dataflow/server/support/SpringDocJsonDecodeFilterTest.java b/spring-cloud-dataflow-server-core/src/test/java/org/springframework/cloud/dataflow/server/support/SpringDocJsonDecodeFilterTest.java new file mode 100644 index 0000000000..5dc6dff9a5 --- /dev/null +++ b/spring-cloud-dataflow-server-core/src/test/java/org/springframework/cloud/dataflow/server/support/SpringDocJsonDecodeFilterTest.java @@ -0,0 +1,59 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.dataflow.server.support; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; + +import org.junit.jupiter.api.Test; + +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * This is a test for {@link SpringDocJsonDecodeFilter} to check if the json content is decoded correctly. + * @author Tobias Soloschenko + */ +public class SpringDocJsonDecodeFilterTest { + + private static final String OPENAPI_JSON_ESCAPED_CONTENT = "\"{\\\"openapi:\\\"3.0.1\\\",\\\"info\\\":{\\\"title\\\":\\\"OpenAPI definition\\\",\\\"version\\\":\\\"v0\\\"}}\""; + + private static final String OPENAPI_JSON_UNESCAPED_CONTENT = "{\"openapi:\"3.0.1\",\"info\":{\"title\":\"OpenAPI definition\",\"version\":\"v0\"}}"; + + @Test + public void doFilterTest() throws ServletException, IOException { + MockHttpServletResponse mockHttpServletResponse = new MockHttpServletResponse(); + MockHttpServletRequest mockHttpServletRequest = new MockHttpServletRequest(); + MockFilterChain mockFilterChain = new MockFilterChain() { + @Override + public void doFilter(ServletRequest request, ServletResponse response) throws IOException, ServletException { + response.getOutputStream().write(OPENAPI_JSON_ESCAPED_CONTENT.getBytes(StandardCharsets.UTF_8)); + super.doFilter(request, response); + } + }; + new SpringDocJsonDecodeFilter().doFilter(mockHttpServletRequest, mockHttpServletResponse, mockFilterChain); + assertThat(mockHttpServletResponse.getContentAsString()).isEqualTo(OPENAPI_JSON_UNESCAPED_CONTENT); + } + +}