diff --git a/demos/pom.xml b/demos/pom.xml index 31b5105286..50209cbeae 100644 --- a/demos/pom.xml +++ b/demos/pom.xml @@ -37,6 +37,7 @@ getpath googleappengine perftest + runatcloud-tweetstore speedtest-azurequeue speedtest-sqs simpledb diff --git a/demos/runatcloud-tweetstore/pom.xml b/demos/runatcloud-tweetstore/pom.xml new file mode 100644 index 0000000000..93f30642fc --- /dev/null +++ b/demos/runatcloud-tweetstore/pom.xml @@ -0,0 +1,274 @@ + + + + 4.0.0 + + org.jclouds + jclouds-demos-project + 1.0-SNAPSHOT + + jclouds-demo-runatcloud-tweetstore + war + jclouds TweetStore for RUN@cloud + jclouds TweetStore for CloudBees' RUN@cloud using Guice for Dependency Injection + + + tweetstore + run + localhost + 8088 + cloudfiles-us,aws-s3,azureblob + jclouds-tweetstore + + + + + ${project.groupId} + jclouds-blobstore + ${project.version} + + + org.twitter4j + twitter4j-core + [2.1,) + + + ${project.groupId} + jclouds-blobstore + ${project.version} + test-jar + test + + + org.jclouds.provider + aws-s3 + ${project.version} + runtime + + + org.jclouds.provider + cloudfiles-us + ${project.version} + runtime + + + org.jclouds.provider + azureblob + ${project.version} + runtime + + + com.google.inject.extensions + guice-servlet + 3.0 + + + displaytag + displaytag + 1.2 + runtime + + + org.slf4j + slf4j-log4j12 + + + + + org.slf4j + slf4j-jdk14 + 1.5.6 + runtime + + + jstl + javax.servlet + 1.1.2 + runtime + + + standard + taglibs + 1.1.2 + runtime + + + javax.servlet + servlet-api + 2.5 + provided + + + + + net.stax + stax-appserver + 1.0.20110131-SNAPSHOT + test + + + + + + bees-plugins-snapshots + http://repository-cloudbees.forge.cloudbees.com/public-snapshot/ + + false + + + true + + + + + + ${project.artifactId} + + + maven-surefire-plugin + + + integration + integration-test + + test + + + + + bees.address + ${bees.address} + + + bees.port + ${bees.port} + + + bees.environment + ${bees.environment} + + + bees.basedir + ${project.build.directory}/bees + + + warfile + ${project.build.directory}/${project.artifactId} + + + + + + + + com.cloudbees + bees-maven-plugin + 1.0-SNAPSHOT + + + + + + + live + + + + maven-surefire-plugin + + + integration + integration-test + + test + + + + + test.twitter.identity + ${test.twitter.identity} + + + test.twitter.credential + ${test.twitter.credential} + + + test.azureblob.identity + ${test.azureblob.identity} + + + test.azureblob.credential + ${test.azureblob.credential} + + + test.cloudfiles-us.identity + ${test.cloudfiles-us.identity} + + + test.cloudfiles-us.credential + ${test.cloudfiles-us.credential} + + + test.aws-s3.identity + ${test.aws-s3.identity} + + + test.aws-s3.credential + ${test.aws-s3.credential} + + + bees.address + ${bees.address} + + + bees.port + ${bees.port} + + + bees.environment + ${bees.environment} + + + jclouds.tweetstore.blobstores + ${jclouds.tweetstore.blobstores} + + + jclouds.tweetstore.container + ${jclouds.tweetstore.container} + + + bees.basedir + ${project.build.directory}/bees + + + warfile + ${project.build.directory}/${project.artifactId} + + + + + + + + + + + diff --git a/demos/runatcloud-tweetstore/src/main/java/org/jclouds/demo/tweetstore/config/GuiceServletConfig.java b/demos/runatcloud-tweetstore/src/main/java/org/jclouds/demo/tweetstore/config/GuiceServletConfig.java new file mode 100644 index 0000000000..3af47b0c2f --- /dev/null +++ b/demos/runatcloud-tweetstore/src/main/java/org/jclouds/demo/tweetstore/config/GuiceServletConfig.java @@ -0,0 +1,154 @@ +/** + * + * Copyright (C) 2011 Cloud Conscious, LLC. + * + * ==================================================================== + * 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 org.jclouds.demo.tweetstore.config; + +import static com.google.common.base.Preconditions.checkNotNull; +import static java.util.concurrent.TimeUnit.MINUTES; +import static org.jclouds.demo.tweetstore.reference.TweetStoreConstants.PROPERTY_TWEETSTORE_CONTAINER; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.util.Map; +import java.util.Properties; +import java.util.Set; + +import javax.servlet.ServletContextEvent; + +import org.jclouds.blobstore.BlobStoreContext; +import org.jclouds.blobstore.BlobStoreContextFactory; +import org.jclouds.demo.tweetstore.config.utils.HttpRequestTask; +import org.jclouds.demo.tweetstore.config.utils.HttpRequestTask.Factory; +import org.jclouds.demo.tweetstore.config.utils.TaskQueue; +import org.jclouds.demo.tweetstore.controller.AddTweetsController; +import org.jclouds.demo.tweetstore.controller.StoreTweetsController; +import org.jclouds.http.HttpRequest; + +import twitter4j.Twitter; +import twitter4j.TwitterFactory; +import twitter4j.conf.Configuration; +import twitter4j.conf.ConfigurationBuilder; + +import com.google.common.base.Splitter; +import com.google.common.collect.ImmutableMultimap; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Maps; +import com.google.common.io.Closeables; +import com.google.inject.Guice; +import com.google.inject.Injector; +import com.google.inject.Module; +import com.google.inject.TypeLiteral; +import com.google.inject.name.Names; +import com.google.inject.servlet.GuiceServletContextListener; +import com.google.inject.servlet.ServletModule; + +/** + * Setup Logging and create Injector for use in testing S3. + * + * @author Adrian Cole + */ +public class GuiceServletConfig extends GuiceServletContextListener { + public static final String PROPERTY_BLOBSTORE_CONTEXTS = "blobstore.contexts"; + + private Map providerTypeToBlobStoreMap; + private Twitter twitterClient; + private String container; + private TaskQueue queue; + + @Override + public void contextInitialized(ServletContextEvent servletContextEvent) { + BlobStoreContextFactory blobStoreContextFactory = new BlobStoreContextFactory(); + + Properties props = loadJCloudsProperties(servletContextEvent); + + Set modules = ImmutableSet.of(); + // shared across all blobstores and used to retrieve tweets + try { + Configuration twitterConf = new ConfigurationBuilder() + .setUser(props.getProperty("twitter.identity")) + .setPassword(props.getProperty("twitter.credential")).build(); + twitterClient = new TwitterFactory(twitterConf).getInstance(); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("properties for twitter not configured properly in " + + props.toString(), e); + } + // common namespace for storing tweets + container = checkNotNull(props.getProperty(PROPERTY_TWEETSTORE_CONTAINER), PROPERTY_TWEETSTORE_CONTAINER); + + // instantiate and store references to all blobstores by provider name + providerTypeToBlobStoreMap = Maps.newHashMap(); + for (String hint : Splitter.on(',').split(checkNotNull(props.getProperty(PROPERTY_BLOBSTORE_CONTEXTS), PROPERTY_BLOBSTORE_CONTEXTS))) { + providerTypeToBlobStoreMap.put(hint, blobStoreContextFactory.createContext(hint, modules, props)); + } + + // get a queue for submitting store tweet requests + queue = TaskQueue.builder().name("twitter").period(MINUTES).build(); + Factory taskFactory = HttpRequestTask.factory(props, "twitter"); + // submit a job to store tweets for each configured blobstore + for (String name : providerTypeToBlobStoreMap.keySet()) { + queue.add(taskFactory.create(HttpRequest.builder() + .endpoint(URI.create("http://localhost:8080" + servletContextEvent.getServletContext().getContextPath() + "/store/do")) + .headers(ImmutableMultimap.of("context", name, "X-RUN@cloud-QueueName", "twitter")) + .method("GET").build())); + } + + super.contextInitialized(servletContextEvent); + } + + private Properties loadJCloudsProperties( + ServletContextEvent servletContextEvent) { + InputStream input = servletContextEvent.getServletContext() + .getResourceAsStream("/WEB-INF/jclouds.properties"); + Properties props = new Properties(); + try { + props.load(input); + } catch (IOException e) { + throw new RuntimeException(e); + } finally { + Closeables.closeQuietly(input); + } + return props; + } + + @Override + protected Injector getInjector() { + return Guice.createInjector(new ServletModule() { + @Override + protected void configureServlets() { + bind(new TypeLiteral>() { + }).toInstance(providerTypeToBlobStoreMap); + bind(Twitter.class).toInstance(twitterClient); + bindConstant().annotatedWith( + Names.named(PROPERTY_TWEETSTORE_CONTAINER)).to( + container); + serve("/store/*").with(StoreTweetsController.class); + serve("/tweets/*").with(AddTweetsController.class); + } + }); + } + + @Override + public void contextDestroyed(ServletContextEvent servletContextEvent) { + for (BlobStoreContext context : providerTypeToBlobStoreMap.values()) { + context.close(); + } + queue.destroy(); + super.contextDestroyed(servletContextEvent); + } +} \ No newline at end of file diff --git a/demos/runatcloud-tweetstore/src/main/java/org/jclouds/demo/tweetstore/config/utils/HttpRequestTask.java b/demos/runatcloud-tweetstore/src/main/java/org/jclouds/demo/tweetstore/config/utils/HttpRequestTask.java new file mode 100644 index 0000000000..fe28b80802 --- /dev/null +++ b/demos/runatcloud-tweetstore/src/main/java/org/jclouds/demo/tweetstore/config/utils/HttpRequestTask.java @@ -0,0 +1,132 @@ +package org.jclouds.demo.tweetstore.config.utils; + +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.inject.name.Names.bindProperties; +import static java.lang.String.format; + +import java.util.Properties; + +import javax.ws.rs.core.UriBuilder; + +import org.jclouds.PropertiesBuilder; +import org.jclouds.concurrent.config.ExecutorServiceModule; +import org.jclouds.http.HttpCommand; +import org.jclouds.http.HttpCommandExecutorService; +import org.jclouds.http.HttpRequest; +import org.jclouds.http.config.JavaUrlHttpCommandExecutorServiceModule; + +import com.google.common.collect.ImmutableMultimap; +import com.google.common.collect.Multimap; +import com.google.inject.AbstractModule; +import com.google.inject.Guice; +import com.sun.jersey.api.uri.UriBuilderImpl; + +public class HttpRequestTask implements Runnable { + public static Factory factory(Properties props) { + return factory(props, format("%s@%d", Factory.class.getName(), System.currentTimeMillis())); + } + + public static Factory factory(Properties props, String originator) { + return new Factory(props, originator); + } + + public static class Factory { + private static final String HTTP_REQUEST_ORIGINATOR_HEADER = "X-RUN@cloud-Originator"; + + protected final HttpCommandExecutorService httpClient; + protected final String originator; + + private Factory(final Properties props, String originator) { + this.originator = originator; + httpClient = Guice.createInjector(new ExecutorServiceModule(), + new JavaUrlHttpCommandExecutorServiceModule(), + new AbstractModule() { + @Override + protected void configure() { + // URL connection defaults + Properties toBind = new PropertiesBuilder().build(); + toBind.putAll(checkNotNull(props, "properties")); + toBind.putAll(System.getProperties()); + bindProperties(binder(), toBind); + bind(UriBuilder.class).to(UriBuilderImpl.class); + } + }).getInstance(HttpCommandExecutorService.class); + } + + public HttpRequestTask create(HttpRequest request) { + HttpRequest requestWithSubmitter = request.toBuilder().headers( + copyOfWithEntry(request.getHeaders(), + HTTP_REQUEST_ORIGINATOR_HEADER, originator)).build(); + return new HttpRequestTask(httpClient, requestWithSubmitter); + } + + private static Multimap copyOfWithEntry( + Multimap multimap, K k1, V v1) { + return ImmutableMultimap.builder().putAll(multimap).put(k1, v1).build(); + } + } + + private final HttpCommandExecutorService httpClient; + private final HttpRequest request; + + private HttpRequestTask(HttpCommandExecutorService httpClient, HttpRequest request) { + this.httpClient = httpClient; + this.request = request; + } + + @Override + public void run() { + httpClient.submit(new ImmutableHttpCommand(request)); + } + + private class ImmutableHttpCommand implements HttpCommand { + private final HttpRequest request; + + public ImmutableHttpCommand(HttpRequest request) { + this.request = request; + } + + @Override + public void setException(Exception exception) { + } + + @Override + public void setCurrentRequest(HttpRequest request) { + } + + @Override + public boolean isReplayable() { + return false; + } + + @Override + public int incrementRedirectCount() { + return 0; + } + + @Override + public int incrementFailureCount() { + return 0; + } + + @Override + public int getRedirectCount() { + return 0; + } + + @Override + public int getFailureCount() { + return 0; + } + + @Override + public Exception getException() { + return null; + } + + @Override + public HttpRequest getCurrentRequest() { + return request; + } + } +} diff --git a/demos/runatcloud-tweetstore/src/main/java/org/jclouds/demo/tweetstore/config/utils/ImmutableHttpCommand.java b/demos/runatcloud-tweetstore/src/main/java/org/jclouds/demo/tweetstore/config/utils/ImmutableHttpCommand.java new file mode 100644 index 0000000000..355f98c5e7 --- /dev/null +++ b/demos/runatcloud-tweetstore/src/main/java/org/jclouds/demo/tweetstore/config/utils/ImmutableHttpCommand.java @@ -0,0 +1,55 @@ +package org.jclouds.demo.tweetstore.config.utils; + +import org.jclouds.http.HttpCommand; +import org.jclouds.http.HttpRequest; + +class ImmutableHttpCommand implements HttpCommand { + private final HttpRequest request; + + public ImmutableHttpCommand(HttpRequest request) { + this.request = request; + } + + @Override + public void setException(Exception exception) { + } + + @Override + public void setCurrentRequest(HttpRequest request) { + } + + @Override + public boolean isReplayable() { + return false; + } + + @Override + public int incrementRedirectCount() { + return 0; + } + + @Override + public int incrementFailureCount() { + return 0; + } + + @Override + public int getRedirectCount() { + return 0; + } + + @Override + public int getFailureCount() { + return 0; + } + + @Override + public Exception getException() { + return null; + } + + @Override + public HttpRequest getCurrentRequest() { + return request; + } +} \ No newline at end of file diff --git a/demos/runatcloud-tweetstore/src/main/java/org/jclouds/demo/tweetstore/config/utils/ObjectFields.java b/demos/runatcloud-tweetstore/src/main/java/org/jclouds/demo/tweetstore/config/utils/ObjectFields.java new file mode 100644 index 0000000000..57f857463c --- /dev/null +++ b/demos/runatcloud-tweetstore/src/main/java/org/jclouds/demo/tweetstore/config/utils/ObjectFields.java @@ -0,0 +1,58 @@ +/* + * @(#)ObjectFields.java 26 May 2011 + * + * Copyright © 2010 Andrew Phillips. + * + * ==================================================================== + * 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 org.jclouds.demo.tweetstore.config.utils; + +import java.lang.reflect.Field; + +public class ObjectFields { + + public static Object valueOf(String fieldName, Object source) { + return valueOf(fieldName, source, source.getClass()); + } + + public static Object valueOf(String fieldName, Object source, + Class fieldDeclaringClass) { + try { + return getAccessibleField(fieldName, fieldDeclaringClass).get(source); + } catch (Exception exception) { + throw new IllegalArgumentException(exception); + } + } + + private static Field getAccessibleField(String name, Class declaringClass) throws SecurityException, NoSuchFieldException { + Field field = declaringClass.getDeclaredField(name); + field.setAccessible(true); + return field; + } + + public static void set(String fieldName, Object target, Object value) { + set(fieldName, target, value, target.getClass()); + } + + public static void set(String fieldName, Object target, Object value, + Class fieldDeclaringClass) { + try { + getAccessibleField(fieldName, fieldDeclaringClass).set(target, value); + } catch (Exception exception) { + throw new IllegalArgumentException(exception); + } + } +} diff --git a/demos/runatcloud-tweetstore/src/main/java/org/jclouds/demo/tweetstore/config/utils/TaskQueue.java b/demos/runatcloud-tweetstore/src/main/java/org/jclouds/demo/tweetstore/config/utils/TaskQueue.java new file mode 100644 index 0000000000..617201f875 --- /dev/null +++ b/demos/runatcloud-tweetstore/src/main/java/org/jclouds/demo/tweetstore/config/utils/TaskQueue.java @@ -0,0 +1,67 @@ +package org.jclouds.demo.tweetstore.config.utils; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.TimeUnit; + +import com.google.inject.Provider; + +public class TaskQueue { + public static Builder builder() { + return new Builder(); + } + + public static class Builder implements Provider { + protected String name = "default"; + protected long taskPeriodMillis = TimeUnit.SECONDS.toMillis(1); + + public Builder name(String name) { + this.name = checkNotNull(name, "name"); + return this; + } + + public Builder period(TimeUnit period) { + this.taskPeriodMillis = checkNotNull(period, "period").toMillis(1); + return this; + } + + public Builder period(long taskPeriodMillis) { + checkArgument(taskPeriodMillis > 0, "taskPeriodMillis"); + this.taskPeriodMillis = taskPeriodMillis; + return this; + } + + public TaskQueue build() { + return new TaskQueue(name, taskPeriodMillis); + } + + @Override + public TaskQueue get() { + return build(); + } + } + + private final Timer timer; + private final long taskPeriodMillis; + + private TaskQueue(String name, long taskPeriodMillis) { + timer = new Timer(name); + this.taskPeriodMillis = taskPeriodMillis; + } + + public void add(final Runnable task) { + timer.scheduleAtFixedRate(new TimerTask() { + @Override + public void run() { + task.run(); + } + }, 0, taskPeriodMillis); + } + + public void destroy() { + timer.cancel(); + } +} \ No newline at end of file diff --git a/demos/runatcloud-tweetstore/src/main/java/org/jclouds/demo/tweetstore/controller/AddTweetsController.java b/demos/runatcloud-tweetstore/src/main/java/org/jclouds/demo/tweetstore/controller/AddTweetsController.java new file mode 100644 index 0000000000..7ef8e1cd6f --- /dev/null +++ b/demos/runatcloud-tweetstore/src/main/java/org/jclouds/demo/tweetstore/controller/AddTweetsController.java @@ -0,0 +1,96 @@ +/** + * + * Copyright (C) 2011 Cloud Conscious, LLC. + * + * ==================================================================== + * 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 org.jclouds.demo.tweetstore.controller; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +import javax.annotation.Resource; +import javax.inject.Inject; +import javax.inject.Singleton; +import javax.servlet.RequestDispatcher; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.jclouds.blobstore.BlobStoreContext; +import org.jclouds.demo.tweetstore.domain.StoredTweetStatus; +import org.jclouds.demo.tweetstore.functions.ServiceToStoredTweetStatuses; +import org.jclouds.logging.Logger; + +import com.google.common.base.Function; +import com.google.common.collect.Iterables; +import com.google.common.collect.Lists; + +/** + * Shows an example of how to use @{link BlobStoreContext} injected with Guice. + * + * @author Adrian Cole + */ +@Singleton +public class AddTweetsController extends HttpServlet implements + Function, List> { + + /** The serialVersionUID */ + private static final long serialVersionUID = 3888348023150822683L; + private final Map contexts; + private final ServiceToStoredTweetStatuses blobStoreContextToContainerResult; + + @Resource + protected Logger logger = Logger.NULL; + + @Inject + AddTweetsController(Map contexts, + ServiceToStoredTweetStatuses blobStoreContextToContainerResult) { + this.contexts = contexts; + this.blobStoreContextToContainerResult = blobStoreContextToContainerResult; + } + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + try { + addMyTweetsToRequest(request); + RequestDispatcher dispatcher = getServletContext().getRequestDispatcher("/tweets.jsp"); + dispatcher.forward(request, response); + } catch (Exception e) { + logger.error(e, "Error listing containers"); + throw new ServletException(e); + } + } + + void addMyTweetsToRequest(HttpServletRequest request) throws InterruptedException, + ExecutionException, TimeoutException { + request.setAttribute("tweets", apply(contexts.keySet())); + } + + public List apply(Set in) { + List statuses = Lists.newArrayList(); + for (Iterable list : Iterables.transform(in, + blobStoreContextToContainerResult)) { + Iterables.addAll(statuses, list); + } + return statuses; + } +} diff --git a/demos/runatcloud-tweetstore/src/main/java/org/jclouds/demo/tweetstore/controller/StoreTweetsController.java b/demos/runatcloud-tweetstore/src/main/java/org/jclouds/demo/tweetstore/controller/StoreTweetsController.java new file mode 100644 index 0000000000..efe708ea23 --- /dev/null +++ b/demos/runatcloud-tweetstore/src/main/java/org/jclouds/demo/tweetstore/controller/StoreTweetsController.java @@ -0,0 +1,129 @@ +/** + * + * Copyright (C) 2011 Cloud Conscious, LLC. + * + * ==================================================================== + * 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 org.jclouds.demo.tweetstore.controller; + +import static com.google.common.base.Preconditions.checkNotNull; + +import java.io.IOException; +import java.util.Map; + +import javax.annotation.Resource; +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.core.MediaType; + +import org.jclouds.blobstore.BlobMap; +import org.jclouds.blobstore.BlobStoreContext; +import org.jclouds.blobstore.domain.Blob; +import org.jclouds.demo.tweetstore.reference.TweetStoreConstants; +import org.jclouds.logging.Logger; +import org.jclouds.rest.AuthorizationException; + +import twitter4j.Status; +import twitter4j.Twitter; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Function; + +/** + * Grab tweets related to me and store them into blobstores + * + * @author Adrian Cole + */ +@Singleton +public class StoreTweetsController extends HttpServlet { + + private static final class StatusToBlob implements Function { + private final BlobMap map; + + private StatusToBlob(BlobMap map) { + this.map = map; + } + + public Blob apply(Status from) { + Blob to = map.blobBuilder().name(from.getId() + "").build(); + to.setPayload(from.getText()); + to.getPayload().getContentMetadata().setContentType(MediaType.TEXT_PLAIN); + to.getMetadata().getUserMetadata().put(TweetStoreConstants.SENDER_NAME, from.getUser().getScreenName()); + return to; + } + } + + /** The serialVersionUID */ + private static final long serialVersionUID = 7215420527854203714L; + + private final Map contexts; + private final Twitter client; + private final String container; + + @Resource + protected Logger logger = Logger.NULL; + + @Inject + @VisibleForTesting + public StoreTweetsController(Map contexts, + @Named(TweetStoreConstants.PROPERTY_TWEETSTORE_CONTAINER) String container, Twitter client) { + this.container = container; + this.contexts = contexts; + this.client = client; + } + + @VisibleForTesting + public void addMyTweets(String contextName, Iterable responseList) { + BlobStoreContext context = checkNotNull(contexts.get(contextName), "no context for " + contextName + " in " + + contexts.keySet()); + BlobMap map = context.createBlobMap(container); + for (Status status : responseList) { + Blob blob = null; + try { + blob = new StatusToBlob(map).apply(status); + map.put(status.getId() + "", blob); + } catch (AuthorizationException e) { + throw e; + } catch (Exception e) { + logger.error(e, "Error storing tweet %s (blob[%s]) on map %s/%s", status.getId(), blob, context, container); + } + } + } + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + if (request.getHeader("X-RUN@cloud-Submitter") != null + && request.getHeader("X-RUN@cloud-Submitter").equals("twitter")) { + try { + String contextName = checkNotNull(request.getHeader("context"), "missing header context"); + logger.info("retrieving tweets"); + addMyTweets(contextName, client.getMentions()); + logger.debug("done storing tweets"); + response.setContentType(MediaType.TEXT_PLAIN); + response.getWriter().println("Done!"); + } catch (Exception e) { + logger.error(e, "Error storing tweets"); + throw new ServletException(e); + } + } else { + response.sendError(401); + } + } +} \ No newline at end of file diff --git a/demos/runatcloud-tweetstore/src/main/java/org/jclouds/demo/tweetstore/domain/StoredTweetStatus.java b/demos/runatcloud-tweetstore/src/main/java/org/jclouds/demo/tweetstore/domain/StoredTweetStatus.java new file mode 100644 index 0000000000..eebd4b4c99 --- /dev/null +++ b/demos/runatcloud-tweetstore/src/main/java/org/jclouds/demo/tweetstore/domain/StoredTweetStatus.java @@ -0,0 +1,149 @@ +/** + * + * Copyright (C) 2011 Cloud Conscious, LLC. + * + * ==================================================================== + * 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 org.jclouds.demo.tweetstore.domain; + +import java.io.Serializable; + +/** + * + * @author Adrian Cole + */ +public class StoredTweetStatus implements Comparable, Serializable { + + /** The serialVersionUID */ + private static final long serialVersionUID = -3257496189689220018L; + private final String service; + private final String host; + private final String container; + private final String id; + private final String from; + private final String tweet; + private final String status; + + @Override + public String toString() { + return "StoredTweetStatus [container=" + container + ", from=" + from + ", host=" + host + + ", id=" + id + ", service=" + service + ", status=" + status + ", tweet=" + tweet + + "]"; + } + + public StoredTweetStatus(String service, String host, String container, String id, String from, + String tweet, String status) { + this.service = service; + this.host = host; + this.container = container; + this.id = id; + this.from = from; + this.tweet = tweet; + this.status = status; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((container == null) ? 0 : container.hashCode()); + result = prime * result + ((from == null) ? 0 : from.hashCode()); + result = prime * result + ((host == null) ? 0 : host.hashCode()); + result = prime * result + ((id == null) ? 0 : id.hashCode()); + result = prime * result + ((service == null) ? 0 : service.hashCode()); + result = prime * result + ((tweet == null) ? 0 : tweet.hashCode()); + return result; + } + + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (getClass() != obj.getClass()) + return false; + StoredTweetStatus other = (StoredTweetStatus) obj; + if (container == null) { + if (other.container != null) + return false; + } else if (!container.equals(other.container)) + return false; + if (from == null) { + if (other.from != null) + return false; + } else if (!from.equals(other.from)) + return false; + if (host == null) { + if (other.host != null) + return false; + } else if (!host.equals(other.host)) + return false; + if (id == null) { + if (other.id != null) + return false; + } else if (!id.equals(other.id)) + return false; + if (service == null) { + if (other.service != null) + return false; + } else if (!service.equals(other.service)) + return false; + if (tweet == null) { + if (other.tweet != null) + return false; + } else if (!tweet.equals(other.tweet)) + return false; + return true; + } + + + public String getService() { + return service; + } + + public String getHost() { + return host; + } + + public String getContainer() { + return container; + } + + public String getFrom() { + return from; + } + + public String getTweet() { + return tweet; + } + + public String getStatus() { + return status; + } + + public int compareTo(StoredTweetStatus o) { + if (id == null) + return -1; + return (int) ((this == o) ? 0 : id.compareTo(o.id)); + } + + + public String getId() { + return id; + } + +} diff --git a/demos/runatcloud-tweetstore/src/main/java/org/jclouds/demo/tweetstore/functions/KeyToStoredTweetStatus.java b/demos/runatcloud-tweetstore/src/main/java/org/jclouds/demo/tweetstore/functions/KeyToStoredTweetStatus.java new file mode 100644 index 0000000000..602e51ddc4 --- /dev/null +++ b/demos/runatcloud-tweetstore/src/main/java/org/jclouds/demo/tweetstore/functions/KeyToStoredTweetStatus.java @@ -0,0 +1,71 @@ +/** + * + * Copyright (C) 2011 Cloud Conscious, LLC. + * + * ==================================================================== + * 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 org.jclouds.demo.tweetstore.functions; + +import static org.jclouds.util.Strings2.toStringAndClose; + +import javax.annotation.Resource; + +import org.jclouds.blobstore.BlobMap; +import org.jclouds.blobstore.domain.Blob; +import org.jclouds.demo.tweetstore.domain.StoredTweetStatus; +import org.jclouds.demo.tweetstore.reference.TweetStoreConstants; +import org.jclouds.logging.Logger; + +import com.google.common.base.Function; + +/** + * + * @author Adrian Cole + */ +public class KeyToStoredTweetStatus implements Function { + private final String host; + private final BlobMap map; + private final String service; + private final String container; + + @Resource + protected Logger logger = Logger.NULL; + + KeyToStoredTweetStatus(BlobMap map, String service, String host, String container) { + this.host = host; + this.map = map; + this.service = service; + this.container = container; + } + + public StoredTweetStatus apply(String id) { + String status; + String from; + String tweet; + try { + long start = System.currentTimeMillis(); + Blob blob = map.get(id); + status = ((System.currentTimeMillis() - start) + "ms"); + from = blob.getMetadata().getUserMetadata().get(TweetStoreConstants.SENDER_NAME); + tweet = toStringAndClose(blob.getPayload().getInput()); + } catch (Exception e) { + logger.error(e, "Error listing container %s//%s/$s", service, container, id); + status = (e.getMessage()); + tweet = ""; + from = ""; + } + return new StoredTweetStatus(service, host, container, id, from, tweet, status); + } +} diff --git a/demos/runatcloud-tweetstore/src/main/java/org/jclouds/demo/tweetstore/functions/ServiceToStoredTweetStatuses.java b/demos/runatcloud-tweetstore/src/main/java/org/jclouds/demo/tweetstore/functions/ServiceToStoredTweetStatuses.java new file mode 100644 index 0000000000..1f3092a640 --- /dev/null +++ b/demos/runatcloud-tweetstore/src/main/java/org/jclouds/demo/tweetstore/functions/ServiceToStoredTweetStatuses.java @@ -0,0 +1,71 @@ +/** + * + * Copyright (C) 2011 Cloud Conscious, LLC. + * + * ==================================================================== + * 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 org.jclouds.demo.tweetstore.functions; + +import java.util.Collections; +import java.util.Map; +import java.util.Set; + +import javax.annotation.Resource; +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +import org.jclouds.blobstore.BlobMap; +import org.jclouds.blobstore.BlobStoreContext; +import org.jclouds.demo.tweetstore.domain.StoredTweetStatus; +import org.jclouds.demo.tweetstore.reference.TweetStoreConstants; +import org.jclouds.logging.Logger; + +import com.google.common.base.Function; +import com.google.common.collect.Iterables; + +@Singleton +public class ServiceToStoredTweetStatuses implements Function> { + + private final Map contexts; + private final String container; + + @Inject + public ServiceToStoredTweetStatuses(Map contexts, + @Named(TweetStoreConstants.PROPERTY_TWEETSTORE_CONTAINER) String container) { + this.contexts = contexts; + this.container = container; + } + + @Resource + protected Logger logger = Logger.NULL; + + public Iterable apply(String service) { + BlobStoreContext context = contexts.get(service); + String host = context.getProviderSpecificContext().getEndpoint().getHost(); + try { + BlobMap blobMap = context.createBlobMap(container); + Set blobs = blobMap.keySet(); + return Iterables.transform(blobs, new KeyToStoredTweetStatus(blobMap, service, host, + container)); + } catch (Exception e) { + StoredTweetStatus result = new StoredTweetStatus(service, host, container, null, null, + null, e.getMessage()); + logger.error(e, "Error listing service %s", service); + return Collections.singletonList(result); + } + + } +} \ No newline at end of file diff --git a/demos/runatcloud-tweetstore/src/main/java/org/jclouds/demo/tweetstore/reference/TweetStoreConstants.java b/demos/runatcloud-tweetstore/src/main/java/org/jclouds/demo/tweetstore/reference/TweetStoreConstants.java new file mode 100644 index 0000000000..4e75d5a7fa --- /dev/null +++ b/demos/runatcloud-tweetstore/src/main/java/org/jclouds/demo/tweetstore/reference/TweetStoreConstants.java @@ -0,0 +1,33 @@ +/** + * + * Copyright (C) 2011 Cloud Conscious, LLC. + * + * ==================================================================== + * 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 org.jclouds.demo.tweetstore.reference; + +/** + * Configuration properties and constants used in TweetStore connections. + * + * @author Adrian Cole + */ +public interface TweetStoreConstants { + public static final String PROPERTY_TWEETSTORE_CONTAINER = "jclouds.tweetstore.container"; + /** + * Note that this has to conform to restrictions of all blobstores. for example, azure doesn't + * support periods. + */ + public static final String SENDER_NAME = "sendername"; +} diff --git a/demos/runatcloud-tweetstore/src/main/webapp/WEB-INF/cloudbees-web.xml b/demos/runatcloud-tweetstore/src/main/webapp/WEB-INF/cloudbees-web.xml new file mode 100644 index 0000000000..607745f47c --- /dev/null +++ b/demos/runatcloud-tweetstore/src/main/webapp/WEB-INF/cloudbees-web.xml @@ -0,0 +1,8 @@ + + + ${bees.appid} + + application.environment + ${bees.environment} + + \ No newline at end of file diff --git a/demos/runatcloud-tweetstore/src/main/webapp/WEB-INF/web.xml b/demos/runatcloud-tweetstore/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 0000000000..9b5c1f3d3c --- /dev/null +++ b/demos/runatcloud-tweetstore/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,47 @@ + + + + jclouds-tweetstore + + + + guiceFilter + com.google.inject.servlet.GuiceFilter + + + + guiceFilter + /* + + + + org.jclouds.demo.tweetstore.config.GuiceServletConfig + + + + + index.jsp + + + \ No newline at end of file diff --git a/demos/runatcloud-tweetstore/src/main/webapp/index.jsp b/demos/runatcloud-tweetstore/src/main/webapp/index.jsp new file mode 100644 index 0000000000..9b1238e1dd --- /dev/null +++ b/demos/runatcloud-tweetstore/src/main/webapp/index.jsp @@ -0,0 +1,31 @@ +<%-- + + + Copyright (C) 2011 Cloud Conscious, LLC. + + ==================================================================== + 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. + ==================================================================== + +--%> + + +jclouds: anyweight cloudware for java + + +

Welcome!

+Click +here +to see tweets about jclouds. + + diff --git a/demos/runatcloud-tweetstore/src/main/webapp/tweets.jsp b/demos/runatcloud-tweetstore/src/main/webapp/tweets.jsp new file mode 100644 index 0000000000..677199188d --- /dev/null +++ b/demos/runatcloud-tweetstore/src/main/webapp/tweets.jsp @@ -0,0 +1,108 @@ +<%-- + + + Copyright (C) 2011 Cloud Conscious, LLC. + + ==================================================================== + 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. + ==================================================================== + +--%> +<%@ page buffer="20kb"%> +<%@ taglib uri="http://displaytag.sf.net" prefix="display"%> + + +jclouds: anyweight cloudware for java + + + +

Tweets in Clouds

+ + + + +
+ +
+ + + + + + + + + + +
+
+ + diff --git a/demos/runatcloud-tweetstore/src/test/java/org/jclouds/demo/tweetstore/controller/AddTweetsControllerTest.java b/demos/runatcloud-tweetstore/src/test/java/org/jclouds/demo/tweetstore/controller/AddTweetsControllerTest.java new file mode 100644 index 0000000000..f0a624d2b3 --- /dev/null +++ b/demos/runatcloud-tweetstore/src/test/java/org/jclouds/demo/tweetstore/controller/AddTweetsControllerTest.java @@ -0,0 +1,76 @@ +/** + * + * Copyright (C) 2011 Cloud Conscious, LLC. + * + * ==================================================================== + * 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 org.jclouds.demo.tweetstore.controller; + +import static org.testng.Assert.assertEquals; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutionException; + +import org.jclouds.blobstore.BlobStoreContext; +import org.jclouds.blobstore.BlobStoreContextFactory; +import org.jclouds.blobstore.domain.Blob; +import org.jclouds.demo.tweetstore.domain.StoredTweetStatus; +import org.jclouds.demo.tweetstore.functions.ServiceToStoredTweetStatuses; +import org.jclouds.demo.tweetstore.reference.TweetStoreConstants; +import org.testng.annotations.Test; +import org.testng.collections.Maps; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; + +/** + * Tests behavior of {@code AddTweetsController} + * + * @author Adrian Cole + */ +@Test(groups = "unit") +public class AddTweetsControllerTest { + + Map createServices(String container) throws InterruptedException, + ExecutionException { + Map services = Maps.newHashMap(); + for (String name : new String[] { "1", "2" }) { + BlobStoreContext context = new BlobStoreContextFactory().createContext("transient", "dummy", "dummy"); + context.getAsyncBlobStore().createContainerInLocation(null, container).get(); + Blob blob = context.getAsyncBlobStore().blobBuilder("1").build(); + blob.getMetadata().getUserMetadata().put(TweetStoreConstants.SENDER_NAME, "frank"); + blob.setPayload("I love beans!"); + context.getAsyncBlobStore().putBlob(container, blob).get(); + services.put(name, context); + } + return services; + } + + public void testStoreTweets() throws IOException, InterruptedException, ExecutionException { + String container = "container"; + Map contexts = createServices(container); + + ServiceToStoredTweetStatuses function = new ServiceToStoredTweetStatuses(contexts, container); + AddTweetsController controller = new AddTweetsController(contexts, function); + List list = controller.apply(ImmutableSet.of("1", "2")); + assertEquals(list.size(), 2); + assertEquals(list, ImmutableList.of(new StoredTweetStatus("1", "localhost", container, "1", + "frank", "I love beans!", null), new StoredTweetStatus("2", "localhost", container, + "1", "frank", "I love beans!", null))); + + } +} diff --git a/demos/runatcloud-tweetstore/src/test/java/org/jclouds/demo/tweetstore/controller/StoreTweetsControllerTest.java b/demos/runatcloud-tweetstore/src/test/java/org/jclouds/demo/tweetstore/controller/StoreTweetsControllerTest.java new file mode 100644 index 0000000000..c8aaea43ba --- /dev/null +++ b/demos/runatcloud-tweetstore/src/test/java/org/jclouds/demo/tweetstore/controller/StoreTweetsControllerTest.java @@ -0,0 +1,118 @@ +/** + * + * Copyright (C) 2011 Cloud Conscious, LLC. + * + * ==================================================================== + * 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 org.jclouds.demo.tweetstore.controller; + +import static org.easymock.EasyMock.expect; +import static org.easymock.classextension.EasyMock.createMock; +import static org.easymock.classextension.EasyMock.replay; +import static org.easymock.classextension.EasyMock.verify; +import static org.jclouds.util.Strings2.toStringAndClose; +import static org.testng.Assert.assertEquals; + +import java.io.IOException; +import java.util.Map; +import java.util.Map.Entry; +import java.util.concurrent.ExecutionException; + +import org.jclouds.blobstore.BlobMap; +import org.jclouds.blobstore.BlobStoreContext; +import org.jclouds.blobstore.BlobStoreContextFactory; +import org.jclouds.blobstore.domain.Blob; +import org.jclouds.demo.tweetstore.reference.TweetStoreConstants; +import org.testng.annotations.Test; + +import twitter4j.Status; +import twitter4j.Twitter; +import twitter4j.User; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; + +/** + * Tests behavior of {@code StoreTweetsController} + * + * @author Adrian Cole + */ +@Test(groups = "unit") +public class StoreTweetsControllerTest { + + Twitter createTwitter() { + return createMock(Twitter.class); + } + + Map createBlobStores() throws InterruptedException, ExecutionException { + Map contexts = ImmutableMap. of("test1", + new BlobStoreContextFactory().createContext("transient", "dummy", "dummy"), "test2", + new BlobStoreContextFactory().createContext("transient", "dummy", "dummy")); + for (BlobStoreContext blobstore : contexts.values()) { + blobstore.getAsyncBlobStore().createContainerInLocation(null, "favo").get(); + } + return contexts; + } + + public void testStoreTweets() throws IOException, InterruptedException, ExecutionException { + Map stores = createBlobStores(); + StoreTweetsController function = new StoreTweetsController(stores, "favo", createTwitter()); + + User frank = createMock(User.class); + expect(frank.getScreenName()).andReturn("frank").atLeastOnce(); + + Status frankStatus = createMock(Status.class); + expect(frankStatus.getId()).andReturn(1l).atLeastOnce(); + expect(frankStatus.getUser()).andReturn(frank).atLeastOnce(); + expect(frankStatus.getText()).andReturn("I love beans!").atLeastOnce(); + + User jimmy = createMock(User.class); + expect(jimmy.getScreenName()).andReturn("jimmy").atLeastOnce(); + + Status jimmyStatus = createMock(Status.class); + expect(jimmyStatus.getId()).andReturn(2l).atLeastOnce(); + expect(jimmyStatus.getUser()).andReturn(jimmy).atLeastOnce(); + expect(jimmyStatus.getText()).andReturn("cloud is king").atLeastOnce(); + + replay(frank); + replay(frankStatus); + replay(jimmy); + replay(jimmyStatus); + + function.addMyTweets("test1", ImmutableList.of(frankStatus, jimmyStatus)); + function.addMyTweets("test2", ImmutableList.of(frankStatus, jimmyStatus)); + + verify(frank); + verify(frankStatus); + verify(jimmy); + verify(jimmyStatus); + + for (Entry entry : stores.entrySet()) { + BlobMap map = entry.getValue().createBlobMap("favo"); + Blob frankBlob = map.get("1"); + assertEquals(frankBlob.getMetadata().getName(), "1"); + assertEquals(frankBlob.getMetadata().getUserMetadata().get(TweetStoreConstants.SENDER_NAME), "frank"); + assertEquals(frankBlob.getMetadata().getContentMetadata().getContentType(), "text/plain"); + assertEquals(toStringAndClose(frankBlob.getPayload().getInput()), "I love beans!"); + + Blob jimmyBlob = map.get("2"); + assertEquals(jimmyBlob.getMetadata().getName(), "2"); + assertEquals(jimmyBlob.getMetadata().getUserMetadata().get(TweetStoreConstants.SENDER_NAME), "jimmy"); + assertEquals(jimmyBlob.getMetadata().getContentMetadata().getContentType(), "text/plain"); + assertEquals(toStringAndClose(jimmyBlob.getPayload().getInput()), "cloud is king"); + } + + } +} diff --git a/demos/runatcloud-tweetstore/src/test/java/org/jclouds/demo/tweetstore/functions/KeyToStoredTweetStatusTest.java b/demos/runatcloud-tweetstore/src/test/java/org/jclouds/demo/tweetstore/functions/KeyToStoredTweetStatusTest.java new file mode 100644 index 0000000000..4f65c97f83 --- /dev/null +++ b/demos/runatcloud-tweetstore/src/test/java/org/jclouds/demo/tweetstore/functions/KeyToStoredTweetStatusTest.java @@ -0,0 +1,67 @@ +/** + * + * Copyright (C) 2011 Cloud Conscious, LLC. + * + * ==================================================================== + * 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 org.jclouds.demo.tweetstore.functions; + +import static org.testng.Assert.assertEquals; + +import java.io.IOException; +import java.util.concurrent.ExecutionException; + +import org.jclouds.blobstore.BlobMap; +import org.jclouds.blobstore.BlobStoreContext; +import org.jclouds.blobstore.BlobStoreContextFactory; +import org.jclouds.blobstore.domain.Blob; +import org.jclouds.demo.tweetstore.domain.StoredTweetStatus; +import org.jclouds.demo.tweetstore.reference.TweetStoreConstants; +import org.testng.annotations.Test; + +/** + * Tests behavior of {@code KeyToStoredTweetStatus} + * + * @author Adrian Cole + */ +@Test(groups = "unit") +public class KeyToStoredTweetStatusTest { + + BlobMap createMap() throws InterruptedException, ExecutionException { + BlobStoreContext context = new BlobStoreContextFactory().createContext("transient", "dummy", "dummy"); + context.getBlobStore().createContainerInLocation(null, "test1"); + return context.createBlobMap("test1"); + } + + public void testStoreTweets() throws IOException, InterruptedException, ExecutionException { + BlobMap map = createMap(); + Blob blob = map.blobBuilder().name("1").build(); + blob.getMetadata().getUserMetadata().put(TweetStoreConstants.SENDER_NAME, "frank"); + blob.setPayload("I love beans!"); + map.put("1", blob); + String host = "localhost"; + String service = "stub"; + String container = "tweetstore"; + + KeyToStoredTweetStatus function = new KeyToStoredTweetStatus(map, service, host, container); + StoredTweetStatus result = function.apply("1"); + + StoredTweetStatus expected = new StoredTweetStatus(service, host, container, "1", "frank", + "I love beans!", null); + + assertEquals(result, expected); + + } +} diff --git a/demos/runatcloud-tweetstore/src/test/java/org/jclouds/demo/tweetstore/functions/ServiceToStoredTweetStatusesTest.java b/demos/runatcloud-tweetstore/src/test/java/org/jclouds/demo/tweetstore/functions/ServiceToStoredTweetStatusesTest.java new file mode 100644 index 0000000000..c63d9821fb --- /dev/null +++ b/demos/runatcloud-tweetstore/src/test/java/org/jclouds/demo/tweetstore/functions/ServiceToStoredTweetStatusesTest.java @@ -0,0 +1,74 @@ +/** + * + * Copyright (C) 2011 Cloud Conscious, LLC. + * + * ==================================================================== + * 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 org.jclouds.demo.tweetstore.functions; + +import static org.testng.Assert.assertEquals; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ExecutionException; + +import org.jclouds.blobstore.BlobStoreContext; +import org.jclouds.blobstore.BlobStoreContextFactory; +import org.jclouds.blobstore.domain.Blob; +import org.jclouds.demo.tweetstore.domain.StoredTweetStatus; +import org.jclouds.demo.tweetstore.reference.TweetStoreConstants; +import org.testng.annotations.Test; +import org.testng.collections.Maps; + +import com.google.common.collect.Iterables; + +/** + * Tests behavior of {@code ServiceToStoredTweetStatuses} + * + * @author Adrian Cole + */ +@Test(groups = "unit") +public class ServiceToStoredTweetStatusesTest { + + Map createServices(String container) throws InterruptedException, + ExecutionException { + Map services = Maps.newHashMap(); + for (String name : new String[] { "1", "2" }) { + BlobStoreContext context = new BlobStoreContextFactory().createContext("transient", + "dummy", "dummy"); + context.getAsyncBlobStore().createContainerInLocation(null, container).get(); + Blob blob = context.getAsyncBlobStore().blobBuilder("1").build(); + blob.getMetadata().getUserMetadata().put(TweetStoreConstants.SENDER_NAME, "frank"); + blob.setPayload("I love beans!"); + context.getAsyncBlobStore().putBlob(container, blob).get(); + services.put(name, context); + } + return services; + } + + public void testStoreTweets() throws IOException, InterruptedException, ExecutionException { + String container = "container"; + Map contexts = createServices(container); + + ServiceToStoredTweetStatuses function = new ServiceToStoredTweetStatuses(contexts, container); + + assertEquals(Iterables.getLast(function.apply("1")), new StoredTweetStatus("1", "localhost", + container, "1", "frank", "I love beans!", null)); + + assertEquals(Iterables.getLast(function.apply("2")), new StoredTweetStatus("2", "localhost", + container, "1", "frank", "I love beans!", null)); + + } +} diff --git a/demos/runatcloud-tweetstore/src/test/java/org/jclouds/demo/tweetstore/integration/RunAtCloudServer.java b/demos/runatcloud-tweetstore/src/test/java/org/jclouds/demo/tweetstore/integration/RunAtCloudServer.java new file mode 100644 index 0000000000..75d3cb8177 --- /dev/null +++ b/demos/runatcloud-tweetstore/src/test/java/org/jclouds/demo/tweetstore/integration/RunAtCloudServer.java @@ -0,0 +1,57 @@ +/** + * + * Copyright (C) 2011 Cloud Conscious, LLC. + * + * ==================================================================== + * 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 org.jclouds.demo.tweetstore.integration; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.Properties; +import java.util.concurrent.TimeUnit; + +import javax.servlet.ServletException; + +import org.apache.commons.cli.ParseException; + +/** + * Basic functionality to start a local google app engine instance. + * + * @author Adrian Cole + */ +public class RunAtCloudServer { + protected StaxSdkAppServer2 server; + + public void writePropertiesAndStartServer(final String address, final String port, + final String warfile, final String environments, + final String serverBaseDirectory, Properties props) throws IOException, InterruptedException, ParseException, ServletException { + String filename = String.format( + "%1$s/WEB-INF/jclouds.properties", warfile); + System.err.println("file: " + filename); + props.store(new FileOutputStream(filename), "test"); + assert new File(filename).exists(); + server = StaxSdkAppServer2.createServer(new String[] { "-web", warfile, "-port", port, "-env", environments, + "-dir", serverBaseDirectory }, new String[0], Thread.currentThread().getContextClassLoader()); + server.start(); + TimeUnit.SECONDS.sleep(30); + } + + public void stop() throws Exception { + server.stop(); + } + +} \ No newline at end of file diff --git a/demos/runatcloud-tweetstore/src/test/java/org/jclouds/demo/tweetstore/integration/StaxSdkAppServer2.java b/demos/runatcloud-tweetstore/src/test/java/org/jclouds/demo/tweetstore/integration/StaxSdkAppServer2.java new file mode 100644 index 0000000000..3d196a79a4 --- /dev/null +++ b/demos/runatcloud-tweetstore/src/test/java/org/jclouds/demo/tweetstore/integration/StaxSdkAppServer2.java @@ -0,0 +1,187 @@ +/* + * @(#)StaxSdkAppServer2.java 25 May 2011 + * + * Copyright © 2010 Andrew Phillips. + * + * ==================================================================== + * 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 org.jclouds.demo.tweetstore.integration; + +import static com.google.common.base.Predicates.instanceOf; +import static java.util.Arrays.asList; +import static org.jclouds.demo.tweetstore.config.utils.ObjectFields.set; +import static org.jclouds.demo.tweetstore.config.utils.ObjectFields.valueOf; + +import java.io.File; +import java.io.IOException; +import java.util.Timer; + +import javax.servlet.ServletException; + +import net.stax.appserver.webapp.RequestMonitorValve; +import net.stax.appserver.webapp.WebAppEngine; + +import org.apache.catalina.Engine; +import org.apache.commons.cli.ParseException; + +import com.google.common.base.Predicate; +import com.google.common.collect.Iterables; +import com.staxnet.appserver.IAppServerConfiguration; +import com.staxnet.appserver.IEngineFactory; +import com.staxnet.appserver.ServerCallbackClient; +import com.staxnet.appserver.StaxAppServerBase; +import com.staxnet.appserver.StaxSdkAppServer; +import com.staxnet.appserver.StaxSdkAppServerCLI; +import com.staxnet.appserver.TomcatServerBase; +import com.staxnet.appserver.WarBasedServerConfiguration; +import com.staxnet.appserver.config.AppServerConfig; + +class StaxSdkAppServer2 { + // code more or less exactly from StaxSdkAppServer.java + public static StaxSdkAppServer2 createServer(String[] args, String[] classPaths, + ClassLoader cl) throws ParseException, ServletException { + StaxSdkAppServerCLI cli = StaxSdkAppServerCLI.parse(args); + if (cli.getMissingOptions().length > 0) { + throw new ParseException("Missing required options: " + cli.formatMissingOptions(", ")); + } + + String[] environments = cli.getEnvironment(); + File serverConfig = cli.getServerConfigFile(); + File baseDir = new File(cli.getBaseDir()); + File webRoot = new File(cli.getWebdir()); + File workDir = new File(baseDir, "work"); + + File staxWebXml = new File(webRoot, "WEB-INF/cloudbees-web.xml"); + if (!(staxWebXml.exists())) + staxWebXml = new File(webRoot, "WEB-INF/stax-web.xml"); + IAppServerConfiguration config = WarBasedServerConfiguration.load( + serverConfig, webRoot, staxWebXml, environments); + // force the RequestMonitorValve to sleep for only a short period + set("statusInterval", StaxReflect.getAppServerConfig(config), 5); + StaxSdkAppServer server = new StaxSdkAppServer( + baseDir.getAbsolutePath(), workDir.getAbsolutePath(), cl, + classPaths, cli.getPort(), config, cli.getRepositoryPath()); + return new StaxSdkAppServer2(server); + } + + private final StaxSdkAppServer server; + private Thread serverThread; + + private StaxSdkAppServer2(StaxSdkAppServer server) { + this.server = server; + serverThread = new Thread(new Runnable() { + public void run() { + try { + StaxSdkAppServer2.this.server.start(); + } catch (ServletException exception) { + System.err.println("exception starting server: " + exception); + } + } + }); + } + + void start() throws ServletException { + Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() { + public void run() { + stop(); + } + })); + serverThread.start(); + } + + void stop() { + server.stop(); + serverThread.interrupt(); + StaxReflect.getStaxAppQueryTimer(server).cancel(); + KillerCallback requestMonitorAssassin = new KillerCallback(StaxReflect.getRequestMonitorTimerCallback(server)); + set("callbackClient", StaxReflect.getRequestMonitorTimer(server), requestMonitorAssassin); + requestMonitorAssassin.setToKill(); + } + + private class KillerCallback extends ServerCallbackClient { + private final ServerCallbackClient delegate; + private volatile boolean killCaller = false; + + private KillerCallback(ServerCallbackClient delegate) { + super("", ""); + this.delegate = delegate; + } + + @Override + public AuthenticationResult getApplicationTicket(String username, + String password) throws IOException { + return delegate.getApplicationTicket(username, password); + } + + @Override + public AuthenticationResult renewApplicationTicket(String userAuthTicket) + throws IOException { + return delegate.renewApplicationTicket(userAuthTicket); + } + + @Override + public void updateStatus(State state) throws ServletException, + IOException { + if (killCaller) { + throw new ThreadDeath(); + } + delegate.updateStatus(state); + } + + private void setToKill() { + killCaller = true; + } + } + + private static class StaxReflect { + private static WebAppEngine getWebAppEngine(StaxSdkAppServer server) { + return (WebAppEngine) Iterables.find(asList((IEngineFactory[]) + valueOf("engineFactories", server, StaxAppServerBase.class)), + instanceOf(WebAppEngine.class)); + } + + private static Timer getStaxAppQueryTimer(StaxSdkAppServer server) { + return (Timer) valueOf("timer", getWebAppEngine(server)); + } + + private static AppServerConfig getAppServerConfig(IAppServerConfiguration config) { + return (AppServerConfig) valueOf("appServerConfig", config); + } + + private static Engine getLocalEngine(StaxSdkAppServer server) { + return (Engine) Iterables.find(asList((Engine[]) + valueOf("engines", valueOf("container", server, TomcatServerBase.class))), + new Predicate() { + @Override + public boolean apply(Engine input) { + return input.getName().equals("localEngine"); + } + }); + } + + private static Runnable getRequestMonitorTimer(StaxSdkAppServer server) { + return (Runnable) valueOf("idleTimer", Iterables.find( + asList(getLocalEngine(server).getPipeline().getValves()), + instanceOf(RequestMonitorValve.class))); + } + + private static ServerCallbackClient getRequestMonitorTimerCallback( + StaxSdkAppServer server) { + return (ServerCallbackClient) valueOf("callbackClient", + getRequestMonitorTimer(server)); + } + } +} diff --git a/demos/runatcloud-tweetstore/src/test/java/org/jclouds/demo/tweetstore/integration/TweetStoreLiveTest.java b/demos/runatcloud-tweetstore/src/test/java/org/jclouds/demo/tweetstore/integration/TweetStoreLiveTest.java new file mode 100644 index 0000000000..7ab92ebce2 --- /dev/null +++ b/demos/runatcloud-tweetstore/src/test/java/org/jclouds/demo/tweetstore/integration/TweetStoreLiveTest.java @@ -0,0 +1,218 @@ +/** + * + * Copyright (C) 2011 Cloud Conscious, LLC. + * + * ==================================================================== + * 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 org.jclouds.demo.tweetstore.integration; + +import static com.google.common.base.Preconditions.checkNotNull; +import static org.jclouds.demo.tweetstore.reference.TweetStoreConstants.PROPERTY_TWEETSTORE_CONTAINER; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Properties; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +import org.jclouds.blobstore.BlobStoreContext; +import org.jclouds.blobstore.BlobStoreContextFactory; +import org.jclouds.demo.tweetstore.config.GuiceServletConfig; +import org.jclouds.demo.tweetstore.controller.StoreTweetsController; +import org.jclouds.logging.log4j.config.Log4JLoggingModule; +import org.jclouds.rest.AuthorizationException; +import org.jclouds.util.Strings2; +import org.testng.annotations.BeforeTest; +import org.testng.annotations.Parameters; +import org.testng.annotations.Test; + +import twitter4j.ResponseList; +import twitter4j.Status; +import twitter4j.Twitter; +import twitter4j.TwitterException; +import twitter4j.TwitterFactory; +import twitter4j.conf.Configuration; +import twitter4j.conf.ConfigurationBuilder; + +import com.google.common.base.Joiner; +import com.google.common.base.Splitter; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Maps; +import com.google.inject.Module; + +/** + * Starts up the Google App Engine for Java Development environment and deploys an application which + * tests accesses twitter and blobstores. + * + * @author Adrian Cole + */ +@Test(groups = "live", singleThreaded = true) +public class TweetStoreLiveTest { + + RunAtCloudServer server; + private URL url; + private Map contexts; + private String container; + private static final Iterable blobstores = + Splitter.on(',').split(System.getProperty("jclouds.tweetstore.blobstores", + "cloudfiles-us,aws-s3,azureblob")); + private static final Properties props = new Properties(); + + @BeforeTest + void clearAndCreateContainers() throws InterruptedException, ExecutionException, TimeoutException, IOException, + TwitterException { + container = checkNotNull(System.getProperty(PROPERTY_TWEETSTORE_CONTAINER), PROPERTY_TWEETSTORE_CONTAINER); + + props.setProperty(PROPERTY_TWEETSTORE_CONTAINER, container); + props.setProperty(GuiceServletConfig.PROPERTY_BLOBSTORE_CONTEXTS, Joiner.on(',').join(blobstores)); + + // put all identity/credential pairs into the client + addCredentialsForBlobStores(props); + + // example of an ad-hoc client configuration + addConfigurationForTwitter(props); + + final BlobStoreContextFactory factory = new BlobStoreContextFactory(); + // for testing, capture logs. + final Set wiring = ImmutableSet. of(new Log4JLoggingModule()); + this.contexts = Maps.newConcurrentMap(); + + for (String provider : blobstores) { + contexts.put(provider, factory.createContext(provider, wiring, props)); + } + + Configuration conf = new ConfigurationBuilder() + .setUser(props.getProperty("twitter.identity")) + .setPassword(props.getProperty("twitter.credential")).build(); + Twitter client = new TwitterFactory(conf).getInstance(); + StoreTweetsController controller = new StoreTweetsController(contexts, container, client); + + ResponseList statuses = client.getMentions(); + + boolean deleted = false; + for (BlobStoreContext context : contexts.values()) { + try { + if (context.getBlobStore().containerExists(container)) { + System.err.printf("deleting container %s at %s%n", container, context.getProviderSpecificContext() + .getEndpoint()); + context.getBlobStore().deleteContainer(container); + deleted = true; + } + } catch (AuthorizationException e) { + throw new AuthorizationException("for context: " + context, e); + } + } + if (deleted) { + System.err.println("sleeping 60 seconds to allow containers to clear"); + Thread.sleep(60000); + } + for (BlobStoreContext context : contexts.values()) { + System.err.printf("creating container %s at %s%n", container, context.getProviderSpecificContext() + .getEndpoint()); + context.getBlobStore().createContainerInLocation(null, container); + } + + if (deleted) { + System.err.println("sleeping 5 seconds to allow containers to create"); + Thread.sleep(5000); + } + + for (Entry entry : contexts.entrySet()) { + System.err.printf("filling container %s at %s%n", container, entry.getKey()); + controller.addMyTweets(entry.getKey(), statuses); + } + } + + @BeforeTest(dependsOnMethods = "clearAndCreateContainers") + @Parameters({ "warfile", "bees.address", "bees.port", "bees.environment", "bees.basedir" }) + public void startDevAppServer(final String warfile, final String address, final String port, + String environments, String serverBaseDirectory) throws Exception { + url = new URL(String.format("http://%s:%s", address, port)); + + server = new RunAtCloudServer(); + server.writePropertiesAndStartServer(address, port, warfile, environments, + serverBaseDirectory, props); + } + + private void addConfigurationForTwitter(Properties props) { + props.setProperty("twitter.identity", checkNotNull(System.getProperty("test.twitter.identity"), "test.twitter.identity")); + props.setProperty("twitter.credential", + checkNotNull(System.getProperty("test.twitter.credential"), "test.twitter.credential")); + } + + private void addCredentialsForBlobStores(Properties props) { + for (String provider : blobstores) { + props.setProperty(provider + ".identity", + checkNotNull(System.getProperty("test." + provider + ".identity"), "test." + provider + ".identity")); + props.setProperty(provider + ".credential", + checkNotNull(System.getProperty("test." + provider + ".credential"), "test." + provider + ".credential")); + } + } + + @Test + public void shouldPass() throws InterruptedException, IOException { + InputStream i = url.openStream(); + String string = Strings2.toStringAndClose(i); + assert string.indexOf("Welcome") >= 0 : string; + } + + @Test(dependsOnMethods = "shouldPass", expectedExceptions = IOException.class) + public void shouldFail() throws InterruptedException, IOException { + new URL(url, "/store/do").openStream(); + } + + @Test(dependsOnMethods = "shouldFail") + public void testPrimeContainers() throws IOException, InterruptedException { + URL gurl = new URL(url, "/store/do"); + + for (String context : blobstores) { + System.out.println("storing at context: " + context); + HttpURLConnection connection = (HttpURLConnection) gurl.openConnection(); + connection.addRequestProperty("X-AppEngine-QueueName", "twitter"); + connection.addRequestProperty("context", context); + InputStream i = connection.getInputStream(); + String string = Strings2.toStringAndClose(i); + assert string.indexOf("Done!") >= 0 : string; + connection.disconnect(); + } + + System.err.println("sleeping 20 seconds to allow for eventual consistency delay"); + Thread.sleep(20000); + for (BlobStoreContext context : contexts.values()) { + assert context.createInputStreamMap(container).size() > 0 : context.getProviderSpecificContext().getEndpoint(); + } + } + + @Test(invocationCount = 5, dependsOnMethods = "testPrimeContainers") + public void testSerial() throws InterruptedException, IOException { + URL gurl = new URL(url, "/tweets/get"); + InputStream i = gurl.openStream(); + String string = Strings2.toStringAndClose(i); + assert string.indexOf("Tweets in Clouds") >= 0 : string; + } + + @Test(invocationCount = 10, dependsOnMethods = "testPrimeContainers", threadPoolSize = 3) + public void testParallel() throws InterruptedException, IOException { + URL gurl = new URL(url, "/tweets/get"); + InputStream i = gurl.openStream(); + String string = Strings2.toStringAndClose(i); + assert string.indexOf("Tweets in Clouds") >= 0 : string; + } +} diff --git a/demos/runatcloud-tweetstore/src/test/resources/log4j.xml b/demos/runatcloud-tweetstore/src/test/resources/log4j.xml new file mode 100644 index 0000000000..033a95384d --- /dev/null +++ b/demos/runatcloud-tweetstore/src/test/resources/log4j.xml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +