Publish stats on project generation

If an elastic instance is available, publish a document to a
configurable index every time a `ProjectRequest` is handled by the
service.

In practice, this means that every attempt to generate a project leads to
a new document in the index. The document gathers the settings of the
required project, including invalid ones if any. If an exception is
thrown, the message of the cause is made available.

CloudFlare is explicitely supported and the IP and country of the request
is added to the document. If that information is not available and the
request contains a `X-Forwarded-For` header, the value is also associated
with the document. If an IPv4 is detected, it is set in a separate
`requestIpv4` property.

If for some reason the document could not be indexed, we attempt to retry
a configurable amount of times.

Closes gh-185
This commit is contained in:
Stephane Nicoll
2016-01-31 10:54:07 +01:00
parent 7f6d348a3b
commit 4d2fbfe856
20 changed files with 1438 additions and 2 deletions

View File

@@ -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
}
}
}

View File

@@ -32,6 +32,11 @@
<artifactId>spring-boot-starter-redis</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
@@ -85,6 +90,10 @@
<artifactId>jsonassert</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.jayway.jsonpath</groupId>
<artifactId>json-path</artifactId>
</dependency>
<dependency>
<groupId>org.gebish</groupId>
<artifactId>geb-core</artifactId>

View File

@@ -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<String,Object> 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<String, Object> 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);
}
}
}

View File

@@ -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
.<Class<? extends Throwable>, Boolean> singletonMap(Exception.class, true))
retryTemplate.setBackOffPolicy(backOffPolicy)
retryTemplate.setRetryPolicy(retryPolicy)
retryTemplate
}
}

View File

@@ -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
}
}
}

View File

@@ -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 {
/**

View File

@@ -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.
* <p>
* 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<ClientHttpRequestInterceptor> interceptors = Collections
.<ClientHttpRequestInterceptor> 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)
}
}
}

View File

@@ -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<String> request = RequestEntity
.post(this.statsProperties.elastic.entityUrl)
.contentType(MediaType.APPLICATION_JSON)
.body(json)
this.retryTemplate.execute(new RetryCallback<Void, RuntimeException>() {
@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
}
}

View File

@@ -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<String> dependencies = []
String errorMessage
boolean invalid
boolean invalidJavaVersion
boolean invalidLanguage
boolean invalidPackaging
boolean invalidType
final List<String> invalidDependencies = []
}

View File

@@ -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
}
}

View File

@@ -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();
}

View File

@@ -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<io.spring.initializr.metadata.Type>",

View File

@@ -1,3 +1,7 @@
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
io.spring.initializr.config.InitializrAutoConfiguration,\
io.spring.initializr.config.InitializrMetricsExporterAutoConfiguration
io.spring.initializr.config.InitializrMetricsExporterAutoConfiguration,\
io.spring.initializr.config.InitializrStatsAutoConfiguration
org.springframework.boot.env.EnvironmentPostProcessor=\
io.spring.initializr.config.CloudfoundryEnvironmentPostProcessor

View File

@@ -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()))
}
}

View File

@@ -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'))
}
}

View File

@@ -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
}
}

View File

@@ -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.<Class<? extends Throwable>, 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
}
}

View File

@@ -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()
}
}

View File

@@ -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<Content> stats = []
@RequestMapping(path = '/elastic/test/my-entity', method = RequestMethod.POST)
void handleProjectRequestDocument(RequestEntity<String> 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
}
}
}

View File

@@ -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