diff --git a/initializr-service/app.groovy b/initializr-service/app.groovy index 8de70715..0bd2f490 100644 --- a/initializr-service/app.groovy +++ b/initializr-service/app.groovy @@ -1,5 +1,12 @@ package app +import java.util.concurrent.Executor + +import org.springframework.context.annotation.Configuration +import org.springframework.scheduling.annotation.AsyncConfigurerSupport +import org.springframework.scheduling.annotation.EnableAsync +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor + import io.spring.initializr.web.LegacyStsController @Grab('io.spring.initalizr:initializr:1.0.0.BUILD-SNAPSHOT') @@ -11,4 +18,20 @@ class InitializerService { LegacyStsController legacyStsController() { new LegacyStsController() } + + @Configuration + @EnableAsync + static class AsyncConfiguration extends AsyncConfigurerSupport { + + @Override + Executor getAsyncExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor() + executor.setCorePoolSize(1) + executor.setMaxPoolSize(5) + executor.setThreadNamePrefix("initializr-") + executor.initialize() + executor + } + + } } \ No newline at end of file diff --git a/initializr/pom.xml b/initializr/pom.xml index 94f8e85b..800bf568 100644 --- a/initializr/pom.xml +++ b/initializr/pom.xml @@ -32,6 +32,11 @@ spring-boot-starter-redis true + + org.springframework.retry + spring-retry + + com.fasterxml.jackson.core jackson-core @@ -85,6 +90,10 @@ jsonassert test + + com.jayway.jsonpath + json-path + org.gebish geb-core diff --git a/initializr/src/main/groovy/io/spring/initializr/config/CloudfoundryEnvironmentPostProcessor.groovy b/initializr/src/main/groovy/io/spring/initializr/config/CloudfoundryEnvironmentPostProcessor.groovy new file mode 100644 index 00000000..0375f9bc --- /dev/null +++ b/initializr/src/main/groovy/io/spring/initializr/config/CloudfoundryEnvironmentPostProcessor.groovy @@ -0,0 +1,92 @@ +/* + * Copyright 2012-2016 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 + * + * 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.spring.initializr.config + +import org.springframework.boot.SpringApplication +import org.springframework.boot.context.config.ConfigFileApplicationListener +import org.springframework.boot.env.EnvironmentPostProcessor +import org.springframework.core.Ordered +import org.springframework.core.env.ConfigurableEnvironment +import org.springframework.core.env.MapPropertySource +import org.springframework.core.env.MutablePropertySources +import org.springframework.core.env.PropertySource +import org.springframework.util.StringUtils +import org.springframework.web.util.UriComponents +import org.springframework.web.util.UriComponentsBuilder + +/** + * Post-process the environment to extract the service credentials provided by + * CloudFoundry. Injects the elastic service URI if present. + * + * @author Stephane Nicoll + * @since 1.0 + */ +class CloudfoundryEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered { + + private static final String PROPERTY_SOURCE_NAME = "defaultProperties"; + + private final int order = ConfigFileApplicationListener.DEFAULT_ORDER + 1; + + @Override + public void postProcessEnvironment(ConfigurableEnvironment environment, + SpringApplication springApplication) { + + Map map = [:] + String uri = environment.getProperty('vcap.services.stats-index.credentials.uri') + if (StringUtils.hasText(uri)) { + UriComponents uriComponents = UriComponentsBuilder.fromUriString(uri).build() + def userInfo = uriComponents.getUserInfo() + if (userInfo) { + String[] credentials = userInfo.split(':') + map['initializr.stats.elastic.username'] = credentials[0] + map['initializr.stats.elastic.password'] = credentials[1] + } + map['initializr.stats.elastic.uri'] = UriComponentsBuilder.fromUriString(uri) + .userInfo(null).build().toString() + + addOrReplace(environment.getPropertySources(), map); + } + } + + @Override + public int getOrder() { + return this.order + } + + private static void addOrReplace(MutablePropertySources propertySources, + Map map) { + MapPropertySource target = null + if (propertySources.contains(PROPERTY_SOURCE_NAME)) { + PropertySource source = propertySources.get(PROPERTY_SOURCE_NAME) + if (source instanceof MapPropertySource) { + target = (MapPropertySource) source + for (String key : map.keySet()) { + if (!target.containsProperty(key)) { + target.getSource().put(key, map.get(key)) + } + } + } + } + if (target == null) { + target = new MapPropertySource(PROPERTY_SOURCE_NAME, map); + } + if (!propertySources.contains(PROPERTY_SOURCE_NAME)) { + propertySources.addLast(target); + } + } + +} diff --git a/initializr/src/main/groovy/io/spring/initializr/config/InitializrStatsAutoConfiguration.groovy b/initializr/src/main/groovy/io/spring/initializr/config/InitializrStatsAutoConfiguration.groovy new file mode 100644 index 00000000..a3421df3 --- /dev/null +++ b/initializr/src/main/groovy/io/spring/initializr/config/InitializrStatsAutoConfiguration.groovy @@ -0,0 +1,68 @@ +/* + * Copyright 2012-2016 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 + * + * 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.spring.initializr.config + +import io.spring.initializr.metadata.InitializrMetadataProvider +import io.spring.initializr.stat.ProjectGenerationStatPublisher +import io.spring.initializr.stat.ProjectRequestDocumentFactory + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.autoconfigure.AutoConfigureAfter +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.retry.backoff.ExponentialBackOffPolicy +import org.springframework.retry.policy.SimpleRetryPolicy +import org.springframework.retry.support.RetryTemplate + +/** + * {@link org.springframework.boot.autoconfigure.EnableAutoConfiguration + * Auto-configuration} to publish statistics of each generated project. + * + * @author Stephane Nicoll + * @since 1.0 + */ +@Configuration +@EnableConfigurationProperties(StatsProperties) +@AutoConfigureAfter(InitializrAutoConfiguration) +@ConditionalOnProperty('initializr.stats.elastic.uri') +class InitializrStatsAutoConfiguration { + + @Autowired + private StatsProperties statsProperties + + @Bean + ProjectGenerationStatPublisher projectRequestStatHandler(InitializrMetadataProvider provider) { + new ProjectGenerationStatPublisher(new ProjectRequestDocumentFactory(provider), + statsProperties, statsRetryTemplate()) + } + + @Bean + @ConditionalOnMissingBean(name = "statsRetryTemplate") + RetryTemplate statsRetryTemplate() { + RetryTemplate retryTemplate = new RetryTemplate() + def backOffPolicy = new ExponentialBackOffPolicy(initialInterval: 3000L, multiplier: 3) + def retryPolicy = new SimpleRetryPolicy(statsProperties.elastic.maxAttempts, Collections + ., Boolean> singletonMap(Exception.class, true)) + retryTemplate.setBackOffPolicy(backOffPolicy) + retryTemplate.setRetryPolicy(retryPolicy) + retryTemplate + } + +} diff --git a/initializr/src/main/groovy/io/spring/initializr/config/StatsProperties.groovy b/initializr/src/main/groovy/io/spring/initializr/config/StatsProperties.groovy new file mode 100644 index 00000000..fb8f2845 --- /dev/null +++ b/initializr/src/main/groovy/io/spring/initializr/config/StatsProperties.groovy @@ -0,0 +1,83 @@ +/* + * Copyright 2012-2016 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 + * + * 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.spring.initializr.config + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.util.StringUtils + +/** + * Statistics-related properties. + * + * @author Stephane Nicoll + * @since 1.0 + */ +@ConfigurationProperties("initializr.stats") +class StatsProperties { + + final Elastic elastic = new Elastic() + + static final class Elastic { + + /** + * Elastic service uri. + */ + String uri + + /** + * Elastic service username. + */ + String username + + /** + * Elastic service password + */ + String password + + /** + * Name of the index. + */ + String indexName = 'initializr' + + /** + * Name of the entity to use to publish stats. + */ + String entityName = 'request' + + /** + * Number of attempts before giving up. + */ + int maxAttempts = 3 + + void setUri(String uri) { + this.uri = cleanUri(uri) + } + + URI getEntityUrl() { + def string = "$uri/$indexName/$entityName" + new URI(string) + } + + private static String cleanUri(String contextPath) { + if (StringUtils.hasText(contextPath) && contextPath.endsWith("/")) { + return contextPath.substring(0, contextPath.length() - 1) + } + return contextPath + } + + } + +} diff --git a/initializr/src/main/groovy/io/spring/initializr/generator/ProjectRequest.groovy b/initializr/src/main/groovy/io/spring/initializr/generator/ProjectRequest.groovy index 9972734c..ba75efd6 100644 --- a/initializr/src/main/groovy/io/spring/initializr/generator/ProjectRequest.groovy +++ b/initializr/src/main/groovy/io/spring/initializr/generator/ProjectRequest.groovy @@ -16,6 +16,7 @@ package io.spring.initializr.generator +import groovy.transform.ToString import groovy.util.logging.Slf4j import io.spring.initializr.metadata.BillOfMaterials import io.spring.initializr.metadata.Dependency @@ -33,6 +34,7 @@ import io.spring.initializr.util.VersionRange * @since 1.0 */ @Slf4j +@ToString(ignoreNulls = true, includePackage = false, includeNames = true) class ProjectRequest extends BasicProjectRequest { /** diff --git a/initializr/src/main/groovy/io/spring/initializr/stat/BasicAuthRestTemplate.groovy b/initializr/src/main/groovy/io/spring/initializr/stat/BasicAuthRestTemplate.groovy new file mode 100644 index 00000000..42dc1f47 --- /dev/null +++ b/initializr/src/main/groovy/io/spring/initializr/stat/BasicAuthRestTemplate.groovy @@ -0,0 +1,82 @@ +/* + * Copyright 2012-2016 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 + * + * 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.spring.initializr.stat + +import java.nio.charset.StandardCharsets + +import org.springframework.http.HttpRequest +import org.springframework.http.client.ClientHttpRequestExecution +import org.springframework.http.client.ClientHttpRequestInterceptor +import org.springframework.http.client.ClientHttpResponse +import org.springframework.http.client.InterceptingClientHttpRequestFactory +import org.springframework.util.Base64Utils +import org.springframework.web.client.RestTemplate + +/** + * A simple {@link RestTemplate} extension that automatically provides the + * {@code Authorization} header if credentials are provided. + *

+ * Largely inspired from Spring Boot's {@code TestRestTemplate}. + * + * @author Stephane Nicoll + * @since 1.0 + */ +class BasicAuthRestTemplate extends RestTemplate { + + /** + * Create a new instance. {@code username} and {@code password} can be + * {@code null} if no authentication is necessary. + */ + BasicAuthRestTemplate(String username, String password) { + addAuthentication(username, password) + } + + private void addAuthentication(String username, String password) { + if (!username) { + return; + } + List interceptors = Collections + . singletonList( + new BasicAuthorizationInterceptor(username, password)) + setRequestFactory(new InterceptingClientHttpRequestFactory(getRequestFactory(), + interceptors)) + } + + private static class BasicAuthorizationInterceptor + implements ClientHttpRequestInterceptor { + + private final String username + + private final String password + + BasicAuthorizationInterceptor(String username, String password) { + this.username = username; + this.password = (password == null ? "" : password) + } + + @Override + public ClientHttpResponse intercept(HttpRequest request, byte[] body, + ClientHttpRequestExecution execution) throws IOException { + String token = Base64Utils.encodeToString( + (this.username + ":" + this.password).getBytes(StandardCharsets.UTF_8)) + request.getHeaders().add("Authorization", "Basic " + token) + return execution.execute(request, body) + } + + } + +} diff --git a/initializr/src/main/groovy/io/spring/initializr/stat/ProjectGenerationStatPublisher.groovy b/initializr/src/main/groovy/io/spring/initializr/stat/ProjectGenerationStatPublisher.groovy new file mode 100644 index 00000000..54c48fe2 --- /dev/null +++ b/initializr/src/main/groovy/io/spring/initializr/stat/ProjectGenerationStatPublisher.groovy @@ -0,0 +1,99 @@ +/* + * Copyright 2012-2016 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 + * + * 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.spring.initializr.stat + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.databind.ObjectMapper +import groovy.util.logging.Slf4j +import io.spring.initializr.config.StatsProperties +import io.spring.initializr.generator.ProjectRequestEvent + +import org.springframework.context.event.EventListener +import org.springframework.http.MediaType +import org.springframework.http.RequestEntity +import org.springframework.retry.RetryCallback +import org.springframework.retry.RetryContext +import org.springframework.retry.support.RetryTemplate +import org.springframework.scheduling.annotation.Async +import org.springframework.web.client.RestTemplate + +/** + * Publish stats for each project generated to an Elastic index. + * + * @author Stephane Nicoll + * @since 1.0 + */ +@Slf4j +class ProjectGenerationStatPublisher { + + private final ProjectRequestDocumentFactory documentFactory + private final StatsProperties statsProperties + private final ObjectMapper objectMapper + private final RestTemplate restTemplate + private final RetryTemplate retryTemplate + + ProjectGenerationStatPublisher(ProjectRequestDocumentFactory documentFactory, + StatsProperties statsProperties, + RetryTemplate retryTemplate) { + this.documentFactory = documentFactory + this.statsProperties = statsProperties + this.objectMapper = createObjectMapper() + this.restTemplate = new BasicAuthRestTemplate( + statsProperties.elastic.username, statsProperties.elastic.password) + this.retryTemplate = retryTemplate + } + + @EventListener + @Async + void handleEvent(ProjectRequestEvent event) { + String json = null + try { + ProjectRequestDocument document = documentFactory.createDocument(event) + if (log.isDebugEnabled()) { + log.debug("Publishing $document") + } + json = toJson(document) + + RequestEntity request = RequestEntity + .post(this.statsProperties.elastic.entityUrl) + .contentType(MediaType.APPLICATION_JSON) + .body(json) + + this.retryTemplate.execute(new RetryCallback() { + @Override + Void doWithRetry(RetryContext context) { + restTemplate.exchange(request, String) + return null + } + }) + } catch (Exception ex) { + log.warn(String.format( + "Failed to publish stat to index, document follows %n%n%s%n", json), ex) + } + } + + private String toJson(ProjectRequestDocument stats) { + this.objectMapper.writeValueAsString(stats) + } + + private static ObjectMapper createObjectMapper() { + def mapper = new ObjectMapper() + mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL) + mapper + } + +} diff --git a/initializr/src/main/groovy/io/spring/initializr/stat/ProjectRequestDocument.groovy b/initializr/src/main/groovy/io/spring/initializr/stat/ProjectRequestDocument.groovy new file mode 100644 index 00000000..cf252a6a --- /dev/null +++ b/initializr/src/main/groovy/io/spring/initializr/stat/ProjectRequestDocument.groovy @@ -0,0 +1,56 @@ +/* + * Copyright 2012-2016 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 + * + * 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.spring.initializr.stat + +import groovy.transform.ToString + +/** + * Define the statistics of a project generation. + * + * @author Stephane Nicoll + * @since 1.0 + */ +@ToString(ignoreNulls = true, includePackage = false, includeNames = true) +class ProjectRequestDocument { + + long generationTimestamp + + String requestIp + String requestIpv4 + String requestCountry + String clientId + String clientVersion + + String groupId + String artifactId + String packageName + String bootVersion + String javaVersion + String language + String packaging + String type + final List dependencies = [] + + String errorMessage + boolean invalid + boolean invalidJavaVersion + boolean invalidLanguage + boolean invalidPackaging + boolean invalidType + final List invalidDependencies = [] + +} diff --git a/initializr/src/main/groovy/io/spring/initializr/stat/ProjectRequestDocumentFactory.groovy b/initializr/src/main/groovy/io/spring/initializr/stat/ProjectRequestDocumentFactory.groovy new file mode 100644 index 00000000..012fc4c5 --- /dev/null +++ b/initializr/src/main/groovy/io/spring/initializr/stat/ProjectRequestDocumentFactory.groovy @@ -0,0 +1,149 @@ +/* + * Copyright 2012-2016 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 + * + * 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.spring.initializr.stat + +import java.util.regex.Matcher +import java.util.regex.Pattern + +import io.spring.initializr.generator.ProjectFailedEvent +import io.spring.initializr.generator.ProjectRequest +import io.spring.initializr.generator.ProjectRequestEvent +import io.spring.initializr.metadata.InitializrMetadataProvider +import io.spring.initializr.util.UserAgentWrapper + +import static io.spring.initializr.util.UserAgentWrapper.AgentInformation + +/** + * Create {@link ProjectRequestDocument} instances. + * + * @author Stephane Nicoll + * @since 1.0 + */ +class ProjectRequestDocumentFactory { + + private static final IP_PATTERN = Pattern.compile("[0-9]*\\.[0-9]*\\.[0-9]*\\.[0-9]*") + + private final InitializrMetadataProvider metadataProvider + + ProjectRequestDocumentFactory(InitializrMetadataProvider metadataProvider) { + this.metadataProvider = metadataProvider + } + + ProjectRequestDocument createDocument(ProjectRequestEvent event) { + def metadata = metadataProvider.get() + def request = event.projectRequest + + ProjectRequestDocument document = new ProjectRequestDocument() + document.generationTimestamp = event.timestamp + + handleCloudFlareHeaders(request, document) + def candidate = request.parameters['x-forwarded-for'] + if (!document.requestIp && candidate) { + document.requestIp = candidate + document.requestIpv4 = extractIpv4(candidate) + } + + AgentInformation agentInfo = extractAgentInformation(request) + if (agentInfo) { + document.clientId = agentInfo.id.id + document.clientVersion = agentInfo.version + } + + document.groupId = request.groupId + document.artifactId = request.artifactId + document.packageName = request.packageName + document.bootVersion = request.bootVersion + + document.javaVersion = request.javaVersion + if (request.javaVersion && !metadata.javaVersions.get(request.javaVersion)) { + document.invalid = true + document.invalidJavaVersion = true + } + + document.language = request.language + if (request.language && !metadata.languages.get(request.language)) { + document.invalid = true + document.invalidLanguage = true + } + + document.packaging = request.packaging + if (request.packaging && !metadata.packagings.get(request.packaging)) { + document.invalid = true + document.invalidPackaging = true + } + + document.type = request.type + if (request.type && !metadata.types.get(request.type)) { + document.invalid = true + document.invalidType = true + } + + // Let's not rely on the resolved dependencies here + def dependencies = [] + dependencies.addAll(request.style) + dependencies.addAll(request.dependencies) + dependencies.each { id -> + if (metadata.dependencies.get(id)) { + document.dependencies << id + } else { + document.invalid = true + document.invalidDependencies << id + } + } + + // Let's make sure that the document is flagged as invalid no matter what + if (event instanceof ProjectFailedEvent) { + document.invalid = true + if (event.cause) { + document.errorMessage = event.cause.message + } + } + + document + } + + private static void handleCloudFlareHeaders(ProjectRequest request, ProjectRequestDocument document) { + def candidate = request.parameters['cf-connecting-ip'] + if (candidate) { + document.requestIp = candidate + document.requestIpv4 = extractIpv4(candidate) + } + String country = request.parameters['cf-ipcountry'] + if (country && !country.toLowerCase().equals('xx')) { + document.requestCountry = country + } + } + + private static AgentInformation extractAgentInformation(ProjectRequest request) { + String userAgent = request.parameters['user-agent'] + if (userAgent) { + return new UserAgentWrapper(userAgent).extractAgentInformation() + } + return null + } + + private static String extractIpv4(def candidate) { + if (candidate) { + Matcher matcher = IP_PATTERN.matcher(candidate) + if (matcher.find()) { + return matcher.group() + } + } + return null + } + +} diff --git a/initializr/src/main/groovy/io/spring/initializr/web/MainController.groovy b/initializr/src/main/groovy/io/spring/initializr/web/MainController.groovy index 137bea8f..6ec0a524 100644 --- a/initializr/src/main/groovy/io/spring/initializr/web/MainController.groovy +++ b/initializr/src/main/groovy/io/spring/initializr/web/MainController.groovy @@ -145,7 +145,7 @@ class MainController extends AbstractInitializrController { } private static InitializrMetadataJsonMapper getJsonMapper(InitializrMetadataVersion version) { - switch(version) { + switch (version) { case InitializrMetadataVersion.V2: return new InitializrMetadataV2JsonMapper(); default: return new InitializrMetadataV21JsonMapper(); } diff --git a/initializr/src/main/resources/META-INF/spring-configuration-metadata.json b/initializr/src/main/resources/META-INF/spring-configuration-metadata.json index 73cf097b..c4a3e505 100644 --- a/initializr/src/main/resources/META-INF/spring-configuration-metadata.json +++ b/initializr/src/main/resources/META-INF/spring-configuration-metadata.json @@ -41,6 +41,17 @@ "sourceType": "io.spring.initializr.metadata.InitializrProperties", "sourceMethod": "getPackageName()" }, + { + "name": "initializr.stats", + "type": "io.spring.initializr.config.StatsProperties", + "sourceType": "io.spring.initializr.config.StatsProperties" + }, + { + "name": "initializr.stats.elastic", + "type": "io.spring.initializr.config.StatsProperties$Elastic", + "sourceType": "io.spring.initializr.config.StatsProperties", + "sourceMethod": "getElastic()" + }, { "name": "initializr.version", "type": "io.spring.initializr.metadata.InitializrProperties$SimpleElement", @@ -234,6 +245,43 @@ "description": "Available packaging types.", "sourceType": "io.spring.initializr.metadata.InitializrProperties" }, + { + "name": "initializr.stats.elastic.uri", + "type": "java.lang.String", + "description": "Elastic service uri.", + "sourceType": "io.spring.initializr.config.StatsProperties$Elastic" + }, + { + "name": "initializr.stats.elastic.username", + "type": "java.lang.String", + "description": "Elastic service username.", + "sourceType": "io.spring.initializr.config.StatsProperties$Elastic" + }, + { + "name": "initializr.stats.elastic.password", + "type": "java.lang.String", + "description": "Elastic service password.", + "sourceType": "io.spring.initializr.config.StatsProperties$Elastic" + }, + { + "name": "initializr.stats.elastic.index-name", + "type": "java.lang.String", + "description": "Name of the index.", + "sourceType": "io.spring.initializr.config.StatsProperties$Elastic" + }, + { + "name": "initializr.stats.elastic.entity-name", + "type": "java.lang.String", + "description": "Name of the entity to use to publish stats.", + "sourceType": "io.spring.initializr.config.StatsProperties$Elastic" + }, + { + "name": "initializr.stats.elastic.max-attempts", + "type": "java.lang.Integer", + "description": "Number of attempts before giving up.", + "defaultValue": 3, + "sourceType": "io.spring.initializr.config.StatsProperties$Elastic" + }, { "name": "initializr.types", "type": "java.util.List", diff --git a/initializr/src/main/resources/META-INF/spring.factories b/initializr/src/main/resources/META-INF/spring.factories index 6c786bad..7339ecf6 100644 --- a/initializr/src/main/resources/META-INF/spring.factories +++ b/initializr/src/main/resources/META-INF/spring.factories @@ -1,3 +1,7 @@ org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ io.spring.initializr.config.InitializrAutoConfiguration,\ -io.spring.initializr.config.InitializrMetricsExporterAutoConfiguration \ No newline at end of file +io.spring.initializr.config.InitializrMetricsExporterAutoConfiguration,\ +io.spring.initializr.config.InitializrStatsAutoConfiguration + +org.springframework.boot.env.EnvironmentPostProcessor=\ +io.spring.initializr.config.CloudfoundryEnvironmentPostProcessor diff --git a/initializr/src/test/groovy/io/spring/initializr/config/CloudfoundryEnvironmentPostProcessorTests.groovy b/initializr/src/test/groovy/io/spring/initializr/config/CloudfoundryEnvironmentPostProcessorTests.groovy new file mode 100644 index 00000000..ddcd8058 --- /dev/null +++ b/initializr/src/test/groovy/io/spring/initializr/config/CloudfoundryEnvironmentPostProcessorTests.groovy @@ -0,0 +1,70 @@ +/* + * Copyright 2012-2016 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 + * + * 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.spring.initializr.config + +import org.junit.Test + +import org.springframework.boot.SpringApplication +import org.springframework.mock.env.MockEnvironment + +import static org.hamcrest.CoreMatchers.nullValue +import static org.junit.Assert.assertThat +import static org.hamcrest.CoreMatchers.is + +/** + * @author Stephane Nicoll + */ +class CloudfoundryEnvironmentPostProcessorTests { + + private final CloudfoundryEnvironmentPostProcessor postProcessor = new CloudfoundryEnvironmentPostProcessor() + private final MockEnvironment environment = new MockEnvironment(); + private final SpringApplication application = new SpringApplication() + + @Test + void parseCredentials() { + environment.setProperty('vcap.services.stats-index.credentials.uri', + 'http://user:pass@example.com/bar/biz?param=one') + postProcessor.postProcessEnvironment(environment, application) + + assertThat(environment.getProperty('initializr.stats.elastic.uri'), + is('http://example.com/bar/biz?param=one')) + assertThat(environment.getProperty('initializr.stats.elastic.username'), is('user')) + assertThat(environment.getProperty('initializr.stats.elastic.password'), is('pass')) + } + + @Test + void parseNoCredentials() { + environment.setProperty('vcap.services.stats-index.credentials.uri', + 'http://example.com/bar/biz?param=one') + postProcessor.postProcessEnvironment(environment, application) + + assertThat(environment.getProperty('initializr.stats.elastic.uri'), + is('http://example.com/bar/biz?param=one')) + assertThat(environment.getProperty('initializr.stats.elastic.username'), is(nullValue())) + assertThat(environment.getProperty('initializr.stats.elastic.password'), is(nullValue())) + } + + @Test + void parseNoVcapUri() { + postProcessor.postProcessEnvironment(environment, application) + + assertThat(environment.getProperty('initializr.stats.elastic.uri'), is(nullValue())) + assertThat(environment.getProperty('initializr.stats.elastic.username'), is(nullValue())) + assertThat(environment.getProperty('initializr.stats.elastic.password'), is(nullValue())) + } + +} diff --git a/initializr/src/test/groovy/io/spring/initializr/config/StatsPropertiesTests.groovy b/initializr/src/test/groovy/io/spring/initializr/config/StatsPropertiesTests.groovy new file mode 100644 index 00000000..b2aff988 --- /dev/null +++ b/initializr/src/test/groovy/io/spring/initializr/config/StatsPropertiesTests.groovy @@ -0,0 +1,46 @@ +/* + * Copyright 2012-2016 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 + * + * 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.spring.initializr.config + +import org.junit.Test + +import static org.junit.Assert.assertThat +import static org.hamcrest.CoreMatchers.is + +/** + * @author Stephane Nicoll + */ +class StatsPropertiesTests { + + private final StatsProperties properties = new StatsProperties() + + @Test + void cleanTrailingSlash() { + properties.elastic.uri = 'http://example.com/' + assertThat(properties.elastic.uri, is('http://example.com')) + } + + @Test + void provideEntityUrl() { + properties.elastic.uri = 'http://example.com/' + properties.elastic.indexName = 'my-index' + properties.elastic.entityName = 'foo' + assertThat(properties.elastic.entityUrl.toString(), + is('http://example.com/my-index/foo')) + } + +} diff --git a/initializr/src/test/groovy/io/spring/initializr/stat/AbstractInitializrStatTests.groovy b/initializr/src/test/groovy/io/spring/initializr/stat/AbstractInitializrStatTests.groovy new file mode 100644 index 00000000..afe21d98 --- /dev/null +++ b/initializr/src/test/groovy/io/spring/initializr/stat/AbstractInitializrStatTests.groovy @@ -0,0 +1,52 @@ +/* + * Copyright 2012-2016 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 + * + * 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.spring.initializr.stat + +import io.spring.initializr.generator.ProjectRequest +import io.spring.initializr.metadata.InitializrMetadata +import io.spring.initializr.metadata.InitializrMetadataProvider +import io.spring.initializr.test.InitializrMetadataTestBuilder + +/** + * @author Stephane Nicoll + */ +abstract class AbstractInitializrStatTests { + + def metadata = InitializrMetadataTestBuilder + .withDefaults() + .addDependencyGroup('core', 'security', 'validation', 'aop') + .addDependencyGroup('web', 'web', 'data-rest', 'jersey') + .addDependencyGroup('data', 'data-jpa', 'jdbc') + .addDependencyGroup('database', 'h2', 'mysql') + .build() + + protected InitializrMetadataProvider createProvider(def metadata) { + new InitializrMetadataProvider() { + @Override + InitializrMetadata get() { + return metadata + } + } + } + + protected ProjectRequest createProjectRequest() { + ProjectRequest request = new ProjectRequest() + request.initialize(metadata) + request + } + +} diff --git a/initializr/src/test/groovy/io/spring/initializr/stat/ProjectGenerationStatPublisherTests.groovy b/initializr/src/test/groovy/io/spring/initializr/stat/ProjectGenerationStatPublisherTests.groovy new file mode 100644 index 00000000..0b886fa8 --- /dev/null +++ b/initializr/src/test/groovy/io/spring/initializr/stat/ProjectGenerationStatPublisherTests.groovy @@ -0,0 +1,130 @@ +/* + * Copyright 2012-2016 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 + * + * 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.spring.initializr.stat + +import io.spring.initializr.config.StatsProperties +import io.spring.initializr.generator.ProjectGeneratedEvent +import io.spring.initializr.generator.ProjectRequest +import org.junit.Before +import org.junit.Test + +import org.springframework.http.HttpMethod +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.retry.policy.SimpleRetryPolicy +import org.springframework.retry.support.RetryTemplate +import org.springframework.test.web.client.MockRestServiceServer + +import static org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus + +/** + * @author Stephane Nicoll + */ +class ProjectGenerationStatPublisherTests extends AbstractInitializrStatTests { + + private StatsProperties properties + private RetryTemplate retryTemplate + private ProjectGenerationStatPublisher statPublisher + private MockRestServiceServer mockServer + + + @Before + public void setUp() { + this.properties = createProperties() + ProjectRequestDocumentFactory documentFactory = + new ProjectRequestDocumentFactory(createProvider(metadata)) + this.retryTemplate = new RetryTemplate() + this.statPublisher = new ProjectGenerationStatPublisher(documentFactory, properties, retryTemplate) + mockServer = MockRestServiceServer.createServer(this.statPublisher.restTemplate); + } + + @Test + public void publishSimpleDocument() { + ProjectRequest request = createProjectRequest() + request.groupId = 'com.example.foo' + request.artifactId = 'my-project' + + mockServer.expect(requestTo('http://example.com/elastic/initializr/request')) + .andExpect(method(HttpMethod.POST)) + .andExpect(jsonPath('$.groupId').value('com.example.foo')) + .andExpect(jsonPath('$.artifactId').value('my-project')) + .andRespond(withStatus(HttpStatus.CREATED) + .body(mockResponse(UUID.randomUUID().toString(), true)) + .contentType(MediaType.APPLICATION_JSON) + ) + + this.statPublisher.handleEvent(new ProjectGeneratedEvent(request)) + mockServer.verify() + } + + @Test + public void recoverFromError() { + ProjectRequest request = createProjectRequest() + + mockServer.expect(requestTo('http://example.com/elastic/initializr/request')) + .andExpect(method(HttpMethod.POST)) + .andRespond(withStatus(HttpStatus.INTERNAL_SERVER_ERROR)) + + mockServer.expect(requestTo('http://example.com/elastic/initializr/request')) + .andExpect(method(HttpMethod.POST)) + .andRespond(withStatus(HttpStatus.INTERNAL_SERVER_ERROR)) + + mockServer.expect(requestTo('http://example.com/elastic/initializr/request')) + .andExpect(method(HttpMethod.POST)) + .andRespond(withStatus(HttpStatus.CREATED) + .body(mockResponse(UUID.randomUUID().toString(), true)) + .contentType(MediaType.APPLICATION_JSON)) + + this.statPublisher.handleEvent(new ProjectGeneratedEvent(request)) + mockServer.verify() + } + + @Test + public void fatalErrorOnlyLogs() { + ProjectRequest request = createProjectRequest() + this.retryTemplate.setRetryPolicy(new SimpleRetryPolicy(2, + Collections., Boolean> singletonMap(Exception.class, true))) + + mockServer.expect(requestTo('http://example.com/elastic/initializr/request')) + .andExpect(method(HttpMethod.POST)) + .andRespond(withStatus(HttpStatus.INTERNAL_SERVER_ERROR)) + + mockServer.expect(requestTo('http://example.com/elastic/initializr/request')) + .andExpect(method(HttpMethod.POST)) + .andRespond(withStatus(HttpStatus.INTERNAL_SERVER_ERROR)) + + this.statPublisher.handleEvent(new ProjectGeneratedEvent(request)) + mockServer.verify() + } + + private static String mockResponse(String id, boolean created) { + '{"_index":"initializr","_type":"request","_id":"' + id + '","_version":1,"_shards"' + + ':{"total":1,"successful":1,"failed":0},"created":' + created + '}' + } + + private static StatsProperties createProperties() { + def properties = new StatsProperties() + properties.elastic.uri = 'http://example.com/elastic' + properties.elastic.username = 'foo' + properties.elastic.password = 'bar' + properties + } + +} diff --git a/initializr/src/test/groovy/io/spring/initializr/stat/ProjectRequestDocumentFactoryTests.groovy b/initializr/src/test/groovy/io/spring/initializr/stat/ProjectRequestDocumentFactoryTests.groovy new file mode 100644 index 00000000..ccf631c0 --- /dev/null +++ b/initializr/src/test/groovy/io/spring/initializr/stat/ProjectRequestDocumentFactoryTests.groovy @@ -0,0 +1,219 @@ +/* + * Copyright 2012-2016 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 + * + * 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.spring.initializr.stat + +import io.spring.initializr.generator.ProjectFailedEvent +import io.spring.initializr.generator.ProjectGeneratedEvent +import io.spring.initializr.generator.ProjectRequest +import org.junit.Test + +import static org.junit.Assert.assertEquals +import static org.junit.Assert.assertFalse +import static org.junit.Assert.assertNull +import static org.junit.Assert.assertTrue + +/** + * + * @author Stephane Nicoll + */ +class ProjectRequestDocumentFactoryTests extends AbstractInitializrStatTests { + + private final ProjectRequestDocumentFactory factory = + new ProjectRequestDocumentFactory(createProvider(metadata)) + + @Test + void createDocumentForSimpleProject() { + ProjectRequest request = createProjectRequest() + def event = new ProjectGeneratedEvent(request) + def document = factory.createDocument(event) + assertEquals event.timestamp, document.generationTimestamp + assertEquals null, document.requestIp + assertEquals 'com.example', document.groupId + assertEquals 'demo', document.artifactId + assertEquals 'com.example', document.packageName + assertEquals '1.2.3.RELEASE', document.bootVersion + assertEquals '1.8', document.javaVersion + assertEquals 'java', document.language + assertEquals 'jar', document.packaging + assertEquals 'maven-project', document.type + assertEquals 0, document.dependencies.size() + assertValid document + } + + @Test + void createDocumentWithRequestIp() { + ProjectRequest request = createProjectRequest() + request.parameters['x-forwarded-for'] = '10.0.0.123' + def event = new ProjectGeneratedEvent(request) + def document = factory.createDocument(event) + assertEquals '10.0.0.123', document.requestIp + assertEquals '10.0.0.123', document.requestIpv4 + assertNull document.requestCountry + } + + @Test + void createDocumentWithRequestIpv6() { + ProjectRequest request = createProjectRequest() + request.parameters['x-forwarded-for'] = '2001:db8:a0b:12f0::1' + def event = new ProjectGeneratedEvent(request) + def document = factory.createDocument(event) + assertEquals '2001:db8:a0b:12f0::1', document.requestIp + assertNull document.requestIpv4 + assertNull document.requestCountry + } + + @Test + void createDocumentWithCloudFlareHeaders() { + ProjectRequest request = createProjectRequest() + request.parameters['cf-connecting-ip'] = '10.0.0.123' + request.parameters['cf-ipcountry'] = 'BE' + def event = new ProjectGeneratedEvent(request) + def document = factory.createDocument(event) + assertEquals '10.0.0.123', document.requestIp + assertEquals '10.0.0.123', document.requestIpv4 + assertEquals 'BE', document.requestCountry + } + + @Test + void createDocumentWithCloudFlareIpv6() { + ProjectRequest request = createProjectRequest() + request.parameters['cf-connecting-ip'] = '2001:db8:a0b:12f0::1' + def event = new ProjectGeneratedEvent(request) + def document = factory.createDocument(event) + assertEquals '2001:db8:a0b:12f0::1', document.requestIp + assertNull document.requestIpv4 + assertNull document.requestCountry + } + + @Test + void createDocumentWithCloudFlareHeadersAndOtherHeaders() { + ProjectRequest request = createProjectRequest() + request.parameters['cf-connecting-ip'] = '10.0.0.123' + request.parameters['x-forwarded-for'] = '192.168.1.101' + def event = new ProjectGeneratedEvent(request) + def document = factory.createDocument(event) + assertEquals '10.0.0.123', document.requestIp + assertEquals '10.0.0.123', document.requestIpv4 + assertNull document.requestCountry + } + + @Test + void createDocumentWithCloudFlareCountrySetToXX() { + ProjectRequest request = createProjectRequest() + request.parameters['cf-connecting-ip'] = 'Xx' // case insensitive + def event = new ProjectGeneratedEvent(request) + def document = factory.createDocument(event) + assertNull document.requestCountry + } + + @Test + void createDocumentWithUserAgent() { + ProjectRequest request = createProjectRequest() + request.parameters['user-agent'] = 'HTTPie/0.8.0' + def event = new ProjectGeneratedEvent(request) + def document = factory.createDocument(event) + assertEquals 'httpie', document.clientId + assertEquals '0.8.0', document.clientVersion + } + + @Test + void createDocumentWithUserAgentNoVersion() { + ProjectRequest request = createProjectRequest() + request.parameters['user-agent'] = 'IntelliJ IDEA' + def event = new ProjectGeneratedEvent(request) + def document = factory.createDocument(event) + assertEquals 'intellijidea', document.clientId + assertEquals null, document.clientVersion + } + + @Test + void createDocumentInvalidJavaVersion() { + ProjectRequest request = createProjectRequest() + request.javaVersion = '1.2' + def event = new ProjectGeneratedEvent(request) + def document = factory.createDocument(event) + assertEquals '1.2', document.javaVersion + assertTrue document.invalid + assertTrue document.invalidJavaVersion + } + + @Test + void createDocumentInvalidLanguage() { + ProjectRequest request = createProjectRequest() + request.language = 'c++' + def event = new ProjectGeneratedEvent(request) + def document = factory.createDocument(event) + assertEquals 'c++', document.language + assertTrue document.invalid + assertTrue document.invalidLanguage + } + + @Test + void createDocumentInvalidPackaging() { + ProjectRequest request = createProjectRequest() + request.packaging = 'ear' + def event = new ProjectGeneratedEvent(request) + def document = factory.createDocument(event) + assertEquals 'ear', document.packaging + assertTrue document.invalid + assertTrue document.invalidPackaging + } + + @Test + void createDocumentInvalidType() { + ProjectRequest request = createProjectRequest() + request.type = 'ant-project' + def event = new ProjectGeneratedEvent(request) + def document = factory.createDocument(event) + assertEquals 'ant-project', document.type + assertTrue document.invalid + assertTrue document.invalidType + } + + @Test + void createDocumentInvalidDependency() { + ProjectRequest request = createProjectRequest() + request.dependencies << 'web' << 'invalid' << 'data-jpa' << 'invalid-2' + def event = new ProjectGeneratedEvent(request) + def document = factory.createDocument(event) + assertEquals 'web', document.dependencies[0] + assertEquals 'data-jpa', document.dependencies[1] + assertEquals 2, document.dependencies.size() + assertTrue document.invalid + assertEquals 'invalid', document.invalidDependencies[0] + assertEquals 'invalid-2', document.invalidDependencies[1] + assertEquals 2, document.invalidDependencies.size() + } + + @Test + void createDocumentWithProjectFailedEvent() { + ProjectRequest request = createProjectRequest() + def event = new ProjectFailedEvent(request, new IllegalStateException('my test message')) + def document = factory.createDocument(event) + assertTrue document.invalid + assertEquals 'my test message', document.errorMessage + } + + private static void assertValid(ProjectRequestDocument document) { + assertFalse document.invalid + assertFalse document.invalidJavaVersion + assertFalse document.invalidLanguage + assertFalse document.invalidPackaging + assertEquals 0, document.invalidDependencies.size() + } + +} diff --git a/initializr/src/test/groovy/io/spring/initializr/web/MainControllerStatsIntegrationTests.groovy b/initializr/src/test/groovy/io/spring/initializr/web/MainControllerStatsIntegrationTests.groovy new file mode 100644 index 00000000..fc13b382 --- /dev/null +++ b/initializr/src/test/groovy/io/spring/initializr/web/MainControllerStatsIntegrationTests.groovy @@ -0,0 +1,195 @@ +/* + * Copyright 2012-2016 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 + * + * 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.spring.initializr.web + +import groovy.json.JsonSlurper +import io.spring.initializr.config.StatsProperties +import org.junit.Before +import org.junit.Test + +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.SpringApplicationConfiguration +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.RequestEntity +import org.springframework.test.context.ActiveProfiles +import org.springframework.util.Base64Utils +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestMethod +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.client.HttpClientErrorException + +import static org.junit.Assert.assertEquals +import static org.junit.Assert.assertNotNull +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse +import static org.junit.Assert.fail; + +/** + * @author Stephane Nicoll + */ +@SpringApplicationConfiguration(StatsMockController.class) +@ActiveProfiles(['test-default', 'test-custom-stats']) +class MainControllerStatsIntegrationTests extends AbstractInitializrControllerIntegrationTests { + + @Autowired + private StatsMockController statsMockController + + @Autowired + private StatsProperties statsProperties + + private final JsonSlurper slurper = new JsonSlurper() + + @Before + public void setup() { + this.statsMockController.stats.clear() + // Make sure our mock is going to be invoked with the stats + this.statsProperties.elastic.uri = "http://localhost:$port/elastic" + } + + @Test + void simpleProject() { + downloadArchive('/starter.zip?groupId=com.foo&artifactId=bar&dependencies=web') + assertEquals 'No stat got generated', 1, statsMockController.stats.size() + def content = statsMockController.stats[0] + + def json = slurper.parseText(content.json) + assertEquals 'com.foo', json.groupId + assertEquals 'bar', json.artifactId + assertEquals 1, json.dependencies.size() + assertEquals 'web', json.dependencies[0] + } + + @Test + void authorizationHeaderIsSet() { + downloadArchive('/starter.zip') + assertEquals 'No stat got generated', 1, statsMockController.stats.size() + def content = statsMockController.stats[0] + + def authorization = content.authorization + assertNotNull 'Authorization header must be set', authorization + assertTrue 'Wrong value for authorization header', authorization.startsWith('Basic ') + def token = authorization.substring('Basic '.length(), authorization.size()) + def data = new String(Base64Utils.decodeFromString(token)).split(':') + assertEquals "Wrong user from $token", 'test-user', data[0] + assertEquals "Wrong password $token", 'test-password', data[1] + } + + @Test + void requestIpNotSetByDefault() { + downloadArchive('/starter.zip?groupId=com.foo&artifactId=bar&dependencies=web') + assertEquals 'No stat got generated', 1, statsMockController.stats.size() + def content = statsMockController.stats[0] + + def json = slurper.parseText(content.json) + assertFalse 'requestIp property should not be set', json.containsKey('requestIp') + } + + @Test + void requestIpIsSetWhenHeaderIsPresent() { + RequestEntity request = RequestEntity.get(new URI(createUrl('/starter.zip'))) + .header('X-FORWARDED-FOR', '10.0.0.123').build() + restTemplate.exchange(request, String) + assertEquals 'No stat got generated', 1, statsMockController.stats.size() + def content = statsMockController.stats[0] + + def json = slurper.parseText(content.json) + assertEquals 'Wrong requestIp', '10.0.0.123', json.requestIp + } + + @Test + void requestIpv4IsNotSetWhenHeaderHasGarbage() { + RequestEntity request = RequestEntity.get(new URI(createUrl('/starter.zip'))) + .header('x-forwarded-for', 'foo-bar').build() + restTemplate.exchange(request, String) + assertEquals 'No stat got generated', 1, statsMockController.stats.size() + def content = statsMockController.stats[0] + + def json = slurper.parseText(content.json) + assertFalse 'requestIpv4 property should not be set if value is not a valid IPv4', + json.containsKey('requestIpv4') + } + + @Test + void requestCountryIsNotSetWhenHeaderIsSetToXX() { + RequestEntity request = RequestEntity.get(new URI(createUrl('/starter.zip'))) + .header('cf-ipcountry', 'XX').build() + restTemplate.exchange(request, String) + assertEquals 'No stat got generated', 1, statsMockController.stats.size() + def content = statsMockController.stats[0] + + def json = slurper.parseText(content.json) + assertFalse 'requestCountry property should not be set if value is set to xx', + json.containsKey('requestCountry') + } + + @Test + void invalidProjectSillHasStats() { + try { + downloadArchive('/starter.zip?type=invalid-type') + fail("Should have failed to generate project with invalid type") + } catch (HttpClientErrorException ex) { + assertEquals HttpStatus.BAD_REQUEST, ex.statusCode + } + assertEquals 'No stat got generated', 1, statsMockController.stats.size() + def content = statsMockController.stats[0] + + def json = slurper.parseText(content.json) + assertEquals 'com.example', json.groupId + assertEquals 'demo', json.artifactId + assertEquals true, json.invalid + assertEquals true, json.invalidType + assertNotNull json.errorMessage + assertTrue json.errorMessage.contains('invalid-type') + } + + @Test + void errorPublishingStatsDoesNotBubbleUp() { + this.statsProperties.elastic.uri = "http://localhost:$port/elastic-error" + downloadArchive('/starter.zip') + assertEquals 'No stat should be available', 0, statsMockController.stats.size() + } + + + @RestController + static class StatsMockController { + + private final List stats = [] + + @RequestMapping(path = '/elastic/test/my-entity', method = RequestMethod.POST) + void handleProjectRequestDocument(RequestEntity input) { + def authorization = input.headers.getFirst(HttpHeaders.AUTHORIZATION) + def content = new Content(authorization: authorization, json: input.body) + this.stats << content + } + + @RequestMapping(path = '/elastic-error/test/my-entity', method = RequestMethod.POST) + void handleExpectedError() { + throw new IllegalStateException('Expected exception') + } + + static class Content { + + String authorization + + String json + + } + + } + +} diff --git a/initializr/src/test/resources/application-test-custom-stats.yml b/initializr/src/test/resources/application-test-custom-stats.yml new file mode 100644 index 00000000..8868dc38 --- /dev/null +++ b/initializr/src/test/resources/application-test-custom-stats.yml @@ -0,0 +1,9 @@ +initializr: + stats: + elastic: + uri: http://localhost:${server.port}/elastic + indexName: test + entityName: my-entity + username: test-user + password: test-password + max-attempts: 1 \ No newline at end of file