diff --git a/applications/sink/twitter-update-sink/README.adoc b/applications/sink/twitter-update-sink/README.adoc
index d4e0e6d6..533b9546 100644
--- a/applications/sink/twitter-update-sink/README.adoc
+++ b/applications/sink/twitter-update-sink/README.adoc
@@ -41,22 +41,3 @@ By default the `twitter-update` implements the following composite function chai
`spring.cloud.stream.function.definition=byteArrayTextToString|messageToStatusUpdateFunction|updateStatus` or (`byteArrayTextToString|twitterStatusUpdateConsumer`)
-== Examples
-
-```
-java -jar twitter-update-sink.jar
-
- --twitter.connection.consumerKey= ...
- --twitter.connection.consumerSecret= ...
- --twitter.connection.accessToken= ...
- --twitter.connection.accessTokenSecret= ...
-```
-
-and send message to the `input` channel.
-
-And here is a example pipeline that uses twitter-update:
-
-```
-twitter-update-stream= time | twitter-update --twitter.connection.consumerKey= ... --twitter.connection.consumerSecret= ... --twitter.connection.accessToken= ... --twitter.connection.accessTokenSecret= ...
-```
-
diff --git a/functions/common/twitter-common/src/main/java/org/springframework/cloud/fn/common/twitter/StringToSpelConversionFunction.java b/functions/common/twitter-common/src/main/java/org/springframework/cloud/fn/common/twitter/StringToSpelConversionFunction.java
new file mode 100644
index 00000000..8ea52284
--- /dev/null
+++ b/functions/common/twitter-common/src/main/java/org/springframework/cloud/fn/common/twitter/StringToSpelConversionFunction.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2020-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.cloud.fn.common.twitter;
+
+import java.util.function.Function;
+
+import org.springframework.expression.EvaluationContext;
+import org.springframework.expression.Expression;
+import org.springframework.expression.ParseException;
+import org.springframework.expression.spel.standard.SpelExpression;
+import org.springframework.expression.spel.standard.SpelExpressionParser;
+
+/**
+ * Converter from String to Spring Expression.
+ *
+ * TODO: This could be a top level project.
+ */
+public class StringToSpelConversionFunction implements Function {
+
+ private final SpelExpressionParser parser;
+
+ private final EvaluationContext evaluationContext;
+
+ public StringToSpelConversionFunction(EvaluationContext evaluationContext) {
+ this(new SpelExpressionParser(), evaluationContext);
+ }
+
+ public StringToSpelConversionFunction(SpelExpressionParser parser, EvaluationContext evaluationContext) {
+ this.evaluationContext = evaluationContext;
+ this.parser = parser;
+ }
+
+ @Override
+ public Expression apply(String source) {
+ try {
+ Expression expression = parser.parseExpression(source);
+ if (expression instanceof SpelExpression) {
+ ((SpelExpression) expression).setEvaluationContext(evaluationContext);
+ }
+ return expression;
+ }
+ catch (ParseException e) {
+ throw new IllegalArgumentException(String.format(
+ "Could not convert '%s' into a SpEL expression", source), e);
+ }
+ }
+}
diff --git a/functions/common/twitter-common/src/main/java/org/springframework/cloud/fn/common/twitter/TwitterConnectionConfiguration.java b/functions/common/twitter-common/src/main/java/org/springframework/cloud/fn/common/twitter/TwitterConnectionConfiguration.java
index b5b65c78..f3ea97fe 100644
--- a/functions/common/twitter-common/src/main/java/org/springframework/cloud/fn/common/twitter/TwitterConnectionConfiguration.java
+++ b/functions/common/twitter-common/src/main/java/org/springframework/cloud/fn/common/twitter/TwitterConnectionConfiguration.java
@@ -31,9 +31,16 @@ import twitter4j.TwitterStream;
import twitter4j.TwitterStreamFactory;
import twitter4j.conf.ConfigurationBuilder;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.context.properties.ConfigurationPropertiesBinding;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.expression.EvaluationContext;
+import org.springframework.expression.Expression;
+import org.springframework.integration.context.IntegrationContextUtils;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageHeaders;
import org.springframework.messaging.support.MessageBuilder;
@@ -124,4 +131,22 @@ public class TwitterConnectionConfiguration {
Function rawJsonExtractor, Function> json) {
return list -> (properties.isRawJson()) ? rawJsonExtractor.andThen(json).apply(list) : json.apply(list);
}
+
+ @Bean
+ public Function stringToSpelFunction(
+ @Qualifier(IntegrationContextUtils.INTEGRATION_EVALUATION_CONTEXT_BEAN_NAME)
+ @Lazy EvaluationContext evaluationContext) {
+ return new StringToSpelConversionFunction(evaluationContext);
+ }
+
+ @Bean
+ @ConfigurationPropertiesBinding
+ public Converter propertiesSpelConverter(Function stringToSpelFunction) {
+ return new Converter() { // NOTE Using lambda causes Java Generics issues.
+ @Override
+ public Expression convert(String source) {
+ return stringToSpelFunction.apply(source);
+ }
+ };
+ }
}
diff --git a/functions/function/twitter-function/README.adoc b/functions/function/twitter-function/README.adoc
index 9d2e19af..7a957afd 100644
--- a/functions/function/twitter-function/README.adoc
+++ b/functions/function/twitter-function/README.adoc
@@ -2,7 +2,18 @@
This module provides couple of twitter functions that can be reused and composed in other applications.
-## Twitter Trend Function
+To use those functions add the following dependency to your POM:
+
+[source,XML]
+----
+
+ org.springframework.cloud.fn
+ twitter-function
+ ${java-functions.version}
+
+----
+
+## 1. Twitter Trend Function
Functions can return either Trends topics or the Locations of the trending topics. The `twitter.trend.trend-query-type` property allows choosing between both types.
@@ -13,7 +24,7 @@ If the `latitude`, `longitude` parameters are provided the processor performs th
Response is an array of `locations` that encode the location's WOEID and some other human-readable information such as a canonical name and country the location belongs in.
-### Beans for injection
+### 1.1 Beans for injection
You can import the `TwitterTrendFunctionConfiguration` in a Spring Boot application and then inject the following bean.
@@ -23,8 +34,116 @@ You can use `Function, Message> twitterTrendFunction` as a qu
Once injected, you can use the `apply` method of the `Function` to invoke it and get the result.
-### Configuration Options
+### 1.2 Configuration Options
+
+TIP: For `SpEL` expression properties wrap the literal values in single quotes (`'`).
+
+For more information on the various options available, please see link:../twitter-function/src/main/java/org/springframework/cloud/fn/twitter/trend/TwitterTrendFunctionProperties.java[TwitterTrendFunctionProperties.java]
+
+### 1.3 Tests
+See this link:src/test/java/org/springframework/cloud/fn/twitter/trend/TwitterTrendFunctionTests.java[test suite] for examples of how this function is used.
+
+### 1.4 Other usage
+
+Leveraging the Spring Cloud Function composability, you can compose the Trend function in your boot app like this:
+
+[source,Java]
+----
+@SpringBootApplication
+@Import(TwitterTrendFunctionConfiguration.class )
+public class MyTwitterTrendBootApp {
+
+ public static void main(String[] args) {
+ SpringApplication.run(MyTwitterTrendBootApp.class,
+ "--spring.cloud.stream.function.definition=trend|twitterTrendFunction|managedJson");
+ }
+}
+----
+
+## 2. Twitter Geo Function.
+
+Function based on the https://developer.twitter.com/en/docs/geo/places-near-location/overview[Geo API] that retrieves Twitter place information based on query parameters such as (`latitude`, `longitude`) pair, an `IP` address, or a place `name`.
+
+There are two types for geo search queries `search` and `reverse` controlled by the `twitter.geo.search.type` property.
+
+* reverse - Given a latitude and a longitude, searches for up to 20 places that can be used as a `placeId` when updating a status.
+This request is an informative call and will deliver generalized results about geography.
+
+* search - Search for places that can be attached to statuses/update. Given a latitude and a longitude pair, an IP address, or a name, this request will return a list of all the valid places that can be used as the place_id when updating a status.
+
+Conceptually, a query can be made from the user’s location, retrieve a list of places, have the user validate the location he or she is at, and then send the ID of this location with a call to POST statuses/update.
+
+This is the recommended method to use find places that can be attached to statuses/update. Unlike GET geo/reverse_geocode which provides raw data access, this endpoint can potentially re-order places with regard to the user who is authenticated. Use this approach for interactive place matching with the user.
+
+Some parameters in this method are only required based on the existence of other parameters. For instance, “lat” is required if “long” is provided, and vice-versa. Authentication is recommended, but not required with this method.
+
+NOTE: Limits: 15 requests / 15-min window (user auth).
+
+### 2.1 Beans for injection
+
+You can import the `TwitterGeoFunctionConfiguration` in a Spring Boot application and then inject the following bean.
+
+`filterFunction`
+
+You can use `Function, Message> twitterGeoFunction` as a qualifier when injecting.
+Once injected, you can use the `apply` method of the `Function` to invoke it and get the result.
+
+### 2.2 Configuration Options
+
+TIP: For `SpEL` expression properties wrap the literal values in single quotes (`'`).
+
+For more information on the various options available, please see link:../twitter-function/src/main/java/org/springframework/cloud/fn/twitter/geo/TwitterGeoFunctionProperties.java[TwitterGeoFunctionProperties.java]
+
+### 2.3 Tests
+
+See this link:src/test/java/org/springframework/cloud/fn/twitter/geo/TwitterGeoFunctionTest.java[test suite] for examples of how this function is used.
+
+### 2.4 Other usage
+
+Leveraging the Spring Cloud Function composability, you can compose the Geo function in your boot app like this:
+
+[source,Java]
+----
+@SpringBootApplication
+@Import(TwitterGeoFunctionConfiguration.class )
+public class MyTwitterGeoProcessorBootApp {
+
+ public static void main(String[] args) {
+ SpringApplication.run(MyTwitterGeoProcessorBootApp.class,
+ "--spring.cloud.stream.function.definition=messageToGeoQueryFunction|twitterSearchPlacesFunction|managedJson");
+ }
+}
+----
+
+## 3. Twitter Users Function
+
+Retrieves users either by list of use ids and/or screen-names (https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-users-lookup[Users Lookup API]) or by text search query (https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-users-search[Users Search API]).
+Uses SpEL expressions to compute the query parameters from the input message.
+Use the single quoted literals to set static values (e.g. user-id: '6666, 9999, 10000').
+
+Use `twitter.users.type` property allow to select the query approaches.
+
+* https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-users-lookup[Users Lookup API] - Returns fully-hydrated user objects for up to 100 users per request, as specified by comma-separated values passed to the `userId` and/or `screenName` parameters. Rate limits: (300 requests / 15-min window)
+* https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-users-search[Users Search API] - Relevance-based search interface to public user accounts on Twitter.
+Querying by topical interest, full name, company name, location, or other criteria. Exact match searches are not supported. Only the first 1,000 matching results are available. Rate limits:(900 requests / 15-min window)
+
+### 3.1 Beans for injection
+
+You can import the `TwitterUsersFunctionConfiguration` in a Spring Boot application and then inject the following bean.
+
+`filterFunction`
+
+You can use `Function, Message> twitterUsersFunction` as a qualifier when injecting.
+Once injected, you can use the `apply` method of the `Function` to invoke it and get the result.
-### Other usage
+### 3.2 Configuration Options
+TIP: For `SpEL` expression properties wrap the literal values in single quotes (`'`).
+
+For more information on the various options available, please see link:../twitter-function/src/main/java/org/springframework/cloud/fn/twitter/users/TwitterUsersFunctionProperties.java[TwitterUsersFunctionProperties.java]
+
+### 3.3 Tests
+See this link:src/test/java/org/springframework/cloud/fn/twitter/users/TwitterUsersFunctionTests.java[test suite] for examples of how this function is used.
+
+### 3.4 Other usage
diff --git a/functions/function/twitter-function/pom.xml b/functions/function/twitter-function/pom.xml
index 93098c54..bf85b726 100644
--- a/functions/function/twitter-function/pom.xml
+++ b/functions/function/twitter-function/pom.xml
@@ -25,6 +25,7 @@
org.springframework.boot
spring-boot-starter-integration
+
org.springframework.boot
spring-boot-starter-test
@@ -41,6 +42,23 @@
spring-boot-configuration-processor
provided
+
+ org.mock-server
+ mockserver-netty
+ 5.10
+ test
+
+
+ org.mock-server
+ mockserver-client-java
+ 5.10
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
diff --git a/functions/function/twitter-function/src/main/java/org/springframework/cloud/fn/twitter/geo/TwitterGeoFunctionConfiguration.java b/functions/function/twitter-function/src/main/java/org/springframework/cloud/fn/twitter/geo/TwitterGeoFunctionConfiguration.java
new file mode 100644
index 00000000..e58443ce
--- /dev/null
+++ b/functions/function/twitter-function/src/main/java/org/springframework/cloud/fn/twitter/geo/TwitterGeoFunctionConfiguration.java
@@ -0,0 +1,113 @@
+/*
+ * Copyright 2020-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.cloud.fn.twitter.geo;
+
+import java.util.List;
+import java.util.function.Function;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import twitter4j.GeoLocation;
+import twitter4j.GeoQuery;
+import twitter4j.Place;
+import twitter4j.Twitter;
+import twitter4j.TwitterException;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.cloud.fn.common.twitter.TwitterConnectionConfiguration;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+import org.springframework.messaging.Message;
+
+/**
+ *
+ * @author Christian Tzolov
+ */
+@Configuration
+@EnableConfigurationProperties(TwitterGeoFunctionProperties.class)
+@Import(TwitterConnectionConfiguration.class)
+public class TwitterGeoFunctionConfiguration {
+
+ private static final Log logger = LogFactory.getLog(TwitterGeoFunctionConfiguration.class);
+
+ @Bean
+ public Function, GeoQuery> messageToGeoQueryFunction(TwitterGeoFunctionProperties geoProperties) {
+ return message -> {
+ String ip = null;
+ if (geoProperties.getSearch().getIp() != null) {
+ ip = geoProperties.getSearch().getIp().getValue(message, String.class);
+ }
+ GeoLocation geoLocation = null;
+ if (geoProperties.getLocation().getLat() != null && geoProperties.getLocation().getLon() != null) {
+ Double lat = geoProperties.getLocation().getLat().getValue(message, Double.class);
+ Double lon = geoProperties.getLocation().getLon().getValue(message, Double.class);
+ geoLocation = new GeoLocation(lat, lon);
+ }
+
+ String query = null;
+ if (geoProperties.getSearch().getQuery() != null) {
+ query = geoProperties.getSearch().getQuery().getValue(message, String.class);
+ }
+ GeoQuery geoQuery = new GeoQuery(query, ip, geoLocation);
+
+ geoQuery.setMaxResults(geoProperties.getMaxResults());
+ geoQuery.setAccuracy(geoProperties.getAccuracy());
+ geoQuery.setGranularity(geoProperties.getGranularity());
+
+ return geoQuery;
+ };
+ }
+
+ @Bean
+ @ConditionalOnProperty(name = "twitter.geo.search.type", havingValue = "search", matchIfMissing = true)
+ public Function> twitterSearchPlacesFunction(Twitter twitter) {
+ return geoQuery -> {
+ try {
+ return twitter.searchPlaces(geoQuery);
+ }
+ catch (TwitterException e) {
+ logger.error("Places Search failed!", e);
+ }
+ return null;
+ };
+ }
+
+ @Bean
+ @ConditionalOnProperty(name = "twitter.geo.search.type", havingValue = "reverse")
+ public Function> twitterReverseGeocodeFunction(Twitter twitter) {
+ return geoQuery -> {
+ try {
+ return twitter.reverseGeoCode(geoQuery);
+ }
+ catch (TwitterException e) {
+ logger.error("Reverse Geocode failed!", e);
+ }
+ return null;
+ };
+ }
+
+ @Bean
+ public Function, Message> twitterGeoFunction(
+ Function, GeoQuery> toGeoQuery,
+ Function> places,
+ Function> managedJson) {
+
+ return toGeoQuery.andThen(places).andThen(managedJson)::apply;
+ }
+}
diff --git a/functions/function/twitter-function/src/main/java/org/springframework/cloud/fn/twitter/geo/TwitterGeoFunctionProperties.java b/functions/function/twitter-function/src/main/java/org/springframework/cloud/fn/twitter/geo/TwitterGeoFunctionProperties.java
new file mode 100644
index 00000000..822cc398
--- /dev/null
+++ b/functions/function/twitter-function/src/main/java/org/springframework/cloud/fn/twitter/geo/TwitterGeoFunctionProperties.java
@@ -0,0 +1,197 @@
+/*
+ * Copyright 2020-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.cloud.fn.twitter.geo;
+
+import javax.validation.constraints.AssertTrue;
+import javax.validation.constraints.NotNull;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.expression.Expression;
+import org.springframework.expression.spel.standard.SpelExpressionParser;
+import org.springframework.validation.annotation.Validated;
+
+
+/**
+ * @author Christian Tzolov
+ */
+@ConfigurationProperties("twitter.geo")
+@Validated
+public class TwitterGeoFunctionProperties {
+
+ private static final Expression DEFAULT_EXPRESSION = new SpelExpressionParser().parseExpression("payload");
+
+ public enum GeoType {
+ /** Geo retrieval type. */
+ reverse, search
+ }
+
+ /**
+ * Geo search API type: reverse or search.
+ */
+ @NotNull
+ private GeoType type = GeoType.search;
+
+ /**
+ * Search geo type filter parameters.
+ */
+ private Search search = new Search();
+
+ /**
+ *
+ */
+ private Location location = new Location();
+
+ /**
+ * Hints for the number of results to return. This does not guarantee that the number of results
+ * returned will equal max_results, but instead informs how many "nearby" results to return.
+ */
+ private int maxResults = -1;
+
+ /**
+ * Sets a hint on the "region" in which to search. If a number, then this is a radius in meters, but it
+ * can also take a string that is suffixed with ft to specify feet. If this is not passed in, then it is
+ * assumed to be 0m. If coming from a device, in practice, this value is whatever accuracy the device
+ * has measuring its location (whether it be coming from a GPS, WiFi triangulation, etc.).
+ */
+ private String accuracy = null;
+
+ /**
+ * Minimal granularity of data to return. If this is not passed in, then neighborhood is assumed.
+ * City can also be passed.
+ */
+ private String granularity = null;
+
+ public Search getSearch() {
+ return search;
+ }
+
+ public void setSearch(Search search) {
+ this.search = search;
+ }
+
+ public GeoType getType() {
+ return type;
+ }
+
+ public void setType(GeoType type) {
+ this.type = type;
+ }
+
+ public Location getLocation() {
+ return location;
+ }
+
+ public void setLocation(Location location) {
+ this.location = location;
+ }
+
+ public int getMaxResults() {
+ return maxResults;
+ }
+
+ public void setMaxResults(int maxResults) {
+ this.maxResults = maxResults;
+ }
+
+ public String getAccuracy() {
+ return accuracy;
+ }
+
+ public void setAccuracy(String accuracy) {
+ this.accuracy = accuracy;
+ }
+
+ public String getGranularity() {
+ return granularity;
+ }
+
+ public void setGranularity(String granularity) {
+ this.granularity = granularity;
+ }
+
+ @AssertTrue(message = "Either the IP or the Location must be set")
+ public boolean isAtLeastOne() {
+ return this.getSearch().getIp() == null ^ (this.getLocation().getLat() == null && this.getLocation().getLon() == null);
+ }
+
+ @AssertTrue(message = "The IP parameter is applicable only for 'Search' GeoType")
+ public boolean isIpUsedWithSearchGeoType() {
+ if (this.getSearch().getIp() != null) {
+ return this.type == GeoType.search;
+ }
+ return true;
+ }
+
+ public static class Search {
+ /**
+ * An IP address. Used when attempting to fix geolocation based off of the user's IP address.
+ * Applicable only for "search" geo type.
+ */
+ private Expression ip = null;
+
+ /**
+ * Query expression to filter Places in search results.
+ */
+ private Expression query = null;
+
+ public Expression getIp() {
+ return ip;
+ }
+
+ public void setIp(Expression ip) {
+ this.ip = ip;
+ }
+
+ public Expression getQuery() {
+ return query;
+ }
+
+ public void setQuery(Expression query) {
+ this.query = query;
+ }
+ }
+
+ public static class Location {
+
+ /**
+ * User's lat.
+ */
+ private Expression lat;
+
+ /**
+ * User's lon.
+ */
+ private Expression lon;
+
+
+ public Expression getLat() {
+ return lat;
+ }
+
+ public void setLat(Expression lat) {
+ this.lat = lat;
+ }
+
+ public Expression getLon() {
+ return lon;
+ }
+
+ public void setLon(Expression lon) {
+ this.lon = lon;
+ }
+ }
+}
diff --git a/functions/function/twitter-function/src/main/java/org/springframework/cloud/fn/twitter/users/TwitterUsersFunctionConfiguration.java b/functions/function/twitter-function/src/main/java/org/springframework/cloud/fn/twitter/users/TwitterUsersFunctionConfiguration.java
new file mode 100644
index 00000000..17dfafe3
--- /dev/null
+++ b/functions/function/twitter-function/src/main/java/org/springframework/cloud/fn/twitter/users/TwitterUsersFunctionConfiguration.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright 2020-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.cloud.fn.twitter.users;
+
+import java.util.List;
+import java.util.function.Function;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import twitter4j.ResponseList;
+import twitter4j.Twitter;
+import twitter4j.TwitterException;
+import twitter4j.User;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.cloud.fn.common.twitter.TwitterConnectionConfiguration;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+import org.springframework.messaging.Message;
+
+/**
+ *
+ * @author Christian Tzolov
+ */
+@Configuration
+@EnableConfigurationProperties(TwitterUsersFunctionProperties.class)
+@Import(TwitterConnectionConfiguration.class)
+public class TwitterUsersFunctionConfiguration {
+
+ private static final Log logger = LogFactory.getLog(TwitterUsersFunctionConfiguration.class);
+
+ @Bean
+ @ConditionalOnProperty(name = "twitter.users.type", havingValue = "search")
+ public Function, List> userSearch(Twitter twitter, TwitterUsersFunctionProperties properties) {
+
+ return message -> {
+ String query = properties.getSearch().getQuery().getValue(message, String.class);
+ try {
+ ResponseList users = twitter.searchUsers(query, properties.getSearch().getPage());
+ return users;
+ }
+ catch (TwitterException e) {
+ logger.error("Twitter API error!", e);
+ }
+ return null;
+ };
+ }
+
+ @Bean
+ @ConditionalOnProperty(name = "twitter.users.type", havingValue = "lookup")
+ public Function, List> userLookup(Twitter twitter, TwitterUsersFunctionProperties properties) {
+
+ return message -> {
+
+ try {
+ TwitterUsersFunctionProperties.Lookup lookup = properties.getLookup();
+ if (lookup.getScreenName() != null) {
+ String[] screenNames = lookup.getScreenName().getValue(message, String[].class);
+ return twitter.lookupUsers(screenNames);
+ }
+ else if (lookup.getUserId() != null) {
+ long[] ids = lookup.getUserId().getValue(message, long[].class);
+ return twitter.lookupUsers(ids);
+ }
+ }
+ catch (TwitterException e) {
+ logger.error("Twitter API error!", e);
+ }
+ return null;
+ };
+ }
+
+ @Bean
+ /**
+ * queryUsers - depends on the `twitter.users.type` property is either userSearch or userLookup.
+ * managedJson - converts Users into JSON message payload.
+ */
+ public Function, Message> twitterUsersFunction(Function, List> queryUsers,
+ Function> managedJson) {
+ return queryUsers.andThen(managedJson);
+ }
+}
diff --git a/functions/function/twitter-function/src/main/java/org/springframework/cloud/fn/twitter/users/TwitterUsersFunctionProperties.java b/functions/function/twitter-function/src/main/java/org/springframework/cloud/fn/twitter/users/TwitterUsersFunctionProperties.java
new file mode 100644
index 00000000..458083e9
--- /dev/null
+++ b/functions/function/twitter-function/src/main/java/org/springframework/cloud/fn/twitter/users/TwitterUsersFunctionProperties.java
@@ -0,0 +1,144 @@
+/*
+ * Copyright 2020-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.cloud.fn.twitter.users;
+
+import javax.validation.constraints.AssertTrue;
+import javax.validation.constraints.NotNull;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.expression.Expression;
+import org.springframework.expression.spel.standard.SpelExpressionParser;
+import org.springframework.validation.annotation.Validated;
+
+/**
+ * @author Christian Tzolov
+ */
+@ConfigurationProperties("twitter.users")
+@Validated
+public class TwitterUsersFunctionProperties {
+
+ private static final Expression DEFAULT_EXPRESSION = new SpelExpressionParser().parseExpression("payload");
+
+ public enum UserQueryType {
+ /** User retrieval types. */
+ search, lookup
+ }
+
+ /**
+ * Perform search or lookup type of search.
+ */
+ @NotNull
+ private UserQueryType type = UserQueryType.search;
+
+ /**
+ * Returns fully-hydrated user objects for specified by comma-separated values passed to the user_id and/or
+ * screen_name parameters.
+ */
+ private final Lookup lookup = new Lookup();
+
+ /**
+ * relevance-based search interface for querying by topical interest, full name, company name, location,
+ * or other criteria.
+ */
+ private final Search search = new Search();
+
+ public UserQueryType getType() {
+ return type;
+ }
+
+ public void setType(UserQueryType type) {
+ this.type = type;
+ }
+
+ public Lookup getLookup() {
+ return lookup;
+ }
+
+ public Search getSearch() {
+ return search;
+ }
+
+ @AssertTrue(message = "Per query type validate the required parameters")
+ public boolean checkParametersPerType() {
+ if (this.getType() == UserQueryType.lookup) {
+ return (this.getLookup().getScreenName() != null) || (this.getLookup().getUserId() != null);
+ }
+ else if (this.getType() == UserQueryType.search) {
+ return (this.getSearch().getQuery() != null);
+ }
+
+ return false;
+ }
+
+ public static class Lookup {
+ /**
+ * A comma separated list of user IDs, up to 100 are allowed in a single request.
+ * You are strongly encouraged to use a POST for larger requests.
+ */
+ private Expression userId;
+
+ /**
+ * A comma separated list of screen names, up to 100 are allowed in a single request.
+ * You are strongly encouraged to use a POST for larger (up to 100 screen names) requests.
+ */
+ private Expression screenName;
+
+ public Expression getUserId() {
+ return userId;
+ }
+
+ public void setUserId(Expression userId) {
+ this.userId = userId;
+ }
+
+ public Expression getScreenName() {
+ return screenName;
+ }
+
+ public void setScreenName(Expression screenName) {
+ this.screenName = screenName;
+ }
+ }
+
+ public static class Search {
+ /**
+ * The search query to run against people search.
+ */
+ private Expression query = DEFAULT_EXPRESSION;
+
+ /**
+ * Specifies the page of results to retrieve.
+ */
+ private int page = 3;
+
+ public Expression getQuery() {
+ return query;
+ }
+
+ public void setQuery(Expression query) {
+ this.query = query;
+ }
+
+ public int getPage() {
+ return page;
+ }
+
+ public void setPage(int page) {
+ this.page = page;
+ }
+ }
+}
diff --git a/functions/function/twitter-function/src/test/java/org/springframework/cloud/fn/twitter/geo/TwitterGeoFunctionTest.java b/functions/function/twitter-function/src/test/java/org/springframework/cloud/fn/twitter/geo/TwitterGeoFunctionTest.java
new file mode 100644
index 00000000..e2d8721f
--- /dev/null
+++ b/functions/function/twitter-function/src/test/java/org/springframework/cloud/fn/twitter/geo/TwitterGeoFunctionTest.java
@@ -0,0 +1,279 @@
+/*
+ * Copyright 2020-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.cloud.fn.twitter.geo;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.mockserver.client.MockServerClient;
+import org.mockserver.integration.ClientAndServer;
+import twitter4j.conf.ConfigurationBuilder;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.SpringBootConfiguration;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.cloud.fn.common.twitter.TwitterConnectionProperties;
+import org.springframework.cloud.fn.common.twitter.util.TwitterTestUtils;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Import;
+import org.springframework.context.annotation.Primary;
+import org.springframework.integration.support.MessageBuilder;
+import org.springframework.messaging.Message;
+import org.springframework.test.annotation.DirtiesContext;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.util.MimeTypeUtils;
+import org.springframework.util.SocketUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockserver.matchers.Times.unlimited;
+import static org.mockserver.model.HttpRequest.request;
+import static org.mockserver.model.HttpResponse.response;
+import static org.mockserver.verify.VerificationTimes.once;
+
+/**
+ * @author Christian Tzolov
+ */
+@SpringBootTest(
+ webEnvironment = SpringBootTest.WebEnvironment.NONE,
+ properties = {
+ "twitter.connection.consumerKey=consumerKey666",
+ "twitter.connection.consumerSecret=consumerSecret666",
+ "twitter.connection.accessToken=accessToken666",
+ "twitter.connection.accessTokenSecret=accessTokenSecret666"
+ })
+@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
+public abstract class TwitterGeoFunctionTest {
+
+ private static final String MOCK_SERVER_IP = "127.0.0.1";
+
+ private static final Integer MOCK_SERVER_PORT = SocketUtils.findAvailableTcpPort();
+
+ private static ClientAndServer mockServer;
+
+ private static MockServerClient mockClient;
+
+ @Autowired
+ protected Function, Message> twitterUsersFunction;
+
+ public static void recordRequestExpectation(Map> parameters) {
+
+ mockClient
+ .when(
+ request()
+ .withMethod("GET")
+ .withPath("/geo/search.json")
+ .withQueryStringParameters(parameters),
+ unlimited())
+ .respond(
+ response()
+ .withStatusCode(200)
+ .withHeader("Content-Type", "application/json; charset=utf-8")
+ .withBody(TwitterTestUtils.asString("classpath:/response/search_places_amsterdam.json"))
+ .withDelay(TimeUnit.SECONDS, 1));
+
+ }
+
+ @BeforeAll
+ public static void startMockServer() {
+ mockServer = ClientAndServer.startClientAndServer(MOCK_SERVER_PORT);
+ mockClient = new MockServerClient(MOCK_SERVER_IP, MOCK_SERVER_PORT);
+ }
+
+ @AfterAll
+ public static void stopMockServer() {
+ mockServer.stop();
+ }
+
+ @TestPropertySource(properties = {
+ "twitter.geo.search.ip='127.0.0.1'",
+ "twitter.geo.search.query=payload.toUpperCase()"
+ })
+ public static class TwitterGeoSearchByIPAndQueryTests extends TwitterGeoFunctionTest {
+
+ @Test
+ public void testOne() throws IOException {
+
+ Map> queryParameters = new HashMap<>();
+ queryParameters.put("ip", Collections.singletonList("127.0.0.1"));
+ queryParameters.put("query", Collections.singletonList("Amsterdam"));
+
+ recordRequestExpectation(queryParameters);
+
+ String inPayload = "Amsterdam";
+
+ Message> received = twitterUsersFunction.apply(MessageBuilder.withPayload(inPayload).build());
+
+ mockClient.verify(request()
+ .withMethod("GET")
+ .withPath("/geo/search.json")
+ .withQueryStringParameter("ip", "127.0.0.1")
+ .withQueryStringParameter("query", "AMSTERDAM"),
+ once());
+
+ String outPayload = new String((byte[]) received.getPayload());
+
+ assertThat(outPayload).isNotNull();
+
+ List places = new ObjectMapper().readValue(outPayload, List.class);
+ assertThat(places).hasSize(12);
+ }
+ }
+
+ @TestPropertySource(properties = {
+ "twitter.geo.location.lat='52.378'",
+ "twitter.geo.location.lon='4.9'",
+ "twitter.geo.search.query=payload.toUpperCase()"
+ })
+ public static class TwitterGeoSearchByLocationTests extends TwitterGeoFunctionTest {
+
+ @Test
+ public void testOne() throws IOException {
+
+ Map> queryParameters = new HashMap<>();
+ queryParameters.put("lat", Collections.singletonList("52.378"));
+ queryParameters.put("long", Collections.singletonList("4.9"));
+ queryParameters.put("query", Collections.singletonList("Amsterdam"));
+
+ recordRequestExpectation(queryParameters);
+
+ String inPayload = "Amsterdam";
+
+ Message> received = twitterUsersFunction.apply(MessageBuilder.withPayload(inPayload).build());
+
+ mockClient.verify(request()
+ .withMethod("GET")
+ .withPath("/geo/search.json")
+ .withQueryStringParameter("lat", "52.378")
+ .withQueryStringParameter("long", "4.9")
+ .withQueryStringParameter("query", "Amsterdam"),
+ once());
+
+ String outPayload = new String((byte[]) received.getPayload());
+
+ assertThat(outPayload).isNotNull();
+
+ List places = new ObjectMapper().readValue(outPayload, List.class);
+ assertThat(places).hasSize(12);
+ }
+ }
+
+ @TestPropertySource(properties = {
+ "twitter.geo.type=reverse",
+ "twitter.geo.location.lat='52.378'",
+ "twitter.geo.location.lon='4.9'"
+ })
+ public static class TwitterGeoSearchByLocation2Tests extends TwitterGeoFunctionTest {
+
+ @Test
+ public void testOne() throws IOException {
+
+ Map> queryParameters = new HashMap<>();
+ queryParameters.put("lat", Arrays.asList("52.378"));
+ queryParameters.put("long", Arrays.asList("4.9"));
+
+ recordRequestExpectation(queryParameters);
+
+ String inPayload = "Amsterdam";
+
+ Message> received = twitterUsersFunction.apply(MessageBuilder.withPayload(inPayload).build());
+
+ mockClient.verify(request()
+ .withMethod("GET")
+ .withPath("/geo/search.json")
+ .withQueryStringParameter("lat", "52.378")
+ .withQueryStringParameter("long", "4.9"),
+ once());
+
+ String outPayload = new String((byte[]) received.getPayload());
+
+ assertThat(outPayload).isNotNull();
+
+ List places = new ObjectMapper().readValue(outPayload, List.class);
+ assertThat(places).hasSize(12);
+ }
+ }
+
+ @TestPropertySource(properties = {
+ "twitter.geo.location.lat=#jsonPath(new String(payload),'$.location.lat')",
+ "twitter.geo.location.lon=#jsonPath(new String(payload),'$.location.lon')",
+ "twitter.geo.search.query=#jsonPath(new String(payload),'$.country')"
+ })
+ public static class TwitterGeoSearchJsonPathTests extends TwitterGeoFunctionTest {
+
+ @Test
+ public void testOne() throws IOException {
+
+ Map> queryParameters = new HashMap<>();
+ queryParameters.put("lat", Collections.singletonList("52.0"));
+ queryParameters.put("long", Collections.singletonList("5.0"));
+ queryParameters.put("query", Collections.singletonList("Netherlands"));
+
+ recordRequestExpectation(queryParameters);
+
+ String inPayload = "{ \"country\" : \"Netherlands\", \"location\" : { \"lat\" : 52.00 , \"lon\" : 5.0 } }";
+
+ Message> received = twitterUsersFunction.apply(MessageBuilder
+ .withPayload(inPayload)
+ .setHeader("contentType", MimeTypeUtils.APPLICATION_JSON_VALUE)
+ .build());
+
+ mockClient.verify(request()
+ .withMethod("GET")
+ .withPath("/geo/search.json")
+ .withQueryStringParameter("lat", "52.0")
+ .withQueryStringParameter("long", "5.0")
+ .withQueryStringParameter("query", "Netherlands"),
+ once());
+
+ String outPayload = new String((byte[]) received.getPayload());
+
+ assertThat(outPayload).isNotNull();
+
+ List places = new ObjectMapper().readValue(outPayload, List.class);
+ assertThat(places).hasSize(12);
+ }
+ }
+
+ @SpringBootConfiguration
+ @EnableAutoConfiguration
+ @Import(TwitterGeoFunctionConfiguration.class)
+ public static class TestTwitterGeoProcessorApplication {
+ @Bean
+ @Primary
+ public twitter4j.conf.Configuration twitterConfiguration2(TwitterConnectionProperties properties,
+ Function toConfigurationBuilder) {
+
+ Function mockedConfiguration =
+ toConfigurationBuilder.andThen(
+ new TwitterTestUtils().mockTwitterUrls(
+ String.format("http://%s:%s", MOCK_SERVER_IP, MOCK_SERVER_PORT)));
+
+ return mockedConfiguration.apply(properties).build();
+ }
+ }
+}
diff --git a/functions/function/twitter-function/src/test/java/org/springframework/cloud/fn/twitter/trend/TwitterTrendFunctionTests.java b/functions/function/twitter-function/src/test/java/org/springframework/cloud/fn/twitter/trend/TwitterTrendFunctionTests.java
new file mode 100644
index 00000000..246be793
--- /dev/null
+++ b/functions/function/twitter-function/src/test/java/org/springframework/cloud/fn/twitter/trend/TwitterTrendFunctionTests.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright 2020-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.cloud.fn.twitter.trend;
+
+import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.mockserver.client.MockServerClient;
+import org.mockserver.integration.ClientAndServer;
+import org.mockserver.model.Header;
+import org.mockserver.model.HttpRequest;
+import twitter4j.conf.ConfigurationBuilder;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.SpringBootConfiguration;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.cloud.fn.common.twitter.TwitterConnectionProperties;
+import org.springframework.cloud.fn.common.twitter.util.TwitterTestUtils;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Import;
+import org.springframework.context.annotation.Primary;
+import org.springframework.integration.support.MessageBuilder;
+import org.springframework.messaging.Message;
+import org.springframework.test.annotation.DirtiesContext;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.util.SocketUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockserver.matchers.Times.exactly;
+import static org.mockserver.model.HttpRequest.request;
+import static org.mockserver.model.HttpResponse.response;
+import static org.mockserver.verify.VerificationTimes.once;
+
+/**
+ * @author Christian Tzolov
+ */
+@SpringBootTest(
+ webEnvironment = SpringBootTest.WebEnvironment.NONE,
+ properties = {
+ "twitter.connection.consumerKey=consumerKey666",
+ "twitter.connection.consumerSecret=consumerSecret666",
+ "twitter.connection.accessToken=accessToken666",
+ "twitter.connection.accessTokenSecret=accessTokenSecret666"
+ })
+@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
+public abstract class TwitterTrendFunctionTests {
+
+ private static final String MOCK_SERVER_IP = "127.0.0.1";
+
+ private static final Integer MOCK_SERVER_PORT = SocketUtils.findAvailableTcpPort();
+
+ private static ClientAndServer mockServer;
+
+ private static MockServerClient mockClient;
+ private static HttpRequest trendsRequest;
+
+ @Autowired
+ protected Function, Message> twitterTrendFunction;
+
+ @BeforeAll
+ public static void startServer() {
+ mockServer = ClientAndServer.startClientAndServer(MOCK_SERVER_PORT);
+ mockClient = new MockServerClient(MOCK_SERVER_IP, MOCK_SERVER_PORT);
+
+ trendsRequest = setExpectation(request()
+ .withMethod("GET")
+ .withPath("/trends/place.json")
+ .withQueryStringParameter("id", "2972"));
+ }
+
+ @AfterAll
+ public static void stopServer() {
+ mockServer.stop();
+ }
+
+ public static HttpRequest setExpectation(HttpRequest request) {
+ mockClient
+ .when(request, exactly(1))
+ .respond(response()
+ .withStatusCode(200)
+ .withHeaders(
+ new Header("Content-Type", "application/json; charset=utf-8"),
+ new Header("Cache-Control", "public, max-age=86400"))
+ .withBody(TwitterTestUtils.asString("classpath:/response/trends.json"))
+ .withDelay(TimeUnit.SECONDS, 1)
+ );
+ return request;
+ }
+
+ @TestPropertySource(properties = {
+ "twitter.trend.locationId='2972'",
+ "twitter.connection.rawJson=true"
+ })
+ public static class TwitterTrendPayloadTests extends TwitterTrendFunctionTests {
+
+ @Test
+ public void testOne() {
+ Message> received = twitterTrendFunction.apply(MessageBuilder.withPayload("Hello").build());
+ mockClient.verify(trendsRequest, once());
+ assertThat(received).isNotNull();
+ }
+ }
+
+ @SpringBootConfiguration
+ @EnableAutoConfiguration
+ @Import(TwitterTrendFunctionConfiguration.class)
+ public static class TestTwitterTrendFunctionApplication {
+ @Bean
+ @Primary
+ public twitter4j.conf.Configuration twitterConfiguration2(TwitterConnectionProperties properties,
+ Function toConfigurationBuilder) {
+
+ Function mockedConfiguration =
+ toConfigurationBuilder.andThen(
+ new TwitterTestUtils().mockTwitterUrls(
+ String.format("http://%s:%s", MOCK_SERVER_IP, MOCK_SERVER_PORT)));
+
+ return mockedConfiguration.apply(properties).build();
+ }
+ }
+
+}
diff --git a/functions/function/twitter-function/src/test/java/org/springframework/cloud/fn/twitter/users/TwitterUsersFunctionTests.java b/functions/function/twitter-function/src/test/java/org/springframework/cloud/fn/twitter/users/TwitterUsersFunctionTests.java
new file mode 100644
index 00000000..6841020b
--- /dev/null
+++ b/functions/function/twitter-function/src/test/java/org/springframework/cloud/fn/twitter/users/TwitterUsersFunctionTests.java
@@ -0,0 +1,204 @@
+/*
+ * Copyright 2020-2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.cloud.fn.twitter.users;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.mockserver.client.MockServerClient;
+import org.mockserver.integration.ClientAndServer;
+import org.mockserver.model.Header;
+import org.mockserver.model.HttpRequest;
+import twitter4j.conf.ConfigurationBuilder;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.SpringBootConfiguration;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.cloud.fn.common.twitter.TwitterConnectionProperties;
+import org.springframework.cloud.fn.common.twitter.util.TwitterTestUtils;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Import;
+import org.springframework.context.annotation.Primary;
+import org.springframework.integration.support.MessageBuilder;
+import org.springframework.messaging.Message;
+import org.springframework.test.annotation.DirtiesContext;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.util.SocketUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockserver.matchers.Times.exactly;
+import static org.mockserver.model.HttpRequest.request;
+import static org.mockserver.model.HttpResponse.response;
+import static org.mockserver.verify.VerificationTimes.once;
+
+/**
+ * @author Christian Tzolov
+ */
+@SpringBootTest(
+ webEnvironment = SpringBootTest.WebEnvironment.NONE,
+ properties = {
+ "twitter.connection.consumerKey=consumerKey666",
+ "twitter.connection.consumerSecret=consumerSecret666",
+ "twitter.connection.accessToken=accessToken666",
+ "twitter.connection.accessTokenSecret=accessTokenSecret666"
+ })
+@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
+public abstract class TwitterUsersFunctionTests {
+
+ private static final String MOCK_SERVER_IP = "127.0.0.1";
+
+ private static final Integer MOCK_SERVER_PORT = SocketUtils.findAvailableTcpPort();
+
+ private static ClientAndServer mockServer;
+
+ private static MockServerClient mockClient;
+ private static HttpRequest searchUsersRequest;
+ private static HttpRequest lookupUsersRequest;
+ private static HttpRequest lookupUsersRequest2;
+
+ @Autowired
+ protected ObjectMapper mapper;
+
+ @Autowired
+ Function, Message> twitterUsersFunction;
+
+ public static HttpRequest setExpectation(HttpRequest request, String responseUri) {
+ mockClient
+ .when(request, exactly(1))
+ .respond(response()
+ .withStatusCode(200)
+ .withHeaders(
+ new Header("Content-Type", "application/json; charset=utf-8"),
+ new Header("Cache-Control", "public, max-age=86400"))
+ .withBody(TwitterTestUtils.asString(responseUri))
+ .withDelay(TimeUnit.SECONDS, 1)
+ );
+ return request;
+ }
+
+ @BeforeAll
+ public static void startServer() {
+ mockServer = ClientAndServer.startClientAndServer(MOCK_SERVER_PORT);
+ mockClient = new MockServerClient(MOCK_SERVER_IP, MOCK_SERVER_PORT);
+
+ searchUsersRequest = setExpectation(request()
+ .withMethod("GET")
+ .withPath("/users/search.json")
+ .withQueryStringParameter("q", "tzolov")
+ .withQueryStringParameter("page", "3"),
+ "classpath:/response/search_users.json");
+
+ lookupUsersRequest = setExpectation(request()
+ .withMethod("GET")
+ .withPath("/users/lookup.json")
+ .withQueryStringParameter("user_id",
+ "710705860343963648,326896547,267603736,781497571629989888,838754923"),
+ "classpath:/response/lookup_users_id.json");
+
+ lookupUsersRequest2 = setExpectation(request()
+ .withMethod("GET")
+ .withPath("/users/lookup.json")
+ .withQueryStringParameter("screen_name",
+ "TzolovMarto,Rabotnik57,antzolov,peyo_tzolov,ivantzolov"),
+ "classpath:/response/lookup_users_id.json");
+ }
+
+ @AfterAll
+ public static void stopServer() {
+ mockServer.stop();
+ }
+
+ @TestPropertySource(properties = {
+ "twitter.users.type=search",
+ "twitter.users.search.query=payload"
+ })
+ public static class TwitterSearchUsersTests extends TwitterUsersFunctionTests {
+
+ @Test
+ public void testOne() throws IOException {
+ Message> received = twitterUsersFunction.apply(MessageBuilder.withPayload("tzolov").build());
+ mockClient.verify(searchUsersRequest, once());
+ assertThat(received).isNotNull();
+ List list = mapper.readValue(new String((byte[]) received.getPayload()), List.class);
+ assertThat(list).hasSize(20);
+ }
+ }
+
+ @TestPropertySource(properties = {
+ "twitter.users.type=lookup",
+ "twitter.users.lookup.userId='710705860343963648,326896547,267603736,781497571629989888,838754923'"
+ })
+ public static class TwitterLookupUserIdLiteralTests extends TwitterUsersFunctionTests {
+
+ @Test
+ public void testOne() throws IOException {
+
+ Message> received = twitterUsersFunction.apply(MessageBuilder.withPayload("tzolov").build());
+
+ mockClient.verify(lookupUsersRequest, once());
+ assertThat(received).isNotNull();
+ List list = mapper.readValue(new String((byte[]) received.getPayload()), List.class);
+ assertThat(list).hasSize(5);
+ }
+ }
+
+ @TestPropertySource(properties = {
+ "twitter.users.type=lookup",
+ "twitter.users.lookup.screenName=#jsonPath(payload,'$..[*].code')"
+ })
+ public static class TwitterLookupScreenNamePayloadTests extends TwitterUsersFunctionTests {
+
+ @Test
+ public void testOne() throws IOException {
+
+ Object payload = "[{\"code\":\"TzolovMarto\"},{\"code\":\"Rabotnik57\"},{\"code\":\"antzolov\"},{\"code\":\"peyo_tzolov\"},{\"code\":\"ivantzolov\"}]";
+
+ Message> received = twitterUsersFunction.apply(MessageBuilder.withPayload(payload).build());
+ assertThat(received).isNotNull();
+
+ mockClient.verify(lookupUsersRequest2, once());
+ assertThat(received).isNotNull();
+ List list = mapper.readValue(new String((byte[]) received.getPayload()), List.class);
+ assertThat(list).hasSize(5);
+ }
+ }
+
+ @SpringBootConfiguration
+ @EnableAutoConfiguration
+ @Import(TwitterUsersFunctionConfiguration.class)
+ static class TestTwitterUsersApplication {
+ @Bean
+ @Primary
+ public twitter4j.conf.Configuration twitterConfiguration2(TwitterConnectionProperties properties,
+ Function toConfigurationBuilder) {
+
+ Function mockedConfiguration =
+ toConfigurationBuilder.andThen(
+ new TwitterTestUtils().mockTwitterUrls(
+ String.format("http://%s:%s", MOCK_SERVER_IP, MOCK_SERVER_PORT)));
+
+ return mockedConfiguration.apply(properties).build();
+ }
+ }
+}
diff --git a/functions/function/twitter-function/src/test/resources/response/lookup_users_id.json b/functions/function/twitter-function/src/test/resources/response/lookup_users_id.json
new file mode 100644
index 00000000..afbae782
--- /dev/null
+++ b/functions/function/twitter-function/src/test/resources/response/lookup_users_id.json
@@ -0,0 +1 @@
+[{"id":710705860343963648,"id_str":"710705860343963648","name":"Marto Tzolov","screen_name":"TzolovMarto","location":"","description":"","url":null,"entities":{"description":{"urls":[]}},"protected":false,"followers_count":10,"friends_count":34,"listed_count":0,"created_at":"Fri Mar 18 05:54:16 +0000 2016","favourites_count":0,"utc_offset":null,"time_zone":null,"geo_enabled":false,"verified":false,"statuses_count":0,"lang":"bg","contributors_enabled":false,"is_translator":false,"is_translation_enabled":false,"profile_background_color":"F5F8FA","profile_background_image_url":null,"profile_background_image_url_https":null,"profile_background_tile":false,"profile_image_url":"http:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_image_url_https":"https:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_link_color":"1DA1F2","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"has_extended_profile":false,"default_profile":true,"default_profile_image":true,"following":false,"follow_request_sent":false,"notifications":false,"translator_type":"none"},{"id":326896547,"id_str":"326896547","name":"Mladen Tzolov","screen_name":"Rabotnik57","location":"","description":"","url":null,"entities":{"description":{"urls":[]}},"protected":false,"followers_count":0,"friends_count":1,"listed_count":0,"created_at":"Thu Jun 30 17:25:54 +0000 2011","favourites_count":0,"utc_offset":null,"time_zone":null,"geo_enabled":false,"verified":false,"statuses_count":0,"lang":"en","contributors_enabled":false,"is_translator":false,"is_translation_enabled":false,"profile_background_color":"C0DEED","profile_background_image_url":"http:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_image_url_https":"https:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_tile":false,"profile_image_url":"http:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_image_url_https":"https:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_link_color":"1DA1F2","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"has_extended_profile":false,"default_profile":true,"default_profile_image":true,"following":false,"follow_request_sent":false,"notifications":false,"translator_type":"none"},{"id":267603736,"id_str":"267603736","name":"Anatoli Tzolov","screen_name":"antzolov","location":"","description":"","url":null,"entities":{"description":{"urls":[]}},"protected":false,"followers_count":4,"friends_count":0,"listed_count":0,"created_at":"Thu Mar 17 06:40:31 +0000 2011","favourites_count":0,"utc_offset":null,"time_zone":null,"geo_enabled":false,"verified":false,"statuses_count":0,"lang":"en","contributors_enabled":false,"is_translator":false,"is_translation_enabled":false,"profile_background_color":"C0DEED","profile_background_image_url":"http:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_image_url_https":"https:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_tile":false,"profile_image_url":"http:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_image_url_https":"https:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_link_color":"1DA1F2","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"has_extended_profile":false,"default_profile":true,"default_profile_image":true,"following":false,"follow_request_sent":false,"notifications":false,"translator_type":"none"},{"id":781497571629989888,"id_str":"781497571629989888","name":"Peyo Tzolov","screen_name":"peyo_tzolov","location":"","description":"","url":null,"entities":{"description":{"urls":[]}},"protected":false,"followers_count":1,"friends_count":34,"listed_count":0,"created_at":"Thu Sep 29 14:15:15 +0000 2016","favourites_count":0,"utc_offset":null,"time_zone":null,"geo_enabled":false,"verified":false,"statuses_count":0,"lang":"en","contributors_enabled":false,"is_translator":false,"is_translation_enabled":false,"profile_background_color":"F5F8FA","profile_background_image_url":null,"profile_background_image_url_https":null,"profile_background_tile":false,"profile_image_url":"http:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_image_url_https":"https:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_link_color":"1DA1F2","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"has_extended_profile":false,"default_profile":true,"default_profile_image":true,"following":false,"follow_request_sent":false,"notifications":false,"translator_type":"none"},{"id":838754923,"id_str":"838754923","name":"Ivan Stoyanov Tzolov","screen_name":"ivantzolov","location":"","description":"","url":null,"entities":{"description":{"urls":[]}},"protected":false,"followers_count":2,"friends_count":1,"listed_count":0,"created_at":"Fri Sep 21 23:43:31 +0000 2012","favourites_count":0,"utc_offset":null,"time_zone":null,"geo_enabled":false,"verified":false,"statuses_count":17,"lang":"en","status":{"created_at":"Fri Sep 12 08:47:45 +0000 2014","id":510349029784694784,"id_str":"510349029784694784","full_text":"http:\/\/t.co\/DAjlG8Nb2H","truncated":false,"display_text_range":[0,22],"entities":{"hashtags":[],"symbols":[],"user_mentions":[],"urls":[{"url":"http:\/\/t.co\/DAjlG8Nb2H","expanded_url":"http:\/\/startupmedia.net\/goodenough.html","display_url":"startupmedia.net\/goodenough.html","indices":[0,22]}]},"source":"\u003ca href=\"http:\/\/twitter.com\" rel=\"nofollow\"\u003eTwitter Web Client\u003c\/a\u003e","in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"geo":null,"coordinates":null,"place":null,"contributors":null,"is_quote_status":false,"retweet_count":0,"favorite_count":0,"favorited":false,"retweeted":false,"possibly_sensitive":false,"lang":"und"},"contributors_enabled":false,"is_translator":false,"is_translation_enabled":false,"profile_background_color":"C0DEED","profile_background_image_url":"http:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_image_url_https":"https:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_tile":false,"profile_image_url":"http:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_image_url_https":"https:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_link_color":"1DA1F2","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"has_extended_profile":false,"default_profile":true,"default_profile_image":true,"following":false,"follow_request_sent":false,"notifications":false,"translator_type":"none"}]
diff --git a/functions/function/twitter-function/src/test/resources/response/search_places_amsterdam.json b/functions/function/twitter-function/src/test/resources/response/search_places_amsterdam.json
new file mode 100644
index 00000000..6c0a1af0
--- /dev/null
+++ b/functions/function/twitter-function/src/test/resources/response/search_places_amsterdam.json
@@ -0,0 +1 @@
+{"result":{"places":[{"id":"99cdab25eddd6bce","url":"https:\/\/api.twitter.com\/1.1\/geo\/id\/99cdab25eddd6bce.json","place_type":"city","name":"Amsterdam","full_name":"Amsterdam, The Netherlands","country_code":"NL","country":"The Netherlands","contained_within":[{"id":"00201664628f798b","url":"https:\/\/api.twitter.com\/1.1\/geo\/id\/00201664628f798b.json","place_type":"admin","name":"De Randstad","full_name":"De Randstad","country_code":"NL","country":"The Netherlands","centroid":[4.316128202618518,52.068423499999994],"bounding_box":{"type":"Polygon","coordinates":[[[4.112784,51.761243],[4.112784,52.52808],[5.468162,52.52808],[5.468162,51.761243],[4.112784,51.761243]]]},"attributes":{}}],"centroid":[4.8980371531271425,52.37482405],"bounding_box":{"type":"Polygon","coordinates":[[[4.7288999,52.2782266],[4.7288999,52.4312289],[5.0792072,52.4312289],[5.0792072,52.2782266],[4.7288999,52.2782266]]]},"attributes":{}},{"id":"4e542833600bca97","url":"https:\/\/api.twitter.com\/1.1\/geo\/id\/4e542833600bca97.json","place_type":"city","name":"Amsterdam","full_name":"Amsterdam, NY","country_code":"US","country":"United States","contained_within":[{"id":"4ffa03b662822a0e","url":"https:\/\/api.twitter.com\/1.1\/geo\/id\/4ffa03b662822a0e.json","place_type":"admin","name":"ALBANY-SCHENECTADY-TROY","full_name":"ALBANY-SCHENECTADY-TROY","country_code":"","country":"","centroid":[-73.88149182600827,43.049265000000005],"bounding_box":{"type":"Polygon","coordinates":[[[-74.867704,41.977978],[-74.867704,44.120552],[-72.819773,44.120552],[-72.819773,41.977978],[-74.867704,41.977978]]]},"attributes":{}}],"centroid":[-74.18907623554325,42.948039],"bounding_box":{"type":"Polygon","coordinates":[[[-74.222596,42.918987],[-74.222596,42.977091],[-74.158646,42.977091],[-74.158646,42.918987],[-74.222596,42.918987]]]},"attributes":{}},{"id":"014354e25fd7e37d","url":"https:\/\/api.twitter.com\/1.1\/geo\/id\/014354e25fd7e37d.json","place_type":"city","name":"New Amsterdam","full_name":"New Amsterdam, WI","country_code":"US","country":"United States","contained_within":[{"id":"f265fa733db1efa2","url":"https:\/\/api.twitter.com\/1.1\/geo\/id\/f265fa733db1efa2.json","place_type":"admin","name":"LA CROSSE-EAU CLAIRE","full_name":"LA CROSSE-EAU CLAIRE","country_code":"","country":"","centroid":[-91.11594598161932,44.313717999999994],"bounding_box":{"type":"Polygon","coordinates":[[[-92.31617,42.98817],[-92.31617,45.639266],[-90.311017,45.639266],[-90.311017,42.98817],[-92.31617,42.98817]]]},"attributes":{}}],"centroid":[-91.29723353577143,43.977616999999995],"bounding_box":{"type":"Polygon","coordinates":[[[-91.3194822,43.969888],[-91.3194822,43.985346],[-91.286008,43.985346],[-91.286008,43.969888],[-91.3194822,43.969888]]]},"attributes":{}},{"id":"6b1c779ec0b0bf96","url":"https:\/\/api.twitter.com\/1.1\/geo\/id\/6b1c779ec0b0bf96.json","place_type":"neighborhood","name":"Ámsterdam","full_name":"Ámsterdam, Corregidora","country_code":"MX","country":"Mexico","contained_within":[{"id":"ff1c0dd545aca772","url":"https:\/\/api.twitter.com\/1.1\/geo\/id\/ff1c0dd545aca772.json","place_type":"city","name":"Corregidora","full_name":"Corregidora, Querétaro Arteaga","country_code":"MX","country":"Mexico","centroid":[-100.44113916818549,20.4787551],"bounding_box":{"type":"Polygon","coordinates":[[[-100.508629,20.3617911],[-100.508629,20.5957191],[-100.370833,20.5957191],[-100.370833,20.3617911],[-100.508629,20.3617911]]]},"attributes":{}}],"centroid":[-100.41340904153098,20.5456092],"bounding_box":{"type":"Polygon","coordinates":[[[-100.414615,20.5443904],[-100.414615,20.546828],[-100.412591,20.546828],[-100.412591,20.5443904],[-100.414615,20.5443904]]]},"attributes":{}},{"id":"40277f690ea6c867","url":"https:\/\/api.twitter.com\/1.1\/geo\/id\/40277f690ea6c867.json","place_type":"neighborhood","name":"Oude Amsterdamsebuurt","full_name":"Oude Amsterdamsebuurt, Haarlem","country_code":"NL","country":"The Netherlands","contained_within":[{"id":"280b2451d4f8f73f","url":"https:\/\/api.twitter.com\/1.1\/geo\/id\/280b2451d4f8f73f.json","place_type":"city","name":"Haarlem","full_name":"Haarlem, Nederland","country_code":"NL","country":"The Netherlands","centroid":[4.643405250172844,52.3837897],"bounding_box":{"type":"Polygon","coordinates":[[[4.5993661,52.3388496],[4.5993661,52.4287298],[4.6866562,52.4287298],[4.6866562,52.3388496],[4.5993661,52.3388496]]]},"attributes":{}}],"centroid":[4.647010110383041,52.378719146890205],"bounding_box":{"type":"Polygon","coordinates":[[[4.64155453233553,52.3762651137569],[4.64155453233553,52.3811731800235],[4.65223371374939,52.3811731800235],[4.65223371374939,52.3762651137569],[4.64155453233553,52.3762651137569]]]},"attributes":{}},{"id":"60c449308fe5ce7b","url":"https:\/\/api.twitter.com\/1.1\/geo\/id\/60c449308fe5ce7b.json","place_type":"neighborhood","name":"Maarn waaronder Klein Amsterdam","full_name":"Maarn waaronder Klein Amsterdam, Utrechtse Heuvelrug","country_code":"NL","country":"The Netherlands","contained_within":[{"id":"9f3d46f4d4289d3d","url":"https:\/\/api.twitter.com\/1.1\/geo\/id\/9f3d46f4d4289d3d.json","place_type":"city","name":"Utrechtse Heuvelrug","full_name":"Utrechtse Heuvelrug, Nederland","country_code":"NL","country":"The Netherlands","centroid":[5.394745705687344,52.02860325],"bounding_box":{"type":"Polygon","coordinates":[[[5.2472216,51.9707539],[5.2472216,52.0864526],[5.5293757,52.0864526],[5.5293757,51.9707539],[5.2472216,51.9707539]]]},"attributes":{}}],"centroid":[5.369017195148887,52.0642160845185],"bounding_box":{"type":"Polygon","coordinates":[[[5.35037061367215,52.0576013365081],[5.35037061367215,52.0708308325289],[5.38919817898849,52.0708308325289],[5.38919817898849,52.0576013365081],[5.35037061367215,52.0576013365081]]]},"attributes":{}},{"id":"1e1900a11be73ee5","url":"https:\/\/api.twitter.com\/1.1\/geo\/id\/1e1900a11be73ee5.json","place_type":"neighborhood","name":"Gebied ten zuiden van Amsterdam-Rijnkanaal","full_name":"Gebied ten zuiden van Amsterdam-Rijnkanaal, Wijk bij Duurstede","country_code":"NL","country":"The Netherlands","contained_within":[{"id":"55e84f33f17942fa","url":"https:\/\/api.twitter.com\/1.1\/geo\/id\/55e84f33f17942fa.json","place_type":"city","name":"Wijk bij Duurstede","full_name":"Wijk bij Duurstede, Nederland","country_code":"NL","country":"The Netherlands","centroid":[5.329360942494974,51.990985699999996],"bounding_box":{"type":"Polygon","coordinates":[[[5.2426404,51.9549448],[5.2426404,52.0270266],[5.415259,52.0270266],[5.415259,51.9549448],[5.2426404,51.9549448]]]},"attributes":{}}],"centroid":[5.292265575333346,51.9678009541292],"bounding_box":{"type":"Polygon","coordinates":[[[5.25623018002343,51.9547209695773],[5.25623018002343,51.9808809386811],[5.34094161624555,51.9808809386811],[5.34094161624555,51.9547209695773],[5.25623018002343,51.9547209695773]]]},"attributes":{}},{"id":"208b7abc84e1d0bb","url":"https:\/\/api.twitter.com\/1.1\/geo\/id\/208b7abc84e1d0bb.json","place_type":"neighborhood","name":"Verspreide huizen Nieuw-Amsterdam","full_name":"Verspreide huizen Nieuw-Amsterdam, Emmen","country_code":"NL","country":"The Netherlands","contained_within":[{"id":"bd593b98d3277413","url":"https:\/\/api.twitter.com\/1.1\/geo\/id\/bd593b98d3277413.json","place_type":"city","name":"Emmen","full_name":"Emmen, Nederland","country_code":"NL","country":"The Netherlands","centroid":[6.952461959050538,52.75285365],"bounding_box":{"type":"Polygon","coordinates":[[[6.8263158,52.6327357],[6.8263158,52.8729716],[7.0926136,52.8729716],[7.0926136,52.6327357],[6.8263158,52.6327357]]]},"attributes":{}}],"centroid":[6.880029781032325,52.72497950062615],"bounding_box":{"type":"Polygon","coordinates":[[[6.84381703095638,52.6832729158951],[6.84381703095638,52.7368586649565],[6.90332685388762,52.7368586649565],[6.90332685388762,52.6832729158951],[6.84381703095638,52.6832729158951]]]},"attributes":{}},{"id":"34033fdb84866032","url":"https:\/\/api.twitter.com\/1.1\/geo\/id\/34033fdb84866032.json","place_type":"neighborhood","name":"Amsterdamscheveld","full_name":"Amsterdamscheveld, Emmen","country_code":"NL","country":"The Netherlands","contained_within":[{"id":"bd593b98d3277413","url":"https:\/\/api.twitter.com\/1.1\/geo\/id\/bd593b98d3277413.json","place_type":"city","name":"Emmen","full_name":"Emmen, Nederland","country_code":"NL","country":"The Netherlands","centroid":[6.952461959050538,52.75285365],"bounding_box":{"type":"Polygon","coordinates":[[[6.8263158,52.6327357],[6.8263158,52.8729716],[7.0926136,52.8729716],[7.0926136,52.6327357],[6.8263158,52.6327357]]]},"attributes":{}}],"centroid":[6.9184542815347765,52.6909569408471],"bounding_box":{"type":"Polygon","coordinates":[[[6.90917004254957,52.6839229502653],[6.90917004254957,52.6979909314289],[6.92513464497432,52.6979909314289],[6.92513464497432,52.6839229502653],[6.90917004254957,52.6839229502653]]]},"attributes":{}},{"id":"36b507ef2eebeced","url":"https:\/\/api.twitter.com\/1.1\/geo\/id\/36b507ef2eebeced.json","place_type":"neighborhood","name":"Nieuw-Amsterdam-Centrum","full_name":"Nieuw-Amsterdam-Centrum, Emmen","country_code":"NL","country":"The Netherlands","contained_within":[{"id":"bd593b98d3277413","url":"https:\/\/api.twitter.com\/1.1\/geo\/id\/bd593b98d3277413.json","place_type":"city","name":"Emmen","full_name":"Emmen, Nederland","country_code":"NL","country":"The Netherlands","centroid":[6.952461959050538,52.75285365],"bounding_box":{"type":"Polygon","coordinates":[[[6.8263158,52.6327357],[6.8263158,52.8729716],[7.0926136,52.8729716],[7.0926136,52.6327357],[6.8263158,52.6327357]]]},"attributes":{}}],"centroid":[6.859243233543406,52.70883893584595],"bounding_box":{"type":"Polygon","coordinates":[[[6.84660242442842,52.6924882873464],[6.84660242442842,52.7251895843455],[6.87877464927096,52.7251895843455],[6.87877464927096,52.6924882873464],[6.84660242442842,52.6924882873464]]]},"attributes":{}},{"id":"42d34e9faa2b4dc6","url":"https:\/\/api.twitter.com\/1.1\/geo\/id\/42d34e9faa2b4dc6.json","place_type":"neighborhood","name":"Amsterdamse Bos","full_name":"Amsterdamse Bos, Amstelveen","country_code":"NL","country":"The Netherlands","contained_within":[{"id":"be6da6fc44d6fd09","url":"https:\/\/api.twitter.com\/1.1\/geo\/id\/be6da6fc44d6fd09.json","place_type":"city","name":"Amstelveen","full_name":"Amstelveen, Nederland","country_code":"NL","country":"The Netherlands","centroid":[4.852805257748059,52.2862412],"bounding_box":{"type":"Polygon","coordinates":[[[4.7951199,52.2418745],[4.7951199,52.3306079],[4.9092753,52.3306079],[4.9092753,52.2418745],[4.7951199,52.2418745]]]},"attributes":{}}],"centroid":[4.828504535673222,52.310381530918306],"bounding_box":{"type":"Polygon","coordinates":[[[4.80951516342238,52.2906215346603],[4.80951516342238,52.3301415271763],[4.85648163248058,52.3301415271763],[4.85648163248058,52.2906215346603],[4.80951516342238,52.2906215346603]]]},"attributes":{}},{"id":"3c0675b1ab813077","url":"https:\/\/api.twitter.com\/1.1\/geo\/id\/3c0675b1ab813077.json","place_type":"city","name":"Amsterdam","full_name":"Amsterdam, South Africa","country_code":"ZA","country":"South Africa","contained_within":[{"id":"5679c21e1181c3a3","url":"https:\/\/api.twitter.com\/1.1\/geo\/id\/5679c21e1181c3a3.json","place_type":"admin","name":"Mpumalanga","full_name":"Mpumalanga","country_code":"ZA","country":"South Africa","centroid":[30.158989601575485,-25.731830000000002],"bounding_box":{"type":"Polygon","coordinates":[[[28.2434629,-27.5062538],[28.2434629,-23.9574062],[32.0339072,-23.9574062],[32.0339072,-27.5062538],[28.2434629,-27.5062538]]]},"attributes":{}}],"centroid":[30.665700090075823,-26.638494],"bounding_box":{"type":"Polygon","coordinates":[[[30.6473754,-26.6670719],[30.6473754,-26.6099161],[30.6860752,-26.6099161],[30.6860752,-26.6670719],[30.6473754,-26.6670719]]]},"attributes":{}}]},"query":{"url":"https:\/\/api.twitter.com\/1.1\/geo\/search.json?ip=127.0.0.1&query=AMSTERDAM&include_entities=true&include_ext_alt_text=true&tweet_mode=extended","type":"search","params":{"accuracy":0,"granularity":"neighborhood","query":"AMSTERDAM","autocomplete":false,"trim_place":false,"ip":"127.0.0.1"}}}
diff --git a/functions/function/twitter-function/src/test/resources/response/search_users.json b/functions/function/twitter-function/src/test/resources/response/search_users.json
new file mode 100644
index 00000000..6ad473be
--- /dev/null
+++ b/functions/function/twitter-function/src/test/resources/response/search_users.json
@@ -0,0 +1 @@
+[{"id":710705860343963648,"id_str":"710705860343963648","name":"Marto Tzolov","screen_name":"TzolovMarto","location":"","description":"","url":null,"entities":{"description":{"urls":[]}},"protected":false,"followers_count":10,"friends_count":34,"listed_count":0,"created_at":"Fri Mar 18 05:54:16 +0000 2016","favourites_count":0,"utc_offset":null,"time_zone":null,"geo_enabled":false,"verified":false,"statuses_count":0,"lang":"bg","contributors_enabled":false,"is_translator":false,"is_translation_enabled":false,"profile_background_color":"F5F8FA","profile_background_image_url":null,"profile_background_image_url_https":null,"profile_background_tile":false,"profile_image_url":"http:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_image_url_https":"https:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_link_color":"1DA1F2","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"has_extended_profile":false,"default_profile":true,"default_profile_image":true,"following":false,"follow_request_sent":false,"notifications":false,"translator_type":"none"},{"id":326896547,"id_str":"326896547","name":"Mladen Tzolov","screen_name":"Rabotnik57","location":"","description":"","url":null,"entities":{"description":{"urls":[]}},"protected":false,"followers_count":0,"friends_count":1,"listed_count":0,"created_at":"Thu Jun 30 17:25:54 +0000 2011","favourites_count":0,"utc_offset":null,"time_zone":null,"geo_enabled":false,"verified":false,"statuses_count":0,"lang":"en","contributors_enabled":false,"is_translator":false,"is_translation_enabled":false,"profile_background_color":"C0DEED","profile_background_image_url":"http:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_image_url_https":"https:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_tile":false,"profile_image_url":"http:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_image_url_https":"https:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_link_color":"1DA1F2","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"has_extended_profile":false,"default_profile":true,"default_profile_image":true,"following":false,"follow_request_sent":false,"notifications":false,"translator_type":"none"},{"id":267603736,"id_str":"267603736","name":"Anatoli Tzolov","screen_name":"antzolov","location":"","description":"","url":null,"entities":{"description":{"urls":[]}},"protected":false,"followers_count":4,"friends_count":0,"listed_count":0,"created_at":"Thu Mar 17 06:40:31 +0000 2011","favourites_count":0,"utc_offset":null,"time_zone":null,"geo_enabled":false,"verified":false,"statuses_count":0,"lang":"en","contributors_enabled":false,"is_translator":false,"is_translation_enabled":false,"profile_background_color":"C0DEED","profile_background_image_url":"http:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_image_url_https":"https:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_tile":false,"profile_image_url":"http:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_image_url_https":"https:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_link_color":"1DA1F2","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"has_extended_profile":false,"default_profile":true,"default_profile_image":true,"following":false,"follow_request_sent":false,"notifications":false,"translator_type":"none"},{"id":781497571629989888,"id_str":"781497571629989888","name":"Peyo Tzolov","screen_name":"peyo_tzolov","location":"","description":"","url":null,"entities":{"description":{"urls":[]}},"protected":false,"followers_count":1,"friends_count":34,"listed_count":0,"created_at":"Thu Sep 29 14:15:15 +0000 2016","favourites_count":0,"utc_offset":null,"time_zone":null,"geo_enabled":false,"verified":false,"statuses_count":0,"lang":"en","contributors_enabled":false,"is_translator":false,"is_translation_enabled":false,"profile_background_color":"F5F8FA","profile_background_image_url":null,"profile_background_image_url_https":null,"profile_background_tile":false,"profile_image_url":"http:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_image_url_https":"https:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_link_color":"1DA1F2","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"has_extended_profile":false,"default_profile":true,"default_profile_image":true,"following":false,"follow_request_sent":false,"notifications":false,"translator_type":"none"},{"id":838754923,"id_str":"838754923","name":"Ivan Stoyanov Tzolov","screen_name":"ivantzolov","location":"","description":"","url":null,"entities":{"description":{"urls":[]}},"protected":false,"followers_count":2,"friends_count":1,"listed_count":0,"created_at":"Fri Sep 21 23:43:31 +0000 2012","favourites_count":0,"utc_offset":null,"time_zone":null,"geo_enabled":false,"verified":false,"statuses_count":17,"lang":"en","status":{"created_at":"Fri Sep 12 08:47:45 +0000 2014","id":510349029784694784,"id_str":"510349029784694784","full_text":"http:\/\/t.co\/DAjlG8Nb2H","truncated":false,"display_text_range":[0,22],"entities":{"hashtags":[],"symbols":[],"user_mentions":[],"urls":[{"url":"http:\/\/t.co\/DAjlG8Nb2H","expanded_url":"http:\/\/startupmedia.net\/goodenough.html","display_url":"startupmedia.net\/goodenough.html","indices":[0,22]}]},"source":"\u003ca href=\"http:\/\/twitter.com\" rel=\"nofollow\"\u003eTwitter Web Client\u003c\/a\u003e","in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"geo":null,"coordinates":null,"place":null,"contributors":null,"is_quote_status":false,"retweet_count":0,"favorite_count":0,"favorited":false,"retweeted":false,"possibly_sensitive":false,"lang":"und"},"contributors_enabled":false,"is_translator":false,"is_translation_enabled":false,"profile_background_color":"C0DEED","profile_background_image_url":"http:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_image_url_https":"https:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_tile":false,"profile_image_url":"http:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_image_url_https":"https:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_link_color":"1DA1F2","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"has_extended_profile":false,"default_profile":true,"default_profile_image":true,"following":false,"follow_request_sent":false,"notifications":false,"translator_type":"none"},{"id":891056763964977152,"id_str":"891056763964977152","name":"Marto Tzolov","screen_name":"marto_tzolov","location":"","description":"","url":null,"entities":{"description":{"urls":[]}},"protected":false,"followers_count":0,"friends_count":1,"listed_count":0,"created_at":"Fri Jul 28 22:04:22 +0000 2017","favourites_count":0,"utc_offset":null,"time_zone":null,"geo_enabled":false,"verified":false,"statuses_count":0,"lang":"bg","contributors_enabled":false,"is_translator":false,"is_translation_enabled":false,"profile_background_color":"F5F8FA","profile_background_image_url":null,"profile_background_image_url_https":null,"profile_background_tile":false,"profile_image_url":"http:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_image_url_https":"https:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_link_color":"1DA1F2","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"has_extended_profile":false,"default_profile":true,"default_profile_image":true,"following":false,"follow_request_sent":false,"notifications":false,"translator_type":"none"},{"id":765259099,"id_str":"765259099","name":"Veronique Tzolov","screen_name":"Veronica_Tzolov","location":"","description":"Animal lover, love One Direction, fun and outgoing","url":null,"entities":{"description":{"urls":[]}},"protected":false,"followers_count":2,"friends_count":12,"listed_count":0,"created_at":"Sat Aug 18 07:22:00 +0000 2012","favourites_count":0,"utc_offset":null,"time_zone":null,"geo_enabled":false,"verified":false,"statuses_count":1,"lang":"en","status":{"created_at":"Wed Jan 02 14:23:37 +0000 2013","id":286477854449995776,"id_str":"286477854449995776","full_text":"@Harry_Styles \nHey Harry... I'm sure you get this a lot but you seem like a really sweet and fun guy... I hope one day I'll get to meet you","truncated":false,"display_text_range":[0,139],"entities":{"hashtags":[],"symbols":[],"user_mentions":[{"screen_name":"Harry_Styles","name":"Harry Styles.","id":181561712,"id_str":"181561712","indices":[0,13]}],"urls":[]},"source":"\u003ca href=\"http:\/\/twitter.com\" rel=\"nofollow\"\u003eTwitter Web Client\u003c\/a\u003e","in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":181561712,"in_reply_to_user_id_str":"181561712","in_reply_to_screen_name":"Harry_Styles","geo":null,"coordinates":null,"place":null,"contributors":null,"is_quote_status":false,"retweet_count":0,"favorite_count":0,"favorited":false,"retweeted":false,"lang":"en"},"contributors_enabled":false,"is_translator":false,"is_translation_enabled":false,"profile_background_color":"C0DEED","profile_background_image_url":"http:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_image_url_https":"https:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_tile":false,"profile_image_url":"http:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_image_url_https":"https:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_link_color":"1DA1F2","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"has_extended_profile":false,"default_profile":true,"default_profile_image":true,"following":false,"follow_request_sent":false,"notifications":false,"translator_type":"none"},{"id":4236673876,"id_str":"4236673876","name":"Marto Tzolov","screen_name":"MartoTzolov","location":"","description":"","url":null,"entities":{"description":{"urls":[]}},"protected":false,"followers_count":10,"friends_count":59,"listed_count":0,"created_at":"Fri Nov 20 19:05:04 +0000 2015","favourites_count":43,"utc_offset":null,"time_zone":null,"geo_enabled":false,"verified":false,"statuses_count":1,"lang":"bg","status":{"created_at":"Sat Nov 21 16:54:02 +0000 2015","id":668110130723057664,"id_str":"668110130723057664","full_text":"https:\/\/t.co\/1sSG8HlbYP","truncated":false,"display_text_range":[0,23],"entities":{"hashtags":[],"symbols":[],"user_mentions":[],"urls":[{"url":"https:\/\/t.co\/1sSG8HlbYP","expanded_url":"https:\/\/youtu.be\/4oxrPkgUuFI","display_url":"youtu.be\/4oxrPkgUuFI","indices":[0,23]}]},"source":"\u003ca href=\"http:\/\/twitter.com\/download\/android\" rel=\"nofollow\"\u003eTwitter for Android\u003c\/a\u003e","in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"geo":null,"coordinates":null,"place":null,"contributors":null,"is_quote_status":false,"retweet_count":1,"favorite_count":23,"favorited":false,"retweeted":false,"possibly_sensitive":false,"lang":"und"},"contributors_enabled":false,"is_translator":false,"is_translation_enabled":false,"profile_background_color":"C0DEED","profile_background_image_url":"http:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_image_url_https":"https:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_tile":false,"profile_image_url":"http:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_image_url_https":"https:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_link_color":"1DA1F2","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"has_extended_profile":false,"default_profile":true,"default_profile_image":true,"following":false,"follow_request_sent":false,"notifications":false,"translator_type":"none"},{"id":725201088262103040,"id_str":"725201088262103040","name":"Aleksandar Tzolov Ma","screen_name":"ma_tzolov","location":"","description":"","url":null,"entities":{"description":{"urls":[]}},"protected":false,"followers_count":0,"friends_count":0,"listed_count":0,"created_at":"Wed Apr 27 05:53:07 +0000 2016","favourites_count":0,"utc_offset":null,"time_zone":null,"geo_enabled":false,"verified":false,"statuses_count":0,"lang":"bg","contributors_enabled":false,"is_translator":false,"is_translation_enabled":false,"profile_background_color":"F5F8FA","profile_background_image_url":null,"profile_background_image_url_https":null,"profile_background_tile":false,"profile_image_url":"http:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_image_url_https":"https:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_link_color":"1DA1F2","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"has_extended_profile":false,"default_profile":true,"default_profile_image":true,"following":false,"follow_request_sent":false,"notifications":false,"translator_type":"none"},{"id":2191899364,"id_str":"2191899364","name":"Philip Tzolov","screen_name":"holymolyass","location":"","description":"","url":null,"entities":{"description":{"urls":[]}},"protected":false,"followers_count":1,"friends_count":0,"listed_count":0,"created_at":"Fri Nov 22 21:06:24 +0000 2013","favourites_count":0,"utc_offset":null,"time_zone":null,"geo_enabled":false,"verified":false,"statuses_count":0,"lang":"en","contributors_enabled":false,"is_translator":false,"is_translation_enabled":false,"profile_background_color":"C0DEED","profile_background_image_url":"http:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_image_url_https":"https:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_tile":false,"profile_image_url":"http:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_image_url_https":"https:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_link_color":"1DA1F2","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"has_extended_profile":false,"default_profile":true,"default_profile_image":true,"following":false,"follow_request_sent":false,"notifications":false,"translator_type":"none"},{"id":3367523121,"id_str":"3367523121","name":"Sharoma Tzolov","screen_name":"SharomaT","location":"","description":"","url":null,"entities":{"description":{"urls":[]}},"protected":false,"followers_count":4,"friends_count":0,"listed_count":0,"created_at":"Thu Jul 09 12:43:28 +0000 2015","favourites_count":0,"utc_offset":null,"time_zone":null,"geo_enabled":false,"verified":false,"statuses_count":0,"lang":"en","contributors_enabled":false,"is_translator":false,"is_translation_enabled":false,"profile_background_color":"C0DEED","profile_background_image_url":"http:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_image_url_https":"https:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_tile":false,"profile_image_url":"http:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_image_url_https":"https:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_link_color":"1DA1F2","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"has_extended_profile":false,"default_profile":true,"default_profile_image":true,"following":false,"follow_request_sent":false,"notifications":false,"translator_type":"none"},{"id":1272912426,"id_str":"1272912426","name":"Elisa Tzolov","screen_name":"Ellu_xx","location":"","description":"","url":null,"entities":{"description":{"urls":[]}},"protected":false,"followers_count":4,"friends_count":3,"listed_count":0,"created_at":"Sat Mar 16 17:57:20 +0000 2013","favourites_count":0,"utc_offset":null,"time_zone":null,"geo_enabled":false,"verified":false,"statuses_count":0,"lang":"fi","contributors_enabled":false,"is_translator":false,"is_translation_enabled":false,"profile_background_color":"C0DEED","profile_background_image_url":"http:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_image_url_https":"https:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_tile":false,"profile_image_url":"http:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_image_url_https":"https:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_link_color":"1DA1F2","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"has_extended_profile":false,"default_profile":true,"default_profile_image":true,"following":false,"follow_request_sent":false,"notifications":false,"translator_type":"none"},{"id":1440863107,"id_str":"1440863107","name":"Veronica Tzolov","screen_name":"VeronicaTzolov","location":"","description":"","url":null,"entities":{"description":{"urls":[]}},"protected":false,"followers_count":2,"friends_count":8,"listed_count":0,"created_at":"Sun May 19 10:45:38 +0000 2013","favourites_count":0,"utc_offset":null,"time_zone":null,"geo_enabled":false,"verified":false,"statuses_count":0,"lang":"en","contributors_enabled":false,"is_translator":false,"is_translation_enabled":false,"profile_background_color":"C0DEED","profile_background_image_url":"http:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_image_url_https":"https:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_tile":false,"profile_image_url":"http:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_image_url_https":"https:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_link_color":"1DA1F2","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"has_extended_profile":false,"default_profile":true,"default_profile_image":true,"following":false,"follow_request_sent":false,"notifications":false,"translator_type":"none"},{"id":65257397,"id_str":"65257397","name":"Rosko Tzolov","screen_name":"oldblindpew","location":"","description":"","url":null,"entities":{"description":{"urls":[]}},"protected":false,"followers_count":0,"friends_count":1,"listed_count":0,"created_at":"Thu Aug 13 03:14:32 +0000 2009","favourites_count":2,"utc_offset":null,"time_zone":null,"geo_enabled":false,"verified":false,"statuses_count":0,"lang":"en","contributors_enabled":false,"is_translator":false,"is_translation_enabled":false,"profile_background_color":"C0DEED","profile_background_image_url":"http:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_image_url_https":"https:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_tile":false,"profile_image_url":"http:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_image_url_https":"https:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_link_color":"1DA1F2","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"has_extended_profile":false,"default_profile":true,"default_profile_image":true,"following":false,"follow_request_sent":false,"notifications":false,"translator_type":"none"},{"id":555653883,"id_str":"555653883","name":"Ves Tzolov","screen_name":"VesTzolov","location":"","description":"","url":null,"entities":{"description":{"urls":[]}},"protected":false,"followers_count":1,"friends_count":0,"listed_count":0,"created_at":"Tue Apr 17 01:29:31 +0000 2012","favourites_count":0,"utc_offset":null,"time_zone":null,"geo_enabled":false,"verified":false,"statuses_count":0,"lang":"en","contributors_enabled":false,"is_translator":false,"is_translation_enabled":false,"profile_background_color":"C0DEED","profile_background_image_url":"http:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_image_url_https":"https:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_tile":false,"profile_image_url":"http:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_image_url_https":"https:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_link_color":"1DA1F2","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"has_extended_profile":false,"default_profile":true,"default_profile_image":true,"following":false,"follow_request_sent":false,"notifications":false,"translator_type":"none"},{"id":3388816924,"id_str":"3388816924","name":"Sharoma Tzolov","screen_name":"sharoma_tzolov","location":"","description":"","url":null,"entities":{"description":{"urls":[]}},"protected":false,"followers_count":11,"friends_count":197,"listed_count":0,"created_at":"Thu Jul 23 07:57:45 +0000 2015","favourites_count":0,"utc_offset":null,"time_zone":null,"geo_enabled":false,"verified":false,"statuses_count":1,"lang":"en","status":{"created_at":"Thu Jul 23 08:06:47 +0000 2015","id":624128509959630848,"id_str":"624128509959630848","full_text":"Pick me to win tickets to experience the first NBA game in Africa #947NBA http:\/\/t.co\/BEn2v37H7D #947NBA via @947","truncated":false,"display_text_range":[0,113],"entities":{"hashtags":[{"text":"947NBA","indices":[66,73]},{"text":"947NBA","indices":[97,104]}],"symbols":[],"user_mentions":[{"screen_name":"947","name":"947","id":41780491,"id_str":"41780491","indices":[109,113]}],"urls":[{"url":"http:\/\/t.co\/BEn2v37H7D","expanded_url":"http:\/\/bit.ly\/1Vazq3V","display_url":"bit.ly\/1Vazq3V","indices":[74,96]}]},"source":"\u003ca href=\"http:\/\/mobile.twitter.com\" rel=\"nofollow\"\u003eMobile Web\u003c\/a\u003e","in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"geo":null,"coordinates":null,"place":null,"contributors":null,"is_quote_status":false,"retweet_count":0,"favorite_count":5,"favorited":false,"retweeted":false,"possibly_sensitive":false,"lang":"en"},"contributors_enabled":false,"is_translator":false,"is_translation_enabled":false,"profile_background_color":"C0DEED","profile_background_image_url":"http:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_image_url_https":"https:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_tile":false,"profile_image_url":"http:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_image_url_https":"https:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_link_color":"1DA1F2","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"has_extended_profile":false,"default_profile":true,"default_profile_image":true,"following":false,"follow_request_sent":false,"notifications":false,"translator_type":"none"},{"id":497663678,"id_str":"497663678","name":"Maria Tzolov","screen_name":"mariatzolov","location":"","description":"i like dylan o'brien and mac n cheese","url":"http:\/\/t.co\/Vp5OnuTrT0","entities":{"url":{"urls":[{"url":"http:\/\/t.co\/Vp5OnuTrT0","expanded_url":"http:\/\/purelymaria.tumblr.com","display_url":"purelymaria.tumblr.com","indices":[0,22]}]},"description":{"urls":[]}},"protected":false,"followers_count":83,"friends_count":235,"listed_count":0,"created_at":"Mon Feb 20 07:09:19 +0000 2012","favourites_count":41,"utc_offset":null,"time_zone":null,"geo_enabled":false,"verified":false,"statuses_count":428,"lang":"en","status":{"created_at":"Tue Jan 20 08:03:02 +0000 2015","id":557448200227803136,"id_str":"557448200227803136","full_text":"RT @zaynmalik: So were in NZ how's every1 doing ?","truncated":false,"display_text_range":[0,49],"entities":{"hashtags":[],"symbols":[],"user_mentions":[{"screen_name":"zaynmalik","name":"zayn","id":176566242,"id_str":"176566242","indices":[3,13]}],"urls":[]},"source":"\u003ca href=\"http:\/\/twitter.com\/download\/iphone\" rel=\"nofollow\"\u003eTwitter for iPhone\u003c\/a\u003e","in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"geo":null,"coordinates":null,"place":null,"contributors":null,"retweeted_status":{"created_at":"Thu Apr 19 06:57:07 +0000 2012","id":192869420853497856,"id_str":"192869420853497856","full_text":"So were in NZ how's every1 doing ?","truncated":false,"display_text_range":[0,34],"entities":{"hashtags":[],"symbols":[],"user_mentions":[],"urls":[]},"source":"\u003ca href=\"http:\/\/blackberry.com\/twitter\" rel=\"nofollow\"\u003eTwitter for BlackBerry\u00ae\u003c\/a\u003e","in_reply_to_status_id":null,"in_reply_to_status_id_str":null,"in_reply_to_user_id":null,"in_reply_to_user_id_str":null,"in_reply_to_screen_name":null,"geo":null,"coordinates":null,"place":null,"contributors":null,"is_quote_status":false,"retweet_count":13160,"favorite_count":10940,"favorited":false,"retweeted":false,"lang":"en"},"is_quote_status":false,"retweet_count":13160,"favorite_count":0,"favorited":false,"retweeted":false,"lang":"en"},"contributors_enabled":false,"is_translator":false,"is_translation_enabled":false,"profile_background_color":"C0DEED","profile_background_image_url":"http:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_image_url_https":"https:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_tile":true,"profile_image_url":"http:\/\/pbs.twimg.com\/profile_images\/517266103186120704\/JdAwOnwF_normal.jpeg","profile_image_url_https":"https:\/\/pbs.twimg.com\/profile_images\/517266103186120704\/JdAwOnwF_normal.jpeg","profile_banner_url":"https:\/\/pbs.twimg.com\/profile_banners\/497663678\/1412160774","profile_image_extensions_alt_text":null,"profile_link_color":"0084B4","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"has_extended_profile":false,"default_profile":false,"default_profile_image":false,"following":false,"follow_request_sent":false,"notifications":false,"translator_type":"none"},{"id":467216441,"id_str":"467216441","name":"George Tzolov","screen_name":"gtzolov","location":"Bulgaria","description":"","url":null,"entities":{"description":{"urls":[]}},"protected":true,"followers_count":4,"friends_count":175,"listed_count":1,"created_at":"Wed Jan 18 07:18:51 +0000 2012","favourites_count":34,"utc_offset":null,"time_zone":null,"geo_enabled":false,"verified":false,"statuses_count":168,"lang":"en","contributors_enabled":false,"is_translator":false,"is_translation_enabled":false,"profile_background_color":"C0DEED","profile_background_image_url":"http:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_image_url_https":"https:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_tile":false,"profile_image_url":"http:\/\/pbs.twimg.com\/profile_images\/841011507508965376\/lkmRA0q2_normal.jpg","profile_image_url_https":"https:\/\/pbs.twimg.com\/profile_images\/841011507508965376\/lkmRA0q2_normal.jpg","profile_image_extensions_alt_text":null,"profile_link_color":"1DA1F2","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"has_extended_profile":false,"default_profile":true,"default_profile_image":false,"following":false,"follow_request_sent":false,"notifications":false,"translator_type":"none"},{"id":949949047,"id_str":"949949047","name":"Milen Tzolov","screen_name":"MilenTzolov","location":"","description":"","url":null,"entities":{"description":{"urls":[]}},"protected":true,"followers_count":0,"friends_count":0,"listed_count":0,"created_at":"Thu Nov 15 15:28:24 +0000 2012","favourites_count":0,"utc_offset":null,"time_zone":null,"geo_enabled":true,"verified":false,"statuses_count":0,"lang":"en","contributors_enabled":false,"is_translator":false,"is_translation_enabled":false,"profile_background_color":"C0DEED","profile_background_image_url":"http:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_image_url_https":"https:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_tile":false,"profile_image_url":"http:\/\/pbs.twimg.com\/profile_images\/2852620531\/6eeafeb0c22030e59c2226a6a508fc74_normal.jpeg","profile_image_url_https":"https:\/\/pbs.twimg.com\/profile_images\/2852620531\/6eeafeb0c22030e59c2226a6a508fc74_normal.jpeg","profile_image_extensions_alt_text":null,"profile_link_color":"1DA1F2","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"has_extended_profile":false,"default_profile":true,"default_profile_image":false,"following":false,"follow_request_sent":false,"notifications":false,"translator_type":"none"},{"id":454245310,"id_str":"454245310","name":"Simeon Tzolov","screen_name":"simeon190","location":"","description":"","url":null,"entities":{"description":{"urls":[]}},"protected":true,"followers_count":0,"friends_count":13,"listed_count":0,"created_at":"Tue Jan 03 19:44:35 +0000 2012","favourites_count":0,"utc_offset":null,"time_zone":null,"geo_enabled":false,"verified":false,"statuses_count":0,"lang":"en","contributors_enabled":false,"is_translator":false,"is_translation_enabled":false,"profile_background_color":"C0DEED","profile_background_image_url":"http:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_image_url_https":"https:\/\/abs.twimg.com\/images\/themes\/theme1\/bg.png","profile_background_tile":false,"profile_image_url":"http:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_image_url_https":"https:\/\/abs.twimg.com\/sticky\/default_profile_images\/default_profile_normal.png","profile_link_color":"1DA1F2","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"has_extended_profile":false,"default_profile":true,"default_profile_image":true,"following":false,"follow_request_sent":false,"notifications":false,"translator_type":"none"}]
diff --git a/functions/function/twitter-function/src/test/resources/response/trends.json b/functions/function/twitter-function/src/test/resources/response/trends.json
new file mode 100644
index 00000000..76901527
--- /dev/null
+++ b/functions/function/twitter-function/src/test/resources/response/trends.json
@@ -0,0 +1 @@
+[{"trends":[{"name":"Phillip Danault","url":"http:\/\/twitter.com\/search?q=%22Phillip+Danault%22","promoted_content":null,"query":"%22Phillip+Danault%22","tweet_volume":null},{"name":"Mali","url":"http:\/\/twitter.com\/search?q=Mali","promoted_content":null,"query":"Mali","tweet_volume":26504},{"name":"Paul Byron","url":"http:\/\/twitter.com\/search?q=%22Paul+Byron%22","promoted_content":null,"query":"%22Paul+Byron%22","tweet_volume":null},{"name":"Adonis Stevenson","url":"http:\/\/twitter.com\/search?q=%22Adonis+Stevenson%22","promoted_content":null,"query":"%22Adonis+Stevenson%22","tweet_volume":null},{"name":"Marcus Stroman","url":"http:\/\/twitter.com\/search?q=%22Marcus+Stroman%22","promoted_content":null,"query":"%22Marcus+Stroman%22","tweet_volume":null},{"name":"Mike Smith","url":"http:\/\/twitter.com\/search?q=%22Mike+Smith%22","promoted_content":null,"query":"%22Mike+Smith%22","tweet_volume":null},{"name":"Brandon Pirri","url":"http:\/\/twitter.com\/search?q=%22Brandon+Pirri%22","promoted_content":null,"query":"%22Brandon+Pirri%22","tweet_volume":null},{"name":"#WASvsTEN","url":"http:\/\/twitter.com\/search?q=%23WASvsTEN","promoted_content":null,"query":"%23WASvsTEN","tweet_volume":null},{"name":"#stlblues","url":"http:\/\/twitter.com\/search?q=%23stlblues","promoted_content":null,"query":"%23stlblues","tweet_volume":null},{"name":"Tatar","url":"http:\/\/twitter.com\/search?q=Tatar","promoted_content":null,"query":"Tatar","tweet_volume":null},{"name":"Bob Cole","url":"http:\/\/twitter.com\/search?q=%22Bob+Cole%22","promoted_content":null,"query":"%22Bob+Cole%22","tweet_volume":null},{"name":"Patrice Bergeron","url":"http:\/\/twitter.com\/search?q=%22Patrice+Bergeron%22","promoted_content":null,"query":"%22Patrice+Bergeron%22","tweet_volume":null},{"name":"#ResignTrump","url":"http:\/\/twitter.com\/search?q=%23ResignTrump","promoted_content":null,"query":"%23ResignTrump","tweet_volume":330688},{"name":"Christmas","url":"http:\/\/twitter.com\/search?q=Christmas","promoted_content":null,"query":"Christmas","tweet_volume":2824948},{"name":"Happy Holidays","url":"http:\/\/twitter.com\/search?q=%22Happy+Holidays%22","promoted_content":null,"query":"%22Happy+Holidays%22","tweet_volume":178360},{"name":"#WinterSolstice","url":"http:\/\/twitter.com\/search?q=%23WinterSolstice","promoted_content":null,"query":"%23WinterSolstice","tweet_volume":27358},{"name":"#The80sTaughtMe","url":"http:\/\/twitter.com\/search?q=%23The80sTaughtMe","promoted_content":null,"query":"%23The80sTaughtMe","tweet_volume":14044},{"name":"Atlas","url":"http:\/\/twitter.com\/search?q=Atlas","promoted_content":null,"query":"Atlas","tweet_volume":29402},{"name":"Liverpool","url":"http:\/\/twitter.com\/search?q=Liverpool","promoted_content":null,"query":"Liverpool","tweet_volume":141820},{"name":"manchester united","url":"http:\/\/twitter.com\/search?q=%22manchester+united%22","promoted_content":null,"query":"%22manchester+united%22","tweet_volume":93060},{"name":"Rocket","url":"http:\/\/twitter.com\/search?q=Rocket","promoted_content":null,"query":"Rocket","tweet_volume":23740},{"name":"F\u00EAtes","url":"http:\/\/twitter.com\/search?q=F%C3%AAtes","promoted_content":null,"query":"F%C3%AAtes","tweet_volume":47651},{"name":"Maple Valley","url":"http:\/\/twitter.com\/search?q=%22Maple+Valley%22","promoted_content":null,"query":"%22Maple+Valley%22","tweet_volume":null},{"name":"Omar Khadr","url":"http:\/\/twitter.com\/search?q=%22Omar+Khadr%22","promoted_content":null,"query":"%22Omar+Khadr%22","tweet_volume":null},{"name":"La Presse","url":"http:\/\/twitter.com\/search?q=%22La+Presse%22","promoted_content":null,"query":"%22La+Presse%22","tweet_volume":null},{"name":"Man City","url":"http:\/\/twitter.com\/search?q=%22Man+City%22","promoted_content":null,"query":"%22Man+City%22","tweet_volume":56236},{"name":"Yorkville","url":"http:\/\/twitter.com\/search?q=Yorkville","promoted_content":null,"query":"Yorkville","tweet_volume":null},{"name":"Kawhi Leonard","url":"http:\/\/twitter.com\/search?q=%22Kawhi+Leonard%22","promoted_content":null,"query":"%22Kawhi+Leonard%22","tweet_volume":null},{"name":"Lotto Max","url":"http:\/\/twitter.com\/search?q=%22Lotto+Max%22","promoted_content":null,"query":"%22Lotto+Max%22","tweet_volume":null},{"name":"Real Madrid","url":"http:\/\/twitter.com\/search?q=%22Real+Madrid%22","promoted_content":null,"query":"%22Real+Madrid%22","tweet_volume":120739},{"name":"White Rock","url":"http:\/\/twitter.com\/search?q=%22White+Rock%22","promoted_content":null,"query":"%22White+Rock%22","tweet_volume":null},{"name":"Pogba","url":"http:\/\/twitter.com\/search?q=Pogba","promoted_content":null,"query":"Pogba","tweet_volume":70788},{"name":"Rangers","url":"http:\/\/twitter.com\/search?q=Rangers","promoted_content":null,"query":"Rangers","tweet_volume":17174},{"name":"Arsenal","url":"http:\/\/twitter.com\/search?q=Arsenal","promoted_content":null,"query":"Arsenal","tweet_volume":151598},{"name":"Boxing Day","url":"http:\/\/twitter.com\/search?q=%22Boxing+Day%22","promoted_content":null,"query":"%22Boxing+Day%22","tweet_volume":23196},{"name":"York University","url":"http:\/\/twitter.com\/search?q=%22York+University%22","promoted_content":null,"query":"%22York+University%22","tweet_volume":null},{"name":"Slovakia","url":"http:\/\/twitter.com\/search?q=Slovakia","promoted_content":null,"query":"Slovakia","tweet_volume":null},{"name":"Finance Department","url":"http:\/\/twitter.com\/search?q=%22Finance+Department%22","promoted_content":null,"query":"%22Finance+Department%22","tweet_volume":null},{"name":"Chelsea","url":"http:\/\/twitter.com\/search?q=Chelsea","promoted_content":null,"query":"Chelsea","tweet_volume":97038},{"name":"New Year","url":"http:\/\/twitter.com\/search?q=%22New+Year%22","promoted_content":null,"query":"%22New+Year%22","tweet_volume":197747},{"name":"Bria Myles","url":"http:\/\/twitter.com\/search?q=%22Bria+Myles%22","promoted_content":null,"query":"%22Bria+Myles%22","tweet_volume":47871},{"name":"Parliament","url":"http:\/\/twitter.com\/search?q=Parliament","promoted_content":null,"query":"Parliament","tweet_volume":51213},{"name":"#BirdBox","url":"http:\/\/twitter.com\/search?q=%23BirdBox","promoted_content":null,"query":"%23BirdBox","tweet_volume":30984},{"name":"#BCstorm","url":"http:\/\/twitter.com\/search?q=%23BCstorm","promoted_content":null,"query":"%23BCstorm","tweet_volume":null},{"name":"#MCICRY","url":"http:\/\/twitter.com\/search?q=%23MCICRY","promoted_content":null,"query":"%23MCICRY","tweet_volume":43210},{"name":"#Caturday","url":"http:\/\/twitter.com\/search?q=%23Caturday","promoted_content":null,"query":"%23Caturday","tweet_volume":13967},{"name":"#CARMUN","url":"http:\/\/twitter.com\/search?q=%23CARMUN","promoted_content":null,"query":"%23CARMUN","tweet_volume":92394},{"name":"#Aquaman","url":"http:\/\/twitter.com\/search?q=%23Aquaman","promoted_content":null,"query":"%23Aquaman","tweet_volume":40278},{"name":"#ReasonsNotToBuildTheWall","url":"http:\/\/twitter.com\/search?q=%23ReasonsNotToBuildTheWall","promoted_content":null,"query":"%23ReasonsNotToBuildTheWall","tweet_volume":31522},{"name":"#AllStars4","url":"http:\/\/twitter.com\/search?q=%23AllStars4","promoted_content":null,"query":"%23AllStars4","tweet_volume":25908}],"as_of":"2018-12-30T16:38:56Z","created_at":"2018-12-22T23:47:56Z","locations":[{"name":"Winnipeg","woeid":2972}]}]