From: Kai Moritz Date: Mon, 13 Jun 2016 12:52:51 +0000 (+0200) Subject: autoconfigure X-Git-Tag: alt~34 X-Git-Url: https://juplo.de/gitweb/?p=maven-thymeleaf-skin;a=commitdiff_plain;h=84714f95d5733369012c304331010dbdcb5bd7e1 autoconfigure --- 84714f95d5733369012c304331010dbdcb5bd7e1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a6f89c2 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target/ \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..031167a --- /dev/null +++ b/pom.xml @@ -0,0 +1,115 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 1.3.5.RELEASE + + + de.juplo + thymeproxy + 1.0-SNAPSHOT + + + + + 1.8 + + + UTF-8 + 1.8 + 1.8 + UTF-8 + + + ${project.name} + http://localhost:8080 + 80 + 300000 + + + 1.0-SNAPSHOT + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + de.juplo + httpclient-spring-boot-starter + ${httpclient-spring-boot-starter.version} + + + + + org.apache.httpcomponents + httpclient + + + + + net.sourceforge.nekohtml + nekohtml + ${nekohtml.version} + + + + + org.springframework + spring-test + test + + + org.springframework.boot + spring-boot-configuration-processor + test + + + junit + junit + test + + + org.slf4j + jcl-over-slf4j + test + + + ch.qos.logback + logback-classic + test + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + false + + + + org.springframework + springloaded + 1.2.4.RELEASE + + + + + + + diff --git a/src/main/java/de/juplo/autoconfigure/ThymeproxyAutoConfiguration.java b/src/main/java/de/juplo/autoconfigure/ThymeproxyAutoConfiguration.java new file mode 100644 index 0000000..d81b31d --- /dev/null +++ b/src/main/java/de/juplo/autoconfigure/ThymeproxyAutoConfiguration.java @@ -0,0 +1,77 @@ +package de.juplo.autoconfigure; + + +import de.juplo.autoconfigure.ThymeproxyProperties.Origin; +import de.juplo.thymeproxy.ProxyTemplateResolver; +import org.apache.http.impl.client.CloseableHttpClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.AutoConfigureBefore; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.thymeleaf.ThymeleafAutoConfiguration; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + + +/** + * Automatic configuration + * + * @author Kai Moritz + */ +@Configuration +@EnableConfigurationProperties(ThymeproxyProperties.class) +@ConditionalOnClass(value = ThymeleafAutoConfiguration.class) +@AutoConfigureBefore(ThymeleafAutoConfiguration.class) +public class ThymeproxyAutoConfiguration +{ + private static final Logger LOG = + LoggerFactory.getLogger(ThymeproxyAutoConfiguration.class); + + + @Bean + @ConditionalOnProperty("thymeproxy.origins[0].uri") + public ProxyTemplateResolver defaultTemplateResolver( + CloseableHttpClient client, + ThymeproxyProperties properties, + ConfigurableApplicationContext context + ) + { + LOG.info("configuring {} proxies", properties.origins.size()); + + Origin origin = properties.origins.get(0); + String uri = origin.uri.toString(); + ProxyTemplateResolver defaultResolver = + new ProxyTemplateResolver( + "0: " + origin.uri.getHost(), + 0, + client, + uri, + origin.ttl + ); + LOG.info("registering defaultTemplateResolver for {}", uri); + + for (int i=1; i origins = new LinkedList<>(); + + + public void setName(String name) + { + this.name = name; + } + + public List getOrigins() + { + return origins; + } + + public void setOrigins(List origins) + { + this.origins = origins; + } + + + public static class Origin + { + @NotEmpty + URI uri; + List patterns = new LinkedList<>(); + Long ttl; + + + public void setUri(URI uri) + { + this.uri = uri; + } + + public List getPatterns() + { + return this.patterns; + } + + public void setPatterns(List patterns) + { + this.patterns = patterns; + } + + public void setTtl(Long ttl) + { + this.ttl = ttl; + } + } +} diff --git a/src/main/java/de/juplo/thymeproxy/ProxyResourceResolver.java b/src/main/java/de/juplo/thymeproxy/ProxyResourceResolver.java new file mode 100644 index 0000000..5e24034 --- /dev/null +++ b/src/main/java/de/juplo/thymeproxy/ProxyResourceResolver.java @@ -0,0 +1,123 @@ +package de.juplo.thymeproxy; + + +import java.io.IOException; +import java.io.InputStream; +import org.apache.http.HttpEntity; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.util.EntityUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.thymeleaf.TemplateProcessingParameters; +import org.thymeleaf.resourceresolver.IResourceResolver; + + + +/** + * + * @author kai + */ +public class ProxyResourceResolver implements IResourceResolver +{ + private final static Logger LOG = + LoggerFactory.getLogger(ProxyResourceResolver.class); + + + private final String resource; + + private final CloseableHttpResponse response; + private final HttpEntity entity; + + + public ProxyResourceResolver( + String resource, + CloseableHttpResponse response, + HttpEntity entity + ) + { + this.resource = resource; + this.response = response; + this.entity = entity; + } + + + @Override + public String getName() + { + return resource; + } + + @Override + public InputStream getResourceAsStream(TemplateProcessingParameters templateProcessingParameters, String resourceName) + { + InputStream is; + try + { + is = entity.getContent(); + } + catch (IOException e) + { + LOG.error("unexpected error while retriving the response-body", e); + return null; + } + + return new InputStream() + { + @Override + public boolean markSupported() + { + return is.markSupported(); + } + + @Override + public synchronized void reset() throws IOException + { + is.reset(); + } + + @Override + public synchronized void mark(int readlimit) + { + is.mark(readlimit); + } + + @Override + public void close() throws IOException + { + is.close(); + EntityUtils.consume(entity); + response.close(); + } + + @Override + public int available() throws IOException + { + return is.available(); + } + + @Override + public long skip(long n) throws IOException + { + return is.skip(n); + } + + @Override + public int read() throws IOException + { + return is.read(); + } + + @Override + public int read(byte[] b, int off, int len) throws IOException + { + return is.read(b, off, len); + } + + @Override + public int read(byte[] b) throws IOException + { + return is.read(b); + } + }; + } +} diff --git a/src/main/java/de/juplo/thymeproxy/ProxyTemplateResolver.java b/src/main/java/de/juplo/thymeproxy/ProxyTemplateResolver.java new file mode 100644 index 0000000..c6601a6 --- /dev/null +++ b/src/main/java/de/juplo/thymeproxy/ProxyTemplateResolver.java @@ -0,0 +1,297 @@ +package de.juplo.thymeproxy; + + +import java.io.IOException; +import java.time.Clock; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.regex.Pattern; +import javax.servlet.http.HttpServletResponse; +import org.apache.http.Header; +import org.apache.http.HeaderElement; +import org.apache.http.HttpEntity; +import org.apache.http.HttpResponse; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.entity.ContentType; +import org.apache.http.impl.client.CloseableHttpClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.thymeleaf.TemplateProcessingParameters; +import org.thymeleaf.templatemode.StandardTemplateModeHandlers; +import org.thymeleaf.templateresolver.ITemplateResolutionValidity; +import org.thymeleaf.templateresolver.ITemplateResolver; +import org.thymeleaf.templateresolver.NonCacheableTemplateResolutionValidity; +import org.thymeleaf.templateresolver.TTLTemplateResolutionValidity; +import org.thymeleaf.templateresolver.TemplateResolution; + + + +/** + * + * @author Kai Moritz + */ +public class ProxyTemplateResolver implements ITemplateResolver +{ + private final static Logger LOG = + LoggerFactory.getLogger(ProxyTemplateResolver.class); + + private final static Pattern HTML = + Pattern.compile("text/html", Pattern.CASE_INSENSITIVE); + private final static Pattern XHTML = + Pattern.compile("application/xhtml+xml", Pattern.CASE_INSENSITIVE); + private final static Pattern XML = + Pattern.compile("(?:/|\\+)xml$", Pattern.CASE_INSENSITIVE); + + public final static Long DEFAULT_TTL = 300000l; /** 5 minutes */ + + private final String name; + private final Integer order; + + private final CloseableHttpClient client; + private final String origin; + private final Long ttl; + private final Clock clock; + + + public ProxyTemplateResolver( + String name, + Integer order, + CloseableHttpClient client, + String origin, + Long ttl, + Clock clock + ) + { + super(); + this.name = name; + this.order = order; + this.client = client; + this.origin = origin; + this.ttl = ttl; + this.clock = clock; + } + + public ProxyTemplateResolver( + String name, + Integer order, + CloseableHttpClient client, + String origin, + Long ttl + ) + { + this(name, order, client, origin, ttl, Clock.systemDefaultZone()); + } + + public ProxyTemplateResolver( + String name, + Integer order, + CloseableHttpClient client, + String origin + ) + { + this(name, order, client, origin, DEFAULT_TTL, Clock.systemDefaultZone()); + } + + + @Override + public String getName() + { + return name; + } + + @Override + public Integer getOrder() + { + return order; + } + + public String getOrigin() + { + return origin; + } + + public Long getDefaultTTL() + { + return ttl; + } + + @Override + public TemplateResolution resolveTemplate(TemplateProcessingParameters params) + { + StringBuilder builder = new StringBuilder(); + builder.append(origin); + builder.append(params.getTemplateName()); + + String resource = builder.toString(); + + try + { + HttpGet request = new HttpGet(resource); + CloseableHttpResponse response = client.execute(request); + + switch (response.getStatusLine().getStatusCode()) + { + case HttpServletResponse.SC_FOUND: + case HttpServletResponse.SC_OK: + /** OK. Continue as normal... */ + break; + + case HttpServletResponse.SC_NOT_FOUND: + case HttpServletResponse.SC_GONE: + /** The resource can not be resolved through this origin */ + return null; + + case HttpServletResponse.SC_MOVED_PERMANENTLY: + // TODO + + case HttpServletResponse.SC_SEE_OTHER: + // TODO + + case HttpServletResponse.SC_TEMPORARY_REDIRECT: + // TODO + + default: + LOG.error("{} -- {}", response.getStatusLine(), resource); + // TODO: throw sensible exceptions, to communicate resolving-errors + throw new RuntimeException(response.getStatusLine().toString() + " -- " + resource); + } + + HttpEntity entity = response.getEntity(); + ContentType content = ContentType.getOrDefault(entity); + + return new TemplateResolution( + params.getTemplateName(), + resource, + new ProxyResourceResolver(resource, response, entity), + content.getCharset().displayName(), + ProxyTemplateResolver.computeTemplateMode(content), + computeValidity(response) + ); + } + catch (IOException e) + { + LOG.error("unexpected error while resolving {}: {}", resource, e.getMessage()); + // TODO: throw sensible exceptions, to communicate resolving-errors + throw new RuntimeException(e); + } + } + + @Override + public void initialize() + { + } + + + public static String computeTemplateMode(ContentType content) + { + String type = content.getMimeType(); + + if (HTML.matcher(type).matches()) + return StandardTemplateModeHandlers.LEGACYHTML5.getTemplateModeName(); + + if (XML.matcher(type).find()) + { + if (XHTML.matcher(type).matches()) + return StandardTemplateModeHandlers.XHTML.getTemplateModeName(); + else + return StandardTemplateModeHandlers.XML.getTemplateModeName(); + } + + throw new RuntimeException("Cannot handle mime-type " + type); + } + + public ITemplateResolutionValidity computeValidity(HttpResponse response) + { + if (ttl == null) + /** Caching is disabled! */ + return NonCacheableTemplateResolutionValidity.INSTANCE; + + boolean cacheable = true; + Integer max_age = null; + + for (Header header : response.getHeaders("Cache-Control")) + { + for (HeaderElement element : header.getElements()) + { + switch (element.getName()) + { + case "no-cache": + case "no-store": + case "must-revalidate": + cacheable = false; + break; + + case "max-age": + try + { + max_age = Integer.parseInt(element.getValue()); + } + catch (NumberFormatException e) + { + LOG.warn( + "invalid header \"Cache-Control: max-age={}\"", + element.getValue() + ); + } + break; + } + } + } + + if (max_age != null && max_age < 1) + cacheable = false; + + if (!cacheable) + return NonCacheableTemplateResolutionValidity.INSTANCE; + + if (max_age != null) + { + long millis = max_age; + if (millis >= Long.MAX_VALUE / 1000l ) + millis = Long.MAX_VALUE; + else + millis = millis * 1000l; + return new TTLTemplateResolutionValidity(millis); + } + + Header header = response.getLastHeader("Expires"); + if (header == null) + /** No TTL specified in response-headers: use configured default */ + return new TTLTemplateResolutionValidity(ttl); + + try + { + OffsetDateTime expires = + OffsetDateTime.parse( + header.getValue(), + DateTimeFormatter.RFC_1123_DATE_TIME + ); + + Duration delta = Duration.between(OffsetDateTime.now(clock), expires); + if (delta.isNegative() || delta.isZero()) + return NonCacheableTemplateResolutionValidity.INSTANCE; + + long millis = delta.getSeconds(); + if (millis >= Long.MAX_VALUE / 1000l) + millis = Long.MAX_VALUE; + else + { + millis = millis * 1000; + millis = millis + (long)(delta.getNano() / 1000000); + } + return new TTLTemplateResolutionValidity(millis); + } + catch (DateTimeParseException e) + { + LOG.warn("invalid header \"Expires: {}\"", header.getValue()); + /** + * No TTL specified in response-headers: assume expired + * (see: https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.21) + */ + return NonCacheableTemplateResolutionValidity.INSTANCE; + } + } +} diff --git a/src/main/resources/META-INF/spring.factories b/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..f6c92b4 --- /dev/null +++ b/src/main/resources/META-INF/spring.factories @@ -0,0 +1 @@ +org.springframework.boot.autoconfigure.EnableAutoConfiguration=de.juplo.autoconfiguration.ThymeproxyAutoConfiguration diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..dd442f2 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,4 @@ +thymeproxy.name=@thymeproxy.name@ +thymeproxy.origin=@thymeproxy.origin@ +server.port=@thymeproxy.port@ +thymeproxy.ttl=@thymeproxy.ttl@ diff --git a/src/main/resources/log4j.xml b/src/main/resources/log4j.xml new file mode 100644 index 0000000..a3d7d44 --- /dev/null +++ b/src/main/resources/log4j.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/java/de/juplo/autoconfigure/ThymeproxyAutoConfigurationTest.java b/src/test/java/de/juplo/autoconfigure/ThymeproxyAutoConfigurationTest.java new file mode 100644 index 0000000..9360b7c --- /dev/null +++ b/src/test/java/de/juplo/autoconfigure/ThymeproxyAutoConfigurationTest.java @@ -0,0 +1,230 @@ +package de.juplo.autoconfigure; + + +import de.juplo.thymeproxy.ProxyTemplateResolver; +import java.net.URI; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import org.junit.Test; +import org.springframework.context.annotation.Configuration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeansException; +import org.springframework.boot.autoconfigure.logging.AutoConfigurationReportLoggingInitializer; +import org.springframework.boot.autoconfigure.thymeleaf.ThymeleafAutoConfiguration; +import org.springframework.boot.test.EnvironmentTestUtils; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.thymeleaf.templateresolver.ITemplateResolver; +import org.thymeleaf.templateresolver.TemplateResolver; + + + +public class ThymeproxyAutoConfigurationTest +{ + private final Logger LOG = + LoggerFactory.getLogger(ThymeproxyAutoConfigurationTest.class); + + + @Test + public void propertyBinding() throws Exception + { + LOG.info("<-- Start Of New Test-Case!"); + + ConfigurableApplicationContext context; + ThymeproxyProperties properties; + + context = load( + EmptyConfiguration.class, + "thymeproxy.name=Thymeproxy", + "thymeproxy.origins[0].uri=http://localhost:8080/test/", + "thymeproxy.origins[0].patterns[0]=^/css/", + "thymeproxy.origins[0].patterns[1]=^/img/", + "thymeproxy.origins[0].patterns[2]=*\\.xml$", + "thymeproxy.origins[1].uri=http://127.0.0.1:8081", + "thymeproxy.origins[1].ttl=30000" + ); + properties = context.getBean(ThymeproxyProperties.class); + assertNotNull(properties); + assertEquals("Thymeproxy", properties.name); + assertNotNull(properties.origins); + assertEquals(2, properties.origins.size()); + assertEquals(new URI("http://localhost:8080/test/"), properties.origins.get(0).uri); + assertNotNull(properties.origins.get(0).patterns); + assertEquals(3, properties.origins.get(0).patterns.size()); + assertEquals("^/css/", properties.origins.get(0).patterns.get(0)); + assertEquals("^/img/", properties.origins.get(0).patterns.get(1)); + assertEquals("*\\.xml$", properties.origins.get(0).patterns.get(2)); + assertNull(properties.origins.get(0).ttl); + assertEquals(new URI("http://127.0.0.1:8081"), properties.origins.get(1).uri); + assertNotNull(properties.origins.get(1).patterns); + assertEquals(0, properties.origins.get(1).patterns.size()); + assertEquals(new Long(30000l), properties.origins.get(1).ttl); + context.close(); + } + + @Test + public void defaultConfiguration() + { + LOG.info("<-- Start Of New Test-Case!"); + + ConfigurableApplicationContext context = load(EmptyConfiguration.class); + + ITemplateResolver resolver = + (ITemplateResolver)context.getBean("defaultTemplateResolver"); + assertNotNull(resolver); + assertTrue( + "Expected an instance of type TemplateResolver", + resolver instanceof TemplateResolver + ); + assertNotEquals(DefaultTemplateResolverConfiguration.RESOLVER, resolver); + + context.close(); + } + + @Test + public void defaultTemplateResolverConfigured() + { + LOG.info("<-- Start Of New Test-Case!"); + + ConfigurableApplicationContext context = + load(DefaultTemplateResolverConfiguration.class); + + ITemplateResolver resolver = + (ITemplateResolver)context.getBean("defaultTemplateResolver"); + assertNotNull(resolver); + assertTrue( + "Expected an instance of type TemplateResolver", + resolver instanceof TemplateResolver + ); + assertEquals(DefaultTemplateResolverConfiguration.RESOLVER, resolver); + + context.close(); + } + + @Test + public void proxiesConfigured() + { + LOG.info("<-- Start Of New Test-Case!"); + + ConfigurableApplicationContext context = load( + EmptyConfiguration.class, + "thymeproxy.name=Thymeproxy", + "thymeproxy.origins[0].uri=http://localhost:8080/test/", + "thymeproxy.origins[0].patterns[0]=^/css/", + "thymeproxy.origins[0].patterns[1]=^/img/", + "thymeproxy.origins[0].patterns[2]=*\\.xml$", + "thymeproxy.origins[1].uri=http://127.0.0.1:8081", + "thymeproxy.origins[1].ttl=30000" + ); + + ITemplateResolver resolver; + ProxyTemplateResolver proxy; + + resolver = (ITemplateResolver)context.getBean("defaultTemplateResolver"); + assertNotNull(resolver); + assertTrue( + "Expected an instance of type ProxyTemplateResolver", + resolver instanceof ProxyTemplateResolver + ); + proxy = (ProxyTemplateResolver)resolver; + assertEquals("0: localhost", proxy.getName()); + assertEquals(new Integer(0), proxy.getOrder()); + assertEquals("http://localhost:8080/test/", proxy.getOrigin()); + assertNull(proxy.getDefaultTTL()); + + resolver = (ITemplateResolver)context.getBean("proxy1"); + assertNotNull(resolver); + assertTrue( + "Expected an instance of type ProxyTemplateResolver", + resolver instanceof ProxyTemplateResolver + ); + proxy = (ProxyTemplateResolver)resolver; + assertEquals("1: 127.0.0.1", proxy.getName()); + assertEquals(new Integer(1), proxy.getOrder()); + assertEquals("http://127.0.0.1:8081", proxy.getOrigin()); + assertEquals(new Long(30000l), proxy.getDefaultTTL()); + + context.close(); + } + + @Test + public void proxiesAndDefaultTemplateResolverConfigured() + { + LOG.info("<-- Start Of New Test-Case!"); + + ConfigurableApplicationContext context = load( + DefaultTemplateResolverConfiguration.class, + "thymeproxy.name=Thymeproxy", + "thymeproxy.origins[0].uri=http://localhost:8080/test/", + "thymeproxy.origins[0].patterns[0]=^/css/", + "thymeproxy.origins[0].patterns[1]=^/img/", + "thymeproxy.origins[0].patterns[2]=*\\.xml$", + "thymeproxy.origins[1].uri=http://127.0.0.1:8081", + "thymeproxy.origins[1].ttl=30000" + ); + + ITemplateResolver resolver; + ProxyTemplateResolver proxy; + + resolver = (ITemplateResolver)context.getBean("defaultTemplateResolver"); + assertNotNull(resolver); + assertTrue( + "Expected an instance of type TemplateResolver", + resolver instanceof TemplateResolver + ); + assertEquals(DefaultTemplateResolverConfiguration.RESOLVER, resolver); + + try + { + resolver = (ITemplateResolver)context.getBean("proxy1"); + fail("Found bean for name proxy1: " + resolver); + } + catch (BeansException e) + { + LOG.info(e.toString()); + } + + context.close(); + } + + + @Configuration + static class EmptyConfiguration + { + } + + @Configuration + static class DefaultTemplateResolverConfiguration + { + public static TemplateResolver RESOLVER = new TemplateResolver(); + + @Bean + public TemplateResolver defaultTemplateResolver() + { + return RESOLVER; + } + } + + + private ConfigurableApplicationContext load(Class config, String... pairs) + { + AnnotationConfigApplicationContext ctx = + new AnnotationConfigApplicationContext(); + EnvironmentTestUtils.addEnvironment(ctx, pairs); + ctx.register(HttpClientAutoConfiguration.class); + ctx.register(ThymeleafAutoConfiguration.class); + ctx.register(ThymeproxyAutoConfiguration.class); + ctx.register(config); + AutoConfigurationReportLoggingInitializer report = + new AutoConfigurationReportLoggingInitializer(); + report.initialize(ctx); + ctx.refresh(); + return ctx; + } +} diff --git a/src/test/resources/logback.xml b/src/test/resources/logback.xml new file mode 100644 index 0000000..e6f6c58 --- /dev/null +++ b/src/test/resources/logback.xml @@ -0,0 +1,23 @@ + + + + + + %p - %c{0}.%M\(%L\) | %m%n + + + + + + + + + + + + + + + + +