From 052f86cf91aa01caefe8e8a79ae39e45443fa46f Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Wed, 24 Jun 2020 12:58:47 +0200 Subject: [PATCH] Port in the Geo and Users twitter function. Fix issue with handling SpEL expression porperties in the twitter functions. --- .../sink/twitter-update-sink/README.adoc | 19 -- .../StringToSpelConversionFunction.java | 61 ++++ .../TwitterConnectionConfiguration.java | 25 ++ .../function/twitter-function/README.adoc | 127 +++++++- functions/function/twitter-function/pom.xml | 18 ++ .../geo/TwitterGeoFunctionConfiguration.java | 113 +++++++ .../geo/TwitterGeoFunctionProperties.java | 197 +++++++++++++ .../TwitterUsersFunctionConfiguration.java | 98 ++++++ .../users/TwitterUsersFunctionProperties.java | 144 +++++++++ .../twitter/geo/TwitterGeoFunctionTest.java | 279 ++++++++++++++++++ .../trend/TwitterTrendFunctionTests.java | 140 +++++++++ .../users/TwitterUsersFunctionTests.java | 204 +++++++++++++ .../resources/response/lookup_users_id.json | 1 + .../response/search_places_amsterdam.json | 1 + .../test/resources/response/search_users.json | 1 + .../src/test/resources/response/trends.json | 1 + 16 files changed, 1406 insertions(+), 23 deletions(-) create mode 100644 functions/common/twitter-common/src/main/java/org/springframework/cloud/fn/common/twitter/StringToSpelConversionFunction.java create mode 100644 functions/function/twitter-function/src/main/java/org/springframework/cloud/fn/twitter/geo/TwitterGeoFunctionConfiguration.java create mode 100644 functions/function/twitter-function/src/main/java/org/springframework/cloud/fn/twitter/geo/TwitterGeoFunctionProperties.java create mode 100644 functions/function/twitter-function/src/main/java/org/springframework/cloud/fn/twitter/users/TwitterUsersFunctionConfiguration.java create mode 100644 functions/function/twitter-function/src/main/java/org/springframework/cloud/fn/twitter/users/TwitterUsersFunctionProperties.java create mode 100644 functions/function/twitter-function/src/test/java/org/springframework/cloud/fn/twitter/geo/TwitterGeoFunctionTest.java create mode 100644 functions/function/twitter-function/src/test/java/org/springframework/cloud/fn/twitter/trend/TwitterTrendFunctionTests.java create mode 100644 functions/function/twitter-function/src/test/java/org/springframework/cloud/fn/twitter/users/TwitterUsersFunctionTests.java create mode 100644 functions/function/twitter-function/src/test/resources/response/lookup_users_id.json create mode 100644 functions/function/twitter-function/src/test/resources/response/search_places_amsterdam.json create mode 100644 functions/function/twitter-function/src/test/resources/response/search_users.json create mode 100644 functions/function/twitter-function/src/test/resources/response/trends.json 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}]}]