diff --git a/applications/processor/bridge-processor/README.adoc.tmp b/applications/processor/bridge-processor/README.adoc.tmp new file mode 100644 index 00000000..9ab2f2d0 --- /dev/null +++ b/applications/processor/bridge-processor/README.adoc.tmp @@ -0,0 +1,11 @@ +//tag::ref-doc[] += Bridge Processor + +A processor that bridges the input and ouput by simply passing the incoming payload to the outbound. + +=== Payload + +Any + +//end::ref-doc[] +null diff --git a/applications/processor/pom.xml b/applications/processor/pom.xml index 007cdfdb..ab56a3a3 100644 --- a/applications/processor/pom.xml +++ b/applications/processor/pom.xml @@ -18,6 +18,7 @@ splitter-processor transform-processor script-processor + twitter-trend-processor diff --git a/applications/processor/twitter-trend-processor/README.adoc b/applications/processor/twitter-trend-processor/README.adoc new file mode 100644 index 00000000..6d00033b --- /dev/null +++ b/applications/processor/twitter-trend-processor/README.adoc @@ -0,0 +1,36 @@ +//tag::ref-doc[] += Twitter Trend and Trend Locations Processor + +Processor that can return either trending topic or the Locations of the trending topics. +The `twitter.trend.trend-query-type` property allow to select the query type. + +== Retrieve trending topic in a location (optionally) + +For this mode set `twitter.trend.trend-query-type` to `trend`. + +Processor based on https://developer.twitter.com/en/docs/trends/trends-for-location/api-reference/get-trends-place[Trends API]. +Returns the https://help.twitter.com/en/using-twitter/twitter-trending-faqs[trending topics] near a specific latitude, longitude location. + +== Retrieve trend Locations + +For this mode set `twitter.trend.trend-query-type` to `trendLocation`. + +Retrieve a full or nearby locations list of trending topics by location. + +If the `latitude`, `longitude` parameters are NOT provided the processor performs the https://developer.twitter.com/en/docs/trends/locations-with-trending-topics/api-reference/get-trends-available[Trends Available API] and returns the locations that Twitter has trending topic information for. + +If the `latitude`, `longitude` parameters are provided the processor performs the https://developer.twitter.com/en/docs/trends/locations-with-trending-topics/api-reference/get-trends-closest[Trends Closest API] and returns the locations that Twitter has trending topic information for, closest to a specified location. + +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. + +== Options + +//tag::configuration-properties[] +$$twitter.trend.closest.lat$$:: $$If provided with a long parameter the available trend locations will be sorted by distance, nearest to furthest, to the co-ordinate pair. The valid ranges for longitude is -180.0 to +180.0 (West is negative, East is positive) inclusive.$$ *($$Expression$$, default: `$$$$`)* +$$twitter.trend.closest.lon$$:: $$If provided with a lat parameter the available trend locations will be sorted by distance, nearest to furthest, to the co-ordinate pair. The valid ranges for longitude is -180.0 to +180.0 (West is negative, East is positive) inclusive.$$ *($$Expression$$, default: `$$$$`)* +$$twitter.trend.location-id$$:: $$The Yahoo! Where On Earth ID of the location to return trending information for. Global information is available by using 1 as the WOEID.$$ *($$Expression$$, default: `$$payload$$`)* +$$twitter.trend.trend-query-type$$:: $$$$ *($$TrendQueryType$$, default: `$$$$`, possible values: `trend`,`trendLocation`)* +//end::configuration-properties[] + +//end::ref-doc[] + diff --git a/applications/processor/twitter-trend-processor/pom.xml b/applications/processor/twitter-trend-processor/pom.xml new file mode 100644 index 00000000..1340356b --- /dev/null +++ b/applications/processor/twitter-trend-processor/pom.xml @@ -0,0 +1,103 @@ + + + 4.0.0 + twitter-trend-processor + twitter-trend-processor + twitter trend processor apps + 3.0.0-SNAPSHOT + + + org.springframework.cloud.stream.app + stream-applications-core + 3.0.0-SNAPSHOT + + + + + + org.springframework.cloud.fn + twitter-function + ${java-functions.version} + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.junit.vintage + junit-vintage-engine + + + + + org.springframework.boot + spring-boot-starter-json + test + + + org.mock-server + mockserver-netty + 5.10 + test + + + org.mock-server + mockserver-client-java + 5.10 + test + + + + + + + org.springframework.cloud + spring-cloud-app-starter-doc-maven-plugin + + + org.springframework.cloud.stream.app.plugin + spring-cloud-stream-app-maven-plugin + + + twitter-trend + processor + ${project.version} + org.springframework.cloud.fn.twitter.trend.TwitterTrendFunctionConfiguration.class + trendOrTrendLocationsFunction + + + + + org.springframework.cloud.fn + twitter-function + ${java-functions.version} + + + + + + + + + + + true + + spring-snapshots + Spring Snapshots + https://repo.spring.io/libs-snapshot-local + + + + false + + spring-milestones + Spring Milestones + https://repo.spring.io/libs-milestone-local + + + diff --git a/applications/processor/twitter-trend-processor/src/main/resources/META-INF/dataflow-configuration-metadata-whitelist.properties b/applications/processor/twitter-trend-processor/src/main/resources/META-INF/dataflow-configuration-metadata-whitelist.properties new file mode 100644 index 00000000..36ac9328 --- /dev/null +++ b/applications/processor/twitter-trend-processor/src/main/resources/META-INF/dataflow-configuration-metadata-whitelist.properties @@ -0,0 +1,3 @@ +configuration-properties.classes=org.springframework.cloud.fn.twitter.trend.TwitterTrendFunctionProperties, \ + org.springframework.cloud.fn.twitter.trend.TwitterTrendFunctionProperties$Closest + diff --git a/applications/processor/twitter-trend-processor/src/test/java/org/springframework/cloud/stream/app/processor/twitter/trend/TwitterTestUtils.java b/applications/processor/twitter-trend-processor/src/test/java/org/springframework/cloud/stream/app/processor/twitter/trend/TwitterTestUtils.java new file mode 100644 index 00000000..b76e8e30 --- /dev/null +++ b/applications/processor/twitter-trend-processor/src/test/java/org/springframework/cloud/stream/app/processor/twitter/trend/TwitterTestUtils.java @@ -0,0 +1,68 @@ +/* + * 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.stream.app.processor.twitter.trend; + + +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.function.Function; + +import twitter4j.conf.ConfigurationBuilder; + +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.util.StreamUtils; + +/** + * @author Christian Tzolov + */ +public class TwitterTestUtils { + + public Function mockTwitterUrls(String baseUrl) { + return configBuilder -> { + configBuilder.setRestBaseURL(baseUrl + "/"); + configBuilder.setStreamBaseURL(baseUrl + "/stream/"); + configBuilder.setUserStreamBaseURL(baseUrl + "/user/"); + configBuilder.setSiteStreamBaseURL(baseUrl + "/site/"); + configBuilder.setUploadBaseURL(baseUrl + "/upload/"); + + configBuilder.setOAuthAccessTokenURL(baseUrl + "/oauth/access_token"); + configBuilder.setOAuthAuthenticationURL(baseUrl + "/oauth/authenticate"); + configBuilder.setOAuthAuthorizationURL(baseUrl + "/oauth/authorize"); + configBuilder.setOAuthRequestTokenURL(baseUrl + "/oauth/request_token"); + configBuilder.setOAuth2TokenURL(baseUrl + "/oauth2/token"); + configBuilder.setOAuth2InvalidateTokenURL(baseUrl + "/oauth2/invalidate_token"); + + return configBuilder; + }; + } + + /** + * Load Spring Resource as String. + * @param resourcePath Resource path (accepts file:// , classpath:// and http:// uri schemas) + * @return Returns text (UTF8) representation of the resource pointed by the resourcePath + */ + public static String asString(String resourcePath) { + try { + return StreamUtils.copyToString(new DefaultResourceLoader().getResource(resourcePath).getInputStream(), + Charset.forName("UTF-8")); + } + catch (IOException e) { + throw new RuntimeException("Can not load resource:" + resourcePath, e); + } + } + +} diff --git a/applications/processor/twitter-trend-processor/src/test/java/org/springframework/cloud/stream/app/processor/twitter/trend/TwitterTrendLocationProcessorIntegrationTests.java b/applications/processor/twitter-trend-processor/src/test/java/org/springframework/cloud/stream/app/processor/twitter/trend/TwitterTrendLocationProcessorIntegrationTests.java new file mode 100644 index 00000000..afafefb6 --- /dev/null +++ b/applications/processor/twitter-trend-processor/src/test/java/org/springframework/cloud/stream/app/processor/twitter/trend/TwitterTrendLocationProcessorIntegrationTests.java @@ -0,0 +1,226 @@ +/* + * 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.stream.app.processor.twitter.trend; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.boot.SpringBootConfiguration; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.cloud.fn.common.twitter.TwitterConnectionProperties; +import org.springframework.cloud.fn.twitter.trend.TwitterTrendFunctionConfiguration; +import org.springframework.cloud.stream.binder.test.InputDestination; +import org.springframework.cloud.stream.binder.test.OutputDestination; +import org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.GenericMessage; + +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 + */ +public class TwitterTrendLocationProcessorIntegrationTests { + + private static final String MOCK_SERVER_IP = "127.0.0.1"; + + private static final Integer MOCK_SERVER_PORT = 1080; + + private static ClientAndServer mockServer; + + private static MockServerClient mockClient; + private static HttpRequest availableTrendsRequest; + private static HttpRequest closestTrendsRequest; + + @BeforeEach + public void startServer() { + mockServer = ClientAndServer.startClientAndServer(MOCK_SERVER_PORT); + mockClient = new MockServerClient(MOCK_SERVER_IP, MOCK_SERVER_PORT); + + availableTrendsRequest = setExpectation(request() + .withMethod("GET") + .withPath("/trends/available.json")); + + closestTrendsRequest = setExpectation(request() + .withMethod("GET") + .withPath("/trends/closest.json") + .withQueryStringParameter("lat", "52.379189") + .withQueryStringParameter("long", "4.899431")); + } + + @AfterEach + public void stopServer() { + mockServer.stop(); + } + + @Test + public void testTwitterAvailableTrends() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration(TwitterTrendProcessorIntegrationTests.TestTwitterTrendProcessorApplication.class)) + .web(WebApplicationType.NONE) + .run("--spring.cloud.stream.function.definition=trendOrTrendLocationsFunction", + + "--twitter.trend.trendQueryType=trendLocation", + "--twitter.connection.rawJson=false", + + "--twitter.connection.consumerKey=myConsumerKey", + "--twitter.connection.consumerSecret=myConsumerSecret", + "--twitter.connection.accessToken=myAccessToken", + "--twitter.connection.accessTokenSecret=myAccessTokenSecret")) { + + InputDestination input = context.getBean(InputDestination.class); + OutputDestination output = context.getBean(OutputDestination.class); + assertThat(input).isNotNull(); + assertThat(output).isNotNull(); + + input.send(new GenericMessage<>("hello".getBytes(StandardCharsets.UTF_8))); + + Message outputMessage = output.receive(Duration.ofSeconds(300).toMillis()); + assertThat(outputMessage).isNotNull(); + + mockClient.verify(availableTrendsRequest, once()); + assertThat(outputMessage); + + String payload = new String(outputMessage.getPayload()); + assertThat(payload).containsSequence("countryName"); + assertThat(payload).contains("placeCode"); + assertThat(payload).doesNotContain("placeType"); + } + } + + @Test + public void testTwitterAvailableTrendsTwitterJson() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration(TwitterTrendProcessorIntegrationTests.TestTwitterTrendProcessorApplication.class)) + .web(WebApplicationType.NONE) + .run("--spring.cloud.stream.function.definition=trendOrTrendLocationsFunction", + + "--twitter.trend.trendQueryType=trendLocation", + "--twitter.connection.rawJson=true", + + "--twitter.connection.consumerKey=myConsumerKey", + "--twitter.connection.consumerSecret=myConsumerSecret", + "--twitter.connection.accessToken=myAccessToken", + "--twitter.connection.accessTokenSecret=myAccessTokenSecret")) { + + InputDestination input = context.getBean(InputDestination.class); + OutputDestination output = context.getBean(OutputDestination.class); + assertThat(input).isNotNull(); + assertThat(output).isNotNull(); + + input.send(new GenericMessage<>("hello".getBytes(StandardCharsets.UTF_8))); + + Message outputMessage = output.receive(Duration.ofSeconds(300).toMillis()); + assertThat(outputMessage).isNotNull(); + + mockClient.verify(availableTrendsRequest, once()); + assertThat(outputMessage).isNotNull(); + + String payload = new String(outputMessage.getPayload()); + + assertThat(payload).contains("placeType"); + assertThat(payload).doesNotContain("placeCode"); + assertThat(payload).doesNotContain("countryName"); + } + } + + @Test + public void testTwitterClosestTrends() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration(TwitterTrendProcessorIntegrationTests.TestTwitterTrendProcessorApplication.class)) + .web(WebApplicationType.NONE) + .run("--spring.cloud.stream.function.definition=trendOrTrendLocationsFunction", + + "--twitter.trend.trendQueryType=trendLocation", + "--twitter.connection.rawJson=true", + "--twitter.trend.closest.lat='52.379189'", + "--twitter.trend.closest.lon='4.899431'", + + "--twitter.connection.consumerKey=myConsumerKey", + "--twitter.connection.consumerSecret=myConsumerSecret", + "--twitter.connection.accessToken=myAccessToken", + "--twitter.connection.accessTokenSecret=myAccessTokenSecret")) { + + InputDestination input = context.getBean(InputDestination.class); + OutputDestination output = context.getBean(OutputDestination.class); + assertThat(input).isNotNull(); + assertThat(output).isNotNull(); + + input.send(new GenericMessage<>("hello".getBytes(StandardCharsets.UTF_8))); + + Message outputMessage = output.receive(Duration.ofSeconds(300).toMillis()); + assertThat(outputMessage).isNotNull(); + + mockClient.verify(closestTrendsRequest, once()); + assertThat(outputMessage).isNotNull(); + } + } + + 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/trend_locations.json")) + .withDelay(TimeUnit.SECONDS, 1) + ); + return request; + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @Import(TwitterTrendFunctionConfiguration.class) + public static class TestTwitterTrendLocationProcessorApplication { + @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/applications/processor/twitter-trend-processor/src/test/java/org/springframework/cloud/stream/app/processor/twitter/trend/TwitterTrendProcessorIntegrationTests.java b/applications/processor/twitter-trend-processor/src/test/java/org/springframework/cloud/stream/app/processor/twitter/trend/TwitterTrendProcessorIntegrationTests.java new file mode 100644 index 00000000..d6c6c9cb --- /dev/null +++ b/applications/processor/twitter-trend-processor/src/test/java/org/springframework/cloud/stream/app/processor/twitter/trend/TwitterTrendProcessorIntegrationTests.java @@ -0,0 +1,151 @@ +/* + * 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.stream.app.processor.twitter.trend; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +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.boot.SpringBootConfiguration; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.cloud.fn.common.twitter.TwitterConnectionProperties; +import org.springframework.cloud.fn.twitter.trend.TwitterTrendFunctionConfiguration; +import org.springframework.cloud.stream.binder.test.InputDestination; +import org.springframework.cloud.stream.binder.test.OutputDestination; +import org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.GenericMessage; + +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 + */ +public class TwitterTrendProcessorIntegrationTests { + + private static final String MOCK_SERVER_IP = "127.0.0.1"; + + private static final Integer MOCK_SERVER_PORT = 1080; + + private static ClientAndServer mockServer; + + private static MockServerClient mockClient; + private static HttpRequest trendsRequest; + + @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(); + } + + @Test + public void testTwitterTrendPayload() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration(TestTwitterTrendProcessorApplication.class)) + .web(WebApplicationType.NONE) + .run("--spring.cloud.stream.function.definition=trendOrTrendLocationsFunction", + + "--twitter.trend.locationId='2972'", + "--twitter.connection.rawJson=true", + + "--twitter.connection.consumerKey=myConsumerKey", + "--twitter.connection.consumerSecret=myConsumerSecret", + "--twitter.connection.accessToken=myAccessToken", + "--twitter.connection.accessTokenSecret=myAccessTokenSecret")) { + + InputDestination input = context.getBean(InputDestination.class); + OutputDestination output = context.getBean(OutputDestination.class); + assertThat(input).isNotNull(); + assertThat(output).isNotNull(); + + input.send(new GenericMessage<>("Hello".getBytes(StandardCharsets.UTF_8))); + + Message outputMessage = output.receive(Duration.ofSeconds(300).toMillis()); + assertThat(outputMessage).isNotNull(); + + mockClient.verify(trendsRequest, once()); + + //Resource trendsResource = new DefaultResourceLoader().getResource("classpath:/response/trends.json"); + //String expected = new String(StreamUtils.copyToByteArray(trendsResource.getInputStream()), StandardCharsets.UTF_8).trim(); + //String actual = new String(outputMessage.getPayload(), StandardCharsets.UTF_8); + //JSONAssert.assertEquals(expected, actual, JSONCompareMode.LENIENT); + } + } + + 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; + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @Import(TwitterTrendFunctionConfiguration.class) + public static class TestTwitterTrendProcessorApplication { + @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/applications/processor/twitter-trend-processor/src/test/resources/response/trend_locations.json b/applications/processor/twitter-trend-processor/src/test/resources/response/trend_locations.json new file mode 100644 index 00000000..6081623c --- /dev/null +++ b/applications/processor/twitter-trend-processor/src/test/resources/response/trend_locations.json @@ -0,0 +1 @@ +[{"name":"Worldwide","placeType":{"code":19,"name":"Supername"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1","parentid":0,"country":"","woeid":1,"countryCode":null},{"name":"Winnipeg","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2972","parentid":23424775,"country":"Canada","woeid":2972,"countryCode":"CA"},{"name":"Ottawa","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/3369","parentid":23424775,"country":"Canada","woeid":3369,"countryCode":"CA"},{"name":"Quebec","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/3444","parentid":23424775,"country":"Canada","woeid":3444,"countryCode":"CA"},{"name":"Montreal","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/3534","parentid":23424775,"country":"Canada","woeid":3534,"countryCode":"CA"},{"name":"Toronto","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/4118","parentid":23424775,"country":"Canada","woeid":4118,"countryCode":"CA"},{"name":"Edmonton","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/8676","parentid":23424775,"country":"Canada","woeid":8676,"countryCode":"CA"},{"name":"Calgary","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/8775","parentid":23424775,"country":"Canada","woeid":8775,"countryCode":"CA"},{"name":"Vancouver","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/9807","parentid":23424775,"country":"Canada","woeid":9807,"countryCode":"CA"},{"name":"Birmingham","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/12723","parentid":23424975,"country":"United Kingdom","woeid":12723,"countryCode":"GB"},{"name":"Blackpool","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/12903","parentid":23424975,"country":"United Kingdom","woeid":12903,"countryCode":"GB"},{"name":"Bournemouth","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/13383","parentid":23424975,"country":"United Kingdom","woeid":13383,"countryCode":"GB"},{"name":"Brighton","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/13911","parentid":23424975,"country":"United Kingdom","woeid":13911,"countryCode":"GB"},{"name":"Bristol","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/13963","parentid":23424975,"country":"United Kingdom","woeid":13963,"countryCode":"GB"},{"name":"Cardiff","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/15127","parentid":23424975,"country":"United Kingdom","woeid":15127,"countryCode":"GB"},{"name":"Coventry","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/17044","parentid":23424975,"country":"United Kingdom","woeid":17044,"countryCode":"GB"},{"name":"Derby","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/18114","parentid":23424975,"country":"United Kingdom","woeid":18114,"countryCode":"GB"},{"name":"Edinburgh","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/19344","parentid":23424975,"country":"United Kingdom","woeid":19344,"countryCode":"GB"},{"name":"Glasgow","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/21125","parentid":23424975,"country":"United Kingdom","woeid":21125,"countryCode":"GB"},{"name":"Hull","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/25211","parentid":23424975,"country":"United Kingdom","woeid":25211,"countryCode":"GB"},{"name":"Leeds","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/26042","parentid":23424975,"country":"United Kingdom","woeid":26042,"countryCode":"GB"},{"name":"Leicester","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/26062","parentid":23424975,"country":"United Kingdom","woeid":26062,"countryCode":"GB"},{"name":"Liverpool","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/26734","parentid":23424975,"country":"United Kingdom","woeid":26734,"countryCode":"GB"},{"name":"Manchester","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/28218","parentid":23424975,"country":"United Kingdom","woeid":28218,"countryCode":"GB"},{"name":"Middlesbrough","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/28869","parentid":23424975,"country":"United Kingdom","woeid":28869,"countryCode":"GB"},{"name":"Newcastle","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/30079","parentid":23424975,"country":"United Kingdom","woeid":30079,"countryCode":"GB"},{"name":"Nottingham","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/30720","parentid":23424975,"country":"United Kingdom","woeid":30720,"countryCode":"GB"},{"name":"Plymouth","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/32185","parentid":23424975,"country":"United Kingdom","woeid":32185,"countryCode":"GB"},{"name":"Portsmouth","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/32452","parentid":23424975,"country":"United Kingdom","woeid":32452,"countryCode":"GB"},{"name":"Preston","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/32566","parentid":23424975,"country":"United Kingdom","woeid":32566,"countryCode":"GB"},{"name":"Sheffield","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/34503","parentid":23424975,"country":"United Kingdom","woeid":34503,"countryCode":"GB"},{"name":"Stoke-on-Trent","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/36240","parentid":23424975,"country":"United Kingdom","woeid":36240,"countryCode":"GB"},{"name":"Swansea","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/36758","parentid":23424975,"country":"United Kingdom","woeid":36758,"countryCode":"GB"},{"name":"London","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/44418","parentid":23424975,"country":"United Kingdom","woeid":44418,"countryCode":"GB"},{"name":"Belfast","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/44544","parentid":23424975,"country":"United Kingdom","woeid":44544,"countryCode":"GB"},{"name":"Santo Domingo","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/76456","parentid":23424800,"country":"Dominican Republic","woeid":76456,"countryCode":"DO"},{"name":"Guatemala City","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/83123","parentid":23424834,"country":"Guatemala","woeid":83123,"countryCode":"GT"},{"name":"Acapulco","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/110978","parentid":23424900,"country":"Mexico","woeid":110978,"countryCode":"MX"},{"name":"Aguascalientes","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/111579","parentid":23424900,"country":"Mexico","woeid":111579,"countryCode":"MX"},{"name":"Chihuahua","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/115958","parentid":23424900,"country":"Mexico","woeid":115958,"countryCode":"MX"},{"name":"Mexico City","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/116545","parentid":23424900,"country":"Mexico","woeid":116545,"countryCode":"MX"},{"name":"Ciudad Juarez","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/116556","parentid":23424900,"country":"Mexico","woeid":116556,"countryCode":"MX"},{"name":"Nezahualc\u00F3yotl","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/116564","parentid":23424900,"country":"Mexico","woeid":116564,"countryCode":"MX"},{"name":"Culiac\u00E1n","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/117994","parentid":23424900,"country":"Mexico","woeid":117994,"countryCode":"MX"},{"name":"Ecatepec de Morelos","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/118466","parentid":23424900,"country":"Mexico","woeid":118466,"countryCode":"MX"},{"name":"Guadalajara","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/124162","parentid":23424900,"country":"Mexico","woeid":124162,"countryCode":"MX"},{"name":"Hermosillo","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/124785","parentid":23424900,"country":"Mexico","woeid":124785,"countryCode":"MX"},{"name":"Le\u00F3n","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/131068","parentid":23424900,"country":"Mexico","woeid":131068,"countryCode":"MX"},{"name":"M\u00E9rida","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/133327","parentid":23424900,"country":"Mexico","woeid":133327,"countryCode":"MX"},{"name":"Mexicali","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/133475","parentid":23424900,"country":"Mexico","woeid":133475,"countryCode":"MX"},{"name":"Monterrey","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/134047","parentid":23424900,"country":"Mexico","woeid":134047,"countryCode":"MX"},{"name":"Morelia","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/134091","parentid":23424900,"country":"Mexico","woeid":134091,"countryCode":"MX"},{"name":"Naucalpan de Ju\u00E1rez","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/134395","parentid":23424900,"country":"Mexico","woeid":134395,"countryCode":"MX"},{"name":"Puebla","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/137612","parentid":23424900,"country":"Mexico","woeid":137612,"countryCode":"MX"},{"name":"Quer\u00E9taro","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/138045","parentid":23424900,"country":"Mexico","woeid":138045,"countryCode":"MX"},{"name":"Saltillo","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/141272","parentid":23424900,"country":"Mexico","woeid":141272,"countryCode":"MX"},{"name":"San Luis Potos\u00ED","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/144265","parentid":23424900,"country":"Mexico","woeid":144265,"countryCode":"MX"},{"name":"Tijuana","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/149361","parentid":23424900,"country":"Mexico","woeid":149361,"countryCode":"MX"},{"name":"Toluca","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/149769","parentid":23424900,"country":"Mexico","woeid":149769,"countryCode":"MX"},{"name":"Zapopan","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/151582","parentid":23424900,"country":"Mexico","woeid":151582,"countryCode":"MX"},{"name":"Mendoza","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/332471","parentid":23424747,"country":"Argentina","woeid":332471,"countryCode":"AR"},{"name":"Santiago","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/349859","parentid":23424782,"country":"Chile","woeid":349859,"countryCode":"CL"},{"name":"Concepcion","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/349860","parentid":23424782,"country":"Chile","woeid":349860,"countryCode":"CL"},{"name":"Valparaiso","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/349861","parentid":23424782,"country":"Chile","woeid":349861,"countryCode":"CL"},{"name":"Bogot\u00E1","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/368148","parentid":23424787,"country":"Colombia","woeid":368148,"countryCode":"CO"},{"name":"Cali","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/368149","parentid":23424787,"country":"Colombia","woeid":368149,"countryCode":"CO"},{"name":"Medell\u00EDn","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/368150","parentid":23424787,"country":"Colombia","woeid":368150,"countryCode":"CO"},{"name":"Barranquilla","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/368151","parentid":23424787,"country":"Colombia","woeid":368151,"countryCode":"CO"},{"name":"Quito","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/375732","parentid":23424801,"country":"Ecuador","woeid":375732,"countryCode":"EC"},{"name":"Guayaquil","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/375733","parentid":23424801,"country":"Ecuador","woeid":375733,"countryCode":"EC"},{"name":"Caracas","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/395269","parentid":23424982,"country":"Venezuela","woeid":395269,"countryCode":"VE"},{"name":"Maracaibo","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/395270","parentid":23424982,"country":"Venezuela","woeid":395270,"countryCode":"VE"},{"name":"Maracay","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/395271","parentid":23424982,"country":"Venezuela","woeid":395271,"countryCode":"VE"},{"name":"Valencia","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/395272","parentid":23424982,"country":"Venezuela","woeid":395272,"countryCode":"VE"},{"name":"Barcelona","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/395273","parentid":23424982,"country":"Venezuela","woeid":395273,"countryCode":"VE"},{"name":"Ciudad Guayana","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/395275","parentid":23424982,"country":"Venezuela","woeid":395275,"countryCode":"VE"},{"name":"Turmero","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/395277","parentid":23424982,"country":"Venezuela","woeid":395277,"countryCode":"VE"},{"name":"Lima","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/418440","parentid":23424919,"country":"Peru","woeid":418440,"countryCode":"PE"},{"name":"Bras\u00EDlia","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/455819","parentid":23424768,"country":"Brazil","woeid":455819,"countryCode":"BR"},{"name":"Bel\u00E9m","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/455820","parentid":23424768,"country":"Brazil","woeid":455820,"countryCode":"BR"},{"name":"Belo Horizonte","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/455821","parentid":23424768,"country":"Brazil","woeid":455821,"countryCode":"BR"},{"name":"Curitiba","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/455822","parentid":23424768,"country":"Brazil","woeid":455822,"countryCode":"BR"},{"name":"Porto Alegre","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/455823","parentid":23424768,"country":"Brazil","woeid":455823,"countryCode":"BR"},{"name":"Recife","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/455824","parentid":23424768,"country":"Brazil","woeid":455824,"countryCode":"BR"},{"name":"Rio de Janeiro","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/455825","parentid":23424768,"country":"Brazil","woeid":455825,"countryCode":"BR"},{"name":"Salvador","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/455826","parentid":23424768,"country":"Brazil","woeid":455826,"countryCode":"BR"},{"name":"S\u00E3o Paulo","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/455827","parentid":23424768,"country":"Brazil","woeid":455827,"countryCode":"BR"},{"name":"Campinas","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/455828","parentid":23424768,"country":"Brazil","woeid":455828,"countryCode":"BR"},{"name":"Fortaleza","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/455830","parentid":23424768,"country":"Brazil","woeid":455830,"countryCode":"BR"},{"name":"Goi\u00E2nia","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/455831","parentid":23424768,"country":"Brazil","woeid":455831,"countryCode":"BR"},{"name":"Manaus","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/455833","parentid":23424768,"country":"Brazil","woeid":455833,"countryCode":"BR"},{"name":"S\u00E3o Lu\u00EDs","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/455834","parentid":23424768,"country":"Brazil","woeid":455834,"countryCode":"BR"},{"name":"Guarulhos","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/455867","parentid":23424768,"country":"Brazil","woeid":455867,"countryCode":"BR"},{"name":"C\u00F3rdoba","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/466861","parentid":23424747,"country":"Argentina","woeid":466861,"countryCode":"AR"},{"name":"Rosario","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/466862","parentid":23424747,"country":"Argentina","woeid":466862,"countryCode":"AR"},{"name":"Barquisimeto","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/468382","parentid":23424982,"country":"Venezuela","woeid":468382,"countryCode":"VE"},{"name":"Matur\u00EDn","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/468384","parentid":23424982,"country":"Venezuela","woeid":468384,"countryCode":"VE"},{"name":"Buenos Aires","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/468739","parentid":23424747,"country":"Argentina","woeid":468739,"countryCode":"AR"},{"name":"Gda\u0144sk","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/493417","parentid":23424923,"country":"Poland","woeid":493417,"countryCode":"PL"},{"name":"Krak\u00F3w","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/502075","parentid":23424923,"country":"Poland","woeid":502075,"countryCode":"PL"},{"name":"Lodz","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/505120","parentid":23424923,"country":"Poland","woeid":505120,"countryCode":"PL"},{"name":"Pozna\u0144","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/514048","parentid":23424923,"country":"Poland","woeid":514048,"countryCode":"PL"},{"name":"Warsaw","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/523920","parentid":23424923,"country":"Poland","woeid":523920,"countryCode":"PL"},{"name":"Wroclaw","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/526363","parentid":23424923,"country":"Poland","woeid":526363,"countryCode":"PL"},{"name":"Vienna","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/551801","parentid":23424750,"country":"Austria","woeid":551801,"countryCode":"AT"},{"name":"Cork","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/560472","parentid":23424803,"country":"Ireland","woeid":560472,"countryCode":"IE"},{"name":"Dublin","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/560743","parentid":23424803,"country":"Ireland","woeid":560743,"countryCode":"IE"},{"name":"Galway","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/560912","parentid":23424803,"country":"Ireland","woeid":560912,"countryCode":"IE"},{"name":"Bordeaux","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/580778","parentid":23424819,"country":"France","woeid":580778,"countryCode":"FR"},{"name":"Lille","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/608105","parentid":23424819,"country":"France","woeid":608105,"countryCode":"FR"},{"name":"Lyon","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/609125","parentid":23424819,"country":"France","woeid":609125,"countryCode":"FR"},{"name":"Marseille","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/610264","parentid":23424819,"country":"France","woeid":610264,"countryCode":"FR"},{"name":"Montpellier","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/612977","parentid":23424819,"country":"France","woeid":612977,"countryCode":"FR"},{"name":"Nantes","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/613858","parentid":23424819,"country":"France","woeid":613858,"countryCode":"FR"},{"name":"Paris","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/615702","parentid":23424819,"country":"France","woeid":615702,"countryCode":"FR"},{"name":"Rennes","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/619163","parentid":23424819,"country":"France","woeid":619163,"countryCode":"FR"},{"name":"Strasbourg","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/627791","parentid":23424819,"country":"France","woeid":627791,"countryCode":"FR"},{"name":"Toulouse","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/628886","parentid":23424819,"country":"France","woeid":628886,"countryCode":"FR"},{"name":"Berlin","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/638242","parentid":23424829,"country":"Germany","woeid":638242,"countryCode":"DE"},{"name":"Bremen","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/641142","parentid":23424829,"country":"Germany","woeid":641142,"countryCode":"DE"},{"name":"Dortmund","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/645458","parentid":23424829,"country":"Germany","woeid":645458,"countryCode":"DE"},{"name":"Dresden","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/645686","parentid":23424829,"country":"Germany","woeid":645686,"countryCode":"DE"},{"name":"Dusseldorf","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/646099","parentid":23424829,"country":"Germany","woeid":646099,"countryCode":"DE"},{"name":"Essen","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/648820","parentid":23424829,"country":"Germany","woeid":648820,"countryCode":"DE"},{"name":"Frankfurt","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/650272","parentid":23424829,"country":"Germany","woeid":650272,"countryCode":"DE"},{"name":"Hamburg","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/656958","parentid":23424829,"country":"Germany","woeid":656958,"countryCode":"DE"},{"name":"Cologne","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/667931","parentid":23424829,"country":"Germany","woeid":667931,"countryCode":"DE"},{"name":"Leipzig","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/671072","parentid":23424829,"country":"Germany","woeid":671072,"countryCode":"DE"},{"name":"Munich","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/676757","parentid":23424829,"country":"Germany","woeid":676757,"countryCode":"DE"},{"name":"Stuttgart","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/698064","parentid":23424829,"country":"Germany","woeid":698064,"countryCode":"DE"},{"name":"Bologna","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/711080","parentid":23424853,"country":"Italy","woeid":711080,"countryCode":"IT"},{"name":"Genoa","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/716085","parentid":23424853,"country":"Italy","woeid":716085,"countryCode":"IT"},{"name":"Milan","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/718345","parentid":23424853,"country":"Italy","woeid":718345,"countryCode":"IT"},{"name":"Naples","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/719258","parentid":23424853,"country":"Italy","woeid":719258,"countryCode":"IT"},{"name":"Palermo","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/719846","parentid":23424853,"country":"Italy","woeid":719846,"countryCode":"IT"},{"name":"Rome","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/721943","parentid":23424853,"country":"Italy","woeid":721943,"countryCode":"IT"},{"name":"Turin","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/725003","parentid":23424853,"country":"Italy","woeid":725003,"countryCode":"IT"},{"name":"Den Haag","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/726874","parentid":23424909,"country":"Netherlands","woeid":726874,"countryCode":"NL"},{"name":"Amsterdam","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/727232","parentid":23424909,"country":"Netherlands","woeid":727232,"countryCode":"NL"},{"name":"Rotterdam","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/733075","parentid":23424909,"country":"Netherlands","woeid":733075,"countryCode":"NL"},{"name":"Utrecht","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/734047","parentid":23424909,"country":"Netherlands","woeid":734047,"countryCode":"NL"},{"name":"Barcelona","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/753692","parentid":23424950,"country":"Spain","woeid":753692,"countryCode":"ES"},{"name":"Bilbao","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/754542","parentid":23424950,"country":"Spain","woeid":754542,"countryCode":"ES"},{"name":"Las Palmas","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/764814","parentid":23424950,"country":"Spain","woeid":764814,"countryCode":"ES"},{"name":"Madrid","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/766273","parentid":23424950,"country":"Spain","woeid":766273,"countryCode":"ES"},{"name":"Malaga","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/766356","parentid":23424950,"country":"Spain","woeid":766356,"countryCode":"ES"},{"name":"Murcia","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/768026","parentid":23424950,"country":"Spain","woeid":768026,"countryCode":"ES"},{"name":"Palma","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/769293","parentid":23424950,"country":"Spain","woeid":769293,"countryCode":"ES"},{"name":"Seville","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/774508","parentid":23424950,"country":"Spain","woeid":774508,"countryCode":"ES"},{"name":"Valencia","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/776688","parentid":23424950,"country":"Spain","woeid":776688,"countryCode":"ES"},{"name":"Zaragoza","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/779063","parentid":23424950,"country":"Spain","woeid":779063,"countryCode":"ES"},{"name":"Geneva","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/782538","parentid":23424957,"country":"Switzerland","woeid":782538,"countryCode":"CH"},{"name":"Lausanne","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/783058","parentid":23424957,"country":"Switzerland","woeid":783058,"countryCode":"CH"},{"name":"Zurich","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/784794","parentid":23424957,"country":"Switzerland","woeid":784794,"countryCode":"CH"},{"name":"Brest","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/824382","parentid":23424765,"country":"Belarus","woeid":824382,"countryCode":"BY"},{"name":"Grodno","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/825848","parentid":23424765,"country":"Belarus","woeid":825848,"countryCode":"BY"},{"name":"Gomel","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/825978","parentid":23424765,"country":"Belarus","woeid":825978,"countryCode":"BY"},{"name":"Minsk","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/834463","parentid":23424765,"country":"Belarus","woeid":834463,"countryCode":"BY"},{"name":"Riga","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/854823","parentid":23424874,"country":"Latvia","woeid":854823,"countryCode":"LV"},{"name":"Bergen","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/857105","parentid":23424910,"country":"Norway","woeid":857105,"countryCode":"NO"},{"name":"Oslo","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/862592","parentid":23424910,"country":"Norway","woeid":862592,"countryCode":"NO"},{"name":"Gothenburg","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/890869","parentid":23424954,"country":"Sweden","woeid":890869,"countryCode":"SE"},{"name":"Stockholm","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/906057","parentid":23424954,"country":"Sweden","woeid":906057,"countryCode":"SE"},{"name":"Dnipropetrovsk","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/918981","parentid":23424976,"country":"Ukraine","woeid":918981,"countryCode":"UA"},{"name":"Donetsk","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/919163","parentid":23424976,"country":"Ukraine","woeid":919163,"countryCode":"UA"},{"name":"Kharkiv","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/922137","parentid":23424976,"country":"Ukraine","woeid":922137,"countryCode":"UA"},{"name":"Kyiv","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/924938","parentid":23424976,"country":"Ukraine","woeid":924938,"countryCode":"UA"},{"name":"Lviv","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/924943","parentid":23424976,"country":"Ukraine","woeid":924943,"countryCode":"UA"},{"name":"Odesa","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/929398","parentid":23424976,"country":"Ukraine","woeid":929398,"countryCode":"UA"},{"name":"Zaporozhye","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/939628","parentid":23424976,"country":"Ukraine","woeid":939628,"countryCode":"UA"},{"name":"Athens","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/946738","parentid":23424833,"country":"Greece","woeid":946738,"countryCode":"GR"},{"name":"Thessaloniki","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/963291","parentid":23424833,"country":"Greece","woeid":963291,"countryCode":"GR"},{"name":"Bekasi","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1030077","parentid":23424846,"country":"Indonesia","woeid":1030077,"countryCode":"ID"},{"name":"Depok","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1032539","parentid":23424846,"country":"Indonesia","woeid":1032539,"countryCode":"ID"},{"name":"Pekanbaru","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1040779","parentid":23424846,"country":"Indonesia","woeid":1040779,"countryCode":"ID"},{"name":"Surabaya","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1044316","parentid":23424846,"country":"Indonesia","woeid":1044316,"countryCode":"ID"},{"name":"Makassar","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1046138","parentid":23424846,"country":"Indonesia","woeid":1046138,"countryCode":"ID"},{"name":"Bandung","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1047180","parentid":23424846,"country":"Indonesia","woeid":1047180,"countryCode":"ID"},{"name":"Jakarta","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1047378","parentid":23424846,"country":"Indonesia","woeid":1047378,"countryCode":"ID"},{"name":"Medan","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1047908","parentid":23424846,"country":"Indonesia","woeid":1047908,"countryCode":"ID"},{"name":"Palembang","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1048059","parentid":23424846,"country":"Indonesia","woeid":1048059,"countryCode":"ID"},{"name":"Semarang","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1048324","parentid":23424846,"country":"Indonesia","woeid":1048324,"countryCode":"ID"},{"name":"Tangerang","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1048536","parentid":23424846,"country":"Indonesia","woeid":1048536,"countryCode":"ID"},{"name":"Singapore","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1062617","parentid":23424948,"country":"Singapore","woeid":1062617,"countryCode":"SG"},{"name":"Perth","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1098081","parentid":23424748,"country":"Australia","woeid":1098081,"countryCode":"AU"},{"name":"Adelaide","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1099805","parentid":23424748,"country":"Australia","woeid":1099805,"countryCode":"AU"},{"name":"Brisbane","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1100661","parentid":23424748,"country":"Australia","woeid":1100661,"countryCode":"AU"},{"name":"Canberra","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1100968","parentid":23424748,"country":"Australia","woeid":1100968,"countryCode":"AU"},{"name":"Darwin","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1101597","parentid":23424748,"country":"Australia","woeid":1101597,"countryCode":"AU"},{"name":"Melbourne","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1103816","parentid":23424748,"country":"Australia","woeid":1103816,"countryCode":"AU"},{"name":"Sydney","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1105779","parentid":23424748,"country":"Australia","woeid":1105779,"countryCode":"AU"},{"name":"Kitakyushu","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1110809","parentid":23424856,"country":"Japan","woeid":1110809,"countryCode":"JP"},{"name":"Saitama","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1116753","parentid":23424856,"country":"Japan","woeid":1116753,"countryCode":"JP"},{"name":"Chiba","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1117034","parentid":23424856,"country":"Japan","woeid":1117034,"countryCode":"JP"},{"name":"Fukuoka","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1117099","parentid":23424856,"country":"Japan","woeid":1117099,"countryCode":"JP"},{"name":"Hamamatsu","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1117155","parentid":23424856,"country":"Japan","woeid":1117155,"countryCode":"JP"},{"name":"Hiroshima","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1117227","parentid":23424856,"country":"Japan","woeid":1117227,"countryCode":"JP"},{"name":"Kawasaki","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1117502","parentid":23424856,"country":"Japan","woeid":1117502,"countryCode":"JP"},{"name":"Kobe","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1117545","parentid":23424856,"country":"Japan","woeid":1117545,"countryCode":"JP"},{"name":"Kumamoto","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1117605","parentid":23424856,"country":"Japan","woeid":1117605,"countryCode":"JP"},{"name":"Nagoya","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1117817","parentid":23424856,"country":"Japan","woeid":1117817,"countryCode":"JP"},{"name":"Niigata","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1117881","parentid":23424856,"country":"Japan","woeid":1117881,"countryCode":"JP"},{"name":"Sagamihara","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1118072","parentid":23424856,"country":"Japan","woeid":1118072,"countryCode":"JP"},{"name":"Sapporo","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1118108","parentid":23424856,"country":"Japan","woeid":1118108,"countryCode":"JP"},{"name":"Sendai","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1118129","parentid":23424856,"country":"Japan","woeid":1118129,"countryCode":"JP"},{"name":"Takamatsu","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1118285","parentid":23424856,"country":"Japan","woeid":1118285,"countryCode":"JP"},{"name":"Tokyo","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1118370","parentid":23424856,"country":"Japan","woeid":1118370,"countryCode":"JP"},{"name":"Yokohama","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1118550","parentid":23424856,"country":"Japan","woeid":1118550,"countryCode":"JP"},{"name":"Goyang","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1130853","parentid":23424868,"country":"Korea","woeid":1130853,"countryCode":"KR"},{"name":"Yongin","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1132094","parentid":23424868,"country":"Korea","woeid":1132094,"countryCode":"KR"},{"name":"Ansan","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1132444","parentid":23424868,"country":"Korea","woeid":1132444,"countryCode":"KR"},{"name":"Bucheon","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1132445","parentid":23424868,"country":"Korea","woeid":1132445,"countryCode":"KR"},{"name":"Busan","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1132447","parentid":23424868,"country":"Korea","woeid":1132447,"countryCode":"KR"},{"name":"Changwon","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1132449","parentid":23424868,"country":"Korea","woeid":1132449,"countryCode":"KR"},{"name":"Daegu","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1132466","parentid":23424868,"country":"Korea","woeid":1132466,"countryCode":"KR"},{"name":"Gwangju","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1132481","parentid":23424868,"country":"Korea","woeid":1132481,"countryCode":"KR"},{"name":"Incheon","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1132496","parentid":23424868,"country":"Korea","woeid":1132496,"countryCode":"KR"},{"name":"Seongnam","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1132559","parentid":23424868,"country":"Korea","woeid":1132559,"countryCode":"KR"},{"name":"Suwon","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1132567","parentid":23424868,"country":"Korea","woeid":1132567,"countryCode":"KR"},{"name":"Ulsan","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1132578","parentid":23424868,"country":"Korea","woeid":1132578,"countryCode":"KR"},{"name":"Seoul","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1132599","parentid":23424868,"country":"Korea","woeid":1132599,"countryCode":"KR"},{"name":"Kajang","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1141268","parentid":23424901,"country":"Malaysia","woeid":1141268,"countryCode":"MY"},{"name":"Ipoh","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1154679","parentid":23424901,"country":"Malaysia","woeid":1154679,"countryCode":"MY"},{"name":"Johor Bahru","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1154698","parentid":23424901,"country":"Malaysia","woeid":1154698,"countryCode":"MY"},{"name":"Klang","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1154726","parentid":23424901,"country":"Malaysia","woeid":1154726,"countryCode":"MY"},{"name":"Kuala Lumpur","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1154781","parentid":23424901,"country":"Malaysia","woeid":1154781,"countryCode":"MY"},{"name":"Calocan","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1167715","parentid":23424934,"country":"Philippines","woeid":1167715,"countryCode":"PH"},{"name":"Makati","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1180689","parentid":23424934,"country":"Philippines","woeid":1180689,"countryCode":"PH"},{"name":"Pasig","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1187115","parentid":23424934,"country":"Philippines","woeid":1187115,"countryCode":"PH"},{"name":"Taguig","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1195098","parentid":23424934,"country":"Philippines","woeid":1195098,"countryCode":"PH"},{"name":"Antipolo","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1198785","parentid":23424934,"country":"Philippines","woeid":1198785,"countryCode":"PH"},{"name":"Cagayan de Oro","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1199002","parentid":23424934,"country":"Philippines","woeid":1199002,"countryCode":"PH"},{"name":"Cebu City","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1199079","parentid":23424934,"country":"Philippines","woeid":1199079,"countryCode":"PH"},{"name":"Davao City","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1199136","parentid":23424934,"country":"Philippines","woeid":1199136,"countryCode":"PH"},{"name":"Manila","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1199477","parentid":23424934,"country":"Philippines","woeid":1199477,"countryCode":"PH"},{"name":"Quezon City","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1199682","parentid":23424934,"country":"Philippines","woeid":1199682,"countryCode":"PH"},{"name":"Zamboanga City","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1199980","parentid":23424934,"country":"Philippines","woeid":1199980,"countryCode":"PH"},{"name":"Bangkok","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1225448","parentid":23424960,"country":"Thailand","woeid":1225448,"countryCode":"TH"},{"name":"Hanoi","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1236594","parentid":23424984,"country":"Vietnam","woeid":1236594,"countryCode":"VN"},{"name":"Hai Phong","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1236690","parentid":23424984,"country":"Vietnam","woeid":1236690,"countryCode":"VN"},{"name":"Can Tho","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1252351","parentid":23424984,"country":"Vietnam","woeid":1252351,"countryCode":"VN"},{"name":"Da Nang","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1252376","parentid":23424984,"country":"Vietnam","woeid":1252376,"countryCode":"VN"},{"name":"Ho Chi Minh City","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1252431","parentid":23424984,"country":"Vietnam","woeid":1252431,"countryCode":"VN"},{"name":"Algiers","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1253079","parentid":23424740,"country":"Algeria","woeid":1253079,"countryCode":"DZ"},{"name":"Accra","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1326075","parentid":23424824,"country":"Ghana","woeid":1326075,"countryCode":"GH"},{"name":"Kumasi","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1330595","parentid":23424824,"country":"Ghana","woeid":1330595,"countryCode":"GH"},{"name":"Benin City","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1387660","parentid":23424908,"country":"Nigeria","woeid":1387660,"countryCode":"NG"},{"name":"Ibadan","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1393672","parentid":23424908,"country":"Nigeria","woeid":1393672,"countryCode":"NG"},{"name":"Kaduna","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1396439","parentid":23424908,"country":"Nigeria","woeid":1396439,"countryCode":"NG"},{"name":"Kano","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1396803","parentid":23424908,"country":"Nigeria","woeid":1396803,"countryCode":"NG"},{"name":"Lagos","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1398823","parentid":23424908,"country":"Nigeria","woeid":1398823,"countryCode":"NG"},{"name":"Port Harcourt","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1404447","parentid":23424908,"country":"Nigeria","woeid":1404447,"countryCode":"NG"},{"name":"Giza","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1521643","parentid":23424802,"country":"Egypt","woeid":1521643,"countryCode":"EG"},{"name":"Cairo","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1521894","parentid":23424802,"country":"Egypt","woeid":1521894,"countryCode":"EG"},{"name":"Alexandria","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1522006","parentid":23424802,"country":"Egypt","woeid":1522006,"countryCode":"EG"},{"name":"Mombasa","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1528335","parentid":23424863,"country":"Kenya","woeid":1528335,"countryCode":"KE"},{"name":"Nairobi","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1528488","parentid":23424863,"country":"Kenya","woeid":1528488,"countryCode":"KE"},{"name":"Durban","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1580913","parentid":23424942,"country":"South Africa","woeid":1580913,"countryCode":"ZA"},{"name":"Johannesburg","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1582504","parentid":23424942,"country":"South Africa","woeid":1582504,"countryCode":"ZA"},{"name":"Port Elizabeth","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1586614","parentid":23424942,"country":"South Africa","woeid":1586614,"countryCode":"ZA"},{"name":"Pretoria","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1586638","parentid":23424942,"country":"South Africa","woeid":1586638,"countryCode":"ZA"},{"name":"Soweto","placeType":{"code":22,"name":"Unknown"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1587677","parentid":23424942,"country":"South Africa","woeid":1587677,"countryCode":"ZA"},{"name":"Cape Town","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1591691","parentid":23424942,"country":"South Africa","woeid":1591691,"countryCode":"ZA"},{"name":"Medina","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1937801","parentid":23424938,"country":"Saudi Arabia","woeid":1937801,"countryCode":"SA"},{"name":"Dammam","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1939574","parentid":23424938,"country":"Saudi Arabia","woeid":1939574,"countryCode":"SA"},{"name":"Riyadh","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1939753","parentid":23424938,"country":"Saudi Arabia","woeid":1939753,"countryCode":"SA"},{"name":"Jeddah","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1939873","parentid":23424938,"country":"Saudi Arabia","woeid":1939873,"countryCode":"SA"},{"name":"Mecca","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1939897","parentid":23424938,"country":"Saudi Arabia","woeid":1939897,"countryCode":"SA"},{"name":"Sharjah","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1940119","parentid":23424738,"country":"United Arab Emirates","woeid":1940119,"countryCode":"AE"},{"name":"Abu Dhabi","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1940330","parentid":23424738,"country":"United Arab Emirates","woeid":1940330,"countryCode":"AE"},{"name":"Dubai","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1940345","parentid":23424738,"country":"United Arab Emirates","woeid":1940345,"countryCode":"AE"},{"name":"Haifa","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1967449","parentid":23424852,"country":"Israel","woeid":1967449,"countryCode":"IL"},{"name":"Tel Aviv","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1968212","parentid":23424852,"country":"Israel","woeid":1968212,"countryCode":"IL"},{"name":"Jerusalem","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1968222","parentid":23424852,"country":"Israel","woeid":1968222,"countryCode":"IL"},{"name":"Amman","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1968902","parentid":23424860,"country":"Jordan","woeid":1968902,"countryCode":"JO"},{"name":"Chelyabinsk","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/1997422","parentid":23424936,"country":"Russia","woeid":1997422,"countryCode":"RU"},{"name":"Khabarovsk","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2018708","parentid":23424936,"country":"Russia","woeid":2018708,"countryCode":"RU"},{"name":"Krasnodar","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2028717","parentid":23424936,"country":"Russia","woeid":2028717,"countryCode":"RU"},{"name":"Krasnoyarsk","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2029043","parentid":23424936,"country":"Russia","woeid":2029043,"countryCode":"RU"},{"name":"Samara","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2077746","parentid":23424936,"country":"Russia","woeid":2077746,"countryCode":"RU"},{"name":"Voronezh","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2108210","parentid":23424936,"country":"Russia","woeid":2108210,"countryCode":"RU"},{"name":"Yekaterinburg","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2112237","parentid":23424936,"country":"Russia","woeid":2112237,"countryCode":"RU"},{"name":"Irkutsk","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2121040","parentid":23424936,"country":"Russia","woeid":2121040,"countryCode":"RU"},{"name":"Kazan","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2121267","parentid":23424936,"country":"Russia","woeid":2121267,"countryCode":"RU"},{"name":"Moscow","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2122265","parentid":23424936,"country":"Russia","woeid":2122265,"countryCode":"RU"},{"name":"Nizhny Novgorod","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2122471","parentid":23424936,"country":"Russia","woeid":2122471,"countryCode":"RU"},{"name":"Novosibirsk","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2122541","parentid":23424936,"country":"Russia","woeid":2122541,"countryCode":"RU"},{"name":"Omsk","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2122641","parentid":23424936,"country":"Russia","woeid":2122641,"countryCode":"RU"},{"name":"Perm","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2122814","parentid":23424936,"country":"Russia","woeid":2122814,"countryCode":"RU"},{"name":"Rostov-on-Don","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2123177","parentid":23424936,"country":"Russia","woeid":2123177,"countryCode":"RU"},{"name":"Saint Petersburg","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2123260","parentid":23424936,"country":"Russia","woeid":2123260,"countryCode":"RU"},{"name":"Ufa","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2124045","parentid":23424936,"country":"Russia","woeid":2124045,"countryCode":"RU"},{"name":"Vladivostok","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2124288","parentid":23424936,"country":"Russia","woeid":2124288,"countryCode":"RU"},{"name":"Volgograd","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2124298","parentid":23424936,"country":"Russia","woeid":2124298,"countryCode":"RU"},{"name":"Karachi","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2211096","parentid":23424922,"country":"Pakistan","woeid":2211096,"countryCode":"PK"},{"name":"Lahore","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2211177","parentid":23424922,"country":"Pakistan","woeid":2211177,"countryCode":"PK"},{"name":"Multan","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2211269","parentid":23424922,"country":"Pakistan","woeid":2211269,"countryCode":"PK"},{"name":"Rawalpindi","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2211387","parentid":23424922,"country":"Pakistan","woeid":2211387,"countryCode":"PK"},{"name":"Faisalabad","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2211574","parentid":23424922,"country":"Pakistan","woeid":2211574,"countryCode":"PK"},{"name":"Muscat","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2268284","parentid":23424898,"country":"Oman","woeid":2268284,"countryCode":"OM"},{"name":"Nagpur","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2282863","parentid":23424848,"country":"India","woeid":2282863,"countryCode":"IN"},{"name":"Lucknow","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2295377","parentid":23424848,"country":"India","woeid":2295377,"countryCode":"IN"},{"name":"Kanpur","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2295378","parentid":23424848,"country":"India","woeid":2295378,"countryCode":"IN"},{"name":"Patna","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2295381","parentid":23424848,"country":"India","woeid":2295381,"countryCode":"IN"},{"name":"Ranchi","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2295383","parentid":23424848,"country":"India","woeid":2295383,"countryCode":"IN"},{"name":"Kolkata","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2295386","parentid":23424848,"country":"India","woeid":2295386,"countryCode":"IN"},{"name":"Srinagar","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2295387","parentid":23424848,"country":"India","woeid":2295387,"countryCode":"IN"},{"name":"Amritsar","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2295388","parentid":23424848,"country":"India","woeid":2295388,"countryCode":"IN"},{"name":"Jaipur","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2295401","parentid":23424848,"country":"India","woeid":2295401,"countryCode":"IN"},{"name":"Ahmedabad","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2295402","parentid":23424848,"country":"India","woeid":2295402,"countryCode":"IN"},{"name":"Rajkot","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2295404","parentid":23424848,"country":"India","woeid":2295404,"countryCode":"IN"},{"name":"Surat","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2295405","parentid":23424848,"country":"India","woeid":2295405,"countryCode":"IN"},{"name":"Bhopal","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2295407","parentid":23424848,"country":"India","woeid":2295407,"countryCode":"IN"},{"name":"Indore","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2295408","parentid":23424848,"country":"India","woeid":2295408,"countryCode":"IN"},{"name":"Thane","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2295410","parentid":23424848,"country":"India","woeid":2295410,"countryCode":"IN"},{"name":"Mumbai","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2295411","parentid":23424848,"country":"India","woeid":2295411,"countryCode":"IN"},{"name":"Pune","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2295412","parentid":23424848,"country":"India","woeid":2295412,"countryCode":"IN"},{"name":"Hyderabad","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2295414","parentid":23424848,"country":"India","woeid":2295414,"countryCode":"IN"},{"name":"Bangalore","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2295420","parentid":23424848,"country":"India","woeid":2295420,"countryCode":"IN"},{"name":"Chennai","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2295424","parentid":23424848,"country":"India","woeid":2295424,"countryCode":"IN"},{"name":"Mersin","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2323778","parentid":23424969,"country":"Turkey","woeid":2323778,"countryCode":"TR"},{"name":"Adana","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2343678","parentid":23424969,"country":"Turkey","woeid":2343678,"countryCode":"TR"},{"name":"Ankara","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2343732","parentid":23424969,"country":"Turkey","woeid":2343732,"countryCode":"TR"},{"name":"Antalya","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2343733","parentid":23424969,"country":"Turkey","woeid":2343733,"countryCode":"TR"},{"name":"Bursa","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2343843","parentid":23424969,"country":"Turkey","woeid":2343843,"countryCode":"TR"},{"name":"Diyarbak\u0131r","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2343932","parentid":23424969,"country":"Turkey","woeid":2343932,"countryCode":"TR"},{"name":"Eski\u015Fehir","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2343980","parentid":23424969,"country":"Turkey","woeid":2343980,"countryCode":"TR"},{"name":"Gaziantep","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2343999","parentid":23424969,"country":"Turkey","woeid":2343999,"countryCode":"TR"},{"name":"Istanbul","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2344116","parentid":23424969,"country":"Turkey","woeid":2344116,"countryCode":"TR"},{"name":"Izmir","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2344117","parentid":23424969,"country":"Turkey","woeid":2344117,"countryCode":"TR"},{"name":"Kayseri","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2344174","parentid":23424969,"country":"Turkey","woeid":2344174,"countryCode":"TR"},{"name":"Konya","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2344210","parentid":23424969,"country":"Turkey","woeid":2344210,"countryCode":"TR"},{"name":"Okinawa","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2345896","parentid":23424856,"country":"Japan","woeid":2345896,"countryCode":"JP"},{"name":"Daejeon","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2345975","parentid":23424868,"country":"Korea","woeid":2345975,"countryCode":"KR"},{"name":"Auckland","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2348079","parentid":23424916,"country":"New Zealand","woeid":2348079,"countryCode":"NZ"},{"name":"Albuquerque","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2352824","parentid":23424977,"country":"United States","woeid":2352824,"countryCode":"US"},{"name":"Atlanta","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2357024","parentid":23424977,"country":"United States","woeid":2357024,"countryCode":"US"},{"name":"Austin","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2357536","parentid":23424977,"country":"United States","woeid":2357536,"countryCode":"US"},{"name":"Baltimore","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2358820","parentid":23424977,"country":"United States","woeid":2358820,"countryCode":"US"},{"name":"Baton Rouge","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2359991","parentid":23424977,"country":"United States","woeid":2359991,"countryCode":"US"},{"name":"Birmingham","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2364559","parentid":23424977,"country":"United States","woeid":2364559,"countryCode":"US"},{"name":"Boston","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2367105","parentid":23424977,"country":"United States","woeid":2367105,"countryCode":"US"},{"name":"Charlotte","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2378426","parentid":23424977,"country":"United States","woeid":2378426,"countryCode":"US"},{"name":"Chicago","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2379574","parentid":23424977,"country":"United States","woeid":2379574,"countryCode":"US"},{"name":"Cincinnati","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2380358","parentid":23424977,"country":"United States","woeid":2380358,"countryCode":"US"},{"name":"Cleveland","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2381475","parentid":23424977,"country":"United States","woeid":2381475,"countryCode":"US"},{"name":"Colorado Springs","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2383489","parentid":23424977,"country":"United States","woeid":2383489,"countryCode":"US"},{"name":"Columbus","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2383660","parentid":23424977,"country":"United States","woeid":2383660,"countryCode":"US"},{"name":"Dallas-Ft. Worth","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2388929","parentid":23424977,"country":"United States","woeid":2388929,"countryCode":"US"},{"name":"Denver","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2391279","parentid":23424977,"country":"United States","woeid":2391279,"countryCode":"US"},{"name":"Detroit","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2391585","parentid":23424977,"country":"United States","woeid":2391585,"countryCode":"US"},{"name":"El Paso","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2397816","parentid":23424977,"country":"United States","woeid":2397816,"countryCode":"US"},{"name":"Fresno","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2407517","parentid":23424977,"country":"United States","woeid":2407517,"countryCode":"US"},{"name":"Greensboro","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2414469","parentid":23424977,"country":"United States","woeid":2414469,"countryCode":"US"},{"name":"Harrisburg","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2418046","parentid":23424977,"country":"United States","woeid":2418046,"countryCode":"US"},{"name":"Honolulu","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2423945","parentid":23424977,"country":"United States","woeid":2423945,"countryCode":"US"},{"name":"Houston","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2424766","parentid":23424977,"country":"United States","woeid":2424766,"countryCode":"US"},{"name":"Indianapolis","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2427032","parentid":23424977,"country":"United States","woeid":2427032,"countryCode":"US"},{"name":"Jackson","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2428184","parentid":23424977,"country":"United States","woeid":2428184,"countryCode":"US"},{"name":"Jacksonville","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2428344","parentid":23424977,"country":"United States","woeid":2428344,"countryCode":"US"},{"name":"Kansas City","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2430683","parentid":23424977,"country":"United States","woeid":2430683,"countryCode":"US"},{"name":"Las Vegas","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2436704","parentid":23424977,"country":"United States","woeid":2436704,"countryCode":"US"},{"name":"Long Beach","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2441472","parentid":23424977,"country":"United States","woeid":2441472,"countryCode":"US"},{"name":"Los Angeles","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2442047","parentid":23424977,"country":"United States","woeid":2442047,"countryCode":"US"},{"name":"Louisville","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2442327","parentid":23424977,"country":"United States","woeid":2442327,"countryCode":"US"},{"name":"Memphis","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2449323","parentid":23424977,"country":"United States","woeid":2449323,"countryCode":"US"},{"name":"Mesa","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2449808","parentid":23424977,"country":"United States","woeid":2449808,"countryCode":"US"},{"name":"Miami","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2450022","parentid":23424977,"country":"United States","woeid":2450022,"countryCode":"US"},{"name":"Milwaukee","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2451822","parentid":23424977,"country":"United States","woeid":2451822,"countryCode":"US"},{"name":"Minneapolis","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2452078","parentid":23424977,"country":"United States","woeid":2452078,"countryCode":"US"},{"name":"Nashville","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2457170","parentid":23424977,"country":"United States","woeid":2457170,"countryCode":"US"},{"name":"New Haven","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2458410","parentid":23424977,"country":"United States","woeid":2458410,"countryCode":"US"},{"name":"New Orleans","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2458833","parentid":23424977,"country":"United States","woeid":2458833,"countryCode":"US"},{"name":"New York","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2459115","parentid":23424977,"country":"United States","woeid":2459115,"countryCode":"US"},{"name":"Norfolk","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2460389","parentid":23424977,"country":"United States","woeid":2460389,"countryCode":"US"},{"name":"Oklahoma City","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2464592","parentid":23424977,"country":"United States","woeid":2464592,"countryCode":"US"},{"name":"Omaha","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2465512","parentid":23424977,"country":"United States","woeid":2465512,"countryCode":"US"},{"name":"Orlando","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2466256","parentid":23424977,"country":"United States","woeid":2466256,"countryCode":"US"},{"name":"Philadelphia","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2471217","parentid":23424977,"country":"United States","woeid":2471217,"countryCode":"US"},{"name":"Phoenix","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2471390","parentid":23424977,"country":"United States","woeid":2471390,"countryCode":"US"},{"name":"Pittsburgh","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2473224","parentid":23424977,"country":"United States","woeid":2473224,"countryCode":"US"},{"name":"Portland","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2475687","parentid":23424977,"country":"United States","woeid":2475687,"countryCode":"US"},{"name":"Providence","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2477058","parentid":23424977,"country":"United States","woeid":2477058,"countryCode":"US"},{"name":"Raleigh","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2478307","parentid":23424977,"country":"United States","woeid":2478307,"countryCode":"US"},{"name":"Richmond","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2480894","parentid":23424977,"country":"United States","woeid":2480894,"countryCode":"US"},{"name":"Sacramento","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2486340","parentid":23424977,"country":"United States","woeid":2486340,"countryCode":"US"},{"name":"St. Louis","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2486982","parentid":23424977,"country":"United States","woeid":2486982,"countryCode":"US"},{"name":"Salt Lake City","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2487610","parentid":23424977,"country":"United States","woeid":2487610,"countryCode":"US"},{"name":"San Antonio","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2487796","parentid":23424977,"country":"United States","woeid":2487796,"countryCode":"US"},{"name":"San Diego","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2487889","parentid":23424977,"country":"United States","woeid":2487889,"countryCode":"US"},{"name":"San Francisco","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2487956","parentid":23424977,"country":"United States","woeid":2487956,"countryCode":"US"},{"name":"San Jose","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2488042","parentid":23424977,"country":"United States","woeid":2488042,"countryCode":"US"},{"name":"Seattle","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2490383","parentid":23424977,"country":"United States","woeid":2490383,"countryCode":"US"},{"name":"Tallahassee","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2503713","parentid":23424977,"country":"United States","woeid":2503713,"countryCode":"US"},{"name":"Tampa","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2503863","parentid":23424977,"country":"United States","woeid":2503863,"countryCode":"US"},{"name":"Tucson","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2508428","parentid":23424977,"country":"United States","woeid":2508428,"countryCode":"US"},{"name":"Virginia Beach","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2512636","parentid":23424977,"country":"United States","woeid":2512636,"countryCode":"US"},{"name":"Washington","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/2514815","parentid":23424977,"country":"United States","woeid":2514815,"countryCode":"US"},{"name":"Osaka","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/15015370","parentid":23424856,"country":"Japan","woeid":15015370,"countryCode":"JP"},{"name":"Kyoto","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/15015372","parentid":23424856,"country":"Japan","woeid":15015372,"countryCode":"JP"},{"name":"Delhi","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/20070458","parentid":23424848,"country":"India","woeid":20070458,"countryCode":"IN"},{"name":"United Arab Emirates","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424738","parentid":1,"country":"United Arab Emirates","woeid":23424738,"countryCode":"AE"},{"name":"Algeria","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424740","parentid":1,"country":"Algeria","woeid":23424740,"countryCode":"DZ"},{"name":"Argentina","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424747","parentid":1,"country":"Argentina","woeid":23424747,"countryCode":"AR"},{"name":"Australia","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424748","parentid":1,"country":"Australia","woeid":23424748,"countryCode":"AU"},{"name":"Austria","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424750","parentid":1,"country":"Austria","woeid":23424750,"countryCode":"AT"},{"name":"Bahrain","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424753","parentid":1,"country":"Bahrain","woeid":23424753,"countryCode":"BH"},{"name":"Belgium","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424757","parentid":1,"country":"Belgium","woeid":23424757,"countryCode":"BE"},{"name":"Belarus","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424765","parentid":1,"country":"Belarus","woeid":23424765,"countryCode":"BY"},{"name":"Brazil","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424768","parentid":1,"country":"Brazil","woeid":23424768,"countryCode":"BR"},{"name":"Canada","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424775","parentid":1,"country":"Canada","woeid":23424775,"countryCode":"CA"},{"name":"Chile","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424782","parentid":1,"country":"Chile","woeid":23424782,"countryCode":"CL"},{"name":"Colombia","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424787","parentid":1,"country":"Colombia","woeid":23424787,"countryCode":"CO"},{"name":"Denmark","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424796","parentid":1,"country":"Denmark","woeid":23424796,"countryCode":"DK"},{"name":"Dominican Republic","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424800","parentid":1,"country":"Dominican Republic","woeid":23424800,"countryCode":"DO"},{"name":"Ecuador","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424801","parentid":1,"country":"Ecuador","woeid":23424801,"countryCode":"EC"},{"name":"Egypt","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424802","parentid":1,"country":"Egypt","woeid":23424802,"countryCode":"EG"},{"name":"Ireland","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424803","parentid":1,"country":"Ireland","woeid":23424803,"countryCode":"IE"},{"name":"France","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424819","parentid":1,"country":"France","woeid":23424819,"countryCode":"FR"},{"name":"Ghana","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424824","parentid":1,"country":"Ghana","woeid":23424824,"countryCode":"GH"},{"name":"Germany","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424829","parentid":1,"country":"Germany","woeid":23424829,"countryCode":"DE"},{"name":"Greece","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424833","parentid":1,"country":"Greece","woeid":23424833,"countryCode":"GR"},{"name":"Guatemala","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424834","parentid":1,"country":"Guatemala","woeid":23424834,"countryCode":"GT"},{"name":"Indonesia","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424846","parentid":1,"country":"Indonesia","woeid":23424846,"countryCode":"ID"},{"name":"India","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424848","parentid":1,"country":"India","woeid":23424848,"countryCode":"IN"},{"name":"Israel","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424852","parentid":1,"country":"Israel","woeid":23424852,"countryCode":"IL"},{"name":"Italy","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424853","parentid":1,"country":"Italy","woeid":23424853,"countryCode":"IT"},{"name":"Japan","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424856","parentid":1,"country":"Japan","woeid":23424856,"countryCode":"JP"},{"name":"Jordan","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424860","parentid":1,"country":"Jordan","woeid":23424860,"countryCode":"JO"},{"name":"Kenya","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424863","parentid":1,"country":"Kenya","woeid":23424863,"countryCode":"KE"},{"name":"Korea","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424868","parentid":1,"country":"Korea","woeid":23424868,"countryCode":"KR"},{"name":"Kuwait","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424870","parentid":1,"country":"Kuwait","woeid":23424870,"countryCode":"KW"},{"name":"Lebanon","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424873","parentid":1,"country":"Lebanon","woeid":23424873,"countryCode":"LB"},{"name":"Latvia","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424874","parentid":1,"country":"Latvia","woeid":23424874,"countryCode":"LV"},{"name":"Oman","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424898","parentid":1,"country":"Oman","woeid":23424898,"countryCode":"OM"},{"name":"Mexico","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424900","parentid":1,"country":"Mexico","woeid":23424900,"countryCode":"MX"},{"name":"Malaysia","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424901","parentid":1,"country":"Malaysia","woeid":23424901,"countryCode":"MY"},{"name":"Nigeria","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424908","parentid":1,"country":"Nigeria","woeid":23424908,"countryCode":"NG"},{"name":"Netherlands","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424909","parentid":1,"country":"Netherlands","woeid":23424909,"countryCode":"NL"},{"name":"Norway","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424910","parentid":1,"country":"Norway","woeid":23424910,"countryCode":"NO"},{"name":"New Zealand","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424916","parentid":1,"country":"New Zealand","woeid":23424916,"countryCode":"NZ"},{"name":"Peru","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424919","parentid":1,"country":"Peru","woeid":23424919,"countryCode":"PE"},{"name":"Pakistan","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424922","parentid":1,"country":"Pakistan","woeid":23424922,"countryCode":"PK"},{"name":"Poland","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424923","parentid":1,"country":"Poland","woeid":23424923,"countryCode":"PL"},{"name":"Panama","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424924","parentid":1,"country":"Panama","woeid":23424924,"countryCode":"PA"},{"name":"Portugal","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424925","parentid":1,"country":"Portugal","woeid":23424925,"countryCode":"PT"},{"name":"Qatar","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424930","parentid":1,"country":"Qatar","woeid":23424930,"countryCode":"QA"},{"name":"Philippines","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424934","parentid":1,"country":"Philippines","woeid":23424934,"countryCode":"PH"},{"name":"Puerto Rico","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424935","parentid":1,"country":"Puerto Rico","woeid":23424935,"countryCode":"PR"},{"name":"Russia","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424936","parentid":1,"country":"Russia","woeid":23424936,"countryCode":"RU"},{"name":"Saudi Arabia","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424938","parentid":1,"country":"Saudi Arabia","woeid":23424938,"countryCode":"SA"},{"name":"South Africa","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424942","parentid":1,"country":"South Africa","woeid":23424942,"countryCode":"ZA"},{"name":"Singapore","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424948","parentid":1,"country":"Singapore","woeid":23424948,"countryCode":"SG"},{"name":"Spain","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424950","parentid":1,"country":"Spain","woeid":23424950,"countryCode":"ES"},{"name":"Sweden","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424954","parentid":1,"country":"Sweden","woeid":23424954,"countryCode":"SE"},{"name":"Switzerland","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424957","parentid":1,"country":"Switzerland","woeid":23424957,"countryCode":"CH"},{"name":"Thailand","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424960","parentid":1,"country":"Thailand","woeid":23424960,"countryCode":"TH"},{"name":"Turkey","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424969","parentid":1,"country":"Turkey","woeid":23424969,"countryCode":"TR"},{"name":"United Kingdom","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424975","parentid":1,"country":"United Kingdom","woeid":23424975,"countryCode":"GB"},{"name":"Ukraine","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424976","parentid":1,"country":"Ukraine","woeid":23424976,"countryCode":"UA"},{"name":"United States","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424977","parentid":1,"country":"United States","woeid":23424977,"countryCode":"US"},{"name":"Venezuela","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424982","parentid":1,"country":"Venezuela","woeid":23424982,"countryCode":"VE"},{"name":"Vietnam","placeType":{"code":12,"name":"Country"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/23424984","parentid":1,"country":"Vietnam","woeid":23424984,"countryCode":"VN"},{"name":"Petaling","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/56013632","parentid":23424901,"country":"Malaysia","woeid":56013632,"countryCode":"MY"},{"name":"Hulu Langat","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/56013645","parentid":23424901,"country":"Malaysia","woeid":56013645,"countryCode":"MY"},{"name":"Ahsa","placeType":{"code":9,"name":"Unknown"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/56120136","parentid":23424938,"country":"Saudi Arabia","woeid":56120136,"countryCode":"SA"},{"name":"Okayama","placeType":{"code":7,"name":"Town"},"url":"http:\/\/where.yahooapis.com\/v1\/place\/90036018","parentid":23424856,"country":"Japan","woeid":90036018,"countryCode":"JP"}] diff --git a/applications/processor/twitter-trend-processor/src/test/resources/response/trends.json b/applications/processor/twitter-trend-processor/src/test/resources/response/trends.json new file mode 100644 index 00000000..76901527 --- /dev/null +++ b/applications/processor/twitter-trend-processor/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}]}] diff --git a/applications/sink/pom.xml b/applications/sink/pom.xml index 62aa55ce..84bab959 100644 --- a/applications/sink/pom.xml +++ b/applications/sink/pom.xml @@ -28,5 +28,7 @@ tcp-sink throughput-sink websocket-sink + twitter-update-sink + twitter-message-sink diff --git a/applications/sink/twitter-message-sink/README.adoc b/applications/sink/twitter-message-sink/README.adoc new file mode 100644 index 00000000..db22224a --- /dev/null +++ b/applications/sink/twitter-message-sink/README.adoc @@ -0,0 +1,51 @@ +//tag::ref-doc[] += Twitter Message Sink + +Send Direct Messages to a specified user from the authenticating user. +Requires a JSON POST body and `Content-Type` header to be set to `application/json`. + +NOTE: When a message is received from a user you may send up to 5 messages in response within a 24 hour window. +Each message received resets the 24 hour window and the 5 allotted messages. +Sending a 6th message within a 24 hour window or sending a message outside of a 24 hour window will count towards rate-limiting. +This behavior only applies when using the POST direct_messages/events/new endpoint. + +SpEL expressions are used to compute the request parameters from the input message. + +== Options +TIP: Use single quotes (`'`) to wrap the literal values of the `SpEL` expression properties. +For example to set a fixed message text use `text='Fixed Text'`. +For fixed target userId use `userId='666'`. + +//tag::configuration-properties[] +$$twitter.message.update.media-id$$:: $$A media id to associate with the message. A Direct Message may only reference a single media id.$$ *($$Expression$$, default: `$$$$`)* +$$twitter.message.update.screen-name$$:: $$The screen name of the user to whom send the direct message.$$ *($$Expression$$, default: `$$$$`)* +$$twitter.message.update.text$$:: $$The direct message text. URL encode as necessary. Max length of 10,000 characters.$$ *($$Expression$$, default: `$$payload$$`)* +$$twitter.message.update.user-id$$:: $$The user id of the user to whom send the direct message.$$ *($$Expression$$, default: `$$$$`)* +//end::configuration-properties[] + +//end::ref-doc[] + + +== Examples + +``` +java -jar twitter-message-sink.jar + + --twitter.message.update.userId=headers['user'] + --twitter.message.update.text=payload.concat(\" with suffix \") + + --twitter.connection.consumerKey= ... + --twitter.connection.consumerSecret= ... + --twitter.connection.accessToken= ... + --twitter.connection.accessTokenSecret= ... + + -- + +``` + +And here is a example pipeline that uses twitter-message: + +``` +twitter-message-stream= TODO +``` + diff --git a/applications/sink/twitter-message-sink/pom.xml b/applications/sink/twitter-message-sink/pom.xml new file mode 100644 index 00000000..e4a25dab --- /dev/null +++ b/applications/sink/twitter-message-sink/pom.xml @@ -0,0 +1,103 @@ + + + 4.0.0 + twitter-message-sink + 3.0.0-SNAPSHOT + twitter-message-sink + twitter message sink apps + jar + + + org.springframework.cloud.stream.app + stream-applications-core + 3.0.0-SNAPSHOT + + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.cloud.fn + twitter-consumer + ${java-functions.version} + + + org.awaitility + awaitility + test + + + junit + junit + + + + + org.mock-server + mockserver-netty + 5.10 + test + + + org.mock-server + mockserver-client-java + 5.10 + test + + + + + + + org.springframework.cloud + spring-cloud-app-starter-doc-maven-plugin + + + org.springframework.cloud.stream.app.plugin + spring-cloud-stream-app-maven-plugin + + + twitter-message + sink + ${project.version} + org.springframework.cloud.fn.consumer.twitter.message.TwitterMessageConsumerConfiguration.class + byteArrayTextToString|sendDirectMessageConsumer + + + + org.springframework.cloud.fn + twitter-consumer + ${java-functions.version} + + + + + + + + + + + + true + + spring-snapshots + Spring Snapshots + https://repo.spring.io/libs-snapshot-local + + + + false + + spring-milestones + Spring Milestones + https://repo.spring.io/libs-milestone-local + + + + diff --git a/applications/sink/twitter-message-sink/src/main/resources/META-INF/dataflow-configuration-metadata-whitelist.properties b/applications/sink/twitter-message-sink/src/main/resources/META-INF/dataflow-configuration-metadata-whitelist.properties new file mode 100644 index 00000000..38894cb8 --- /dev/null +++ b/applications/sink/twitter-message-sink/src/main/resources/META-INF/dataflow-configuration-metadata-whitelist.properties @@ -0,0 +1,2 @@ +configuration-properties.classes=org.springframework.cloud.fn.consumer.twitter.message.TwitterMessageConsumerProperties + diff --git a/applications/sink/twitter-message-sink/src/test/java/org/springframework/cloud/stream/app/sink/twitter/message/TwitterMessageSinkIntegrationTests.java b/applications/sink/twitter-message-sink/src/test/java/org/springframework/cloud/stream/app/sink/twitter/message/TwitterMessageSinkIntegrationTests.java new file mode 100644 index 00000000..09117888 --- /dev/null +++ b/applications/sink/twitter-message-sink/src/test/java/org/springframework/cloud/stream/app/sink/twitter/message/TwitterMessageSinkIntegrationTests.java @@ -0,0 +1,225 @@ +/* + * 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.stream.app.sink.twitter.message; + +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockserver.client.MockServerClient; +import org.mockserver.integration.ClientAndServer; +import org.mockserver.model.StringBody; +import twitter4j.conf.ConfigurationBuilder; + +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.cloud.fn.common.twitter.TwitterConnectionProperties; +import org.springframework.cloud.fn.consumer.twitter.message.TwitterMessageConsumerConfiguration; +import org.springframework.cloud.stream.binder.test.InputDestination; +import org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.messaging.support.GenericMessage; + +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 + */ +public class TwitterMessageSinkIntegrationTests { + + private static final String MOCK_SERVER_IP = "127.0.0.1"; + + private static final Integer MOCK_SERVER_PORT = 1080; + + private static ClientAndServer mockServer; + + private static MockServerClient mockClient; + + @BeforeEach + public void startMockServer() { + mockServer = ClientAndServer.startClientAndServer(MOCK_SERVER_PORT); + mockClient = new MockServerClient(MOCK_SERVER_IP, MOCK_SERVER_PORT); + + mockClient + .when( + request() + .withMethod("GET") + .withPath("/users/show.json") + .withQueryStringParameter("screen_name", "user666") + .withQueryStringParameter("include_entities", "true") + .withQueryStringParameter("include_ext_alt_text", "true") + .withQueryStringParameter("tweet_mode", "extended"), + unlimited()) + .respond( + response() + .withStatusCode(200) + .withHeader("Content-Type", "application/json; charset=utf-8") + .withBody(TwitterTestUtils.asString("classpath:/response/user_1075751718749659136.json")) + .withDelay(TimeUnit.SECONDS, 1)); + + mockClient + .when( + request() + .withMethod("POST") + .withPath("/direct_messages/events/new.json"), + unlimited()) + .respond( + response() + .withStatusCode(200) + .withHeader("Content-Type", "application/json; charset=utf-8") + .withBody(TwitterTestUtils.asString("classpath:/response/test_direct_message.json")) + .withDelay(TimeUnit.SECONDS, 1)); + } + + @AfterEach + public void stopMockServer() { + mockServer.stop(); + } + + @Test + public void directMessageScreenName() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration(TestTwitterMessageSinkApplication.class)) + .web(WebApplicationType.NONE) + .run("--spring.cloud.stream.function.definition=byteArrayTextToString|sendDirectMessageConsumer", + + "--twitter.message.update.screenName='user666'", + + "--twitter.connection.consumerKey=myConsumerKey", + "--twitter.connection.consumerSecret=myConsumerSecret", + "--twitter.connection.accessToken=myAccessToken", + "--twitter.connection.accessTokenSecret=myAccessTokenSecret")) { + + InputDestination source = context.getBean(InputDestination.class); + assertThat(source).isNotNull(); + + source.send(new GenericMessage<>("hello".getBytes(StandardCharsets.UTF_8))); + + mockClient.verify(request() + .withMethod("GET") + .withPath("/users/show.json") + .withQueryStringParameter("screen_name", "user666") + .withQueryStringParameter("include_entities", "true") + .withQueryStringParameter("include_ext_alt_text", "true") + .withQueryStringParameter("tweet_mode", "extended"), + once()); + + mockClient.verify(request() + .withMethod("POST") + .withPath("/direct_messages/events/new.json") + .withBody(new StringBody("{\"event\":{\"type\":\"message_create\"," + + "\"message_create\":{\"target\":{\"recipient_id\":1075751718749659136}," + + "\"message_data\":{\"text\":\"hello\"}}}}")), + once()); + } + } + + @Test + public void directMessageUserId() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration(TestTwitterMessageSinkApplication.class)) + .web(WebApplicationType.NONE) + .run("--spring.cloud.stream.function.definition=byteArrayTextToString|sendDirectMessageConsumer", + + "--twitter.message.update.userId='1075751718749659136'", + + "--twitter.connection.consumerKey=myConsumerKey", + "--twitter.connection.consumerSecret=myConsumerSecret", + "--twitter.connection.accessToken=myAccessToken", + "--twitter.connection.accessTokenSecret=myAccessTokenSecret")) { + + InputDestination source = context.getBean(InputDestination.class); + assertThat(source).isNotNull(); + + source.send(new GenericMessage<>("hello".getBytes(StandardCharsets.UTF_8))); + + mockClient.verify(request() + .withMethod("POST") + .withPath("/direct_messages/events/new.json") + .withBody(new StringBody("{\"event\":{\"type\":\"message_create\"," + + "\"message_create\":{\"target\":{\"recipient_id\":1075751718749659136}," + + "\"message_data\":{\"text\":\"hello\"}}}}")), + once()); + } + } + + @Test + public void directMessageDefaults() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration(TestTwitterMessageSinkApplication.class)) + .web(WebApplicationType.NONE) + .run("--spring.cloud.stream.function.definition=byteArrayTextToString|sendDirectMessageConsumer", + + "--twitter.message.update.userId=headers['user']", + "--twitter.message.update.text=payload.concat(\" with suffix \")", + "--twitter.message.update.mediaId='666'", + + "--twitter.connection.consumerKey=myConsumerKey", + "--twitter.connection.consumerSecret=myConsumerSecret", + "--twitter.connection.accessToken=myAccessToken", + "--twitter.connection.accessTokenSecret=myAccessTokenSecret")) { + + InputDestination source = context.getBean(InputDestination.class); + assertThat(source).isNotNull(); + + Map headers = Collections.singletonMap("user", "1075751718749659136"); + source.send(new GenericMessage("hello".getBytes(StandardCharsets.UTF_8), headers)); + + mockClient.verify(request() + .withMethod("POST") + .withPath("/direct_messages/events/new.json") + .withBody(new StringBody("{\"event\":{\"type\":\"message_create\",\"message_create\":" + + "{\"target\":{\"recipient_id\":1075751718749659136},\"message_data\":" + + "{\"text\":\"hello with suffix \",\"attachment\":{\"type\":\"media\",\"media\":" + + "{\"id\":666}}}}}}")), + once()); + } + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @Import(TwitterMessageConsumerConfiguration.class) + public static class TestTwitterMessageSinkApplication { + + @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/applications/sink/twitter-message-sink/src/test/java/org/springframework/cloud/stream/app/sink/twitter/message/TwitterTestUtils.java b/applications/sink/twitter-message-sink/src/test/java/org/springframework/cloud/stream/app/sink/twitter/message/TwitterTestUtils.java new file mode 100644 index 00000000..2507022e --- /dev/null +++ b/applications/sink/twitter-message-sink/src/test/java/org/springframework/cloud/stream/app/sink/twitter/message/TwitterTestUtils.java @@ -0,0 +1,68 @@ +/* + * 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.stream.app.sink.twitter.message; + + +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.function.Function; + +import twitter4j.conf.ConfigurationBuilder; + +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.util.StreamUtils; + +/** + * @author Christian Tzolov + */ +public class TwitterTestUtils { + + public Function mockTwitterUrls(String baseUrl) { + return configBuilder -> { + configBuilder.setRestBaseURL(baseUrl + "/"); + configBuilder.setStreamBaseURL(baseUrl + "/stream/"); + configBuilder.setUserStreamBaseURL(baseUrl + "/user/"); + configBuilder.setSiteStreamBaseURL(baseUrl + "/site/"); + configBuilder.setUploadBaseURL(baseUrl + "/upload/"); + + configBuilder.setOAuthAccessTokenURL(baseUrl + "/oauth/access_token"); + configBuilder.setOAuthAuthenticationURL(baseUrl + "/oauth/authenticate"); + configBuilder.setOAuthAuthorizationURL(baseUrl + "/oauth/authorize"); + configBuilder.setOAuthRequestTokenURL(baseUrl + "/oauth/request_token"); + configBuilder.setOAuth2TokenURL(baseUrl + "/oauth2/token"); + configBuilder.setOAuth2InvalidateTokenURL(baseUrl + "/oauth2/invalidate_token"); + + return configBuilder; + }; + } + + /** + * Load Spring Resource as String. + * @param resourcePath Resource path (accepts file:// , classpath:// and http:// uri schemas) + * @return Returns text (UTF8) representation of the resource pointed by the resourcePath + */ + public static String asString(String resourcePath) { + try { + return StreamUtils.copyToString(new DefaultResourceLoader().getResource(resourcePath).getInputStream(), + Charset.forName("UTF-8")); + } + catch (IOException e) { + throw new RuntimeException("Can not load resource:" + resourcePath, e); + } + } + +} diff --git a/applications/sink/twitter-message-sink/src/test/resources/response/test_direct_message.json b/applications/sink/twitter-message-sink/src/test/resources/response/test_direct_message.json new file mode 100644 index 00000000..05512875 --- /dev/null +++ b/applications/sink/twitter-message-sink/src/test/resources/response/test_direct_message.json @@ -0,0 +1 @@ +{"created_at":"Thu Dec 20 13:56:10 +0000 2018","id":1075751718749659136,"id_str":"1075751718749659136","text":"Codementor: Kubernetes for Python Developers: Part 1\n#100daysofcode #python https:\/\/t.co\/VviTgpFpge","source":"\u003ca href=\"https:\/\/ifttt.com\" rel=\"nofollow\"\u003eIFTTT\u003c\/a\u003e","truncated":false,"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,"user":{"id":859252650512072704,"id_str":"859252650512072704","name":"Freelancing|WebDev","screen_name":"FreelanceForBTC","location":"THE NET","url":"http:\/\/bit.ly\/BTCFREELANCING","description":"This twitter is designed to give the best information from web development to Blockchain.\n|YouTube's |Jobs |learning contented","translator_type":"none","protected":false,"verified":false,"followers_count":146,"friends_count":118,"listed_count":4,"favourites_count":445,"statuses_count":7528,"created_at":"Tue May 02 03:46:10 +0000 2017","utc_offset":null,"time_zone":null,"geo_enabled":false,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"F5F8FA","profile_background_image_url":"","profile_background_image_url_https":"","profile_background_tile":false,"profile_link_color":"1DA1F2","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"profile_image_url":"http:\/\/pbs.twimg.com\/profile_images\/1071607798771793920\/UegrCs84_normal.jpg","profile_image_url_https":"https:\/\/pbs.twimg.com\/profile_images\/1071607798771793920\/UegrCs84_normal.jpg","profile_banner_url":"https:\/\/pbs.twimg.com\/profile_banners\/859252650512072704\/1544326203","default_profile":true,"default_profile_image":false,"following":null,"follow_request_sent":null,"notifications":null},"geo":null,"coordinates":null,"place":null,"contributors":null,"is_quote_status":false,"quote_count":0,"reply_count":0,"retweet_count":0,"favorite_count":0,"entities":{"hashtags":[{"text":"100daysofcode","indices":[53,67]},{"text":"python","indices":[68,75]}],"urls":[{"url":"https:\/\/t.co\/VviTgpFpge","expanded_url":"https:\/\/ift.tt\/2GxdMZF","display_url":"ift.tt\/2GxdMZF","indices":[76,99]}],"user_mentions":[],"symbols":[]},"favorited":false,"retweeted":false,"possibly_sensitive":false,"filter_level":"low","lang":"ca","timestamp_ms":"1545314170907"} diff --git a/applications/sink/twitter-message-sink/src/test/resources/response/user_1075751718749659136.json b/applications/sink/twitter-message-sink/src/test/resources/response/user_1075751718749659136.json new file mode 100644 index 00000000..05512875 --- /dev/null +++ b/applications/sink/twitter-message-sink/src/test/resources/response/user_1075751718749659136.json @@ -0,0 +1 @@ +{"created_at":"Thu Dec 20 13:56:10 +0000 2018","id":1075751718749659136,"id_str":"1075751718749659136","text":"Codementor: Kubernetes for Python Developers: Part 1\n#100daysofcode #python https:\/\/t.co\/VviTgpFpge","source":"\u003ca href=\"https:\/\/ifttt.com\" rel=\"nofollow\"\u003eIFTTT\u003c\/a\u003e","truncated":false,"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,"user":{"id":859252650512072704,"id_str":"859252650512072704","name":"Freelancing|WebDev","screen_name":"FreelanceForBTC","location":"THE NET","url":"http:\/\/bit.ly\/BTCFREELANCING","description":"This twitter is designed to give the best information from web development to Blockchain.\n|YouTube's |Jobs |learning contented","translator_type":"none","protected":false,"verified":false,"followers_count":146,"friends_count":118,"listed_count":4,"favourites_count":445,"statuses_count":7528,"created_at":"Tue May 02 03:46:10 +0000 2017","utc_offset":null,"time_zone":null,"geo_enabled":false,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"F5F8FA","profile_background_image_url":"","profile_background_image_url_https":"","profile_background_tile":false,"profile_link_color":"1DA1F2","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"profile_image_url":"http:\/\/pbs.twimg.com\/profile_images\/1071607798771793920\/UegrCs84_normal.jpg","profile_image_url_https":"https:\/\/pbs.twimg.com\/profile_images\/1071607798771793920\/UegrCs84_normal.jpg","profile_banner_url":"https:\/\/pbs.twimg.com\/profile_banners\/859252650512072704\/1544326203","default_profile":true,"default_profile_image":false,"following":null,"follow_request_sent":null,"notifications":null},"geo":null,"coordinates":null,"place":null,"contributors":null,"is_quote_status":false,"quote_count":0,"reply_count":0,"retweet_count":0,"favorite_count":0,"entities":{"hashtags":[{"text":"100daysofcode","indices":[53,67]},{"text":"python","indices":[68,75]}],"urls":[{"url":"https:\/\/t.co\/VviTgpFpge","expanded_url":"https:\/\/ift.tt\/2GxdMZF","display_url":"ift.tt\/2GxdMZF","indices":[76,99]}],"user_mentions":[],"symbols":[]},"favorited":false,"retweeted":false,"possibly_sensitive":false,"filter_level":"low","lang":"ca","timestamp_ms":"1545314170907"} diff --git a/applications/sink/twitter-update-sink/README.adoc b/applications/sink/twitter-update-sink/README.adoc new file mode 100644 index 00000000..12b0f8cd --- /dev/null +++ b/applications/sink/twitter-update-sink/README.adoc @@ -0,0 +1,62 @@ +//tag::ref-doc[] += Twitter Update Sink + +Updates the authenticating user's current text (e.g Tweeting). + +NOTE: For each update attempt, the update text is compared with the authenticating user's recent Tweets. +Any attempt that would result in duplication will be blocked, resulting in a 403 error. +A user cannot submit the same text twice in a row. + +While not rate limited by the API, a user is limited in the number of Tweets they can create at a time. +The update limit for standard API is 300 in 3 hours windows. +If the number of updates posted by the user reaches the current allowed limit this method will return an HTTP 403 error. + +You can find details for the Update API here: https://developer.twitter.com/en/docs/tweets/post-and-engage/api-reference/post-statuses-update + +== Options + +//tag::configuration-properties[] +$$twitter.update.attachment-url$$:: $$(SpEL expression) In order for a URL to not be counted in the text body of an extended Tweet, provide a URL as a Tweet attachment. This URL must be a Tweet permalink, or Direct Message deep link. Arbitrary, non-Twitter URLs must remain in the text text. URLs passed to the attachment_url parameter not matching either a Tweet permalink or Direct Message deep link will fail at Tweet creation and cause an exception.$$ *($$Expression$$, default: `$$$$`)* +$$twitter.update.display-coordinates$$:: $$(SpEL expression) Whether or not to put a pin on the exact coordinates a Tweet has been sent from.$$ *($$Expression$$, default: `$$$$`)* +$$twitter.update.in-reply-to-status-id$$:: $$(SpEL expression) The ID of an existing text that the update is in reply to. Note: This parameter will be ignored unless the author of the Tweet this parameter references is mentioned within the text text. Therefore, you must include @username, where username is the author of the referenced Tweet, within the update. When inReplyToStatusId is set the auto_populate_reply_metadata is automatically set as well. Later ensures that leading @mentions will be looked up from the original Tweet, and added to the new Tweet from there. This wil append @mentions into the metadata of an extended Tweet as a reply chain grows, until the limit on @mentions is reached. In cases where the original Tweet has been deleted, the reply will fail.$$ *($$Expression$$, default: `$$$$`)* +$$twitter.update.location.lat$$:: $$The latitude of the location this Tweet refers to. This parameter will be ignored unless it is inside the range -90.0 to +90.0 (North is positive) inclusive. It will also be ignored if there is no corresponding long parameter.$$ *($$Expression$$, default: `$$$$`)* +$$twitter.update.location.lon$$:: $$The longitude of the location this Tweet refers to. The valid ranges for longitude are -180.0 to +180.0 (East is positive) inclusive. This parameter will be ignored if outside that range, if it is not a number, if geo_enabled is disabled, or if there no corresponding lat parameter.$$ *($$Expression$$, default: `$$$$`)* +$$twitter.update.media-ids$$:: $$(SpEL expression) A comma-delimited list of media_ids to associate with the Tweet. You may include up to 4 photos or 1 animated GIF or 1 video in a Tweet. See Uploading Media for further details on uploading media.$$ *($$Expression$$, default: `$$$$`)* +$$twitter.update.place-id$$:: $$(SpEL expression) A place in the world.$$ *($$Expression$$, default: `$$$$`)* +$$twitter.update.text$$:: $$(SpEL expression) The text of the text update. URL encode as necessary. t.co link wrapping will affect character counts. Defaults to message's payload$$ *($$Expression$$, default: `$$payload$$`)* +//end::configuration-properties[] + +//end::ref-doc[] + +== Configuration + +`TwitterUpdateConsumerConfiguration` exposes 2 composable functions: + +* `Function, StatusUpdate> toStatusUpdateQuery(TwitterUpdateConsumerProperties updateProperties)` - Converts input message into `StatusUpdate` query object. +* `Consumer updateStatus(Twitter twitter)` - Sends the input `StatusUpdate` argument as Twitter text update. + +Use `@Import(TwitterUpdateConsumerConfiguration.class)` to compose those functions. + +By default the `twitter-update` implements the following composite function chain: +`spring.cloud.stream.function.definition=byteArrayTextToString|toStatusUpdateQuery|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/applications/sink/twitter-update-sink/pom.xml b/applications/sink/twitter-update-sink/pom.xml new file mode 100644 index 00000000..90f95dfa --- /dev/null +++ b/applications/sink/twitter-update-sink/pom.xml @@ -0,0 +1,103 @@ + + + 4.0.0 + twitter-update-sink + 3.0.0-SNAPSHOT + twitter-update-sink + twitter update sink apps + jar + + + org.springframework.cloud.stream.app + stream-applications-core + 3.0.0-SNAPSHOT + + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.cloud.fn + twitter-consumer + ${java-functions.version} + + + org.awaitility + awaitility + test + + + junit + junit + + + + + org.mock-server + mockserver-netty + 5.10 + test + + + org.mock-server + mockserver-client-java + 5.10 + test + + + + + + + org.springframework.cloud + spring-cloud-app-starter-doc-maven-plugin + + + org.springframework.cloud.stream.app.plugin + spring-cloud-stream-app-maven-plugin + + + twitter-update + sink + ${project.version} + org.springframework.cloud.fn.consumer.twitter.status.update.TwitterUpdateConsumerConfiguration.class + byteArrayTextToString|twitterStatusUpdateConsumer + + + + org.springframework.cloud.fn + twitter-consumer + ${java-functions.version} + + + + + + + + + + + + true + + spring-snapshots + Spring Snapshots + https://repo.spring.io/libs-snapshot-local + + + + false + + spring-milestones + Spring Milestones + https://repo.spring.io/libs-milestone-local + + + + diff --git a/applications/sink/twitter-update-sink/src/main/resources/META-INF/dataflow-configuration-metadata-whitelist.properties b/applications/sink/twitter-update-sink/src/main/resources/META-INF/dataflow-configuration-metadata-whitelist.properties new file mode 100644 index 00000000..85fa73e3 --- /dev/null +++ b/applications/sink/twitter-update-sink/src/main/resources/META-INF/dataflow-configuration-metadata-whitelist.properties @@ -0,0 +1,2 @@ +configuration-properties.classes=org.springframework.cloud.fn.consumer.twitter.status.update.TwitterUpdateConsumerProperties, \ + org.springframework.cloud.fn.consumer.twitter.status.update.TwitterUpdateConsumerProperties$Location diff --git a/applications/sink/twitter-update-sink/src/test/java/org/springframework/cloud/stream/app/sink/twitter/update/TwitterTestUtils.java b/applications/sink/twitter-update-sink/src/test/java/org/springframework/cloud/stream/app/sink/twitter/update/TwitterTestUtils.java new file mode 100644 index 00000000..0ed73f54 --- /dev/null +++ b/applications/sink/twitter-update-sink/src/test/java/org/springframework/cloud/stream/app/sink/twitter/update/TwitterTestUtils.java @@ -0,0 +1,68 @@ +/* + * 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.stream.app.sink.twitter.update; + + +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.function.Function; + +import twitter4j.conf.ConfigurationBuilder; + +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.util.StreamUtils; + +/** + * @author Christian Tzolov + */ +public class TwitterTestUtils { + + public Function mockTwitterUrls(String baseUrl) { + return configBuilder -> { + configBuilder.setRestBaseURL(baseUrl + "/"); + configBuilder.setStreamBaseURL(baseUrl + "/stream/"); + configBuilder.setUserStreamBaseURL(baseUrl + "/user/"); + configBuilder.setSiteStreamBaseURL(baseUrl + "/site/"); + configBuilder.setUploadBaseURL(baseUrl + "/upload/"); + + configBuilder.setOAuthAccessTokenURL(baseUrl + "/oauth/access_token"); + configBuilder.setOAuthAuthenticationURL(baseUrl + "/oauth/authenticate"); + configBuilder.setOAuthAuthorizationURL(baseUrl + "/oauth/authorize"); + configBuilder.setOAuthRequestTokenURL(baseUrl + "/oauth/request_token"); + configBuilder.setOAuth2TokenURL(baseUrl + "/oauth2/token"); + configBuilder.setOAuth2InvalidateTokenURL(baseUrl + "/oauth2/invalidate_token"); + + return configBuilder; + }; + } + + /** + * Load Spring Resource as String. + * @param resourcePath Resource path (accepts file:// , classpath:// and http:// uri schemas) + * @return Returns text (UTF8) representation of the resource pointed by the resourcePath + */ + public static String asString(String resourcePath) { + try { + return StreamUtils.copyToString(new DefaultResourceLoader().getResource(resourcePath).getInputStream(), + Charset.forName("UTF-8")); + } + catch (IOException e) { + throw new RuntimeException("Can not load resource:" + resourcePath, e); + } + } + +} diff --git a/applications/sink/twitter-update-sink/src/test/java/org/springframework/cloud/stream/app/sink/twitter/update/TwitterUpdateSinkIntegrationTests.java b/applications/sink/twitter-update-sink/src/test/java/org/springframework/cloud/stream/app/sink/twitter/update/TwitterUpdateSinkIntegrationTests.java new file mode 100644 index 00000000..ca743f26 --- /dev/null +++ b/applications/sink/twitter-update-sink/src/test/java/org/springframework/cloud/stream/app/sink/twitter/update/TwitterUpdateSinkIntegrationTests.java @@ -0,0 +1,228 @@ +/* + * 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.stream.app.sink.twitter.update; + +import java.nio.charset.StandardCharsets; +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.StringBody; +import twitter4j.conf.ConfigurationBuilder; + +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.cloud.fn.common.twitter.TwitterConnectionProperties; +import org.springframework.cloud.fn.consumer.twitter.status.update.TwitterUpdateConsumerConfiguration; +import org.springframework.cloud.fn.consumer.twitter.status.update.TwitterUpdateConsumerProperties; +import org.springframework.cloud.stream.binder.test.InputDestination; +import org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.messaging.support.GenericMessage; + +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 + */ +public class TwitterUpdateSinkIntegrationTests { + + private static final String MOCK_SERVER_IP = "127.0.0.1"; + + private static final Integer MOCK_SERVER_PORT = 1080; + + private static ClientAndServer mockServer; + + private static MockServerClient mockClient; + + @BeforeAll + public static void startMockServer() { + mockServer = ClientAndServer.startClientAndServer(MOCK_SERVER_PORT); + mockClient = new MockServerClient(MOCK_SERVER_IP, MOCK_SERVER_PORT); + mockClient + .when( + request().withMethod("POST").withPath("/statuses/update.json"), + unlimited()) + .respond(response() + .withStatusCode(200) + .withHeader("Content-Type", "application/json; charset=utf-8") + .withBody(TwitterTestUtils.asString("classpath:/response/update_test_1.json")) + .withDelay(TimeUnit.SECONDS, 1)); + } + + @AfterAll + public static void stopMockServer() { + mockServer.stop(); + } + + @Test + public void testUpdateStatus() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration(TestTwitterUpdateSinkApplication.class)) + .web(WebApplicationType.NONE) + .run("--spring.cloud.stream.function.definition=byteArrayTextToString|twitterStatusUpdateConsumer", + "--twitter.connection.consumerKey=myConsumerKey", + "--twitter.connection.consumerSecret=myConsumerSecret", + "--twitter.connection.accessToken=myAccessToken", + "--twitter.connection.accessTokenSecret=myAccessTokenSecret")) { + + InputDestination source = context.getBean(InputDestination.class); + assertThat(source).isNotNull(); + + source.send(new GenericMessage<>("Test Update 678".getBytes(StandardCharsets.UTF_8))); + + mockClient.verify(request() + .withMethod("POST") + .withPath("/statuses/update.json") + .withBody(new StringBody("status=Test%20Update%20678" + + "&include_entities=true" + + "&include_ext_alt_text=true" + + "&tweet_mode=extended")), + once()); + + } + } + + @Test + public void updateWithPayloadExpression() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration(TestTwitterUpdateSinkApplication.class)) + .web(WebApplicationType.NONE) + .run("--spring.cloud.stream.function.definition=byteArrayTextToString|twitterStatusUpdateConsumer", + "--twitter.update.text=payload.toUpperCase().concat(\" With Suffix\")", + + "--twitter.connection.consumerKey=myConsumerKey", + "--twitter.connection.consumerSecret=myConsumerSecret", + "--twitter.connection.accessToken=myAccessToken", + "--twitter.connection.accessTokenSecret=myAccessTokenSecret")) { + + InputDestination source = context.getBean(InputDestination.class); + assertThat(source).isNotNull(); + + source.send(new GenericMessage<>("1 Expression Test".getBytes(StandardCharsets.UTF_8))); + source.send(new GenericMessage<>("2 Expression Test".getBytes(StandardCharsets.UTF_8))); + + mockClient.verify(request() + .withMethod("POST") + .withPath("/statuses/update.json") + .withBody(new StringBody("status=1%20EXPRESSION%20TEST%20With%20Suffix" + + "&include_entities=true" + + "&include_ext_alt_text=true" + + "&tweet_mode=extended")), + once()); + + mockClient.verify(request() + .withMethod("POST") + .withPath("/statuses/update.json") + .withBody(new StringBody("status=2%20EXPRESSION%20TEST%20With%20Suffix" + + "&include_entities=true" + + "&include_ext_alt_text=true" + + "&tweet_mode=extended")), + once()); + + } + } + + @Test + public void updateWithAllParams() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration(TestTwitterUpdateSinkApplication.class)) + .web(WebApplicationType.NONE) + .run("--spring.cloud.stream.function.definition=byteArrayTextToString|twitterStatusUpdateConsumer", + + "--twitter.update.attachmentUrl='http://attachementUrl'", + "--twitter.update.placeId='myPlaceId'", + "--twitter.update.inReplyToStatusId='666666'", + "--twitter.update.displayCoordinates='true'", + "--twitter.update.mediaIds='471592142565957632, 471592142565957633'", + "--twitter.update.location.lat='37.78217'", + "--twitter.update.location.lon='-122.40062'", + + "--twitter.connection.consumerKey=myConsumerKey", + "--twitter.connection.consumerSecret=myConsumerSecret", + "--twitter.connection.accessToken=myAccessToken", + "--twitter.connection.accessTokenSecret=myAccessTokenSecret")) { + + TwitterConnectionProperties twitterConnectionProperties = context.getBean(TwitterConnectionProperties.class); + assertThat(twitterConnectionProperties.getConsumerKey()).isEqualTo("myConsumerKey"); + assertThat(twitterConnectionProperties.getConsumerSecret()).isEqualTo("myConsumerSecret"); + assertThat(twitterConnectionProperties.getAccessToken()).isEqualTo("myAccessToken"); + assertThat(twitterConnectionProperties.getAccessTokenSecret()).isEqualTo("myAccessTokenSecret"); + + TwitterUpdateConsumerProperties twitterUpdateConsumerProperties = context.getBean(TwitterUpdateConsumerProperties.class); + assertThat(twitterUpdateConsumerProperties.getAttachmentUrl().getValue()).isEqualTo("http://attachementUrl"); + assertThat(twitterUpdateConsumerProperties.getPlaceId().getValue()).isEqualTo("myPlaceId"); + assertThat(twitterUpdateConsumerProperties.getInReplyToStatusId().getValue()).isEqualTo("666666"); + assertThat(twitterUpdateConsumerProperties.getDisplayCoordinates().getValue()).isEqualTo("true"); + assertThat(twitterUpdateConsumerProperties.getMediaIds().getValue()).isEqualTo("471592142565957632, 471592142565957633"); + assertThat(twitterUpdateConsumerProperties.getLocation().getLat().getValue()).isEqualTo("37.78217"); + assertThat(twitterUpdateConsumerProperties.getLocation().getLon().getValue()).isEqualTo("-122.40062"); + + InputDestination source = context.getBean(InputDestination.class); + assertThat(source).isNotNull(); + + source.send(new GenericMessage<>("Test Tweet".getBytes(StandardCharsets.UTF_8))); + + mockClient.verify(request() + .withMethod("POST") + .withPath("/statuses/update.json") + .withBody(new StringBody("status=Test%20Tweet" + + "&in_reply_to_status_id=666666" + + "&lat=37.78217&long=-122.40062" + + "&place_id=myPlaceId" + + "&media_ids=471592142565957632%2C471592142565957633" + + "&auto_populate_reply_metadata=true" + + "&attachment_url=http%3A%2F%2FattachementUrl" + + "&include_entities=true" + + "&include_ext_alt_text=true" + + "&tweet_mode=extended")), + once()); + } + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @Import(TwitterUpdateConsumerConfiguration.class) + public static class TestTwitterUpdateSinkApplication { + + @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/applications/sink/twitter-update-sink/src/test/resources/response/update_test_1.json b/applications/sink/twitter-update-sink/src/test/resources/response/update_test_1.json new file mode 100644 index 00000000..05512875 --- /dev/null +++ b/applications/sink/twitter-update-sink/src/test/resources/response/update_test_1.json @@ -0,0 +1 @@ +{"created_at":"Thu Dec 20 13:56:10 +0000 2018","id":1075751718749659136,"id_str":"1075751718749659136","text":"Codementor: Kubernetes for Python Developers: Part 1\n#100daysofcode #python https:\/\/t.co\/VviTgpFpge","source":"\u003ca href=\"https:\/\/ifttt.com\" rel=\"nofollow\"\u003eIFTTT\u003c\/a\u003e","truncated":false,"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,"user":{"id":859252650512072704,"id_str":"859252650512072704","name":"Freelancing|WebDev","screen_name":"FreelanceForBTC","location":"THE NET","url":"http:\/\/bit.ly\/BTCFREELANCING","description":"This twitter is designed to give the best information from web development to Blockchain.\n|YouTube's |Jobs |learning contented","translator_type":"none","protected":false,"verified":false,"followers_count":146,"friends_count":118,"listed_count":4,"favourites_count":445,"statuses_count":7528,"created_at":"Tue May 02 03:46:10 +0000 2017","utc_offset":null,"time_zone":null,"geo_enabled":false,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"F5F8FA","profile_background_image_url":"","profile_background_image_url_https":"","profile_background_tile":false,"profile_link_color":"1DA1F2","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"profile_image_url":"http:\/\/pbs.twimg.com\/profile_images\/1071607798771793920\/UegrCs84_normal.jpg","profile_image_url_https":"https:\/\/pbs.twimg.com\/profile_images\/1071607798771793920\/UegrCs84_normal.jpg","profile_banner_url":"https:\/\/pbs.twimg.com\/profile_banners\/859252650512072704\/1544326203","default_profile":true,"default_profile_image":false,"following":null,"follow_request_sent":null,"notifications":null},"geo":null,"coordinates":null,"place":null,"contributors":null,"is_quote_status":false,"quote_count":0,"reply_count":0,"retweet_count":0,"favorite_count":0,"entities":{"hashtags":[{"text":"100daysofcode","indices":[53,67]},{"text":"python","indices":[68,75]}],"urls":[{"url":"https:\/\/t.co\/VviTgpFpge","expanded_url":"https:\/\/ift.tt\/2GxdMZF","display_url":"ift.tt\/2GxdMZF","indices":[76,99]}],"user_mentions":[],"symbols":[]},"favorited":false,"retweeted":false,"possibly_sensitive":false,"filter_level":"low","lang":"ca","timestamp_ms":"1545314170907"} diff --git a/applications/source/pom.xml b/applications/source/pom.xml index f2e2b994..fc1c28f1 100644 --- a/applications/source/pom.xml +++ b/applications/source/pom.xml @@ -24,5 +24,8 @@ rabbit-source websocket-source s3-source + twitter-stream-source + twitter-search-source + twitter-message-source diff --git a/applications/source/twitter-message-source/README.adoc b/applications/source/twitter-message-source/README.adoc new file mode 100644 index 00000000..fc324de2 --- /dev/null +++ b/applications/source/twitter-message-source/README.adoc @@ -0,0 +1,30 @@ +//tag::ref-doc[] += Twitter Message Source + +Repeatedly retrieves the direct messages (both sent and received) within the last 30 days, sorted in reverse-chronological order. +The relieved messages are cached (in a `MetadataStore` cache) to prevent duplications. +By default an in-memory `SimpleMetadataStore` is used. + +The `twitter.message.source.count` controls the number or returned messages. + +The `spring.cloud.stream.poller` properties control the message poll interval. +Must be aligned with used APIs rate limit + +== Options + +//tag::configuration-properties[] +$$spring.cloud.stream.poller.cron$$:: $$Cron expression value for the Cron Trigger.$$ *($$String$$, default: `$$$$`)* +$$spring.cloud.stream.poller.fixed-delay$$:: $$Fixed delay for default poller.$$ *($$Long$$, default: `$$1000$$`)* +$$spring.cloud.stream.poller.initial-delay$$:: $$Initial delay for periodic triggers.$$ *($$Integer$$, default: `$$0$$`)* +$$spring.cloud.stream.poller.max-messages-per-poll$$:: $$Maximum messages per poll for the default poller.$$ *($$Long$$, default: `$$1$$`)* +$$twitter.connection.access-token$$:: $$Your Twitter token.$$ *($$String$$, default: `$$$$`)* +$$twitter.connection.access-token-secret$$:: $$Your Twitter token secret.$$ *($$String$$, default: `$$$$`)* +$$twitter.connection.consumer-key$$:: $$Your Twitter key.$$ *($$String$$, default: `$$$$`)* +$$twitter.connection.consumer-secret$$:: $$Your Twitter secret.$$ *($$String$$, default: `$$$$`)* +$$twitter.connection.debug-enabled$$:: $$Enables Twitter4J debug mode.$$ *($$Boolean$$, default: `$$false$$`)* +$$twitter.connection.raw-json$$:: $$Enable caching the original (raw) JSON objects as returned by the Twitter APIs. When set to False the result will use the Twitter4J's json representations. When set to True the result will use the original Twitter APISs json representations.$$ *($$Boolean$$, default: `$$true$$`)* +$$twitter.message.source.count$$:: $$Max number of events to be returned. 20 default. 50 max.$$ *($$Integer$$, default: `$$20$$`)* +//end::configuration-properties[] + +//end::ref-doc[] + diff --git a/applications/source/twitter-message-source/pom.xml b/applications/source/twitter-message-source/pom.xml new file mode 100644 index 00000000..314c16eb --- /dev/null +++ b/applications/source/twitter-message-source/pom.xml @@ -0,0 +1,97 @@ + + + 4.0.0 + twitter-message-source + 3.0.0-SNAPSHOT + twitter-message-source + twitter message source apps + jar + + + org.springframework.cloud.stream.app + stream-applications-core + 3.0.0-SNAPSHOT + + + + + + org.springframework.cloud.fn + twitter-supplier + ${java-functions.version} + + + + org.springframework.cloud.fn + function-test-support + ${java-functions.version} + test + + + + org.mock-server + mockserver-netty + 5.10 + test + + + org.mock-server + mockserver-client-java + 5.10 + test + + + + + + + + org.springframework.cloud + spring-cloud-app-starter-doc-maven-plugin + + + org.springframework.cloud.stream.app.plugin + spring-cloud-stream-app-maven-plugin + + + twitter-message + source + ${project.version} + + org.springframework.cloud.fn.supplier.twitter.message.TwitterMessageSupplierConfiguration.class + + twitterMessageSupplier + + + + org.springframework.cloud.fn + twitter-supplier + ${java-functions.version} + + + + + + + + + + + true + + spring-snapshots + Spring Snapshots + https://repo.spring.io/libs-snapshot-local + + + + false + + spring-milestones + Spring Milestones + https://repo.spring.io/libs-milestone-local + + + + diff --git a/applications/source/twitter-message-source/src/main/resources/META-INF/dataflow-configuration-metadata-whitelist.properties b/applications/source/twitter-message-source/src/main/resources/META-INF/dataflow-configuration-metadata-whitelist.properties new file mode 100644 index 00000000..cd892a8b --- /dev/null +++ b/applications/source/twitter-message-source/src/main/resources/META-INF/dataflow-configuration-metadata-whitelist.properties @@ -0,0 +1,4 @@ +configuration-properties.classes=org.springframework.cloud.fn.common.twitter.TwitterConnectionProperties,\ + org.springframework.cloud.fn.supplier.twitter.message.TwitterMessageSupplierProperties, \ + org.springframework.cloud.stream.config.DefaultPollerProperties + diff --git a/applications/source/twitter-message-source/src/test/java/org/springframework/cloud/stream/app/source/twitter/message/TwitterMessageSourceIntegrationTests.java b/applications/source/twitter-message-source/src/test/java/org/springframework/cloud/stream/app/source/twitter/message/TwitterMessageSourceIntegrationTests.java new file mode 100644 index 00000000..47d2cccd --- /dev/null +++ b/applications/source/twitter-message-source/src/test/java/org/springframework/cloud/stream/app/source/twitter/message/TwitterMessageSourceIntegrationTests.java @@ -0,0 +1,161 @@ +/* + * 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.stream.app.source.twitter.message; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +import com.fasterxml.jackson.core.JsonProcessingException; +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.boot.SpringBootConfiguration; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.cloud.fn.common.twitter.TwitterConnectionProperties; +import org.springframework.cloud.fn.supplier.twitter.message.TwitterMessageSupplierConfiguration; +import org.springframework.cloud.fn.supplier.twitter.message.TwitterMessageSupplierProperties; +import org.springframework.cloud.stream.binder.test.OutputDestination; +import org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration; +import org.springframework.cloud.stream.config.DefaultPollerProperties; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.messaging.Message; + +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 + */ +public class TwitterMessageSourceIntegrationTests { + + private static final String MOCK_SERVER_IP = "127.0.0.1"; + + private static final Integer MOCK_SERVER_PORT = 1080; + + private static ClientAndServer mockServer; + + private static MockServerClient mockClient; + private static HttpRequest messageRequest; + + + @BeforeAll + public static void startServer() { + mockServer = ClientAndServer.startClientAndServer(MOCK_SERVER_PORT); + mockClient = new MockServerClient(MOCK_SERVER_IP, MOCK_SERVER_PORT); + + messageRequest = setExpectation(request() + .withMethod("GET") + .withPath("/direct_messages/events/list.json") + .withQueryStringParameter("count", "15")); + } + + @AfterAll + public static void stopServer() { + mockServer.stop(); + } + + @Test + public void twitterMessageSourceTests() throws JsonProcessingException { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration + .getCompleteConfiguration(TestTwitterMessageSourceApplication.class)) + + .web(WebApplicationType.NONE) + .run("--spring.cloud.stream.function.definition=twitterMessageSupplier", + + "--twitter.connection.consumerKey=consumerKey666", + "--twitter.connection.consumerSecret=consumerSecret666", + "--twitter.connection.accessToken=accessToken666", + "--twitter.connection.accessTokenSecret=accessTokenSecret666", + + "--twitter.message.source.count=15", + "--spring.cloud.stream.poller.fixed-delay=3000")) { + + TwitterConnectionProperties twitterConnectionProperties = context.getBean(TwitterConnectionProperties.class); + assertThat(twitterConnectionProperties.getConsumerKey()).isEqualTo("consumerKey666"); + assertThat(twitterConnectionProperties.getConsumerSecret()).isEqualTo("consumerSecret666"); + assertThat(twitterConnectionProperties.getAccessToken()).isEqualTo("accessToken666"); + assertThat(twitterConnectionProperties.getAccessTokenSecret()).isEqualTo("accessTokenSecret666"); + + DefaultPollerProperties defaultPollerProperties = context.getBean(DefaultPollerProperties.class); + assertThat(defaultPollerProperties.getFixedDelay()).isEqualTo(3000); + + TwitterMessageSupplierProperties twitterMessageSupplierProperties = context.getBean(TwitterMessageSupplierProperties.class); + assertThat(twitterMessageSupplierProperties.getCount()).isEqualTo(15); + + OutputDestination outputDestination = context.getBean(OutputDestination.class); + // Using local region here + Message message = outputDestination.receive(Duration.ofSeconds(300).toMillis()); + assertThat(message).isNotNull(); + String payload = new String(message.getPayload()); + + List tweets = new ObjectMapper().readValue(payload, List.class); + assertThat(tweets).hasSize(4); + mockClient.verify(messageRequest, once()); + } + } + + private 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/messages.json")) + .withDelay(TimeUnit.SECONDS, 10)); + return request; + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @Import(TwitterMessageSupplierConfiguration.class) + public static class TestTwitterMessageSourceApplication { + + @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/applications/source/twitter-message-source/src/test/java/org/springframework/cloud/stream/app/source/twitter/message/TwitterTestUtils.java b/applications/source/twitter-message-source/src/test/java/org/springframework/cloud/stream/app/source/twitter/message/TwitterTestUtils.java new file mode 100644 index 00000000..a314ef3a --- /dev/null +++ b/applications/source/twitter-message-source/src/test/java/org/springframework/cloud/stream/app/source/twitter/message/TwitterTestUtils.java @@ -0,0 +1,68 @@ +/* + * 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.stream.app.source.twitter.message; + + +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.function.Function; + +import twitter4j.conf.ConfigurationBuilder; + +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.util.StreamUtils; + +/** + * @author Christian Tzolov + */ +public class TwitterTestUtils { + + public Function mockTwitterUrls(String baseUrl) { + return configBuilder -> { + configBuilder.setRestBaseURL(baseUrl + "/"); + configBuilder.setStreamBaseURL(baseUrl + "/stream/"); + configBuilder.setUserStreamBaseURL(baseUrl + "/user/"); + configBuilder.setSiteStreamBaseURL(baseUrl + "/site/"); + configBuilder.setUploadBaseURL(baseUrl + "/upload/"); + + configBuilder.setOAuthAccessTokenURL(baseUrl + "/oauth/access_token"); + configBuilder.setOAuthAuthenticationURL(baseUrl + "/oauth/authenticate"); + configBuilder.setOAuthAuthorizationURL(baseUrl + "/oauth/authorize"); + configBuilder.setOAuthRequestTokenURL(baseUrl + "/oauth/request_token"); + configBuilder.setOAuth2TokenURL(baseUrl + "/oauth2/token"); + configBuilder.setOAuth2InvalidateTokenURL(baseUrl + "/oauth2/invalidate_token"); + + return configBuilder; + }; + } + + /** + * Load Spring Resource as String. + * @param resourcePath Resource path (accepts file:// , classpath:// and http:// uri schemas) + * @return Returns text (UTF8) representation of the resource pointed by the resourcePath + */ + public static String asString(String resourcePath) { + try { + return StreamUtils.copyToString(new DefaultResourceLoader().getResource(resourcePath).getInputStream(), + Charset.forName("UTF-8")); + } + catch (IOException e) { + throw new RuntimeException("Can not load resource:" + resourcePath, e); + } + } + +} diff --git a/applications/source/twitter-message-source/src/test/resources/response/messages.json b/applications/source/twitter-message-source/src/test/resources/response/messages.json new file mode 100644 index 00000000..f7b418b2 --- /dev/null +++ b/applications/source/twitter-message-source/src/test/resources/response/messages.json @@ -0,0 +1 @@ +{"events":[{"type":"message_create","id":"1073991611720351749","created_timestamp":"1544894528690","message_create":{"target":{"recipient_id":"1073883577107038208"},"sender_id":"10219792","message_data":{"text":"Test6","entities":{"hashtags":[],"symbols":[],"user_mentions":[],"urls":[]}}}},{"type":"message_create","id":"1073922523325218821","created_timestamp":"1544878056733","message_create":{"target":{"recipient_id":"10219792"},"sender_id":"1073883577107038208","source_app_id":"268278","message_data":{"text":"Here again","entities":{"hashtags":[],"symbols":[],"user_mentions":[],"urls":[]}}}},{"type":"message_create","id":"1073885014805356549","created_timestamp":"1544869114005","message_create":{"target":{"recipient_id":"1073883577107038208"},"sender_id":"10219792","message_data":{"text":"Test Back","entities":{"hashtags":[],"symbols":[],"user_mentions":[],"urls":[]}}}},{"type":"message_create","id":"1073884892927275012","created_timestamp":"1544869084947","message_create":{"target":{"recipient_id":"10219792"},"sender_id":"1073883577107038208","source_app_id":"268278","message_data":{"text":"Test","entities":{"hashtags":[],"symbols":[],"user_mentions":[],"urls":[]}}}}],"apps":{"268278":{"id":"268278","name":"Twitter Web Client","url":"http:\/\/twitter.com"}}} diff --git a/applications/source/twitter-search-source/README.adoc b/applications/source/twitter-search-source/README.adoc new file mode 100644 index 00000000..3aebe37b --- /dev/null +++ b/applications/source/twitter-search-source/README.adoc @@ -0,0 +1,64 @@ +//tag::ref-doc[] += Twitter Search Source + +The Twitter's https://developer.twitter.com/en/docs/tweets/search/api-reference/get-search-tweets.html[Standard search API] (search/tweets) allows simple queries against the indices of recent or popular Tweets. This `Source` provides continuous searches against a sampling of recent Tweets published in the past 7 days. Part of the 'public' set of APIs. + +Returns a collection of relevant Tweets matching a specified query. + +Use the `spring.cloud.stream.poller` properties to control the interval between consecutive search requests. Rate Limit - 180 requests per 30 min. window (e.g. ~6 r/m, ~ 1 req / 10 sec.) + +The `twitter.search` query properties allows querying by keywords and filter the result by time and geolocation. + +The `twitter.search.count` and `twitter.search.page` control the result pagination in accordance with to the Search API. + +Note: Twitter's search service and, by extension, the Search API is not meant to be an exhaustive source of Tweets. Not all Tweets will be indexed or made available via the search interface. + +== Options + +//tag::configuration-properties[] +$$spring.cloud.stream.poller.cron$$:: $$Cron expression value for the Cron Trigger.$$ *($$String$$, default: `$$$$`)* +$$spring.cloud.stream.poller.fixed-delay$$:: $$Fixed delay for default poller.$$ *($$Long$$, default: `$$1000$$`)* +$$spring.cloud.stream.poller.initial-delay$$:: $$Initial delay for periodic triggers.$$ *($$Integer$$, default: `$$0$$`)* +$$spring.cloud.stream.poller.max-messages-per-poll$$:: $$Maximum messages per poll for the default poller.$$ *($$Long$$, default: `$$1$$`)* +$$twitter.connection.access-token$$:: $$Your Twitter token.$$ *($$String$$, default: `$$$$`)* +$$twitter.connection.access-token-secret$$:: $$Your Twitter token secret.$$ *($$String$$, default: `$$$$`)* +$$twitter.connection.consumer-key$$:: $$Your Twitter key.$$ *($$String$$, default: `$$$$`)* +$$twitter.connection.consumer-secret$$:: $$Your Twitter secret.$$ *($$String$$, default: `$$$$`)* +$$twitter.connection.debug-enabled$$:: $$Enables Twitter4J debug mode.$$ *($$Boolean$$, default: `$$false$$`)* +$$twitter.connection.raw-json$$:: $$Enable caching the original (raw) JSON objects as returned by the Twitter APIs. When set to False the result will use the Twitter4J's json representations. When set to True the result will use the original Twitter APISs json representations.$$ *($$Boolean$$, default: `$$true$$`)* +$$twitter.search.count$$:: $$Number of tweets to return per page (e.g. per single request), up to a max of 100.$$ *($$Integer$$, default: `$$100$$`)* +$$twitter.search.geocode.latitude$$:: $$User's latitude.$$ *($$Double$$, default: `$$-1$$`)* +$$twitter.search.geocode.longitude$$:: $$User's longitude.$$ *($$Double$$, default: `$$-1$$`)* +$$twitter.search.geocode.radius$$:: $$Radius (in kilometers) around the (latitude, longitude) point.$$ *($$Double$$, default: `$$-1$$`)* +$$twitter.search.lang$$:: $$Restricts searched tweets to the given language, given by an ISO 639-1 code.$$ *($$String$$, default: `$$$$`)* +$$twitter.search.page$$:: $$Number of pages (e.g. requests) to search backwards (from most recent to the oldest tweets) before start the search from the most recent tweets again. The total amount of tweets searched backwards is (page * count)$$ *($$Integer$$, default: `$$3$$`)* +$$twitter.search.query$$:: $$Search tweets by search query string.$$ *($$String$$, default: `$$$$`)* +$$twitter.search.restart-from-most-recent-on-empty-response$$:: $$Restart search from the most recent tweets on empty response. Applied only after the first restart (e.g. when since_id != UNBOUNDED)$$ *($$Boolean$$, default: `$$false$$`)* +$$twitter.search.result-type$$:: $$Specifies what type of search results you would prefer to receive. The current default is "mixed." Valid values include: mixed : Include both popular and real time results in the response. recent : return only the most recent results in the response popular : return only the most popular results in the response$$ *($$ResultType$$, default: `$$$$`, possible values: `popular`,`mixed`,`recent`)* +$$twitter.search.since$$:: $$If specified, returns tweets with since the given date. Date should be formatted as YYYY-MM-DD.$$ *($$String$$, default: `$$$$`)* +//end::configuration-properties[] + +//end::ref-doc[] + + +== Examples + +``` +java -jar twitter-search-source.jar + --twitter.connection.consumerKey= ... + --twitter.connection.consumerSecret= ... + --twitter.connection.accessToken= ... + --twitter.connection.accessTokenSecret= ... + + --twitter.search.query=Amsterdam + --twitter.search.count=30 + --twitter.search.page=3 +``` + +And here is an example pipeline that uses twitter-search: + +``` +twitter-search-stream= twitter-search --twitter.connection.consumerKey= ... --twitter.connection.consumerSecret= ... --twitter.connection.accessToken= ... --twitter.connection.accessTokenSecret= ... --twitter.search.query=Amsterdam --twitter.search.count=30 --twitter.search.page=3 + +``` + diff --git a/applications/source/twitter-search-source/pom.xml b/applications/source/twitter-search-source/pom.xml new file mode 100644 index 00000000..1377db64 --- /dev/null +++ b/applications/source/twitter-search-source/pom.xml @@ -0,0 +1,94 @@ + + + 4.0.0 + twitter-search-source + 3.0.0-SNAPSHOT + twitter-search-source + twitter search source apps + jar + + + org.springframework.cloud.stream.app + stream-applications-core + 3.0.0-SNAPSHOT + + + + + + org.springframework.cloud.fn + twitter-supplier + ${java-functions.version} + + + + org.springframework.cloud.fn + function-test-support + ${java-functions.version} + test + + + + org.mock-server + mockserver-netty + 5.10 + test + + + org.mock-server + mockserver-client-java + 5.10 + test + + + + + + + org.springframework.cloud + spring-cloud-app-starter-doc-maven-plugin + + + org.springframework.cloud.stream.app.plugin + spring-cloud-stream-app-maven-plugin + + + twitter-search + source + ${project.version} + org.springframework.cloud.fn.supplier.twitter.status.search.TwitterSearchSupplierConfiguration.class + twitterSearchSupplier + + + + org.springframework.cloud.fn + twitter-supplier + ${java-functions.version} + + + + + + + + + + + true + + spring-snapshots + Spring Snapshots + https://repo.spring.io/libs-snapshot-local + + + + false + + spring-milestones + Spring Milestones + https://repo.spring.io/libs-milestone-local + + + + diff --git a/applications/source/twitter-search-source/src/main/resources/META-INF/dataflow-configuration-metadata-whitelist.properties b/applications/source/twitter-search-source/src/main/resources/META-INF/dataflow-configuration-metadata-whitelist.properties new file mode 100644 index 00000000..5a241b23 --- /dev/null +++ b/applications/source/twitter-search-source/src/main/resources/META-INF/dataflow-configuration-metadata-whitelist.properties @@ -0,0 +1,5 @@ +configuration-properties.classes=org.springframework.cloud.fn.common.twitter.TwitterConnectionProperties,\ + org.springframework.cloud.fn.supplier.twitter.status.search.TwitterSearchSupplierProperties, \ + org.springframework.cloud.fn.supplier.twitter.status.search.TwitterSearchSupplierProperties$Geocode, \ + org.springframework.cloud.stream.config.DefaultPollerProperties + diff --git a/applications/source/twitter-search-source/src/test/java/org/springframework/cloud/stream/app/source/twitter/search/TwitterSearchSourceIntegrationTests.java b/applications/source/twitter-search-source/src/test/java/org/springframework/cloud/stream/app/source/twitter/search/TwitterSearchSourceIntegrationTests.java new file mode 100644 index 00000000..5cea47d3 --- /dev/null +++ b/applications/source/twitter-search-source/src/test/java/org/springframework/cloud/stream/app/source/twitter/search/TwitterSearchSourceIntegrationTests.java @@ -0,0 +1,220 @@ +/* + * 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.stream.app.source.twitter.search; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +import com.fasterxml.jackson.core.JsonProcessingException; +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.boot.SpringBootConfiguration; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.cloud.fn.common.twitter.TwitterConnectionProperties; +import org.springframework.cloud.fn.supplier.twitter.status.search.TwitterSearchSupplierConfiguration; +import org.springframework.cloud.fn.supplier.twitter.status.search.TwitterSearchSupplierProperties; +import org.springframework.cloud.stream.binder.test.OutputDestination; +import org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration; +import org.springframework.cloud.stream.config.DefaultPollerProperties; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.messaging.Message; + +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 + */ +public class TwitterSearchSourceIntegrationTests { + + private static final String MOCK_SERVER_IP = "127.0.0.1"; + + private static final Integer MOCK_SERVER_PORT = 1080; + + private static ClientAndServer mockServer; + + private static MockServerClient mockClient; + + private static HttpRequest searchVratsaRequest; + + private static HttpRequest searchAmsterdamRequest; + + @BeforeAll + public static void startServer() { + + mockServer = ClientAndServer.startClientAndServer(MOCK_SERVER_PORT); + mockClient = new MockServerClient(MOCK_SERVER_IP, MOCK_SERVER_PORT); + + searchVratsaRequest = setExpectation(request() + .withMethod("GET") + .withPath("/search/tweets.json") + .withQueryStringParameter("q", "Vratsa") + .withQueryStringParameter("count", "3")); + + searchAmsterdamRequest = setExpectation(request() + .withMethod("GET") + .withPath("/search/tweets.json") + .withQueryStringParameter("q", "Amsterdam") + .withQueryStringParameter("count", "3") + .withQueryStringParameter("result_type", "popular") + .withQueryStringParameter("geocode", "52.1,4.8,10.0km") + .withQueryStringParameter("since", "2018-01-01") + .withQueryStringParameter("lang", "en")); + } + + @AfterAll + public static void stopServer() { + mockServer.stop(); + } + + @Test + public void twitterSearchTests() throws JsonProcessingException { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration + .getCompleteConfiguration(TestTwitterSearchSourceApplication.class)) + + .web(WebApplicationType.NONE) + .run("--spring.cloud.stream.function.definition=twitterSearchSupplier", + "--twitter.connection.consumerKey=consumerKey666", + "--twitter.connection.consumerSecret=consumerSecret666", + "--twitter.connection.accessToken=accessToken666", + "--twitter.connection.accessTokenSecret=accessTokenSecret666", + "--twitter.search.query=Vratsa", + "--twitter.search.count=3", + "--twitter.search.page=3")) { + + OutputDestination outputDestination = context.getBean(OutputDestination.class); + // Using local region here + Message message = outputDestination.receive(Duration.ofSeconds(300).toMillis()); + assertThat(message).isNotNull(); + String payload = new String(message.getPayload()); + + List tweets = new ObjectMapper().readValue(payload, List.class); + assertThat(tweets).hasSize(3); + mockClient.verify(searchVratsaRequest, once()); + + } + } + + @Test + public void twitterSearchTestsAmsterdam() throws JsonProcessingException { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration + .getCompleteConfiguration(TestTwitterSearchSourceApplication.class)) + .web(WebApplicationType.NONE) + .run("--spring.cloud.stream.function.definition=twitterSearchSupplier", + "--twitter.connection.consumerKey=consumerKey666", + "--twitter.connection.consumerSecret=consumerSecret666", + "--twitter.connection.accessToken=accessToken666", + "--twitter.connection.accessTokenSecret=accessTokenSecret666", + "--twitter.search.query=Amsterdam", + "--twitter.search.count=3", + "--twitter.search.page=3", + "--twitter.search.lang=en", + "--twitter.search.geocode.latitude=52.1", + "--twitter.search.geocode.longitude=4.8", + "--twitter.search.geocode.radius=10", + "--twitter.search.since=2018-01-01", + "--twitter.search.resultType=popular", + "--spring.cloud.stream.poller.fixed-delay=10000")) { + + TwitterConnectionProperties twitterConnectionProperties = context.getBean(TwitterConnectionProperties.class); + assertThat(twitterConnectionProperties.getConsumerKey()).isEqualTo("consumerKey666"); + assertThat(twitterConnectionProperties.getConsumerSecret()).isEqualTo("consumerSecret666"); + assertThat(twitterConnectionProperties.getAccessToken()).isEqualTo("accessToken666"); + assertThat(twitterConnectionProperties.getAccessTokenSecret()).isEqualTo("accessTokenSecret666"); + + DefaultPollerProperties defaultPollerProperties = context.getBean(DefaultPollerProperties.class); + assertThat(defaultPollerProperties.getFixedDelay()).isEqualTo(10000); + + TwitterSearchSupplierProperties searchSupplierProperties = + context.getBean(TwitterSearchSupplierProperties.class); + + assertThat(searchSupplierProperties.getQuery()).isEqualTo("Amsterdam"); + assertThat(searchSupplierProperties.getCount()).isEqualTo(3); + assertThat(searchSupplierProperties.getPage()).isEqualTo(3); + assertThat(searchSupplierProperties.getLang()).isEqualTo("en"); + assertThat(searchSupplierProperties.getGeocode().getLatitude()).isEqualTo(52.1D); + assertThat(searchSupplierProperties.getGeocode().getLongitude()).isEqualTo(4.8D); + assertThat(searchSupplierProperties.getGeocode().getRadius()).isEqualTo(10D); + assertThat(searchSupplierProperties.getSince()).isEqualTo("2018-01-01"); + + OutputDestination outputDestination = context.getBean(OutputDestination.class); + // Using local region here + Message message = outputDestination.receive(Duration.ofSeconds(300).toMillis()); + assertThat(message).isNotNull(); + String payload = new String(message.getPayload()); + + List tweets = new ObjectMapper().readValue(payload, List.class); + assertThat(tweets).hasSize(3); + mockClient.verify(searchAmsterdamRequest, once()); + + } + } + + + 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/search_3.json")) + .withDelay(TimeUnit.SECONDS, 1) + ); + return request; + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @Import(TwitterSearchSupplierConfiguration.class) + public static class TestTwitterSearchSourceApplication { + + @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/applications/source/twitter-search-source/src/test/java/org/springframework/cloud/stream/app/source/twitter/search/TwitterTestUtils.java b/applications/source/twitter-search-source/src/test/java/org/springframework/cloud/stream/app/source/twitter/search/TwitterTestUtils.java new file mode 100644 index 00000000..3f6c36ba --- /dev/null +++ b/applications/source/twitter-search-source/src/test/java/org/springframework/cloud/stream/app/source/twitter/search/TwitterTestUtils.java @@ -0,0 +1,68 @@ +/* + * 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.stream.app.source.twitter.search; + + +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.function.Function; + +import twitter4j.conf.ConfigurationBuilder; + +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.util.StreamUtils; + +/** + * @author Christian Tzolov + */ +public class TwitterTestUtils { + + public Function mockTwitterUrls(String baseUrl) { + return configBuilder -> { + configBuilder.setRestBaseURL(baseUrl + "/"); + configBuilder.setStreamBaseURL(baseUrl + "/stream/"); + configBuilder.setUserStreamBaseURL(baseUrl + "/user/"); + configBuilder.setSiteStreamBaseURL(baseUrl + "/site/"); + configBuilder.setUploadBaseURL(baseUrl + "/upload/"); + + configBuilder.setOAuthAccessTokenURL(baseUrl + "/oauth/access_token"); + configBuilder.setOAuthAuthenticationURL(baseUrl + "/oauth/authenticate"); + configBuilder.setOAuthAuthorizationURL(baseUrl + "/oauth/authorize"); + configBuilder.setOAuthRequestTokenURL(baseUrl + "/oauth/request_token"); + configBuilder.setOAuth2TokenURL(baseUrl + "/oauth2/token"); + configBuilder.setOAuth2InvalidateTokenURL(baseUrl + "/oauth2/invalidate_token"); + + return configBuilder; + }; + } + + /** + * Load Spring Resource as String. + * @param resourcePath Resource path (accepts file:// , classpath:// and http:// uri schemas) + * @return Returns text (UTF8) representation of the resource pointed by the resourcePath + */ + public static String asString(String resourcePath) { + try { + return StreamUtils.copyToString(new DefaultResourceLoader().getResource(resourcePath).getInputStream(), + Charset.forName("UTF-8")); + } + catch (IOException e) { + throw new RuntimeException("Can not load resource:" + resourcePath, e); + } + } + +} diff --git a/applications/source/twitter-search-source/src/test/resources/response/search_3.json b/applications/source/twitter-search-source/src/test/resources/response/search_3.json new file mode 100644 index 00000000..cd4c109d --- /dev/null +++ b/applications/source/twitter-search-source/src/test/resources/response/search_3.json @@ -0,0 +1,447 @@ +{ + "statuses": [ + { + "created_at": "Sun Jun 21 05:44:20 +0000 2020", + "id": 1274578883334078468, + "id_str": "1274578883334078468", + "text": "\u0423\u0441\u043c\u0438\u0432\u043a\u0438 \u043e\u0442 \u0441\u0442\u0430\u0440\u0438\u0442\u0435 \u0424\u0423\u0422\u0411\u041e\u041b\u041d\u0418 \u043b\u0435\u043d\u0442\u0438: \u041f\u0443\u0431\u043b\u0438\u043a\u0430\u0442\u0430 \u0432\u044a\u0432 \u0412\u0440\u0430\u0446\u0430 \u0435 \u0433\u043b\u0435\u0434\u0430\u043b\u0430 \u0438 \u0437\u0440\u0435\u043b\u0438\u0449\u0430 \u043e\u0442 \u0447\u0443\u0436\u0434\u0435\u043d\u0446\u0438 : https:\/\/t.co\/odbEXBdbGT https:\/\/t.co\/ODL3TbZdIW", + "truncated": false, + "entities": { + "hashtags": [], + "symbols": [], + "user_mentions": [], + "urls": [ + { + "url": "https:\/\/t.co\/odbEXBdbGT", + "expanded_url": "http:\/\/Konkurent.bg", + "display_url": "Konkurent.bg", + "indices": [ + 89, + 112 + ] + }, + { + "url": "https:\/\/t.co\/ODL3TbZdIW", + "expanded_url": "https:\/\/www.konkurent.bg\/news\/15913509855903\/usmivki-ot-starite-futbolni-lenti-publikata-vav-vratsa-e-gledala-i-zrelishta-ot-chuzhdentsi#.Xu7zsZ0PotM.twitter", + "display_url": "konkurent.bg\/news\/159135098\u2026", + "indices": [ + 113, + 136 + ] + } + ] + }, + "metadata": { + "iso_language_code": "bg", + "result_type": "recent" + }, + "source": "\u003ca href=\"https:\/\/mobile.twitter.com\" rel=\"nofollow\"\u003eTwitter Web App\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, + "user": { + "id": 2793798645, + "id_str": "2793798645", + "name": "konkurent.bg", + "screen_name": "KonkurentBG", + "location": "\u0421\u0435\u0432\u0435\u0440\u043e\u0437\u0430\u043f\u0430\u0434\u043d\u0430 \u0411\u044a\u043b\u0433\u0430\u0440\u0438\u044f", + "description": "\u201e\u041a\u043e\u043d\u043a\u0443\u0440\u0435\u043d\u0442\u201d \u0435 \u043d\u0430\u0439-\u0433\u043e\u043b\u0435\u043c\u0438\u044f\u0442 \u0438 \u0432\u043b\u0438\u044f\u0442\u0435\u043b\u0435\u043d \u0440\u0435\u0433\u0438\u043e\u043d\u0430\u043b\u0435\u043d \u0432\u0441\u0435\u043a\u0438\u0434\u043d\u0435\u0432\u043d\u0438\u043a \u0432 \u0421\u0435\u0432\u0435\u0440\u043e\u0437\u0430\u043f\u0430\u0434\u043d\u0430 \u0411\u044a\u043b\u0433\u0430\u0440\u0438\u044f. \u0426\u0435\u043d\u0442\u0440\u0430\u043b\u043d\u0438\u044f\u0442 \u043e\u0444\u0438\u0441 \u0438 \u0441\u0435\u0434\u0430\u043b\u0438\u0449\u0435\u0442\u043e \u043d\u0430 \u0434\u0440\u0443\u0436\u0435\u0441\u0442\u0432\u043e\u0442\u043e \u0441\u0430 \u043f\u043e\u0437\u0438\u0446\u0438\u043e\u043d\u0438\u0440\u0430\u043d\u0438 \u0432\u044a\u0432 \u0412\u0440\u0430\u0446\u0430", + "url": "http:\/\/t.co\/VMbcih28OB", + "entities": { + "url": { + "urls": [ + { + "url": "http:\/\/t.co\/VMbcih28OB", + "expanded_url": "http:\/\/konkurent.bg\/", + "display_url": "konkurent.bg", + "indices": [ + 0, + 22 + ] + } + ] + }, + "description": { + "urls": [] + } + }, + "protected": false, + "followers_count": 1522, + "friends_count": 1867, + "listed_count": 10, + "created_at": "Tue Sep 30 07:35:14 +0000 2014", + "favourites_count": 32, + "utc_offset": null, + "time_zone": null, + "geo_enabled": false, + "verified": false, + "statuses_count": 121700, + "lang": null, + "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\/608300958607671297\/vNObwz_B_normal.jpg", + "profile_image_url_https": "https:\/\/pbs.twimg.com\/profile_images\/608300958607671297\/vNObwz_B_normal.jpg", + "profile_banner_url": "https:\/\/pbs.twimg.com\/profile_banners\/2793798645\/1433865279", + "profile_link_color": "0084B4", + "profile_sidebar_border_color": "FFFFFF", + "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": null, + "follow_request_sent": null, + "notifications": null, + "translator_type": "none" + }, + "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": "bg" + }, + { + "created_at": "Sun Jun 21 04:37:39 +0000 2020", + "id": 1274562101856567297, + "id_str": "1274562101856567297", + "text": "RT @scparametro2016: \ud83c\udde7\ud83c\uddec #EfbetLeague \u2502 Grupo do Rebaixamento \u2502 Grupo B \u2502 27\u00aa Rodada\n\nBotev Plovdiv 3x2 Botev Vratsa\n\n21\/06 - 13h\nArda x Dun\u2026", + "truncated": false, + "entities": { + "hashtags": [ + { + "text": "EfbetLeague", + "indices": [ + 24, + 36 + ] + } + ], + "symbols": [], + "user_mentions": [ + { + "screen_name": "scparametro2016", + "name": "SC Par\u00e2metro (de \ud83c\udfe0) \u270a\ud83c\udffd", + "id": 1093567069201842178, + "id_str": "1093567069201842178", + "indices": [ + 3, + 19 + ] + } + ], + "urls": [] + }, + "metadata": { + "iso_language_code": "und", + "result_type": "recent" + }, + "source": "\u003ca href=\"https:\/\/mobile.twitter.com\" rel=\"nofollow\"\u003eTwitter Web App\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, + "user": { + "id": 227357431, + "id_str": "227357431", + "name": "Igor Sausmikat", + "screen_name": "igorsausmikat", + "location": "Bras\u00edlia-DF\/SP", + "description": "Ex um monte de @, tinha um blog, mas elogio, critico, sugiro sempre que necess\u00e1rio. No mais? S\u00f3 conhecendo melhor.... e ac\u00e1 estamos donde tenemos que estar!!", + "url": "https:\/\/t.co\/dyjPEcCeQl", + "entities": { + "url": { + "urls": [ + { + "url": "https:\/\/t.co\/dyjPEcCeQl", + "expanded_url": "https:\/\/www.instagram.com\/igorsausmikat\/", + "display_url": "instagram.com\/igorsausmikat\/", + "indices": [ + 0, + 23 + ] + } + ] + }, + "description": { + "urls": [] + } + }, + "protected": false, + "followers_count": 788, + "friends_count": 4989, + "listed_count": 8, + "created_at": "Thu Dec 16 16:41:42 +0000 2010", + "favourites_count": 130, + "utc_offset": null, + "time_zone": null, + "geo_enabled": true, + "verified": false, + "statuses_count": 349115, + "lang": null, + "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\/1221347767966162944\/zEpQD_Gm_normal.jpg", + "profile_image_url_https": "https:\/\/pbs.twimg.com\/profile_images\/1221347767966162944\/zEpQD_Gm_normal.jpg", + "profile_banner_url": "https:\/\/pbs.twimg.com\/profile_banners\/227357431\/1546748487", + "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": true, + "default_profile": true, + "default_profile_image": false, + "following": null, + "follow_request_sent": null, + "notifications": null, + "translator_type": "none" + }, + "geo": null, + "coordinates": null, + "place": null, + "contributors": null, + "retweeted_status": { + "created_at": "Sat Jun 20 20:29:55 +0000 2020", + "id": 1274439357991182336, + "id_str": "1274439357991182336", + "text": "\ud83c\udde7\ud83c\uddec #EfbetLeague \u2502 Grupo do Rebaixamento \u2502 Grupo B \u2502 27\u00aa Rodada\n\nBotev Plovdiv 3x2 Botev Vratsa\n\n21\/06 - 13h\nArda x\u2026 https:\/\/t.co\/7cNRbwTQTb", + "truncated": true, + "entities": { + "hashtags": [ + { + "text": "EfbetLeague", + "indices": [ + 3, + 15 + ] + } + ], + "symbols": [], + "user_mentions": [], + "urls": [ + { + "url": "https:\/\/t.co\/7cNRbwTQTb", + "expanded_url": "https:\/\/twitter.com\/i\/web\/status\/1274439357991182336", + "display_url": "twitter.com\/i\/web\/status\/1\u2026", + "indices": [ + 116, + 139 + ] + } + ] + }, + "metadata": { + "iso_language_code": "und", + "result_type": "recent" + }, + "source": "\u003ca href=\"https:\/\/mobile.twitter.com\" rel=\"nofollow\"\u003eTwitter Web App\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, + "user": { + "id": 1093567069201842178, + "id_str": "1093567069201842178", + "name": "SC Par\u00e2metro (de \ud83c\udfe0) \u270a\ud83c\udffd", + "screen_name": "scparametro2016", + "location": "Mogi das Cruzes, Brasil", + "description": "Cobertura das principais divis\u00f5es masculinas e femininas com bola rolando no mundo!", + "url": "https:\/\/t.co\/yOzONBTTCo", + "entities": { + "url": { + "urls": [ + { + "url": "https:\/\/t.co\/yOzONBTTCo", + "expanded_url": "https:\/\/www.facebook.com\/scparametro2016\/", + "display_url": "facebook.com\/scparametro201\u2026", + "indices": [ + 0, + 23 + ] + } + ] + }, + "description": { + "urls": [] + } + }, + "protected": false, + "followers_count": 3608, + "friends_count": 2336, + "listed_count": 11, + "created_at": "Thu Feb 07 17:48:01 +0000 2019", + "favourites_count": 9961, + "utc_offset": null, + "time_zone": null, + "geo_enabled": false, + "verified": false, + "statuses_count": 24226, + "lang": null, + "contributors_enabled": false, + "is_translator": false, + "is_translation_enabled": false, + "profile_background_color": "000000", + "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\/1267574634523824128\/xreXNzJZ_normal.jpg", + "profile_image_url_https": "https:\/\/pbs.twimg.com\/profile_images\/1267574634523824128\/xreXNzJZ_normal.jpg", + "profile_banner_url": "https:\/\/pbs.twimg.com\/profile_banners\/1093567069201842178\/1584658723", + "profile_link_color": "000080", + "profile_sidebar_border_color": "000000", + "profile_sidebar_fill_color": "000000", + "profile_text_color": "000000", + "profile_use_background_image": false, + "has_extended_profile": true, + "default_profile": false, + "default_profile_image": false, + "following": null, + "follow_request_sent": null, + "notifications": null, + "translator_type": "none" + }, + "geo": null, + "coordinates": null, + "place": null, + "contributors": null, + "is_quote_status": false, + "retweet_count": 2, + "favorite_count": 7, + "favorited": false, + "retweeted": false, + "possibly_sensitive": false, + "lang": "und" + }, + "is_quote_status": false, + "retweet_count": 2, + "favorite_count": 0, + "favorited": false, + "retweeted": false, + "lang": "und" + }, + { + "created_at": "Sun Jun 21 02:26:46 +0000 2020", + "id": 1274529164138287107, + "id_str": "1274529164138287107", + "text": "Campeonato B\u00falgaro - 27\u00b0 Rodada - Playoff de Rebaixamento - Grupo B\n\nBotev Plovdiv 3 x 2 Botev Vratsa\u2026 https:\/\/t.co\/TxFgoqIfGF", + "truncated": true, + "entities": { + "hashtags": [], + "symbols": [], + "user_mentions": [], + "urls": [ + { + "url": "https:\/\/t.co\/TxFgoqIfGF", + "expanded_url": "https:\/\/twitter.com\/i\/web\/status\/1274529164138287107", + "display_url": "twitter.com\/i\/web\/status\/1\u2026", + "indices": [ + 104, + 127 + ] + } + ] + }, + "metadata": { + "iso_language_code": "pt", + "result_type": "recent" + }, + "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, + "user": { + "id": 1110343004768874496, + "id_str": "1110343004768874496", + "name": "Loucos por futebol nacional e Internacional \u26bd", + "screen_name": "EsportePor", + "location": "Rio de Janeiro, Brasil", + "description": "Canal sobre futebol mundial, not\u00edcias, resultados, classifica\u00e7\u00e3o e goleadores.", + "url": null, + "entities": { + "description": { + "urls": [] + } + }, + "protected": false, + "followers_count": 664, + "friends_count": 891, + "listed_count": 4, + "created_at": "Tue Mar 26 00:49:36 +0000 2019", + "favourites_count": 2990, + "utc_offset": null, + "time_zone": null, + "geo_enabled": false, + "verified": false, + "statuses_count": 7437, + "lang": null, + "contributors_enabled": false, + "is_translator": false, + "is_translation_enabled": false, + "profile_background_color": "000000", + "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\/1110343459523710981\/mfUCJj_N_normal.jpg", + "profile_image_url_https": "https:\/\/pbs.twimg.com\/profile_images\/1110343459523710981\/mfUCJj_N_normal.jpg", + "profile_banner_url": "https:\/\/pbs.twimg.com\/profile_banners\/1110343004768874496\/1553561950", + "profile_link_color": "1B95E0", + "profile_sidebar_border_color": "000000", + "profile_sidebar_fill_color": "000000", + "profile_text_color": "000000", + "profile_use_background_image": false, + "has_extended_profile": false, + "default_profile": false, + "default_profile_image": false, + "following": null, + "follow_request_sent": null, + "notifications": null, + "translator_type": "none" + }, + "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": "pt" + } + ], + "search_metadata": { + "completed_in": 0.028, + "max_id": 1274578883334078468, + "max_id_str": "1274578883334078468", + "next_results": "?max_id=1274529164138287106&q=Vratsa&count=3&include_entities=1&result_type=mixed", + "query": "Vratsa", + "refresh_url": "?since_id=1274578883334078468&q=Vratsa&result_type=mixed&include_entities=1", + "count": 3, + "since_id": 0, + "since_id_str": "0" + } +} diff --git a/applications/source/twitter-search-source/src/test/resources/response/update_test_1.json b/applications/source/twitter-search-source/src/test/resources/response/update_test_1.json new file mode 100644 index 00000000..05512875 --- /dev/null +++ b/applications/source/twitter-search-source/src/test/resources/response/update_test_1.json @@ -0,0 +1 @@ +{"created_at":"Thu Dec 20 13:56:10 +0000 2018","id":1075751718749659136,"id_str":"1075751718749659136","text":"Codementor: Kubernetes for Python Developers: Part 1\n#100daysofcode #python https:\/\/t.co\/VviTgpFpge","source":"\u003ca href=\"https:\/\/ifttt.com\" rel=\"nofollow\"\u003eIFTTT\u003c\/a\u003e","truncated":false,"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,"user":{"id":859252650512072704,"id_str":"859252650512072704","name":"Freelancing|WebDev","screen_name":"FreelanceForBTC","location":"THE NET","url":"http:\/\/bit.ly\/BTCFREELANCING","description":"This twitter is designed to give the best information from web development to Blockchain.\n|YouTube's |Jobs |learning contented","translator_type":"none","protected":false,"verified":false,"followers_count":146,"friends_count":118,"listed_count":4,"favourites_count":445,"statuses_count":7528,"created_at":"Tue May 02 03:46:10 +0000 2017","utc_offset":null,"time_zone":null,"geo_enabled":false,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"F5F8FA","profile_background_image_url":"","profile_background_image_url_https":"","profile_background_tile":false,"profile_link_color":"1DA1F2","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"profile_image_url":"http:\/\/pbs.twimg.com\/profile_images\/1071607798771793920\/UegrCs84_normal.jpg","profile_image_url_https":"https:\/\/pbs.twimg.com\/profile_images\/1071607798771793920\/UegrCs84_normal.jpg","profile_banner_url":"https:\/\/pbs.twimg.com\/profile_banners\/859252650512072704\/1544326203","default_profile":true,"default_profile_image":false,"following":null,"follow_request_sent":null,"notifications":null},"geo":null,"coordinates":null,"place":null,"contributors":null,"is_quote_status":false,"quote_count":0,"reply_count":0,"retweet_count":0,"favorite_count":0,"entities":{"hashtags":[{"text":"100daysofcode","indices":[53,67]},{"text":"python","indices":[68,75]}],"urls":[{"url":"https:\/\/t.co\/VviTgpFpge","expanded_url":"https:\/\/ift.tt\/2GxdMZF","display_url":"ift.tt\/2GxdMZF","indices":[76,99]}],"user_mentions":[],"symbols":[]},"favorited":false,"retweeted":false,"possibly_sensitive":false,"filter_level":"low","lang":"ca","timestamp_ms":"1545314170907"} diff --git a/applications/source/twitter-stream-source/README.adoc b/applications/source/twitter-stream-source/README.adoc new file mode 100644 index 00000000..72a74510 --- /dev/null +++ b/applications/source/twitter-stream-source/README.adoc @@ -0,0 +1,43 @@ +//tag::ref-doc[] += Twitter Stream Source + +Real-time Tweet streaming https://developer.twitter.com/en/docs/tweets/filter-realtime/api-reference/post-statuses-filter.html[Filter] and https://developer.twitter.com/en/docs/tweets/sample-realtime/overview/GET_statuse_sample[Sample] APIs support. + +* The `Filter API` returns public statuses that match one or more filter predicates. +Multiple parameters allows using a single connection to the Streaming API. +TIP: The `track`, `follow`, and `locations` fields are combined with an *OR* operator! +Queries with `track=foo` and `follow=1234` returns Tweets matching `foo` *OR* created by user `1234`. + +* The `Sample API` returns a small random sample of all public statuses. +The Tweets returned by the default access level are the same, so if two different clients connect to this endpoint, they will see the same Tweets. + +The default access level allows up to 400 track keywords, 5,000 follow user Ids and 25 0.1-360 degree location boxes. + +== Options + +//tag::configuration-properties[] +$$twitter.connection.access-token$$:: $$Your Twitter token.$$ *($$String$$, default: `$$$$`)* +$$twitter.connection.access-token-secret$$:: $$Your Twitter token secret.$$ *($$String$$, default: `$$$$`)* +$$twitter.connection.consumer-key$$:: $$Your Twitter key.$$ *($$String$$, default: `$$$$`)* +$$twitter.connection.consumer-secret$$:: $$Your Twitter secret.$$ *($$String$$, default: `$$$$`)* +$$twitter.connection.debug-enabled$$:: $$Enables Twitter4J debug mode.$$ *($$Boolean$$, default: `$$false$$`)* +$$twitter.connection.raw-json$$:: $$Enable caching the original (raw) JSON objects as returned by the Twitter APIs. When set to False the result will use the Twitter4J's json representations. When set to True the result will use the original Twitter APISs json representations.$$ *($$Boolean$$, default: `$$true$$`)* +$$twitter.stream.filter.count$$:: $$Indicates the number of previous statuses to stream before transitioning to the live stream.$$ *($$Integer$$, default: `$$0$$`)* +$$twitter.stream.filter.filter-level$$:: $$The filter level limits what tweets appear in the stream to those with a minimum filterLevel attribute value. One of either none, low, or medium.$$ *($$FilterLevel$$, default: `$$$$`)* +$$twitter.stream.filter.follow$$:: $$Specifies the users, by ID, to receive public tweets from.$$ *($$List$$, default: `$$$$`)* +$$twitter.stream.filter.language$$:: $$Specifies the tweets language of the stream.$$ *($$List$$, default: `$$$$`)* +$$twitter.stream.filter.locations$$:: $$Locations to track. Internally represented as 2D array. Bounding box is invalid: 52.38, 4.90, 51.51, -0.12. The first pair must be the SW corner of the box$$ *($$List$$, default: `$$$$`)* +$$twitter.stream.filter.track$$:: $$Specifies keywords to track.$$ *($$List$$, default: `$$$$`)* +$$twitter.stream.type$$:: $$$$ *($$StreamType$$, default: `$$$$`, possible values: `sample`,`filter`,`firehose`,`link`)* +//end::configuration-properties[] + +//end::ref-doc[] + + +== Examples + +``` +java -jar twitter-stream-source.jar --twitter.connection.accessTokenSecret=xxx --twitter.connection.accessToken=xxx --twitter.connection.consumerKey=xxx --twitter.connection.consumerSecret=xxx +``` + + diff --git a/applications/source/twitter-stream-source/pom.xml b/applications/source/twitter-stream-source/pom.xml new file mode 100644 index 00000000..80585942 --- /dev/null +++ b/applications/source/twitter-stream-source/pom.xml @@ -0,0 +1,93 @@ + + + 4.0.0 + twitter-stream-source + 3.0.0-SNAPSHOT + twitter-stream-source + twitter stream source apps + jar + + + org.springframework.cloud.stream.app + stream-applications-core + 3.0.0-SNAPSHOT + + + + + + org.springframework.cloud.fn + twitter-supplier + ${java-functions.version} + + + org.springframework.cloud.fn + function-test-support + ${java-functions.version} + test + + + + org.mock-server + mockserver-netty + 5.10 + test + + + org.mock-server + mockserver-client-java + 5.10 + test + + + + + + + org.springframework.cloud + spring-cloud-app-starter-doc-maven-plugin + + + org.springframework.cloud.stream.app.plugin + spring-cloud-stream-app-maven-plugin + + + twitter-stream + source + ${project.version} + org.springframework.cloud.fn.supplier.twitter.status.stream.TwitterStreamSupplierConfiguration.class + twitterStreamSupplier + + + + org.springframework.cloud.fn + twitter-supplier + ${java-functions.version} + + + + + + + + + + + true + + spring-snapshots + Spring Snapshots + https://repo.spring.io/libs-snapshot-local + + + + false + + spring-milestones + Spring Milestones + https://repo.spring.io/libs-milestone-local + + + + diff --git a/applications/source/twitter-stream-source/src/main/resources/META-INF/dataflow-configuration-metadata-whitelist.properties b/applications/source/twitter-stream-source/src/main/resources/META-INF/dataflow-configuration-metadata-whitelist.properties new file mode 100644 index 00000000..1a8234ae --- /dev/null +++ b/applications/source/twitter-stream-source/src/main/resources/META-INF/dataflow-configuration-metadata-whitelist.properties @@ -0,0 +1,5 @@ +configuration-properties.classes=org.springframework.cloud.fn.common.twitter.TwitterConnectionProperties,\ + org.springframework.cloud.fn.supplier.twitter.status.stream.TwitterStreamSupplierProperties, \ + org.springframework.cloud.fn.supplier.twitter.status.stream.TwitterStreamSupplierProperties$Filter, \ + org.springframework.cloud.fn.supplier.twitter.status.stream.TwitterStreamSupplierProperties$Filter$BoundingBox, \ + org.springframework.cloud.fn.supplier.twitter.status.stream.TwitterStreamSupplierProperties$Filter$Geocode diff --git a/applications/source/twitter-stream-source/src/test/java/org/springframework/cloud/stream/app/source/twitter/stream/TwitterStreamSourceTests.java b/applications/source/twitter-stream-source/src/test/java/org/springframework/cloud/stream/app/source/twitter/stream/TwitterStreamSourceTests.java new file mode 100644 index 00000000..33186aba --- /dev/null +++ b/applications/source/twitter-stream-source/src/test/java/org/springframework/cloud/stream/app/source/twitter/stream/TwitterStreamSourceTests.java @@ -0,0 +1,164 @@ +/* + * 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.stream.app.source.twitter.stream; + +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.Disabled; +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 org.mockserver.model.StringBody; +import twitter4j.conf.ConfigurationBuilder; + +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.cloud.fn.common.twitter.TwitterConnectionProperties; +import org.springframework.cloud.fn.supplier.twitter.status.stream.TwitterStreamSupplierConfiguration; +import org.springframework.cloud.fn.supplier.twitter.status.stream.TwitterStreamSupplierProperties; +import org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; + +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; + +public class TwitterStreamSourceTests { + + private static final String MOCK_SERVER_IP = "127.0.0.1"; + + private static final Integer MOCK_SERVER_PORT = 1080; + + private static ClientAndServer mockServer; + + private static MockServerClient mockClient; + private static HttpRequest streamFilterRequest; + private static HttpRequest streamSampleRequest; + private static HttpRequest streamFirehoseRequest; + private static HttpRequest streamLinknsRequest; + + @BeforeAll + public static void startServer() { + + mockServer = ClientAndServer.startClientAndServer(MOCK_SERVER_PORT); + + mockClient = new MockServerClient(MOCK_SERVER_IP, MOCK_SERVER_PORT); + + streamFilterRequest = mockClientRecordRequest(request() + .withMethod("POST") + .withPath("/stream/statuses/filter.json") + .withBody(new StringBody("count=0&track=Java%2CPython&stall_warnings=true"))); + + streamSampleRequest = mockClientRecordRequest(request() + .withMethod("GET") + .withPath("/stream/statuses/sample.json")); + + streamLinknsRequest = mockClientRecordRequest(request() + .withMethod("GET") + .withPath("/stream/statuses/links.json")); + //.withBody(new StringBody("count=0&stall_warnings=true"))); + + streamFirehoseRequest = mockClientRecordRequest(request() + .withMethod("POST") + .withPath("/stream/statuses/firehose.json") + .withBody(new StringBody("count=0&stall_warnings=true"))); + } + + @AfterAll + public static void stopServer() { + mockServer.stop(); + } + + @Test + @Disabled + public void testSourceFromSupplier() { + try (ConfigurableApplicationContext context = new SpringApplicationBuilder( + TestChannelBinderConfiguration.getCompleteConfiguration(TestTwitterStreamSourceApplication.class)) + .web(WebApplicationType.NONE) + .run("--spring.cloud.stream.function.definition=twitterStreamSupplier", + + "--twitter.connection.consumerKey=consumerKey666", + "--twitter.connection.consumerSecret=consumerSecret666", + "--twitter.connection.accessToken=accessToken666", + "--twitter.connection.accessTokenSecret=accessTokenSecret666", + + "--twitter.stream.type=filter", + "--twitter.stream.filter.track=Java,Python", + "--twitter.stream.filter.count=3")) { + + TwitterConnectionProperties twitterConnectionProperties = context.getBean(TwitterConnectionProperties.class); + assertThat(twitterConnectionProperties.getConsumerKey()).isEqualTo("consumerKey666"); + assertThat(twitterConnectionProperties.getConsumerSecret()).isEqualTo("consumerSecret666"); + assertThat(twitterConnectionProperties.getAccessToken()).isEqualTo("accessToken666"); + assertThat(twitterConnectionProperties.getAccessTokenSecret()).isEqualTo("accessTokenSecret666"); + + TwitterStreamSupplierProperties twitterStreamSupplierProperties = + context.getBean(TwitterStreamSupplierProperties.class); + assertThat(twitterStreamSupplierProperties.getType()).isEqualTo(TwitterStreamSupplierProperties.StreamType.filter); + assertThat(twitterStreamSupplierProperties.getFilter().getTrack()).contains("Java", "Python"); + + //OutputDestination target = context.getBean(OutputDestination.class); + //Message sourceMessage = target.receive(10000); + //final String actual = new String(sourceMessage.getPayload()); + + mockClient.verify(streamFilterRequest, once()); + } + } + + private static HttpRequest mockClientRecordRequest(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/stream_test_1.json")) + .withDelay(TimeUnit.SECONDS, 10)); + return request; + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @Import(TwitterStreamSupplierConfiguration.class) + public static class TestTwitterStreamSourceApplication { + + @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/applications/source/twitter-stream-source/src/test/java/org/springframework/cloud/stream/app/source/twitter/stream/TwitterTestUtils.java b/applications/source/twitter-stream-source/src/test/java/org/springframework/cloud/stream/app/source/twitter/stream/TwitterTestUtils.java new file mode 100644 index 00000000..dbf96b3c --- /dev/null +++ b/applications/source/twitter-stream-source/src/test/java/org/springframework/cloud/stream/app/source/twitter/stream/TwitterTestUtils.java @@ -0,0 +1,68 @@ +/* + * 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.stream.app.source.twitter.stream; + + +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.function.Function; + +import twitter4j.conf.ConfigurationBuilder; + +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.util.StreamUtils; + +/** + * @author Christian Tzolov + */ +public class TwitterTestUtils { + + public Function mockTwitterUrls(String baseUrl) { + return configBuilder -> { + configBuilder.setRestBaseURL(baseUrl + "/"); + configBuilder.setStreamBaseURL(baseUrl + "/stream/"); + configBuilder.setUserStreamBaseURL(baseUrl + "/user/"); + configBuilder.setSiteStreamBaseURL(baseUrl + "/site/"); + configBuilder.setUploadBaseURL(baseUrl + "/upload/"); + + configBuilder.setOAuthAccessTokenURL(baseUrl + "/oauth/access_token"); + configBuilder.setOAuthAuthenticationURL(baseUrl + "/oauth/authenticate"); + configBuilder.setOAuthAuthorizationURL(baseUrl + "/oauth/authorize"); + configBuilder.setOAuthRequestTokenURL(baseUrl + "/oauth/request_token"); + configBuilder.setOAuth2TokenURL(baseUrl + "/oauth2/token"); + configBuilder.setOAuth2InvalidateTokenURL(baseUrl + "/oauth2/invalidate_token"); + + return configBuilder; + }; + } + + /** + * Load Spring Resource as String. + * @param resourcePath Resource path (accepts file:// , classpath:// and http:// uri schemas) + * @return Returns text (UTF8) representation of the resource pointed by the resourcePath + */ + public static String asString(String resourcePath) { + try { + return StreamUtils.copyToString(new DefaultResourceLoader().getResource(resourcePath).getInputStream(), + Charset.forName("UTF-8")); + } + catch (IOException e) { + throw new RuntimeException("Can not load resource:" + resourcePath, e); + } + } + +} diff --git a/applications/source/twitter-stream-source/src/test/resources/response/stream_test_1.json b/applications/source/twitter-stream-source/src/test/resources/response/stream_test_1.json new file mode 100644 index 00000000..05512875 --- /dev/null +++ b/applications/source/twitter-stream-source/src/test/resources/response/stream_test_1.json @@ -0,0 +1 @@ +{"created_at":"Thu Dec 20 13:56:10 +0000 2018","id":1075751718749659136,"id_str":"1075751718749659136","text":"Codementor: Kubernetes for Python Developers: Part 1\n#100daysofcode #python https:\/\/t.co\/VviTgpFpge","source":"\u003ca href=\"https:\/\/ifttt.com\" rel=\"nofollow\"\u003eIFTTT\u003c\/a\u003e","truncated":false,"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,"user":{"id":859252650512072704,"id_str":"859252650512072704","name":"Freelancing|WebDev","screen_name":"FreelanceForBTC","location":"THE NET","url":"http:\/\/bit.ly\/BTCFREELANCING","description":"This twitter is designed to give the best information from web development to Blockchain.\n|YouTube's |Jobs |learning contented","translator_type":"none","protected":false,"verified":false,"followers_count":146,"friends_count":118,"listed_count":4,"favourites_count":445,"statuses_count":7528,"created_at":"Tue May 02 03:46:10 +0000 2017","utc_offset":null,"time_zone":null,"geo_enabled":false,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"F5F8FA","profile_background_image_url":"","profile_background_image_url_https":"","profile_background_tile":false,"profile_link_color":"1DA1F2","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"profile_image_url":"http:\/\/pbs.twimg.com\/profile_images\/1071607798771793920\/UegrCs84_normal.jpg","profile_image_url_https":"https:\/\/pbs.twimg.com\/profile_images\/1071607798771793920\/UegrCs84_normal.jpg","profile_banner_url":"https:\/\/pbs.twimg.com\/profile_banners\/859252650512072704\/1544326203","default_profile":true,"default_profile_image":false,"following":null,"follow_request_sent":null,"notifications":null},"geo":null,"coordinates":null,"place":null,"contributors":null,"is_quote_status":false,"quote_count":0,"reply_count":0,"retweet_count":0,"favorite_count":0,"entities":{"hashtags":[{"text":"100daysofcode","indices":[53,67]},{"text":"python","indices":[68,75]}],"urls":[{"url":"https:\/\/t.co\/VviTgpFpge","expanded_url":"https:\/\/ift.tt\/2GxdMZF","display_url":"ift.tt\/2GxdMZF","indices":[76,99]}],"user_mentions":[],"symbols":[]},"favorited":false,"retweeted":false,"possibly_sensitive":false,"filter_level":"low","lang":"ca","timestamp_ms":"1545314170907"} diff --git a/applications/stream-applications-core/pom.xml b/applications/stream-applications-core/pom.xml index 5e87ac5d..5167ab13 100644 --- a/applications/stream-applications-core/pom.xml +++ b/applications/stream-applications-core/pom.xml @@ -50,6 +50,7 @@ 3.0.0-SNAPSHOT 3.1.1 + 5.10 @@ -69,6 +70,16 @@ pom import + + org.mock-server + mockserver-netty + ${mockserver.version} + + + org.mock-server + mockserver-client-java + ${mockserver.version} + diff --git a/functions/common/twitter-common/pom.xml b/functions/common/twitter-common/pom.xml new file mode 100644 index 00000000..d9d2a5fd --- /dev/null +++ b/functions/common/twitter-common/pom.xml @@ -0,0 +1,56 @@ + + + 4.0.0 + twitter-common + 1.0.0-SNAPSHOT + twitter-common + Twitter common + + + org.springframework.cloud.fn + spring-functions-parent + 1.0.0-SNAPSHOT + ../../spring-functions-parent + + + + 4.0.7 + + + + + org.twitter4j + twitter4j-stream + ${twitter4j.version} + + + + org.springframework.boot + spring-boot-starter-json + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springframework.integration + spring-integration-ip + + + org.springframework.boot + spring-boot-starter-integration + + + org.springframework.boot + spring-boot-configuration-processor + provided + + + org.springframework.boot + spring-boot-starter-test + test + + + + diff --git a/functions/common/twitter-common/src/main/java/org/springframework/cloud/fn/common/twitter/Cursor.java b/functions/common/twitter-common/src/main/java/org/springframework/cloud/fn/common/twitter/Cursor.java new file mode 100644 index 00000000..ba6de357 --- /dev/null +++ b/functions/common/twitter-common/src/main/java/org/springframework/cloud/fn/common/twitter/Cursor.java @@ -0,0 +1,37 @@ +/* + * Copyright 2015-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; + +/** + * @author Christian Tzolov + */ +public class Cursor { + private long cursor = -1; + + public long getCursor() { + return cursor; + } + + public void updateCursor(long newCursor) { + this.cursor = (newCursor > 0) ? newCursor : -1; + } + + @Override + public String toString() { + return "Cursor{cursor=" + cursor + '}'; + } +} diff --git a/functions/common/twitter-common/src/main/java/org/springframework/cloud/fn/common/twitter/OnMissingStreamFunctionDefinitionCondition.java b/functions/common/twitter-common/src/main/java/org/springframework/cloud/fn/common/twitter/OnMissingStreamFunctionDefinitionCondition.java new file mode 100644 index 00000000..55565bdd --- /dev/null +++ b/functions/common/twitter-common/src/main/java/org/springframework/cloud/fn/common/twitter/OnMissingStreamFunctionDefinitionCondition.java @@ -0,0 +1,34 @@ +/* + * Copyright 2015-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 org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.NoneNestedConditions; + +/** + * @author Christian Tzolov + */ +public class OnMissingStreamFunctionDefinitionCondition extends NoneNestedConditions { + + public OnMissingStreamFunctionDefinitionCondition() { + super(ConfigurationPhase.REGISTER_BEAN); + } + + @ConditionalOnProperty(name = "spring.cloud.stream.function.definition") + static class OnFunctionDslProperty { + } +} 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 new file mode 100644 index 00000000..057204d2 --- /dev/null +++ b/functions/common/twitter-common/src/main/java/org/springframework/cloud/fn/common/twitter/TwitterConnectionConfiguration.java @@ -0,0 +1,130 @@ +/* + * Copyright 2015-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.ArrayList; +import java.util.List; +import java.util.function.Function; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import twitter4j.Twitter; +import twitter4j.TwitterFactory; +import twitter4j.TwitterObjectFactory; +import twitter4j.TwitterStream; +import twitter4j.TwitterStreamFactory; +import twitter4j.conf.ConfigurationBuilder; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.util.MimeTypeUtils; + +//import org.springframework.cloud.stream.config.BindingProperties; +// import org.springframework.integration.support.MutableMessage; + +/** + * + * @author Christian Tzolov + */ +@Configuration +@EnableConfigurationProperties({ TwitterConnectionProperties.class }) +public class TwitterConnectionConfiguration { + + private static final Log logger = LogFactory.getLog(TwitterConnectionConfiguration.class); + + @Bean + public twitter4j.conf.Configuration twitterConfiguration(TwitterConnectionProperties properties, + Function toConfigurationBuilder) { + return toConfigurationBuilder.apply(properties).build(); + } + + @Bean + public Twitter twitter(twitter4j.conf.Configuration configuration) { + return new TwitterFactory(configuration).getInstance(); + } + + @Bean + public TwitterStream twitterStream(twitter4j.conf.Configuration configuration) { + return new TwitterStreamFactory(configuration).getInstance(); + } + + @Bean + public Function toConfigurationBuilder() { + return properties -> new ConfigurationBuilder() + .setJSONStoreEnabled(properties.isRawJson()) + .setDebugEnabled(properties.isDebugEnabled()) + .setOAuthConsumerKey(properties.getConsumerKey()) + .setOAuthConsumerSecret(properties.getConsumerSecret()) + .setOAuthAccessToken(properties.getAccessToken()) + .setOAuthAccessTokenSecret(properties.getAccessTokenSecret()); + } + + @Bean + public Function> json(ObjectMapper mapper) { + return objects -> { + try { + String json = mapper.writeValueAsString(objects); + + return MessageBuilder + .withPayload(json.getBytes()) + .setHeader(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.APPLICATION_JSON_VALUE) + .build(); + } + catch (JsonProcessingException e) { + logger.error("Status to JSON conversion error!", e); + } + return null; + }; + } + + /** + * Retrieves the raw JSON form of the provided object. + * + * Note that raw JSON forms can be retrieved only from the same thread invoked the last method + * call and will become inaccessible once another method call. + * + * @return Function that can retrieve the raw JSON object from the objects returned by the Twitter4J's APIs. + */ + @Bean + public Function rawJsonExtractor() { + return response -> { + if (response instanceof List) { + List responses = (List) response; + List rawJsonList = new ArrayList<>(); + for (Object object : responses) { + rawJsonList.add(TwitterObjectFactory.getRawJSON(object)); + } + return rawJsonList; + } + else { + return TwitterObjectFactory.getRawJSON(response); + } + }; + } + + @Bean + public Function> managedJson(TwitterConnectionProperties properties, + Function rawJsonExtractor, Function> json) { + return list -> (properties.isRawJson()) ? rawJsonExtractor.andThen(json).apply(list) : json.apply(list); + } +} diff --git a/functions/common/twitter-common/src/main/java/org/springframework/cloud/fn/common/twitter/TwitterConnectionProperties.java b/functions/common/twitter-common/src/main/java/org/springframework/cloud/fn/common/twitter/TwitterConnectionProperties.java new file mode 100644 index 00000000..a340277c --- /dev/null +++ b/functions/common/twitter-common/src/main/java/org/springframework/cloud/fn/common/twitter/TwitterConnectionProperties.java @@ -0,0 +1,115 @@ +/* + * Copyright 2015-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 javax.validation.constraints.NotEmpty; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + + +/** + * @author Christian Tzolov + */ +@ConfigurationProperties("twitter.connection") +@Validated +public class TwitterConnectionProperties { + + /** + * Your Twitter key. + */ + @NotEmpty + private String consumerKey; + + /** + * Your Twitter secret. + */ + @NotEmpty + private String consumerSecret; + + /** + * Your Twitter token. + */ + @NotEmpty + private String accessToken; + + /** + * Your Twitter token secret. + */ + @NotEmpty + private String accessTokenSecret; + + /** + * Enables Twitter4J debug mode. + */ + private boolean debugEnabled = false; + + /** + * Enable caching the original (raw) JSON objects as returned by the Twitter APIs. + * When set to False the result will use the Twitter4J's json representations. + * When set to True the result will use the original Twitter APISs json representations. + */ + private boolean rawJson = true; + + public String getConsumerKey() { + return consumerKey; + } + + public void setConsumerKey(String consumerKey) { + this.consumerKey = consumerKey; + } + + public String getConsumerSecret() { + return consumerSecret; + } + + public void setConsumerSecret(String consumerSecret) { + this.consumerSecret = consumerSecret; + } + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } + + public String getAccessTokenSecret() { + return accessTokenSecret; + } + + public void setAccessTokenSecret(String accessTokenSecret) { + this.accessTokenSecret = accessTokenSecret; + } + + public boolean isDebugEnabled() { + return debugEnabled; + } + + public void setDebugEnabled(boolean debugEnabled) { + this.debugEnabled = debugEnabled; + } + + public boolean isRawJson() { + return this.rawJson; + } + + public void setRawJson(boolean rawJson) { + this.rawJson = rawJson; + } +} diff --git a/functions/consumer/twitter-consumer/README.adoc b/functions/consumer/twitter-consumer/README.adoc new file mode 100644 index 00000000..2e95ceac --- /dev/null +++ b/functions/consumer/twitter-consumer/README.adoc @@ -0,0 +1,110 @@ +# Twitter Consumers + + +## 1. Twitter Status Update Consumer. + +Updates the authenticating user's current text (e.g Tweeting). + +NOTE: For each update attempt, the update text is compared with the authenticating user's recent Tweets. +Any attempt that would result in duplication will be blocked, resulting in a 403 error. +A user cannot submit the same text twice in a row. + +While not rate limited by the API, a user is limited in the number of Tweets they can create at a time. +The update limit for standard API is 300 in 3 hours windows. +If the number of updates posted by the user reaches the current allowed limit this method will return an HTTP 403 error. + +You can find details for the Update API here: https://developer.twitter.com/en/docs/tweets/post-and-engage/api-reference/post-statuses-update + + +### 1.1 Beans for injection + +You can import `TwitterUpdateConsumerConfiguration` in the application and then inject the following beans. + +- `Consumer updateStatus` - if you have an `StatusUpdate` instance you can use the `updateStatus` to apply it. + +- `Function, StatusUpdate> toStatusUpdateQuery` - function that converts a `Message` text into a `StatusUpdate` instance using the `TwitterUpdateConsumerProperties` properties. + +- `Consumer> twitterStatusUpdateConsumer` - composes `toStatusUpdateQuery` and `updateStatus` to update the twitter status from Message text. + +Note: the Message content is expected to be in text format. Consider using the `byteArrayTextToString` utility `Function`. + +You can use `twitterStatusUpdateConsumer` as a qualifier when injecting. + +### 1.2 Configuration Options + +All configuration properties are prefixed with `twitter.update`. + +For more information on the various options available, please see link:src/main/java/org/springframework/cloud/fn/consumer/twitter/status/update/TwitterUpdateConsumerProperties.java[TwitterUpdateConsumerProperties]. + +The twitter function makes uses of link:../spel-function/README.adoc[SpEL function]. + +### 1.3 Other usage + +See this https://github.com/spring-cloud/stream-applications/blob/master/applications/sink/twitter-update-sink/README.adoc[README] where this consumer is used to create a Spring Cloud Stream application where it makes a Twitter Update sink. + +## 2. Twitter Message Consumer. + +Send Direct Messages to a specified user from the authenticating user. +Requires a JSON POST body and `Content-Type` header to be set to `application/json`. + +NOTE: When a message is received from a user you may send up to 5 messages in response within a 24 hour window. +Each message received resets the 24 hour window and the 5 allotted messages. +Sending a 6th message within a 24 hour window or sending a message outside of a 24 hour window will count towards rate-limiting. +This behavior only applies when using the POST direct_messages/events/new endpoint. + +SpEL expressions are used to compute the request parameters from the input message. + +### 2.1 Beans for injection + +You can import `TwitterMessageConsumerConfiguration` in the application and then inject the following bean. + +- `Consumer> sendDirectMessageConsumer` + +Note: the Message content is expected to be in text format. Consider using the `byteArrayTextToString` utility `Function`. + +You can use `twitterStatusUpdateConsumer` as a qualifier when injecting. + +### 2.2 Configuration Options + +All configuration properties are prefixed with `twitter.message.update`. + +For more information on the various options available, please see link:src/main/java/org/springframework/cloud/fn/consumer/twitter/message/TwitterMessageConsumerProperties.java[TwitterMessageConsumerProperties]. + +The twitter function makes uses of link:../spel-function/README.adoc[SpEL function]. + +### 2.3 Other usage + +See this https://github.com/spring-cloud/stream-applications/blob/master/applications/sink/twitter-message-sink/README.adoc[README] where this consumer is used to create a Spring Cloud Stream application where it makes a Twitter Message sink. + +## 3. Twitter Friendship Consumer. + +Allows creating `follow`, `unfollow` and `update` relationships with specified `userId` or `screenName`. +The `twitter.friendships.sink.type` property allows to select the desired friendship operation. + +* https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/post-friendships-create[Friendships Create API] - Allows the authenticating user to follow (friend) the user specified in the ID parameter. +Actions taken in this method are asynchronous. +Changes will be eventually consistent. +* https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/post-friendships-update[Friendships Update API] - Enable or disable Retweets and device notifications from the specified user. +* https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/post-friendships-destroy[Friendships Destroy API] - Allows the authenticating user to unfollow the user specified in the ID parameter. + +SpEL expressions are used to compute the request parameters from the input message. +Every operation type has its own parameters. + +### 3.1 Beans for injection + +You can import `TwitterFriendshipsConsumerConfiguration` in the application and then inject the following bean. + +- `Consumer> friendshipConsumer` + +Note: the Message content is expected to be in text format. Consider using the `byteArrayTextToString` utility `Function`. + +You can use `friendshipConsumer` as a qualifier when injecting. + +### 3.2 Configuration Options + +All configuration properties are prefixed with `twitter.friendships.update`. + +For more information on the various options available, please see link:src/main/java/org/springframework/cloud/fn/consumer/twitter/friendship/TwitterFriendshipsConsumerProperties.java[TwitterFriendshipsConsumerProperties]. + +The twitter function makes uses of link:../spel-function/README.adoc[SpEL function]. + diff --git a/functions/consumer/twitter-consumer/pom.xml b/functions/consumer/twitter-consumer/pom.xml new file mode 100644 index 00000000..678c5420 --- /dev/null +++ b/functions/consumer/twitter-consumer/pom.xml @@ -0,0 +1,57 @@ + + + 4.0.0 + twitter-consumer + 1.0.0-SNAPSHOT + twitter-consumer + twitter consumer + + + org.springframework.cloud.fn + spring-functions-parent + 1.0.0-SNAPSHOT + ../../spring-functions-parent + + + + + org.springframework.cloud.fn + twitter-common + ${project.version} + + + org.springframework.cloud.fn + payload-converter-function + ${project.version} + + + org.springframework.boot + spring-boot-starter-integration + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-configuration-processor + provided + + + org.springframework.boot + spring-boot-starter-test + test + + + io.projectreactor + reactor-test + test + + + org.springframework.integration + spring-integration-test + test + + + + diff --git a/functions/consumer/twitter-consumer/src/main/java/org/springframework/cloud/fn/consumer/twitter/friendship/TwitterFriendshipsConsumerConfiguration.java b/functions/consumer/twitter-consumer/src/main/java/org/springframework/cloud/fn/consumer/twitter/friendship/TwitterFriendshipsConsumerConfiguration.java new file mode 100644 index 00000000..ae55a818 --- /dev/null +++ b/functions/consumer/twitter-consumer/src/main/java/org/springframework/cloud/fn/consumer/twitter/friendship/TwitterFriendshipsConsumerConfiguration.java @@ -0,0 +1,96 @@ +/* + * 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.consumer.twitter.friendship; + +import java.util.function.Consumer; + +import twitter4j.Twitter; +import twitter4j.TwitterException; + +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(TwitterFriendshipsConsumerProperties.class) +@Import(TwitterConnectionConfiguration.class) +public class TwitterFriendshipsConsumerConfiguration { + + @Bean + @SuppressWarnings("Duplicates") + public Consumer> friendshipConsumer(TwitterFriendshipsConsumerProperties properties, Twitter twitter) { + + return message -> { + try { + TwitterFriendshipsConsumerProperties.OperationType type = + properties.getType().getValue(message, TwitterFriendshipsConsumerProperties.OperationType.class); + //TwitterFriendshipsSinkProperties.OperationType type = TwitterFriendshipsSinkProperties.OperationType.create; + if (properties.getUserId() != null) { + Long userId = properties.getUserId().getValue(message, long.class); + switch (type) { + case create: + boolean follow = properties.getCreate().getFollow().getValue(message, boolean.class); + twitter.createFriendship(userId, follow); + return; + + case update: + boolean enableDeviceNotification = properties.getUpdate().getDevice().getValue(message, boolean.class); + boolean retweets = properties.getUpdate().getRetweets().getValue(message, boolean.class); + twitter.updateFriendship(userId, enableDeviceNotification, retweets); + return; + + case destroy: + twitter.destroyFriendship(userId); + return; + } + } + else if (properties.getScreenName() != null) { + String screenName = properties.getScreenName().getValue(message, String.class); + switch (type) { + case create: + boolean follow = properties.getCreate().getFollow().getValue(message, boolean.class); + twitter.createFriendship(screenName, follow); + return; + + case update: + boolean enableDeviceNotification = properties.getUpdate().getDevice().getValue(message, boolean.class); + boolean retweets = properties.getUpdate().getRetweets().getValue(message, boolean.class); + twitter.updateFriendship(screenName, enableDeviceNotification, retweets); + return; + + case destroy: + twitter.destroyFriendship(screenName); + return; + } + } + else { + throw new IllegalStateException("Either ScreenName or UserID must be set"); + } + } + catch (TwitterException te) { + throw new IllegalStateException("Twitter API error!", te); + } + }; + } +} diff --git a/functions/consumer/twitter-consumer/src/main/java/org/springframework/cloud/fn/consumer/twitter/friendship/TwitterFriendshipsConsumerProperties.java b/functions/consumer/twitter-consumer/src/main/java/org/springframework/cloud/fn/consumer/twitter/friendship/TwitterFriendshipsConsumerProperties.java new file mode 100644 index 00000000..981585b4 --- /dev/null +++ b/functions/consumer/twitter-consumer/src/main/java/org/springframework/cloud/fn/consumer/twitter/friendship/TwitterFriendshipsConsumerProperties.java @@ -0,0 +1,149 @@ +/* + * 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.consumer.twitter.friendship; + +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.stereotype.Component; +import org.springframework.validation.annotation.Validated; + + +/** + * @author Christian Tzolov + */ +@Component +@ConfigurationProperties("twitter.friendships.update") +@Validated +public class TwitterFriendshipsConsumerProperties { + + public enum OperationType { + /** Friendship operation types. */ + create, update, destroy + } + + /** + * The screen name of the user to follow (String). + */ + private Expression screenName; + + /** + * The ID of the user to follow (Integer). + */ + private Expression userId; + + /** + * Type of Friendships request. + */ + private Expression type = new SpelExpressionParser().parseExpression("'create'"); + + /** + * Additional properties for the Friendships create requests. + */ + private Create create = new Create(); + + /** + * Additional properties for the Friendships update requests. + */ + private Update update = new Update(); + + public Expression getScreenName() { + return screenName; + } + + public void setScreenName(Expression screenName) { + this.screenName = screenName; + } + + public Expression getUserId() { + return userId; + } + + public void setUserId(Expression userId) { + this.userId = userId; + } + + public Expression getType() { + return type; + } + + public void setType(Expression type) { + this.type = type; + } + + public Create getCreate() { + return create; + } + + public Update getUpdate() { + return update; + } + + @AssertTrue(message = "Either userId or screenName must be provided") + public boolean isUserProvided() { + return this.userId != null || this.screenName != null; + } + + public static class Create { + /** + * The ID of the user to follow (boolean). + */ + @NotNull + private Expression follow = new SpelExpressionParser().parseExpression("'true'"); + + public Expression getFollow() { + return follow; + } + + public void setFollow(Expression follow) { + this.follow = follow; + } + } + + public static class Update { + /** + * Enable/disable device notifications from the target user. + */ + @NotNull + private Expression device = new SpelExpressionParser().parseExpression("'true'"); + + /** + * Enable/disable Retweets from the target user. + */ + @NotNull + private Expression retweets = new SpelExpressionParser().parseExpression("'true'"); + + public Expression getDevice() { + return device; + } + + public void setDevice(Expression device) { + this.device = device; + } + + public Expression getRetweets() { + return retweets; + } + + public void setRetweets(Expression retweets) { + this.retweets = retweets; + } + } +} diff --git a/functions/consumer/twitter-consumer/src/main/java/org/springframework/cloud/fn/consumer/twitter/message/TwitterMessageConsumerConfiguration.java b/functions/consumer/twitter-consumer/src/main/java/org/springframework/cloud/fn/consumer/twitter/message/TwitterMessageConsumerConfiguration.java new file mode 100644 index 00000000..377d8319 --- /dev/null +++ b/functions/consumer/twitter-consumer/src/main/java/org/springframework/cloud/fn/consumer/twitter/message/TwitterMessageConsumerConfiguration.java @@ -0,0 +1,71 @@ +/* + * 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.consumer.twitter.message; + +import java.util.function.Consumer; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import twitter4j.Twitter; +import twitter4j.TwitterException; + +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(TwitterMessageConsumerProperties.class) +@Import(TwitterConnectionConfiguration.class) +public class TwitterMessageConsumerConfiguration { + + private static final Log logger = LogFactory.getLog(TwitterMessageConsumerConfiguration.class); + + @Bean + public Consumer> sendDirectMessageConsumer(TwitterMessageConsumerProperties messageProperties, Twitter twitter) { + return message -> { + try { + String messageText = messageProperties.getText().getValue(message, String.class); + + if (messageProperties.getUserId() != null) { + Long userId = messageProperties.getUserId().getValue(message, long.class); + if (messageProperties.getMediaId() != null) { + Long mediaId = messageProperties.getMediaId().getValue(message, long.class); + twitter.sendDirectMessage(userId, messageText, mediaId); + } + twitter.sendDirectMessage(userId, messageText); + } + else if (messageProperties.getScreenName() != null) { + String screenName = messageProperties.getScreenName().getValue(message, String.class); + twitter.sendDirectMessage(screenName, messageText); + } + else { + throw new RuntimeException("Either the UserId or screenName must be set"); + } + } + catch (TwitterException e) { + logger.error("Failed to process message:" + message, e); + } + }; + } +} diff --git a/functions/consumer/twitter-consumer/src/main/java/org/springframework/cloud/fn/consumer/twitter/message/TwitterMessageConsumerProperties.java b/functions/consumer/twitter-consumer/src/main/java/org/springframework/cloud/fn/consumer/twitter/message/TwitterMessageConsumerProperties.java new file mode 100644 index 00000000..e37be02b --- /dev/null +++ b/functions/consumer/twitter-consumer/src/main/java/org/springframework/cloud/fn/consumer/twitter/message/TwitterMessageConsumerProperties.java @@ -0,0 +1,85 @@ +/* + * 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.consumer.twitter.message; + +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.message.update") +@Validated +public class TwitterMessageConsumerProperties { + + private static final Expression DEFAULT_EXPRESSION = new SpelExpressionParser().parseExpression("payload"); + + /** + * The direct message text. URL encode as necessary. Max length of 10,000 characters. + */ + private Expression text = DEFAULT_EXPRESSION; + + /** + * The screen name of the user to whom send the direct message. + */ + private Expression screenName; + + /** + * The user id of the user to whom send the direct message. + */ + private Expression userId; + + /** + * A media id to associate with the message. A Direct Message may only reference a single media id. + */ + private Expression mediaId; + + public Expression getUserId() { + return userId; + } + + public void setUserId(Expression userId) { + this.userId = userId; + } + + public void setText(Expression text) { + this.text = text; + } + + public Expression getText() { + return text; + } + + public Expression getScreenName() { + return screenName; + } + + public void setScreenName(Expression screenName) { + this.screenName = screenName; + } + + public Expression getMediaId() { + return mediaId; + } + + public void setMediaId(Expression mediaId) { + this.mediaId = mediaId; + } +} diff --git a/functions/consumer/twitter-consumer/src/main/java/org/springframework/cloud/fn/consumer/twitter/status/update/TwitterUpdateConsumerConfiguration.java b/functions/consumer/twitter-consumer/src/main/java/org/springframework/cloud/fn/consumer/twitter/status/update/TwitterUpdateConsumerConfiguration.java new file mode 100644 index 00000000..cb301826 --- /dev/null +++ b/functions/consumer/twitter-consumer/src/main/java/org/springframework/cloud/fn/consumer/twitter/status/update/TwitterUpdateConsumerConfiguration.java @@ -0,0 +1,125 @@ +/* + * 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.consumer.twitter.status.update; + +import java.util.Properties; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import twitter4j.GeoLocation; +import twitter4j.Status; +import twitter4j.StatusUpdate; +import twitter4j.Twitter; +import twitter4j.TwitterException; + +import org.springframework.beans.factory.annotation.Autowired; +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.core.env.ConfigurableEnvironment; +import org.springframework.core.env.PropertiesPropertySource; +import org.springframework.messaging.Message; +import org.springframework.util.Assert; + +/** + * + * @author Christian Tzolov + */ +@Configuration +@EnableConfigurationProperties(TwitterUpdateConsumerProperties.class) +@Import(TwitterConnectionConfiguration.class) +public class TwitterUpdateConsumerConfiguration { + + private static final Log logger = LogFactory.getLog(TwitterUpdateConsumerConfiguration.class); + + @Autowired + public void setInfoProperties(ConfigurableEnvironment env) { + Properties props = new Properties(); + props.put("spring.cloud.stream.function.definition", "toText|upper|sink"); + env.getPropertySources().addFirst(new PropertiesPropertySource("function-dsl-props", props)); + } + + @Bean + public Consumer updateStatus(Twitter twitter) { + return statusUpdate -> { + try { + Status status = twitter.updateStatus(statusUpdate); + + if (logger.isDebugEnabled()) { + logger.debug(status); + } + } + catch (TwitterException e) { + logger.error("Failed apply update status: " + statusUpdate, e); + } + }; + } + + @Bean + public Function, StatusUpdate> toStatusUpdateQuery(TwitterUpdateConsumerProperties updateProperties) { + + return message -> { + + String updateText = updateProperties.getText().getValue(message, String.class); + + StatusUpdate statusUpdate = new StatusUpdate(updateText); + + if (updateProperties.getAttachmentUrl() != null) { + statusUpdate.setAttachmentUrl(updateProperties.getAttachmentUrl().getValue(message, String.class)); + } + + if (updateProperties.getPlaceId() != null) { + statusUpdate.setPlaceId(updateProperties.getPlaceId().getValue(message, String.class)); + } + + if (updateProperties.getInReplyToStatusId() != null) { + statusUpdate.setInReplyToStatusId(updateProperties.getInReplyToStatusId().getValue(message, int.class)); + statusUpdate.setAutoPopulateReplyMetadata(true); + } + + if (updateProperties.getDisplayCoordinates() != null) { + statusUpdate.setDisplayCoordinates( + updateProperties.getDisplayCoordinates().getValue(message, boolean.class)); + } + + if (updateProperties.getMediaIds() != null) { + long[] mediaIds = updateProperties.getMediaIds().getValue(message, long[].class); + statusUpdate.setMediaIds(mediaIds); + } + + if (updateProperties.getLocation().getLat() != null) { + Assert.notNull(updateProperties.getLocation().getLon(), + "If the latitude is set then the longitude must be set too"); + double lat = updateProperties.getLocation().getLat().getValue(message, Double.class); + double lon = updateProperties.getLocation().getLon().getValue(message, Double.class); + statusUpdate.setLocation(new GeoLocation(lat, lon)); + } + + return statusUpdate; + }; + } + + @Bean + public Consumer> twitterStatusUpdateConsumer(Function, StatusUpdate> statusUpdateQuery, + Consumer updateStatus) { + return message -> updateStatus.accept(statusUpdateQuery.apply(message)); + } +} diff --git a/functions/consumer/twitter-consumer/src/main/java/org/springframework/cloud/fn/consumer/twitter/status/update/TwitterUpdateConsumerProperties.java b/functions/consumer/twitter-consumer/src/main/java/org/springframework/cloud/fn/consumer/twitter/status/update/TwitterUpdateConsumerProperties.java new file mode 100644 index 00000000..5ce35d7f --- /dev/null +++ b/functions/consumer/twitter-consumer/src/main/java/org/springframework/cloud/fn/consumer/twitter/status/update/TwitterUpdateConsumerProperties.java @@ -0,0 +1,174 @@ +/* + * 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.consumer.twitter.status.update; + +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.update") +@Validated +public class TwitterUpdateConsumerProperties { + + private static final Expression DEFAULT_EXPRESSION = new SpelExpressionParser().parseExpression("payload"); + + /** + * (SpEL expression) The text of the text update. URL encode as necessary. t.co link wrapping will + * affect character counts. Defaults to message's payload + */ + @NotNull + private Expression text = DEFAULT_EXPRESSION; + + /** + * (SpEL expression) In order for a URL to not be counted in the text body of an extended Tweet, provide a URL as a Tweet attachment. + * This URL must be a Tweet permalink, or Direct Message deep link. Arbitrary, non-Twitter URLs must remain in + * the text text. URLs passed to the attachment_url parameter not matching either a Tweet permalink or Direct + * Message deep link will fail at Tweet creation and cause an exception. + */ + private Expression attachmentUrl; + + /** + * (SpEL expression) A place in the world. + */ + private Expression placeId; + + /** + * (SpEL expression) The ID of an existing text that the update is in reply to. Note: This parameter will be ignored unless the + * author of the Tweet this parameter references is mentioned within the text text. Therefore, you must + * include @username, where username is the author of the referenced Tweet, within the update. + * + * When inReplyToStatusId is set the auto_populate_reply_metadata is automatically set as well. Later ensures + * that leading @mentions will be looked up from the original Tweet, and added to the new Tweet from there. + * This wil append @mentions into the metadata of an extended Tweet as a reply chain grows, until the limit + * on @mentions is reached. In cases where the original Tweet has been deleted, + * the reply will fail. + */ + private Expression inReplyToStatusId; + + /** + * (SpEL expression) Whether or not to put a pin on the exact coordinates a Tweet has been sent from. + */ + private Expression displayCoordinates; + + /** + * (SpEL expression) A comma-delimited list of media_ids to associate with the Tweet. You may include up to 4 photos or 1 animated + * GIF or 1 video in a Tweet. See Uploading Media for further details on uploading media. + */ + private Expression mediaIds; + + /** + * (SpEL expression) The location this Tweet refers to. Ignored if geo_enabled for the user is false! + */ + private Location location = new Location(); + + public Expression getText() { + return text; + } + + public void setText(Expression text) { + this.text = text; + } + + public Expression getAttachmentUrl() { + return attachmentUrl; + } + + public void setAttachmentUrl(Expression attachmentUrl) { + this.attachmentUrl = attachmentUrl; + } + + public Expression getPlaceId() { + return placeId; + } + + public void setPlaceId(Expression placeId) { + this.placeId = placeId; + } + + public Expression getInReplyToStatusId() { + return inReplyToStatusId; + } + + public void setInReplyToStatusId(Expression inReplyToStatusId) { + this.inReplyToStatusId = inReplyToStatusId; + } + + public Expression getDisplayCoordinates() { + return displayCoordinates; + } + + public void setDisplayCoordinates(Expression displayCoordinates) { + this.displayCoordinates = displayCoordinates; + } + + public Expression getMediaIds() { + return mediaIds; + } + + public void setMediaIds(Expression mediaIds) { + this.mediaIds = mediaIds; + } + + public Location getLocation() { + return location; + } + + @AssertTrue(message = "Lat and Long must be set together or both not being set") + public boolean validateLatLon() { + return (this.getLocation().getLat() != null && this.getLocation().getLon() != null) + || (this.getLocation().getLat() == null && this.getLocation().getLon() == null); + } + + public static class Location { + /** + * The latitude of the location this Tweet refers to. This parameter will be ignored unless it is inside the range + * -90.0 to +90.0 (North is positive) inclusive. It will also be ignored if there is no corresponding long parameter. + */ + private Expression lat; + + /** + * The longitude of the location this Tweet refers to. The valid ranges for longitude are -180.0 to +180.0 (East + * is positive) inclusive. This parameter will be ignored if outside that range, if it is not a number, + * if geo_enabled is disabled, or if there no corresponding lat parameter. + */ + 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/consumer/twitter-consumer/src/test/java/org/springframework/cloud/fn/consumer/twitter/status/update/TwitterUpdateSinkFunctionConfigurationTests.java b/functions/consumer/twitter-consumer/src/test/java/org/springframework/cloud/fn/consumer/twitter/status/update/TwitterUpdateSinkFunctionConfigurationTests.java new file mode 100644 index 00000000..808396a0 --- /dev/null +++ b/functions/consumer/twitter-consumer/src/test/java/org/springframework/cloud/fn/consumer/twitter/status/update/TwitterUpdateSinkFunctionConfigurationTests.java @@ -0,0 +1,86 @@ +/* + * 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.consumer.twitter.status.update; + +import java.util.function.Consumer; +import java.util.function.Function; + +import org.junit.Test; +import twitter4j.StatusUpdate; +import twitter4j.Twitter; +import twitter4j.TwitterException; + +import org.springframework.expression.Expression; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.GenericMessage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * @author Christian Tzolov + */ +public class TwitterUpdateSinkFunctionConfigurationTests { + + @Test + public void testStatusUpdateConsumer() throws TwitterException { + Twitter twitter = mock(Twitter.class); + + Consumer statusUpdateConsumer = + new TwitterUpdateConsumerConfiguration().updateStatus(twitter); + + StatusUpdate statusUpdateQuery = new StatusUpdate("Hello World"); + statusUpdateConsumer.accept(statusUpdateQuery); + verify(twitter).updateStatus(eq(statusUpdateQuery)); + } + + @Test + public void testToStatusUpdateQueryFunction() { + TwitterUpdateConsumerProperties properties = new TwitterUpdateConsumerProperties(); + + properties.setAttachmentUrl(expression("'attachmentUrl'")); + properties.setPlaceId(expression("'myPlaceId'")); + properties.setInReplyToStatusId(expression("'666666'")); + properties.setDisplayCoordinates(expression("'true'")); + properties.setMediaIds(expression("'471592142565957632, 471592142565957633'")); + properties.getLocation().setLat(expression("'37.78217'")); + properties.getLocation().setLon(expression("'-122.40062'")); + + Function, StatusUpdate> toStatusUpdateQueryFunction = + new TwitterUpdateConsumerConfiguration().toStatusUpdateQuery(properties); + + StatusUpdate result = toStatusUpdateQueryFunction.apply(new GenericMessage<>("Hello World")); + + assertThat(result).isNotNull(); + assertThat(result.getStatus()).isEqualTo("Hello World"); + assertThat(result.getAttachmentUrl()).isEqualTo("attachmentUrl"); + assertThat(result.getPlaceId()).isEqualTo("myPlaceId"); + assertThat(result.getInReplyToStatusId()).isEqualTo(666666L); + assertThat(result.isDisplayCoordinates()).isTrue(); + assertThat(result.getLocation().getLatitude()).isEqualTo(37.78217); + assertThat(result.getLocation().getLongitude()).isEqualTo(-122.40062); + } + + private Expression expression(String expressionString) { + ExpressionParser parser = new SpelExpressionParser(); + return parser.parseExpression(expressionString); + } +} diff --git a/functions/function/twitter-function/README.adoc b/functions/function/twitter-function/README.adoc new file mode 100644 index 00000000..14b464d4 --- /dev/null +++ b/functions/function/twitter-function/README.adoc @@ -0,0 +1,30 @@ +# Twitter Functions + +This module provides couple of twitter functions that can be reused and composed in other applications. + +## 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. + +* Trends - `twitter.trend.trend-query-type` is set to `trend`. Leverages the https://developer.twitter.com/en/docs/trends/trends-for-location/api-reference/get-trends-place[Trends API] to return the https://help.twitter.com/en/using-twitter/twitter-trending-faqs[trending topics] near a specific latitude, longitude location. + +* Trend Locations - the `twitter.trend.trend-query-type` is set `trendLocation`. Retrieve a full or nearby locations list of trending topics by location. If the `latitude`, `longitude` parameters are NOT provided the processor performs the https://developer.twitter.com/en/docs/trends/locations-with-trending-topics/api-reference/get-trends-available[Trends Available API] and returns the locations that Twitter has trending topic information for. +If the `latitude`, `longitude` parameters are provided the processor performs the https://developer.twitter.com/en/docs/trends/locations-with-trending-topics/api-reference/get-trends-closest[Trends Closest API] and returns the locations that Twitter has trending topic information for, closest to a specified location. + +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 + +You can import the `TwitterTrendFunctionConfiguration` in a Spring Boot application and then inject the following bean. + +`filterFunction` + +You can use `Function, Message> trendOrTrendLocationsFunction` as a qualifier when injecting. + +Once injected, you can use the `apply` method of the `Function` to invoke it and get the result. + +### Configuration Options + + +### Other usage + diff --git a/functions/function/twitter-function/pom.xml b/functions/function/twitter-function/pom.xml new file mode 100644 index 00000000..93098c54 --- /dev/null +++ b/functions/function/twitter-function/pom.xml @@ -0,0 +1,46 @@ + + + 4.0.0 + twitter-function + 1.0.0-SNAPSHOT + twitter-function + twitter functions + + + org.springframework.cloud.fn + spring-functions-parent + 1.0.0-SNAPSHOT + ../../spring-functions-parent + + + + + org.springframework.cloud.fn + twitter-common + ${project.version} + + + + org.springframework.boot + spring-boot-starter-integration + + + org.springframework.boot + spring-boot-starter-test + test + + + org.junit.vintage + junit-vintage-engine + + + + + org.springframework.boot + spring-boot-configuration-processor + provided + + + + diff --git a/functions/function/twitter-function/src/main/java/org/springframework/cloud/fn/twitter/trend/TwitterTrendFunctionConfiguration.java b/functions/function/twitter-function/src/main/java/org/springframework/cloud/fn/twitter/trend/TwitterTrendFunctionConfiguration.java new file mode 100644 index 00000000..61253b87 --- /dev/null +++ b/functions/function/twitter-function/src/main/java/org/springframework/cloud/fn/twitter/trend/TwitterTrendFunctionConfiguration.java @@ -0,0 +1,91 @@ +/* + * 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.List; +import java.util.function.Function; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import twitter4j.GeoLocation; +import twitter4j.Location; +import twitter4j.Trends; +import twitter4j.Twitter; +import twitter4j.TwitterException; + +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(TwitterTrendFunctionProperties.class) +@Import(TwitterConnectionConfiguration.class) +public class TwitterTrendFunctionConfiguration { + + private static final Log logger = LogFactory.getLog(TwitterTrendFunctionConfiguration.class); + + @Bean + public Function, Trends> trend(TwitterTrendFunctionProperties properties, Twitter twitter) { + return message -> { + try { + int woeid = properties.getLocationId().getValue(message, int.class); + return twitter.getPlaceTrends(woeid); + } + catch (TwitterException e) { + logger.error("Twitter API error!", e); + } + return null; + }; + } + + @Bean + public Function, List> closestOrAvailableTrends( + TwitterTrendFunctionProperties properties, Twitter twitter) { + return message -> { + try { + if (properties.getClosest().getLat() != null && properties.getClosest().getLon() != null) { + double lat = properties.getClosest().getLat().getValue(message, double.class); + double lon = properties.getClosest().getLon().getValue(message, double.class); + return twitter.getClosestTrends(new GeoLocation(lat, lon)); + } + else { + return twitter.getAvailableTrends(); + } + } + catch (TwitterException e) { + logger.error("Twitter API error!", e); + } + return null; + }; + } + + @Bean + public Function, Message> trendOrTrendLocationsFunction( + Function> managedJson, Function, Trends> trend, + TwitterTrendFunctionProperties properties, Function, + List> closestOrAvailableTrends) { + + return (properties.getTrendQueryType() == TwitterTrendFunctionProperties.TrendQueryType.trend) ? + trend.andThen(managedJson) : closestOrAvailableTrends.andThen(managedJson); + } +} diff --git a/functions/function/twitter-function/src/main/java/org/springframework/cloud/fn/twitter/trend/TwitterTrendFunctionProperties.java b/functions/function/twitter-function/src/main/java/org/springframework/cloud/fn/twitter/trend/TwitterTrendFunctionProperties.java new file mode 100644 index 00000000..95ec7704 --- /dev/null +++ b/functions/function/twitter-function/src/main/java/org/springframework/cloud/fn/twitter/trend/TwitterTrendFunctionProperties.java @@ -0,0 +1,107 @@ +/* + * 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 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.trend") +@Validated +public class TwitterTrendFunctionProperties { + + private static final Expression DEFAULT_EXPRESSION = new SpelExpressionParser().parseExpression("payload"); + + enum TrendQueryType { + /** Retrieve trending places. */ + trend, + /** Retrieve the Locations of trending places. */ + trendLocation + } + + private TrendQueryType trendQueryType = TrendQueryType.trend; + + public TrendQueryType getTrendQueryType() { + return trendQueryType; + } + + public void setTrendQueryType(TrendQueryType trendQueryType) { + this.trendQueryType = trendQueryType; + } + + /** + * The Yahoo! Where On Earth ID of the location to return trending information for. + * Global information is available by using 1 as the WOEID. + */ + @NotNull + private Expression locationId = DEFAULT_EXPRESSION; + + public Expression getLocationId() { + return locationId; + } + + public void setLocationId(Expression locationId) { + this.locationId = locationId; + } + + /** + * + */ + private Closest closest = new Closest(); + + public Closest getClosest() { + return closest; + } + + public static class Closest { + /** + * If provided with a long parameter the available trend locations will be sorted by distance, nearest + * to furthest, to the co-ordinate pair. + * The valid ranges for longitude is -180.0 to +180.0 (West is negative, East is positive) inclusive. + */ + private Expression lat; + + /** + * If provided with a lat parameter the available trend locations will be sorted by distance, nearest to + * furthest, to the co-ordinate pair. The valid ranges for longitude is -180.0 to +180.0 (West is negative, + * East is positive) inclusive. + */ + 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/pom.xml b/functions/pom.xml index 8d5983e7..2849499a 100644 --- a/functions/pom.xml +++ b/functions/pom.xml @@ -50,6 +50,7 @@ common/metadata-store-common common/mqtt-common common/tcp-common + common/twitter-common consumer/cassandra-consumer @@ -67,6 +68,7 @@ consumer/tcp-consumer consumer/websocket-consumer consumer/s3-consumer + consumer/twitter-consumer function/filter-function function/header-enricher-function @@ -76,6 +78,7 @@ function/splitter-function function/tasklauncher-function function/task-launch-request-function + function/twitter-function supplier/file-supplier supplier/ftp-supplier @@ -90,6 +93,7 @@ supplier/rabbit-supplier supplier/websocket-supplier supplier/s3-supplier + supplier/twitter-supplier spring-functions-parent diff --git a/functions/supplier/twitter-supplier/README.adoc b/functions/supplier/twitter-supplier/README.adoc new file mode 100644 index 00000000..9c795586 --- /dev/null +++ b/functions/supplier/twitter-supplier/README.adoc @@ -0,0 +1,143 @@ +# Twitter Suppliers + +This module provides a Twitter Status, Message, Friendship suppliers that can be reused and composed in various applications. + +`java.util.function.Supplier` + + +## 1. Twitter Status Search + +The Twitter's https://developer.twitter.com/en/docs/tweets/search/api-reference/get-search-tweets.html[Standard search API] (search/tweets) allows simple queries against the indices of recent or popular Tweets. This `Source` provides continuous searches against a sampling of recent Tweets published in the past 7 days. Part of the 'public' set of APIs. + +Returns a collection of relevant Tweets matching a specified query. + +### 1.1 Beans for injection + +You can import the `TwitterSearchSupplierConfiguration` in the application and then inject the following bean. + +`twitterSearchSupplier` + +You need to inject this as `Supplier>`. + +You can use `twitterSearchSupplier` as a qualifier when injecting. + +Once injected, you can use the `get` method of the `Supplier` to invoke it. + +### 1.2 Configuration Options + +The configuration properties prefixed with `twitter.search`. +There are also properties that need to be used with the prefix `twitter.connection`. + +The `spring.cloud.stream.poller` properties control the interval between consecutive search requests. Rate Limit - 180 requests per 30 min. window (e.g. ~6 r/m, ~ 1 req / 10 sec.) + +For more information on the various options available, please see link:src/main/java/org/springframework/cloud/fn/supplier/twitter/search/stream/TwitterSearchSupplierProperties.java[TwitterSearchSupplierProperties]. +See link:src/main/java/org/springframework/cloud/fn/common/twitter/TwitterConnectionProperties.java[TwitterConnectionProperties] and https://github.com/spring-cloud/spring-cloud-stream/blob/master/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/DefaultPollerProperties.java[DefaultPollerProperties] + +### 1.3 Other usage + +See this https://github.com/spring-cloud/stream-applications/blob/master/applications/source/twitter-search-source/README.adoc[README] where this supplier is used to create a Spring Cloud Stream application where it makes a Twitter Search Source. + +## 2. Twitter Status Real-time Retrieval + +Provides real-time, Tweet streaming based on the https://developer.twitter.com/en/docs/tweets/filter-realtime/api-reference/post-statuses-filter.html[Filter] and https://developer.twitter.com/en/docs/tweets/sample-realtime/overview/GET_statuse_sample[Sample] APIs. +The `Filter API` flavor returns public statuses that match one or more filter predicates. +The `Sample API` flavor returns a small random sample of all public statuses. + +This supplier gives you a reactive stream of tweets from the configured connection as the supplier has a signature of `Supplier>>`. +Users have to subscribe to this `Flux` and receive the data. + +The default access level allows up to 400 track keywords, 5,000 follow user Ids and 25 0.1-360 degree location boxes. + +### 2.1 Beans for injection + +You can import the `TwitterStreamSupplierConfiguration` in the application and then inject the following bean. + +`twitterStreamSupplier` + +You need to inject this as `Supplier>>`. + +You can use `twitterStreamSupplier` as a qualifier when injecting. + +Once injected, you can use the `get` method of the `Supplier` to invoke it and then subscribe to the returned `Flux`. + +### 2.2 Configuration Options + +All configuration properties are prefixed with `twitter.stream`. +There are also properties that need to be used with the prefix `twitter.connection`. + +For more information on the various options available, please see link:src/main/java/org/springframework/cloud/fn/supplier/twitter/status/stream/TwitterStreamSupplierProperties.java[TwitterStreamSupplierProperties]. +See link:src/main/java/org/springframework/cloud/fn/common/twitter/TwitterConnectionProperties.java[TwitterConnectionProperties] also. + + +### 2.3 Other usage + +See this https://github.com/spring-cloud/stream-applications/blob/master/applications/source/twitter-stream-source/README.adoc[README] where this supplier is used to create a Spring Cloud Stream application where it makes a Twitter Stream Source. + +## 3. Twitter Direct Message Supplier + +Repeatedly retrieves the direct messages (both sent and received) within the last 30 days, sorted in reverse-chronological order. +The relieved messages are cached (in a `MetadataStore` cache) to prevent duplications. +By default an in-memory `SimpleMetadataStore` is used. + +The `twitter.message.source.count` controls the number or returned messages. + +The `spring.cloud.stream.poller` properties control the message poll interval. +Must be aligned with used APIs rate limit + +### 3.1 Beans for injection + +You can import the `TwitterMessageSupplierConfiguration` in the application and then inject the following bean. + +`twitterMessageSupplier` + +You need to inject this as `Supplier>`. + +You can use `twitterMessageSupplier` as a qualifier when injecting. + +Once injected, you can use the `get` method of the `Supplier` to invoke it. + +### 3.2 Configuration Options + +The configuration properties prefixed with `twitter.search`. +There are also properties that need to be used with the prefix `twitter.connection`. + +The `spring.cloud.stream.poller` properties control the interval between consecutive search requests. Rate Limit - 180 requests per 30 min. window (e.g. ~6 r/m, ~ 1 req / 10 sec.) + +For more information on the various options available, please see link:src/main/java/org/springframework/cloud/fn/supplier/twitter/message/TwitterMessageSupplierProperties.java[TwitterMessageSupplierProperties]. +See link:src/main/java/org/springframework/cloud/fn/common/twitter/TwitterConnectionProperties.java[TwitterConnectionProperties] and https://github.com/spring-cloud/spring-cloud-stream/blob/master/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/DefaultPollerProperties.java[DefaultPollerProperties] + +### 3.3 Other usage + +See this https://github.com/spring-cloud/stream-applications/blob/master/applications/source/twitter-message-source/README.adoc[README] where this supplier is used to create a Spring Cloud Stream application where it makes a Twitter Message Source. + +## 4. Twitter Friendships Supplier + +Returns a cursored collection of user objects either for the https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-followers-list[users following the specified user] (`followers`) or for https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friends-list[every user the specified user is following] (`friends`). + +The `twitter.friendships.source.type` property allow to select between both types. + +TIP: Rate limit: 15 requests per 30 min window. ~ 1 req/ 2 min + +### 4.1 Beans for injection + +You can import the `TwitterFriendshipsSupplierConfiguration` in the application and then inject one the following beans. + +- `followersSupplier` (only if `twitter.friendships.source.type=followers` ) - retrieves the https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-followers-list[users following the specified user] (`followers`) + +- `friendsSupplier` (only if `twitter.friendships.source.type=friends`) - retrieves the for https://developer.twitter.com/en/docs/accounts-and-users/follow-search-get-users/api-reference/get-friends-list[every user the specified user is following] (`friends`). + +Both suppliers expose `Supplier>`. + +- `deduplicatedFriendsJsonSupplier` - retrieves either the followers, or the friends collection (controlled by the `twitter.friendships.source.type`) property, . +encoded as JSON `Message` payloads. You need to inject this as `Supplier>`. + + +### 4.2 Configuration Options + +The configuration properties prefixed with `twitter.friendships.source`. +There are also properties that need to be used with the prefix `twitter.connection`. + +The `spring.cloud.stream.poller` properties control the interval between consecutive search requests. + +For more information on the various options available, please see link:src/main/java/org/springframework/cloud/fn/supplier/twitter/friendships/TwitterFriendshipsSupplierProperties.java[TwitterFriendshipsSupplierProperties]. +See link:src/main/java/org/springframework/cloud/fn/common/twitter/TwitterConnectionProperties.java[TwitterConnectionProperties] and https://github.com/spring-cloud/spring-cloud-stream/blob/master/spring-cloud-stream/src/main/java/org/springframework/cloud/stream/config/DefaultPollerProperties.java[DefaultPollerProperties] diff --git a/functions/supplier/twitter-supplier/pom.xml b/functions/supplier/twitter-supplier/pom.xml new file mode 100644 index 00000000..b9fd36c1 --- /dev/null +++ b/functions/supplier/twitter-supplier/pom.xml @@ -0,0 +1,80 @@ + + + 4.0.0 + twitter-supplier + 1.0.0-SNAPSHOT + twitter-supplier + twitter suppliers + + + org.springframework.cloud.fn + spring-functions-parent + 1.0.0-SNAPSHOT + ../../spring-functions-parent + + + + + org.springframework.cloud.fn + twitter-common + ${project.version} + + + + org.springframework.boot + spring-boot-starter-integration + + + org.springframework.integration + spring-integration-jms + + + javax.jms + javax.jms-api + provided + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-configuration-processor + provided + + + org.apache.activemq + activemq-broker + test + + + org.springframework.boot + spring-boot-starter-test + test + + + io.projectreactor + reactor-test + test + + + org.springframework.integration + spring-integration-test + test + + + + org.mock-server + mockserver-netty + 5.10 + test + + + org.mock-server + mockserver-client-java + 5.10 + test + + + + diff --git a/functions/supplier/twitter-supplier/src/main/java/org/springframework/cloud/fn/supplier/twitter/friendships/TwitterFriendshipsSupplierConfiguration.java b/functions/supplier/twitter-supplier/src/main/java/org/springframework/cloud/fn/supplier/twitter/friendships/TwitterFriendshipsSupplierConfiguration.java new file mode 100644 index 00000000..58587056 --- /dev/null +++ b/functions/supplier/twitter-supplier/src/main/java/org/springframework/cloud/fn/supplier/twitter/friendships/TwitterFriendshipsSupplierConfiguration.java @@ -0,0 +1,150 @@ +/* + * 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.supplier.twitter.friendships; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; +import java.util.function.Supplier; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import twitter4j.PagableResponseList; +import twitter4j.Twitter; +import twitter4j.TwitterException; +import twitter4j.User; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.fn.common.twitter.Cursor; +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.integration.metadata.MetadataStore; +import org.springframework.integration.metadata.SimpleMetadataStore; +import org.springframework.messaging.Message; + +/** + * + * @author Christian Tzolov + */ +@Configuration +@EnableConfigurationProperties(TwitterFriendshipsSupplierProperties.class) +@Import(TwitterConnectionConfiguration.class) +public class TwitterFriendshipsSupplierConfiguration { + + private static final Log logger = LogFactory.getLog(TwitterFriendshipsSupplierConfiguration.class); + + @Bean + @ConditionalOnMissingBean + public MetadataStore metadataStore() { + return new SimpleMetadataStore(); + } + + @Bean + @ConditionalOnMissingBean + public Cursor cursor() { + return new Cursor(); + } + + + @Bean + @ConditionalOnProperty(name = "twitter.friendships.source.type", havingValue = "followers") + public Supplier> followersSupplier(TwitterFriendshipsSupplierProperties properties, + Twitter twitter, Cursor cursorState) { + return () -> { + try { + PagableResponseList users; + if (properties.getUserId() != null) { + users = twitter.getFollowersList(properties.getUserId(), cursorState.getCursor(), + properties.getCount(), properties.isSkipStatus(), properties.isIncludeUserEntities()); + } + else { // by ScreenName + users = twitter.getFollowersList(properties.getScreenName(), cursorState.getCursor(), + properties.getCount(), properties.isSkipStatus(), properties.isIncludeUserEntities()); + } + + if (users != null) { + cursorState.updateCursor(users.getNextCursor()); + return users; + } + + logger.error(String.format("NULL users response for properties: %s and cursor: %s!", properties, cursorState)); + cursorState.updateCursor(-1); + } + catch (TwitterException e) { + logger.error("Twitter API error:", e); + } + + return new ArrayList<>(); + }; + } + + @Bean + @ConditionalOnProperty(name = "twitter.friendships.source.type", havingValue = "friends") + public Supplier> friendsSupplier(TwitterFriendshipsSupplierProperties properties, + Twitter twitter, Cursor cursorState) { + return () -> { + try { + PagableResponseList users; + if (properties.getUserId() != null) { + users = twitter.getFriendsList(properties.getUserId(), cursorState.getCursor(), + properties.getCount(), properties.isSkipStatus(), properties.isIncludeUserEntities()); + } + else { // by ScreenName + users = twitter.getFriendsList(properties.getScreenName(), cursorState.getCursor(), + properties.getCount(), properties.isSkipStatus(), properties.isIncludeUserEntities()); + } + + if (users != null) { + cursorState.updateCursor(users.getNextCursor()); + return users; + } + + logger.error(String.format("NULL users response for properties: %s and cursor: %s!", properties, cursorState)); + cursorState.updateCursor(-1); + } + catch (TwitterException e) { + logger.error("Twitter API error:", e); + } + + return new ArrayList<>(); + }; + } + + @Bean + public Function, List> userDeduplicate(MetadataStore metadataStore) { + return users -> { + List uniqueUsers = new ArrayList<>(); + for (User user : users) { + if (metadataStore.get(user.getId() + "") == null) { + metadataStore.put(user.getId() + "", user.getName()); + uniqueUsers.add(user); + } + } + return uniqueUsers; + }; + } + + @Bean + public Supplier> deduplicatedFriendsJsonSupplier(Function, List> userDeduplication, + Supplier> userRetriever, Function> managedJson) { + return () -> userDeduplication.andThen(managedJson).apply(userRetriever.get()); + } +} diff --git a/functions/supplier/twitter-supplier/src/main/java/org/springframework/cloud/fn/supplier/twitter/friendships/TwitterFriendshipsSupplierProperties.java b/functions/supplier/twitter-supplier/src/main/java/org/springframework/cloud/fn/supplier/twitter/friendships/TwitterFriendshipsSupplierProperties.java new file mode 100644 index 00000000..08c2db93 --- /dev/null +++ b/functions/supplier/twitter-supplier/src/main/java/org/springframework/cloud/fn/supplier/twitter/friendships/TwitterFriendshipsSupplierProperties.java @@ -0,0 +1,151 @@ +/* + * 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.supplier.twitter.friendships; + +import javax.validation.constraints.AssertTrue; +import javax.validation.constraints.Max; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Positive; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + + +/** + * @author Christian Tzolov + */ +@ConfigurationProperties("twitter.friendships.source") +@Validated +public class TwitterFriendshipsSupplierProperties { + + public enum FriendshipsRequestType { + /** Friendship query types. */ + followers, friends + } + + /** + * Selects between followers or friends APIs. + */ + @NotNull + private TwitterFriendshipsSupplierProperties.FriendshipsRequestType type = FriendshipsRequestType.followers; + + /** + * The screen name of the user for whom to return results. + */ + private String screenName; + + /** + * The ID of the user for whom to return results. + */ + private Long userId; + + /** + * The number of users to return per page, up to a maximum of 200. Defaults to 20. + */ + @Positive + @Max(200) + private int count = 200; + + /** + * When set to true, statuses will not be included in the returned user objects. + */ + private boolean skipStatus = false; + + /** + * The user object entities node will be disincluded when set to false. + */ + private boolean includeUserEntities = true; + + /** + * API request poll interval in milliseconds. Must be aligned with used APIs rate limits (~ 1 req/ 2 min). + */ + private int pollInterval = 121000; + + public FriendshipsRequestType getType() { + return type; + } + + public void setType(FriendshipsRequestType type) { + this.type = type; + } + + public String getScreenName() { + return screenName; + } + + public void setScreenName(String screenName) { + this.screenName = screenName; + } + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public int getCount() { + return count; + } + + public void setCount(int count) { + this.count = count; + } + + public boolean isSkipStatus() { + return skipStatus; + } + + public void setSkipStatus(boolean skipStatus) { + this.skipStatus = skipStatus; + } + + public boolean isIncludeUserEntities() { + return includeUserEntities; + } + + public void setIncludeUserEntities(boolean includeUserEntities) { + this.includeUserEntities = includeUserEntities; + } + + public int getPollInterval() { + return pollInterval; + } + + public void setPollInterval(int pollInterval) { + this.pollInterval = pollInterval; + } + + @AssertTrue(message = "Either userId or screenName must be provided") + public boolean isUserProvided() { + return this.userId != null || this.screenName != null; + } + + @Override + public String toString() { + return "TwitterFriendshipsSourceProperties{" + + "type=" + type + + ", screenName='" + screenName + '\'' + + ", userId=" + userId + + ", count=" + count + + ", skipStatus=" + skipStatus + + ", includeUserEntities=" + includeUserEntities + + ", pollInterval=" + pollInterval + + '}'; + } +} diff --git a/functions/supplier/twitter-supplier/src/main/java/org/springframework/cloud/fn/supplier/twitter/message/TwitterMessageSupplierConfiguration.java b/functions/supplier/twitter-supplier/src/main/java/org/springframework/cloud/fn/supplier/twitter/message/TwitterMessageSupplierConfiguration.java new file mode 100644 index 00000000..cc921f00 --- /dev/null +++ b/functions/supplier/twitter-supplier/src/main/java/org/springframework/cloud/fn/supplier/twitter/message/TwitterMessageSupplierConfiguration.java @@ -0,0 +1,126 @@ +/* + * 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.supplier.twitter.message; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; +import java.util.function.Supplier; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import twitter4j.DirectMessage; +import twitter4j.DirectMessageList; +import twitter4j.Twitter; +import twitter4j.TwitterException; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +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.Import; +import org.springframework.integration.metadata.MetadataStore; +import org.springframework.integration.metadata.SimpleMetadataStore; +import org.springframework.messaging.Message; + +/** + * + * @author Christian Tzolov + */ +@EnableConfigurationProperties({ TwitterMessageSupplierProperties.class }) +@Import(TwitterConnectionConfiguration.class) +public class TwitterMessageSupplierConfiguration { + + private static final Log logger = LogFactory.getLog(TwitterMessageSupplierConfiguration.class); + + @Bean + @ConditionalOnMissingBean + public MetadataStore metadataStore() { + return new SimpleMetadataStore(); + } + + @Bean + @ConditionalOnMissingBean + public MessageCursor cursor() { + return new MessageCursor(); + } + + @Bean + public Supplier> directMessagesSupplier(TwitterMessageSupplierProperties properties, + Twitter twitter, MessageCursor cursorState) { + return () -> { + try { + String cs = cursorState.getCursor(); + DirectMessageList messages = (cursorState.getCursor() == null) ? + twitter.getDirectMessages(properties.getCount()) : + twitter.getDirectMessages(properties.getCount(), cursorState.getCursor()); + + if (messages != null) { + cursorState.updateCursor(messages.getNextCursor()); + return messages; + } + + logger.error(String.format("NULL messages response for properties: %s and cursor: %s!", properties, cursorState)); + cursorState.updateCursor(null); + } + catch (TwitterException e) { + logger.error("Twitter API error:", e); + } + + return new ArrayList<>(); + }; + } + + @Bean + public Function, List> messageDeduplicate(MetadataStore metadataStore) { + return messages -> { + List uniqueMessages = new ArrayList<>(); + for (DirectMessage message : messages) { + if (metadataStore.get(message.getId() + "") == null) { + metadataStore.put(message.getId() + "", message.getCreatedAt() + ""); + uniqueMessages.add(message); + } + } + return uniqueMessages; + }; + } + + @Bean + public Supplier> twitterMessageSupplier(Function, List> messageDeduplicate, + Function> managedJson, Supplier> directMessagesSupplier) { + return () -> messageDeduplicate.andThen(managedJson).apply(directMessagesSupplier.get()); + } + + public static class MessageCursor { + private String cursor = null; + + public String getCursor() { + return cursor; + } + + public void updateCursor(String newCursor) { + this.cursor = newCursor; + } + + @Override + public String toString() { + return "Cursor{" + + "cursor=" + cursor + + '}'; + } + } +} diff --git a/functions/supplier/twitter-supplier/src/main/java/org/springframework/cloud/fn/supplier/twitter/message/TwitterMessageSupplierProperties.java b/functions/supplier/twitter-supplier/src/main/java/org/springframework/cloud/fn/supplier/twitter/message/TwitterMessageSupplierProperties.java new file mode 100644 index 00000000..6be5830c --- /dev/null +++ b/functions/supplier/twitter-supplier/src/main/java/org/springframework/cloud/fn/supplier/twitter/message/TwitterMessageSupplierProperties.java @@ -0,0 +1,45 @@ +/* + * 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.supplier.twitter.message; + +import javax.validation.constraints.Max; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + + +/** + * @author Christian Tzolov + */ +@ConfigurationProperties("twitter.message.source") +@Validated +public class TwitterMessageSupplierProperties { + + /** + * Max number of events to be returned. 20 default. 50 max. + */ + @Max(50) + private int count = 20; + + public int getCount() { + return count; + } + + public void setCount(int count) { + this.count = count; + } +} diff --git a/functions/supplier/twitter-supplier/src/main/java/org/springframework/cloud/fn/supplier/twitter/status/search/SearchPagination.java b/functions/supplier/twitter-supplier/src/main/java/org/springframework/cloud/fn/supplier/twitter/status/search/SearchPagination.java new file mode 100644 index 00000000..c13a15a2 --- /dev/null +++ b/functions/supplier/twitter-supplier/src/main/java/org/springframework/cloud/fn/supplier/twitter/status/search/SearchPagination.java @@ -0,0 +1,166 @@ +/* + * 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.supplier.twitter.status.search; + +import java.util.List; + +import twitter4j.Status; + +import org.springframework.util.Assert; + +/** + * Searched tweets are ordered from top to bottom by their IDs. The higher the ID, more recent the tweet is. + * + * The search goes backwards - from most recent to the oldest tweets and it uses the sinceId and the maxId to retrieve + * only tweets with IDs in the [sinceId, maxId) range. The -1 stands for unbounded sinceId or maxId. + * + * The first `pageCount` number requests are performed backwards, leaving the bottom boundary (sinceId) unbounded and + * adjusting the upper boundary (maxId) to the lowest tweet ID received. This ensures that no already processed + * tweets are returned. + * + * The pageCounter is used the to count the number of pages retrieved in the pageCount range. Is starts from pageCount + * and goes backward until 0. On 0 the pageCounter is reset back to pageCount. + * + * After performing pageCount number requests (e.g. pageCounter = 0), we start new iteration of searches from the top, + * most recent tweets but now the bottom boundary (sinceId) is adjusted to the max ID received so far. That means that + * only the newly added tweets will be processed + * + * Search pagination with max_id and since_id: https://developer.twitter.com/en/docs/tweets/timelines/guides/working-with-timelines.html + * + * @author Christian Tzolov + */ +public class SearchPagination { + + /** + * Wildcard used as refer as infinity. + */ + public static final long UNBOUNDED = -1; + + /** + * Number of pages to search in history before start form the top again. + * + * Note that the search goes backwards - from most recent to the oldest tweets. + * (eg. maxId == To max ID , sinceId == From min ID) + */ + private final int pageCount; + + /** + * Min tweet ID (e.g. from ID) to be included in the search result + */ + private long sinceId; + + /** + * Keep the max tweet ID in the last pageCount search requests. + */ + private long pageMaxId; + + /** + * Max tweet ID (e.g. To (ID - 1) )to be included in the search result + */ + private long maxId; + + /** + * Current page. Goes backwards from (pageCount-1) to 0. + */ + private int pageCounter; + + /** + * When set it. + */ + boolean searchBackwardsUntilEmptyResponse = false; + + public SearchPagination(int pageCount, boolean searchBackwardsUntilEmptyResponse) { + + Assert.isTrue(pageCount > 0, "At least one page needs to be set but was: " + pageCount); + + this.searchBackwardsUntilEmptyResponse = searchBackwardsUntilEmptyResponse; + this.pageCount = pageCount; + this.sinceId = UNBOUNDED; // == From ID + this.pageMaxId = UNBOUNDED; // == From ID + this.maxId = UNBOUNDED; // == To (ID - 1) + this.pageCounter = pageCount - 1; + } + + public long getSinceId() { + return sinceId; + } + + public long getMaxId() { + return maxId; + } + + public long getPageMaxId() { + return pageMaxId; + } + + public int getPageCounter() { + return pageCounter; + } + + public void update(List tweets) { + + tweets.stream().mapToLong(t -> t.getId()).min() + .ifPresent(tweetsMinId -> { + this.maxId = tweetsMinId - 1; + }); + + tweets.stream().mapToLong(t -> t.getId()).max() + .ifPresent(tweetsMaxId -> { + Assert.isTrue(this.sinceId <= tweetsMaxId, + String.format("MAX_ID (%s) must be bigger then current SINCE_ID(%s)", + tweetsMaxId, this.sinceId)); + this.pageMaxId = Math.max(this.pageMaxId, tweetsMaxId); + }); + + this.countDown(tweets.size()); + } + + private void countDown(int responseSize) { + + if (this.sinceId == UNBOUNDED) { // == first pass before reset + if (this.pageCounter <= 0) { + this.restartSearchFromMostRecent(); + } + } + else { + if (this.searchBackwardsUntilEmptyResponse) { + if (responseSize == 0) { + this.restartSearchFromMostRecent(); + } + } + else if (this.pageCounter <= 0) { + this.restartSearchFromMostRecent(); + } + } + + this.pageCounter--; + } + + private void restartSearchFromMostRecent() { + this.pageCounter = this.pageCount; + + this.sinceId = Math.max(this.sinceId, Math.max(this.maxId + 1, this.pageMaxId)); + + this.maxId = UNBOUNDED; + this.pageMaxId = UNBOUNDED; + } + + public String status() { + return String.format("MaxId: %s, SinceId: %s, Page Counter# %s, pageMaxId: %s", + this.maxId, this.sinceId, this.pageCounter, this.pageMaxId); + } +} diff --git a/functions/supplier/twitter-supplier/src/main/java/org/springframework/cloud/fn/supplier/twitter/status/search/TwitterSearchSupplierConfiguration.java b/functions/supplier/twitter-supplier/src/main/java/org/springframework/cloud/fn/supplier/twitter/status/search/TwitterSearchSupplierConfiguration.java new file mode 100644 index 00000000..3910561e --- /dev/null +++ b/functions/supplier/twitter-supplier/src/main/java/org/springframework/cloud/fn/supplier/twitter/status/search/TwitterSearchSupplierConfiguration.java @@ -0,0 +1,138 @@ +/* + * 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.supplier.twitter.status.search; + +import java.util.List; +import java.util.function.Function; +import java.util.function.Supplier; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import twitter4j.GeoLocation; +import twitter4j.Query; +import twitter4j.QueryResult; +import twitter4j.Status; +import twitter4j.Twitter; +import twitter4j.TwitterException; + +import org.springframework.beans.factory.annotation.Autowired; +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.Import; +import org.springframework.messaging.Message; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Search pagination with max_id and since_id: https://developer.twitter.com/en/docs/tweets/timelines/guides/working-with-timelines.html . + * + * @author Christian Tzolov + */ + +@EnableConfigurationProperties({ TwitterSearchSupplierProperties.class }) +@Import(TwitterConnectionConfiguration.class) +public class TwitterSearchSupplierConfiguration { + + private static final Log logger = LogFactory.getLog(TwitterSearchSupplierConfiguration.class); + + @Autowired + private TwitterSearchSupplierProperties searchProperties; + + @Autowired + private Twitter twitter; + + @Autowired + private SearchPagination searchPage; + + @Autowired + private Function> json; + + @Bean + public SearchPagination searchPage() { + return new SearchPagination( + this.searchProperties.getPage(), + this.searchProperties.isRestartFromMostRecentOnEmptyResponse()); + } + + + @Bean + public Supplier> twitterSearchSupplier() { + return () -> { + try { + Query query = toQuery(this.searchProperties, this.searchPage); + + QueryResult result = this.twitter.search(query); + + List tweets = result.getTweets(); + + logger.info(String.format("%s, size: %s", this.searchPage.status(), tweets.size())); + + this.searchPage.update(tweets); + + return this.json.apply(tweets); + } + catch (TwitterException e) { + logger.error("Twitter error", e); + } + + return null; + }; + } + + private Query toQuery(TwitterSearchSupplierProperties searchProperties, SearchPagination pagination) { + + Query query = new Query(); + if (searchProperties.getCount() > 0) { + query.count(searchProperties.getCount()); + } + if (StringUtils.hasText(searchProperties.getQuery())) { + query.setQuery(searchProperties.getQuery()); + } + if (StringUtils.hasText(searchProperties.getLang())) { + query.setLang(searchProperties.getLang()); + } + if (StringUtils.hasText(searchProperties.getSince())) { + query.setSince(searchProperties.getSince()); + } + if (searchProperties.getGeocode().isValid()) { + query.setGeoCode( + new GeoLocation( + searchProperties.getGeocode().getLatitude(), + searchProperties.getGeocode().getLongitude()), + searchProperties.getGeocode().getRadius(), + Query.KILOMETERS); + } + + if (searchProperties.getResultType() != Query.ResultType.mixed) { + query.setResultType(searchProperties.getResultType()); + } + + if (pagination.getSinceId() > 0) { + query.setSinceId(pagination.getSinceId()); + } + if (pagination.getMaxId() > 0) { + query.setMaxId(pagination.getMaxId()); + + Assert.isTrue(pagination.getMaxId() >= (pagination.getSinceId() - 1), + String.format("For non empty MAX_ID, The MAX_ID (%s) must always be bigger than [SINCE_ID -1](%s)", + pagination.getMaxId(), (pagination.getSinceId() - 1))); + } + + return query; + } +} diff --git a/functions/supplier/twitter-supplier/src/main/java/org/springframework/cloud/fn/supplier/twitter/status/search/TwitterSearchSupplierProperties.java b/functions/supplier/twitter-supplier/src/main/java/org/springframework/cloud/fn/supplier/twitter/status/search/TwitterSearchSupplierProperties.java new file mode 100644 index 00000000..b8b6bfdf --- /dev/null +++ b/functions/supplier/twitter-supplier/src/main/java/org/springframework/cloud/fn/supplier/twitter/status/search/TwitterSearchSupplierProperties.java @@ -0,0 +1,198 @@ +/* + * 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.supplier.twitter.status.search; + +import javax.validation.constraints.Max; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Pattern; +import javax.validation.constraints.Positive; + +import twitter4j.Query; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +/** + * @author Christian Tzolov + */ +@ConfigurationProperties("twitter.search") +@Validated +public class TwitterSearchSupplierProperties { + + /** + * Search tweets by search query string. + */ + @NotNull + @NotEmpty + private String query; + + /** + * Number of pages (e.g. requests) to search backwards (from most recent to the oldest tweets) before start + * the search from the most recent tweets again. + * The total amount of tweets searched backwards is (page * count) + */ + @Positive + private int page = 3; + + /** + * Number of tweets to return per page (e.g. per single request), up to a max of 100. + */ + @Positive + @Max(100) + private int count = 100; + + /** + * Restricts searched tweets to the given language, given by an ISO 639-1 code. + */ + private String lang = null; + + /** + * If specified, returns tweets with since the given date. Date should be formatted as YYYY-MM-DD. + */ + @Pattern(regexp = "^\\d{4}-\\d{2}-\\d{2}$") + private String since = null; + + /** + * If specified, returns tweets by users located within a given radius (in Km) of the given latitude/longitude, + * where the user's location is taken from their Twitter profile. + * Should be formatted as + */ + private Geocode geocode = new Geocode(); + + /** + * Specifies what type of search results you would prefer to receive. + * The current default is "mixed." Valid values include: + * mixed : Include both popular and real time results in the response. + * recent : return only the most recent results in the response + * popular : return only the most popular results in the response + */ + @NotNull + private Query.ResultType resultType = Query.ResultType.mixed; + + /** + * Restart search from the most recent tweets on empty response. + * Applied only after the first restart (e.g. when since_id != UNBOUNDED) + */ + private boolean restartFromMostRecentOnEmptyResponse = false; + + public String getQuery() { + return query; + } + + public void setQuery(String query) { + this.query = query; + } + + public String getLang() { + return lang; + } + + public void setLang(String lang) { + this.lang = lang; + } + + public int getPage() { + return page; + } + + public void setPage(int page) { + this.page = page; + } + + public int getCount() { + return count; + } + + public void setCount(int count) { + this.count = count; + } + + public String getSince() { + return since; + } + + public void setSince(String since) { + this.since = since; + } + + public Geocode getGeocode() { + return geocode; + } + + public Query.ResultType getResultType() { + return resultType; + } + + public void setResultType(Query.ResultType resultType) { + this.resultType = resultType; + } + + public boolean isRestartFromMostRecentOnEmptyResponse() { + return restartFromMostRecentOnEmptyResponse; + } + + public void setRestartFromMostRecentOnEmptyResponse(boolean restartFromMostRecentOnEmptyResponse) { + this.restartFromMostRecentOnEmptyResponse = restartFromMostRecentOnEmptyResponse; + } + + public static class Geocode { + + /** + * User's latitude. + */ + private double latitude = -1; + + /** + * User's longitude. + */ + private double longitude = -1; + + /** + * Radius (in kilometers) around the (latitude, longitude) point. + */ + private double radius = -1; + + public double getLatitude() { + return latitude; + } + + public void setLatitude(double latitude) { + this.latitude = latitude; + } + + public double getLongitude() { + return longitude; + } + + public void setLongitude(double longitude) { + this.longitude = longitude; + } + + public double getRadius() { + return radius; + } + + public void setRadius(double radius) { + this.radius = radius; + } + + public boolean isValid() { + return this.radius > 0; + } + } +} diff --git a/functions/supplier/twitter-supplier/src/main/java/org/springframework/cloud/fn/supplier/twitter/status/stream/TwitterStreamSupplierConfiguration.java b/functions/supplier/twitter-supplier/src/main/java/org/springframework/cloud/fn/supplier/twitter/status/stream/TwitterStreamSupplierConfiguration.java new file mode 100644 index 00000000..110bc47c --- /dev/null +++ b/functions/supplier/twitter-supplier/src/main/java/org/springframework/cloud/fn/supplier/twitter/status/stream/TwitterStreamSupplierConfiguration.java @@ -0,0 +1,155 @@ +/* + * 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.supplier.twitter.status.stream; + +import java.util.function.Supplier; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.publisher.Flux; +import twitter4j.StallWarning; +import twitter4j.Status; +import twitter4j.StatusDeletionNotice; +import twitter4j.StatusListener; +import twitter4j.TwitterStream; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.fn.common.twitter.TwitterConnectionConfiguration; +import org.springframework.cloud.fn.common.twitter.TwitterConnectionProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.integration.channel.FluxMessageChannel; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.util.MimeTypeUtils; + +/** + * + * @author Christian Tzolov + */ + +@EnableConfigurationProperties({ TwitterStreamSupplierProperties.class, TwitterConnectionProperties.class }) +@Import(TwitterConnectionConfiguration.class) +public class TwitterStreamSupplierConfiguration { + + private static final Log logger = LogFactory.getLog(TwitterStreamSupplierConfiguration.class); + + @Bean + public FluxMessageChannel output() { + return new FluxMessageChannel(); + } + + @Bean + public StatusListener twitterStatusListener(FluxMessageChannel output, TwitterStream twitterStream, + ObjectMapper objectMapper) { + + StatusListener statusListener = new StatusListener() { + + @Override + public void onException(Exception e) { + logger.error("Status Error: ", e); + throw new RuntimeException("Status Error: ", e); + } + + @Override + public void onDeletionNotice(StatusDeletionNotice arg) { + logger.info("StatusDeletionNotice: " + arg); + } + + @Override + public void onScrubGeo(long userId, long upToStatusId) { + logger.info("onScrubGeo: " + userId + ", " + upToStatusId); + } + + @Override + public void onStallWarning(StallWarning warning) { + logger.warn("Stall Warning: " + warning); + throw new RuntimeException("Stall Warning: " + warning); + } + + @Override + public void onStatus(Status status) { + + try { + String json = objectMapper.writeValueAsString(status); + Message message = MessageBuilder.withPayload(json.getBytes()) + .setHeader(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.APPLICATION_JSON_VALUE) + .build(); + output.send(message); + // System.out.println(json); + } + catch (JsonProcessingException e) { + logger.error("Status to JSON conversion error!", e); + throw new RuntimeException("Status to JSON conversion error!", e); + } + } + + @Override + public void onTrackLimitationNotice(int numberOfLimitedStatuses) { + logger.warn("Track Limitation Notice: " + numberOfLimitedStatuses); + } + }; + + twitterStream.addListener(statusListener); + + return statusListener; + } + + @Bean + public Supplier>> twitterStreamSupplier(TwitterStream twitterStream, + FluxMessageChannel output, TwitterStreamSupplierProperties streamProperties) { + + return () -> Flux.from(output) + .doOnSubscribe(subscription -> { + try { + switch (streamProperties.getType()) { + + case filter: + twitterStream.filter(streamProperties.getFilter().toFilterQuery()); + return; + + case sample: + twitterStream.sample(); + return; + + case firehose: + twitterStream.firehose(streamProperties.getFilter().getCount()); + return; + + case link: + twitterStream.links(streamProperties.getFilter().getCount()); + return; + default: + throw new IllegalArgumentException("Unknown stream type:" + streamProperties.getType()); + } + } + catch (Exception e) { + this.logger.error("Filter is not property set"); + } + }) + .doAfterTerminate(() -> { + this.logger.info("Proactive cancel for twitter stream"); + twitterStream.shutdown(); + }) + .doOnError(throwable -> { + this.logger.error(throwable.getMessage(), throwable); + }); + } +} diff --git a/functions/supplier/twitter-supplier/src/main/java/org/springframework/cloud/fn/supplier/twitter/status/stream/TwitterStreamSupplierProperties.java b/functions/supplier/twitter-supplier/src/main/java/org/springframework/cloud/fn/supplier/twitter/status/stream/TwitterStreamSupplierProperties.java new file mode 100644 index 00000000..7c737641 --- /dev/null +++ b/functions/supplier/twitter-supplier/src/main/java/org/springframework/cloud/fn/supplier/twitter/status/stream/TwitterStreamSupplierProperties.java @@ -0,0 +1,267 @@ +/* + * 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.supplier.twitter.status.stream; + +import java.util.ArrayList; +import java.util.List; + +import twitter4j.FilterQuery; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.util.CollectionUtils; +import org.springframework.validation.annotation.Validated; + +/** + * @author Christian Tzolov + */ +@ConfigurationProperties("twitter.stream") +@Validated +public class TwitterStreamSupplierProperties { + + public enum StreamType { + /** Starts listening on random sample of all public statuses. The default access level provides a small + * proportion of the Firehose. */ + sample, + + /** Start consuming public statuses that match one or more filter predicates. At least one predicate parameter, + * follow, locations, or track must be specified. Multiple parameters may be specified which allows most + * clients to use a single connection to the Streaming API. Placing long parameters in the URL may cause the + * request to be rejected for excessive URL length.
+ * The default access level allows up to 200 track keywords, 400 follow userids and 10 1-degree location boxes. + * Increased access levels allow 80,000 follow userids ("shadow" role), 400,000 follow userids ("birddog" role), + * 10,000 track keywords ("restricted track" role), 200,000 track keywords ("partner track" role), + * and 200 10-degree location boxes ("locRestricted" role). Increased track access levels also pass a higher + * proportion of statuses before limiting the stream.*/ + filter, + + /** tarts listening on all public statuses. Available only to approved parties and requires a signed agreement + * to access. */ + firehose, + + /** Starts listening on all public statuses containing links. + * Available only to approved parties and requires a signed agreement to access.*/ + link + } + + private StreamType type = StreamType.sample; + + private Filter filter = new Filter(); + + public Filter getFilter() { + return filter; + } + + public StreamType getType() { + return type; + } + + public void setType(StreamType type) { + this.type = type; + } + + public static class Filter { + + public enum FilterLevel { + /** filter level. */ + all, none, low, medium + } + + /** + * Indicates the number of previous statuses to stream before transitioning to the live stream. + */ + private int count = 0; + + /** + * Specifies the users, by ID, to receive public tweets from. + */ + private List follow; + + /** + * Specifies keywords to track. + */ + private List track; + + + /** + * Locations to track. Internally represented as 2D array. + * Bounding box is invalid: 52.38, 4.90, 51.51, -0.12. The first pair must be the SW corner of the box + */ + private List locations = new ArrayList<>(); + + + /** + * Specifies the tweets language of the stream. + */ + private List language; + + /** + * The filter level limits what tweets appear in the stream to those with a minimum filterLevel attribute value. + * One of either none, low, or medium. + */ + private FilterLevel filterLevel = FilterLevel.all; + + public int getCount() { + return count; + } + + public void setCount(int count) { + this.count = count; + } + + public List getFollow() { + return follow; + } + + public void setFollow(List follow) { + this.follow = follow; + } + + public List getTrack() { + return track; + } + + public void setTrack(List track) { + this.track = track; + } + + public List getLanguage() { + return language; + } + + public void setLanguage(List language) { + this.language = language; + } + + public List getLocations() { + return locations; + } + + public FilterLevel getFilterLevel() { + return filterLevel; + } + + public void setFilterLevel(FilterLevel filterLevel) { + this.filterLevel = filterLevel; + } + + public FilterQuery toFilterQuery() { + + FilterQuery filterQuery = new FilterQuery(); + + filterQuery.count(this.count); + + if (!CollectionUtils.isEmpty(this.track)) { + filterQuery.track(String.join(",", this.track)); + } + + if (!CollectionUtils.isEmpty(this.follow)) { + long[] followIds = new long[this.follow.size()]; + for (int i = 0; i < this.follow.size(); i++) { + followIds[i] = this.follow.get(i); + } + filterQuery.follow(followIds); + } + + if (!CollectionUtils.isEmpty(this.language)) { + filterQuery.language(this.language.toArray(new String[this.language.size()])); + } + + if (!CollectionUtils.isEmpty(this.locations)) { + double[][] bboxLocations = new double[this.locations.size() * 2][2]; + for (int i = 0; i < this.locations.size(); i = i + 2) { + //SW lat, lon + bboxLocations[i][0] = this.locations.get(i).getSw().getLat(); + bboxLocations[i][1] = this.locations.get(i).getSw().getLon(); + //NE lat, lon + bboxLocations[i + 1][0] = this.locations.get(i).getNe().getLat(); + bboxLocations[i + 1][1] = this.locations.get(i).getNe().getLon(); + } + filterQuery.locations(bboxLocations); + } + + if (this.filterLevel != FilterLevel.all) { + filterQuery.filterLevel(this.filterLevel.name()); + } + + return filterQuery; + } + + public boolean isValid() { + return count > 0 || !CollectionUtils.isEmpty(this.track) || !CollectionUtils.isEmpty(this.follow) + || !CollectionUtils.isEmpty(this.language) || this.filterLevel != FilterLevel.all; + } + + public static class BoundingBox { + + /** + * Bounding Box's South-West point (e.g. bottom-left). + */ + private Geocode sw; + + /** + * Bounding Box's North-East point (e.g. top-right). + */ + private Geocode ne; + + public Geocode getSw() { + return sw; + } + + public void setSw(Geocode sw) { + this.sw = sw; + } + + public Geocode getNe() { + return ne; + } + + public void setNe(Geocode ne) { + this.ne = ne; + } + } + + public static class Geocode { + + /** + * latitude. + */ + private double lat = -1; + + /** + * longitude. + */ + private double lon = -1; + + + public double getLat() { + return lat; + } + + public void setLat(double lat) { + this.lat = lat; + } + + public double getLon() { + return lon; + } + + public void setLon(double lon) { + this.lon = lon; + } + } + } +} diff --git a/functions/supplier/twitter-supplier/src/test/java/org/springframework/cloud/fn/supplier/twitter/status/search/SearchPaginationTests.java b/functions/supplier/twitter-supplier/src/test/java/org/springframework/cloud/fn/supplier/twitter/status/search/SearchPaginationTests.java new file mode 100644 index 00000000..67e48ce9 --- /dev/null +++ b/functions/supplier/twitter-supplier/src/test/java/org/springframework/cloud/fn/supplier/twitter/status/search/SearchPaginationTests.java @@ -0,0 +1,349 @@ +/* + * 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.supplier.twitter.status.search; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +import org.junit.Test; +import twitter4j.GeoLocation; +import twitter4j.HashtagEntity; +import twitter4j.MediaEntity; +import twitter4j.Place; +import twitter4j.RateLimitStatus; +import twitter4j.Scopes; +import twitter4j.Status; +import twitter4j.SymbolEntity; +import twitter4j.URLEntity; +import twitter4j.User; +import twitter4j.UserMentionEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.cloud.fn.supplier.twitter.status.search.SearchPagination.UNBOUNDED; + +/** + * @author Christian Tzolov + */ +public class SearchPaginationTests { + + @Test + public void tests1() { + + int maxCountPerRequest = 5; + + int count = 10; + + int pageCount = count / maxCountPerRequest; + + assertThat(pageCount).isEqualTo(2); + + SearchPagination pagination = new SearchPagination(pageCount, false); + + assertThat(pagination.getSinceId()).isEqualTo(UNBOUNDED); + assertThat(pagination.getMaxId()).isEqualTo(UNBOUNDED); + assertThat(pagination.getPageMaxId()).isEqualTo(UNBOUNDED); + assertThat(pagination.getPageCounter()).isEqualTo(pageCount - 1); + + pagination.update(tweets(10, 8, 1)); + + assertThat(pagination.getSinceId()).isEqualTo(UNBOUNDED); + assertThat(pagination.getMaxId()).isEqualTo(8L - 1); // = min - 1 + assertThat(pagination.getPageMaxId()).isEqualTo(10L); + assertThat(pagination.getPageCounter()).isEqualTo(pageCount - 2); + + pagination.update(tweets(7, 4, 1)); + + assertThat(pagination.getSinceId()).isEqualTo(10L); + assertThat(pagination.getMaxId()).isEqualTo(UNBOUNDED); + assertThat(pagination.getPageMaxId()).isEqualTo(UNBOUNDED); + assertThat(pagination.getPageCounter()).isEqualTo(pageCount - 1); + + pagination.update(tweets(20, 14, 1)); + + assertThat(pagination.getSinceId()).isEqualTo(10L); + assertThat(pagination.getMaxId()).isEqualTo(14L - 1); + assertThat(pagination.getPageMaxId()).isEqualTo(20L); + assertThat(pagination.getPageCounter()).isEqualTo(pageCount - 2); + + pagination.update(tweets(13, 8, 1)); + + assertThat(pagination.getSinceId()).isEqualTo(20L); + assertThat(pagination.getMaxId()).isEqualTo(UNBOUNDED); + assertThat(pagination.getPageMaxId()).isEqualTo(UNBOUNDED); + assertThat(pagination.getPageCounter()).isEqualTo(pageCount - 1); + } + + @Test + public void tests2() { + + int maxCountPerRequest = 5; + + int count = 10; + + int pageCount = count / maxCountPerRequest; + + assertThat(pageCount).isEqualTo(2); + + SearchPagination pagination = new SearchPagination(pageCount, true); + + assertThat(pagination.getSinceId()).isEqualTo(UNBOUNDED); + assertThat(pagination.getMaxId()).isEqualTo(UNBOUNDED); + assertThat(pagination.getPageMaxId()).isEqualTo(UNBOUNDED); + assertThat(pagination.getPageCounter()).isEqualTo(pageCount - 1); + + pagination.update(tweets(10, 8, 1)); + + assertThat(pagination.getSinceId()).isEqualTo(UNBOUNDED); + assertThat(pagination.getMaxId()).isEqualTo(8L - 1); // = min - 1 + assertThat(pagination.getPageMaxId()).isEqualTo(10L); + assertThat(pagination.getPageCounter()).isEqualTo(pageCount - 2); + + pagination.update(tweets(7, 4, 1)); + + // Restart from Most Recent due to pageCounter == 0 while no reset have been performed so ar + assertThat(pagination.getSinceId()).isEqualTo(10L); + assertThat(pagination.getMaxId()).isEqualTo(UNBOUNDED); + assertThat(pagination.getPageMaxId()).isEqualTo(UNBOUNDED); + assertThat(pagination.getPageCounter()).isEqualTo(pageCount - 1); + + pagination.update(tweets(20, 14, 1)); + + assertThat(pagination.getSinceId()).isEqualTo(10L); + assertThat(pagination.getMaxId()).isEqualTo(14L - 1); + assertThat(pagination.getPageMaxId()).isEqualTo(20L); + assertThat(pagination.getPageCounter()).isEqualTo(pageCount - 2); + + pagination.update(tweets(13, 8, 1)); + + assertThat(pagination.getSinceId()).isEqualTo(10L); + assertThat(pagination.getMaxId()).isEqualTo(8L - 1); + assertThat(pagination.getPageMaxId()).isEqualTo(20L); + assertThat(pagination.getPageCounter()).isEqualTo(pageCount - 3); + + pagination.update(new ArrayList<>()); // EMPTY + + // Restart from Most Recent on Empty Response + assertThat(pagination.getSinceId()).isEqualTo(20L); + assertThat(pagination.getMaxId()).isEqualTo(UNBOUNDED); + assertThat(pagination.getPageMaxId()).isEqualTo(UNBOUNDED); + assertThat(pagination.getPageCounter()).isEqualTo(pageCount - 1); + } + + public List tweets(int to, int from, int step) { + List tweets = new ArrayList<>(to - from); + for (int i = to; i >= from; i = i - step) { + tweets.add(new MyStatus(i)); + } + + return tweets; + } + + public static class MyStatus implements Status { + + private long id; + + public MyStatus(long id) { + this.id = id; + } + + @Override + public Date getCreatedAt() { + return null; + } + + @Override + public long getId() { + return this.id; + } + + @Override + public String getText() { + return null; + } + + @Override + public int getDisplayTextRangeStart() { + return 0; + } + + @Override + public int getDisplayTextRangeEnd() { + return 0; + } + + @Override + public String getSource() { + return null; + } + + @Override + public boolean isTruncated() { + return false; + } + + @Override + public long getInReplyToStatusId() { + return 0; + } + + @Override + public long getInReplyToUserId() { + return 0; + } + + @Override + public String getInReplyToScreenName() { + return null; + } + + @Override + public GeoLocation getGeoLocation() { + return null; + } + + @Override + public Place getPlace() { + return null; + } + + @Override + public boolean isFavorited() { + return false; + } + + @Override + public boolean isRetweeted() { + return false; + } + + @Override + public int getFavoriteCount() { + return 0; + } + + @Override + public User getUser() { + return null; + } + + @Override + public boolean isRetweet() { + return false; + } + + @Override + public Status getRetweetedStatus() { + return null; + } + + @Override + public long[] getContributors() { + return new long[0]; + } + + @Override + public int getRetweetCount() { + return 0; + } + + @Override + public boolean isRetweetedByMe() { + return false; + } + + @Override + public long getCurrentUserRetweetId() { + return 0; + } + + @Override + public boolean isPossiblySensitive() { + return false; + } + + @Override + public String getLang() { + return null; + } + + @Override + public Scopes getScopes() { + return null; + } + + @Override + public String[] getWithheldInCountries() { + return new String[0]; + } + + @Override + public long getQuotedStatusId() { + return 0; + } + + @Override + public Status getQuotedStatus() { + return null; + } + + @Override + public URLEntity getQuotedStatusPermalink() { + return null; + } + + @Override + public int compareTo(Status o) { + return 0; + } + + @Override + public UserMentionEntity[] getUserMentionEntities() { + return new UserMentionEntity[0]; + } + + @Override + public URLEntity[] getURLEntities() { + return new URLEntity[0]; + } + + @Override + public HashtagEntity[] getHashtagEntities() { + return new HashtagEntity[0]; + } + + @Override + public MediaEntity[] getMediaEntities() { + return new MediaEntity[0]; + } + + @Override + public SymbolEntity[] getSymbolEntities() { + return new SymbolEntity[0]; + } + + @Override + public RateLimitStatus getRateLimitStatus() { + return null; + } + + @Override + public int getAccessLevel() { + return 0; + } + } +} diff --git a/functions/supplier/twitter-supplier/src/test/java/org/springframework/cloud/fn/supplier/twitter/status/stream/TwitterStreamSupplierTests.java b/functions/supplier/twitter-supplier/src/test/java/org/springframework/cloud/fn/supplier/twitter/status/stream/TwitterStreamSupplierTests.java new file mode 100644 index 00000000..809b5b35 --- /dev/null +++ b/functions/supplier/twitter-supplier/src/test/java/org/springframework/cloud/fn/supplier/twitter/status/stream/TwitterStreamSupplierTests.java @@ -0,0 +1,211 @@ +/* + * 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.supplier.twitter.status.stream; + +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.function.Supplier; + +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockserver.client.MockServerClient; +import org.mockserver.integration.ClientAndServer; +import org.mockserver.model.Header; +import org.mockserver.model.HttpRequest; +import org.mockserver.model.StringBody; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; +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.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.messaging.Message; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit4.SpringRunner; + +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 + */ +@RunWith(SpringRunner.class) +@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 TwitterStreamSupplierTests { + + private static final String MOCK_SERVER_IP = "127.0.0.1"; + + private static final Integer MOCK_SERVER_PORT = 1080; + + private static ClientAndServer mockServer; + + private static MockServerClient mockClient; + private static HttpRequest streamFilterRequest; + private static HttpRequest streamSampleRequest; + private static HttpRequest streamFirehoseRequest; + + @Autowired + protected Supplier>> twitterStreamSupplier; + + @BeforeClass + public static void startServer() { + + mockServer = ClientAndServer.startClientAndServer(MOCK_SERVER_PORT); + + mockClient = new MockServerClient(MOCK_SERVER_IP, MOCK_SERVER_PORT); + + streamFilterRequest = mockClientRecordRequest(request() + .withMethod("POST") + .withPath("/stream/statuses/filter.json") + .withBody(new StringBody("count=0&track=Java%2CPython&stall_warnings=true"))); + + streamSampleRequest = mockClientRecordRequest(request() + .withMethod("GET") + .withPath("/stream/statuses/sample.json")); + + streamFirehoseRequest = mockClientRecordRequest(request() + .withMethod("POST") + .withPath("/stream/statuses/links.json") + .withBody(new StringBody("count=0&stall_warnings=true"))); + + streamFirehoseRequest = mockClientRecordRequest(request() + .withMethod("POST") + .withPath("/stream/statuses/firehose.json") + .withBody(new StringBody("count=0&stall_warnings=true"))); + } + + @AfterClass + public static void stopServer() { + mockServer.stop(); + } + + private static HttpRequest mockClientRecordRequest(HttpRequest request) { + mockClient.when(request, /*unlimited())*/ 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/stream_test_1.json")) + .withDelay(TimeUnit.SECONDS, 10)); + return request; + } + + @TestPropertySource(properties = { + "twitter.stream.type=sample" + }) + public static class TwitterStreamSampleTests extends TwitterStreamSupplierTests { + + @Test + public void testOne() { + final Flux> messageFlux = twitterStreamSupplier.get(); + + final StepVerifier stepVerifier = StepVerifier.create(messageFlux) + .assertNext((message) -> assertThat(new String((byte[]) message.getPayload())) + .contains("\"id\":1075751718749659136")) + .thenCancel() + .verifyLater(); + + stepVerifier.verify(); + + mockClient.verify(streamSampleRequest, once()); + } + } + + @TestPropertySource(properties = { + "twitter.stream.type=filter", + "twitter.stream.filter.track=Java,Python" + }) + public static class TwitterStreamFilterTests extends TwitterStreamSupplierTests { + + @Test + public void testOne() { + final Flux> messageFlux = twitterStreamSupplier.get(); + + final StepVerifier stepVerifier = StepVerifier.create(messageFlux) + .assertNext((message) -> assertThat(new String((byte[]) message.getPayload())) + .contains("\"id\":1075751718749659136")) + .thenCancel() + .verifyLater(); + + stepVerifier.verify(); + + mockClient.verify(streamFilterRequest, once()); + } + } + + @TestPropertySource(properties = { + "twitter.stream.type=firehose" + }) + public static class TwitterStreamFirehoseTests extends TwitterStreamSupplierTests { + + @Test + public void testOne() { + final Flux> messageFlux = twitterStreamSupplier.get(); + + final StepVerifier stepVerifier = StepVerifier.create(messageFlux) + .assertNext((message) -> assertThat(new String((byte[]) message.getPayload())) + .contains("\"id\":1075751718749659136")) + .thenCancel() + .verifyLater(); + + stepVerifier.verify(); + + mockClient.verify(streamFirehoseRequest, once()); + } + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @Import(TwitterStreamSupplierConfiguration.class) + public static class TestTwitterStreamSourceApplication { + + @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/supplier/twitter-supplier/src/test/java/org/springframework/cloud/fn/supplier/twitter/status/stream/TwitterTestUtils.java b/functions/supplier/twitter-supplier/src/test/java/org/springframework/cloud/fn/supplier/twitter/status/stream/TwitterTestUtils.java new file mode 100644 index 00000000..69305887 --- /dev/null +++ b/functions/supplier/twitter-supplier/src/test/java/org/springframework/cloud/fn/supplier/twitter/status/stream/TwitterTestUtils.java @@ -0,0 +1,68 @@ +/* + * 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.supplier.twitter.status.stream; + + +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.function.Function; + +import twitter4j.conf.ConfigurationBuilder; + +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.util.StreamUtils; + +/** + * @author Christian Tzolov + */ +public class TwitterTestUtils { + + public Function mockTwitterUrls(String baseUrl) { + return configBuilder -> { + configBuilder.setRestBaseURL(baseUrl + "/"); + configBuilder.setStreamBaseURL(baseUrl + "/stream/"); + configBuilder.setUserStreamBaseURL(baseUrl + "/user/"); + configBuilder.setSiteStreamBaseURL(baseUrl + "/site/"); + configBuilder.setUploadBaseURL(baseUrl + "/upload/"); + + configBuilder.setOAuthAccessTokenURL(baseUrl + "/oauth/access_token"); + configBuilder.setOAuthAuthenticationURL(baseUrl + "/oauth/authenticate"); + configBuilder.setOAuthAuthorizationURL(baseUrl + "/oauth/authorize"); + configBuilder.setOAuthRequestTokenURL(baseUrl + "/oauth/request_token"); + configBuilder.setOAuth2TokenURL(baseUrl + "/oauth2/token"); + configBuilder.setOAuth2InvalidateTokenURL(baseUrl + "/oauth2/invalidate_token"); + + return configBuilder; + }; + } + + /** + * Load Spring Resource as String. + * @param resourcePath Resource path (accepts file:// , classpath:// and http:// uri schemas) + * @return Returns text (UTF8) representation of the resource pointed by the resourcePath + */ + public static String asString(String resourcePath) { + try { + return StreamUtils.copyToString(new DefaultResourceLoader().getResource(resourcePath).getInputStream(), + Charset.forName("UTF-8")); + } + catch (IOException e) { + throw new RuntimeException("Can not load resource:" + resourcePath, e); + } + } + +} diff --git a/functions/supplier/twitter-supplier/src/test/resources/response/stream_test_1.json b/functions/supplier/twitter-supplier/src/test/resources/response/stream_test_1.json new file mode 100644 index 00000000..bb831206 --- /dev/null +++ b/functions/supplier/twitter-supplier/src/test/resources/response/stream_test_1.json @@ -0,0 +1,2 @@ +{"created_at":"Thu Dec 20 13:56:10 +0000 2018","id":1075751718749659136,"id_str":"1075751718749659136","text":"Codementor: Kubernetes for Python Developers: Part 1\n#100daysofcode #python https:\/\/t.co\/VviTgpFpge","source":"\u003ca href=\"https:\/\/ifttt.com\" rel=\"nofollow\"\u003eIFTTT\u003c\/a\u003e","truncated":false,"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,"user":{"id":859252650512072704,"id_str":"859252650512072704","name":"Freelancing|WebDev","screen_name":"FreelanceForBTC","location":"THE NET","url":"http:\/\/bit.ly\/BTCFREELANCING","description":"This twitter is designed to give the best information from web development to Blockchain.\n|YouTube's |Jobs |learning contented","translator_type":"none","protected":false,"verified":false,"followers_count":146,"friends_count":118,"listed_count":4,"favourites_count":445,"statuses_count":7528,"created_at":"Tue May 02 03:46:10 +0000 2017","utc_offset":null,"time_zone":null,"geo_enabled":false,"lang":"en","contributors_enabled":false,"is_translator":false,"profile_background_color":"F5F8FA","profile_background_image_url":"","profile_background_image_url_https":"","profile_background_tile":false,"profile_link_color":"1DA1F2","profile_sidebar_border_color":"C0DEED","profile_sidebar_fill_color":"DDEEF6","profile_text_color":"333333","profile_use_background_image":true,"profile_image_url":"http:\/\/pbs.twimg.com\/profile_images\/1071607798771793920\/UegrCs84_normal.jpg","profile_image_url_https":"https:\/\/pbs.twimg.com\/profile_images\/1071607798771793920\/UegrCs84_normal.jpg","profile_banner_url":"https:\/\/pbs.twimg.com\/profile_banners\/859252650512072704\/1544326203","default_profile":true,"default_profile_image":false,"following":null,"follow_request_sent":null,"notifications":null},"geo":null,"coordinates":null,"place":null,"contributors":null,"is_quote_status":false,"quote_count":0,"reply_count":0,"retweet_count":0,"favorite_count":0,"entities":{"hashtags":[{"text":"100daysofcode","indices":[53,67]},{"text":"python","indices":[68,75]}],"urls":[{"url":"https:\/\/t.co\/VviTgpFpge","expanded_url":"https:\/\/ift.tt\/2GxdMZF","display_url":"ift.tt\/2GxdMZF","indices":[76,99]}],"user_mentions":[],"symbols":[]},"favorited":false,"retweeted":false,"possibly_sensitive":false,"filter_level":"low","lang":"ca","timestamp_ms":"1545314170907"} +