diff --git a/.bazelrc b/.bazelrc index 8a0914b74d..4175338085 100644 --- a/.bazelrc +++ b/.bazelrc @@ -3,7 +3,7 @@ build --strategy=TypeScriptCompile=worker # Use java 11 -build --java_toolchain=@bazel_tools//tools/jdk:toolchain_java11 --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_java11 +build --java_toolchain=@bazel_tools//tools/jdk:toolchain_java11 --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_java11 --workspace_status_command=tools/build/bazel_status.sh # Output test errors by default test --test_output=errors @@ -21,7 +21,6 @@ build:ci --test_env=ROOT_LOG_LEVEL=ERROR build:ci --noshow_progress build:ci --verbose_failures -build:ci --disk_cache=~/.cache/bazel -build:ci --repository_cache==~/.cache/bazel_external +build:ci --remote_cache=https://storage.googleapis.com/airy-ci-cache test:ci --flaky_test_attempts=2 diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 500cf23915..32d7f65396 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -13,3 +13,11 @@ template: | ## Changes $CHANGES + + ## Airy CLI + + You can download the Airy CLI for your operating system from the following links: + + [MacOS](https://airy-core-binaries.s3.amazonaws.com/$RESOLVED_VERSION/darwin/amd64/airy) + [Linux](https://airy-core-binaries.s3.amazonaws.com/$RESOLVED_VERSION/linux/amd64/airy) + [Windows](https://airy-core-binaries.s3.amazonaws.com/$RESOLVED_VERSION/windows/amd64/airy.exe) \ No newline at end of file diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 397893c171..f186c7500c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -15,14 +15,6 @@ jobs: java-version: '11' architecture: 'x64' - - name: Mount bazel cache - uses: actions/cache@v2 - with: - path: | - /home/runner/.cache/bazel - /home/runner/.cache/bazel_external - key: bazel - - name: Install bazelisk run: | curl -LO "https://github.com/bazelbuild/bazelisk/releases/download/v1.1.0/bazelisk-linux-amd64" @@ -32,9 +24,13 @@ jobs: - name: Enable CI settings run: | + echo "$GCS_SA_KEY" > key.json cat <>.bazelrc common --config=ci + build:ci --google_credentials=key.json EOF + env: + GCS_SA_KEY: ${{secrets.GCS_SA_KEY}} - name: Lint run: | @@ -53,3 +49,16 @@ jobs: run: | echo ${{ secrets.PAT }} | docker login ghcr.io -u airydevci --password-stdin ./scripts/push-images.sh ${{ github.ref }} + - name: Install aws cli + uses: chrislennon/action-aws-cli@v1.1 + env: + ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true' + - name: Upload airy binary to S3 + if: startsWith(github.ref, 'refs/heads/release') || startsWith(github.ref, 'refs/heads/main') || startsWith(github.ref, 'refs/heads/develop') + run: | + aws s3 cp bazel-bin/infrastructure/cli/airy_linux_bin s3://airy-core-binaries/`cat ./VERSION`/linux/amd64/airy + aws s3 cp bazel-bin/infrastructure/cli/airy_darwin_bin s3://airy-core-binaries/`cat ./VERSION`/darwin/amd64/airy + aws s3 cp bazel-bin/infrastructure/cli/airy_windows_bin s3://airy-core-binaries/`cat ./VERSION`/windows/amd64/airy.exe + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} diff --git a/.gitignore b/.gitignore index 148b9451f0..91a60c8ca6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,13 @@ # Editors and IDE's + +!.ijwb/.idea/fileTemplates/ + .idea/ .code/ .vscode/ .ijwb/ -# Bazel -!.ijwb/.bazelproject + dist/ bazel-* diff --git a/backend/media/src/main/java/co/airy/core/media/Resolver.java b/.ijwb/.idea/fileTemplates/Stream App.java similarity index 75% rename from backend/media/src/main/java/co/airy/core/media/Resolver.java rename to .ijwb/.idea/fileTemplates/Stream App.java index d9abdc148f..489b70dcd7 100644 --- a/backend/media/src/main/java/co/airy/core/media/Resolver.java +++ b/.ijwb/.idea/fileTemplates/Stream App.java @@ -1,4 +1,4 @@ -package co.airy.core.media; +#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME};#end import co.airy.kafka.streams.KafkaStreamsWrapper; import co.airy.log.AiryLoggerFactory; @@ -11,20 +11,19 @@ import org.springframework.stereotype.Component; @Component -public class Resolver implements ApplicationListener, DisposableBean { +public class ${NAME} implements ApplicationListener, DisposableBean { private final Logger log = AiryLoggerFactory.getLogger(Resolver.class); - - private static final String appId = "media.Resolver"; + private static final String appId = "#[[$AppId$]]#"; private final KafkaStreamsWrapper streams; - public Resolver(KafkaStreamsWrapper streams) { + public ${NAME}(KafkaStreamsWrapper streams) { this.streams = streams; } @Override public void onApplicationEvent(ApplicationStartedEvent event) { final StreamsBuilder builder = new StreamsBuilder(); - + #[[$END$]]# streams.start(builder.build(), appId); } @@ -35,8 +34,7 @@ public void destroy() { } } - - // visible for testing + // Visible for testing KafkaStreams.State getStreamState() { return streams.state(); } diff --git a/BUILD b/BUILD index 097d329b75..10e30e5293 100644 --- a/BUILD +++ b/BUILD @@ -168,6 +168,7 @@ exports_files( ], ) +# gazelle:proto disable_global # gazelle:build_file_name BUILD # gazelle:prefix gazelle(name = "gazelle") diff --git a/README.md b/README.md index 8b13d10a73..4841d2f2af 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,75 @@ +

+ Airy-logo + + +

The open source, fully-featured, production ready
+
Messaging platform
+

+ + + # Airy Core Platform +[![Join the chat on Airy community](https://img.shields.io/badge/forum-join%20discussions-brightgreen.svg)](https://airy.co/community/?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) +[![Documentation Status](https://img.shields.io/badge/docs-stable-brightgreen.svg)](https://docs.airy.co/) +[![CI](https://github.com/airyhq/airy/workflows/CI/badge.svg)](https://github.com/airyhq/airy/actions?query=workflow%3ACI) +[![Commit Frequency](https://img.shields.io/github/commit-activity/m/airyhq/airy)](https://docs.airy.co/) +[![License](https://img.shields.io/github/license/airyhq/airy)](https://docs.airy.co/) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://github.com/airyhq/airy/projects) + + The Airy Core Platform is an open source, fully-featured, production ready -messaging platform to process conversational data from a variety of sources -(like Facebook Messenger, Google Business Messages, Website Live Chat, and -more). +messaging platform. +With Airy you can process conversational data from a variety of sources: + + - **Facebook** + - **WhatsApp** + - **Google's Business Messages** + - **SMS** + - **Website Chat Plugins** + - **Twilio** + - **Your own conversational channels** + +You can then use Airy to: + + - **Unify your messaging channels** + - **Stream your conversational data wherever you want** + - **Integrate with different NLP frameworks** + - **Mediate open requests with Agents via our messaging UI** + - **Analyze your conversations** + +Since Airy's infrastructure is built around Apache Kafka, it can process a +large amount of conversations and messages simultaneously and stream the +relevant conversational data to wherever you need it. + +Learn more about what we open-sourced in the +[announcement blog post](https://airy.co/blog/what-we-open-sourced). + +## About Airy + +- **What does Airy do? 🚀** + [Learn more on our Website](https://airy.co/developers) + +- **I'm new to Airy 😄** + [Get Started with Airy](https://docs.airy.co/) + +- **I'd like to read the detailed docs 📖** + [Read The Docs](https://docs.airy.co/) + +- **I'm ready to install Airy ✨** + [Installation](https://docs.airy.co/) + +- **I have a question ❓** + [The Airy Community will help](https://airy.co/community) + +- **Or continue reading the Read Me** -- [Getting Started](#getting-started) -- [Components](#components) -- [Organization of this Repo](#organization-of-the-repository) -- [Design Principles](#design-principles) -- [How to contribute](#how-to-contribute) -- [Code of Conduct](#code-of-conduct) + - [Getting started](#getting-started) + - [Components](#components) + - [Organization of the Repository](#organization-of-the-repository) + - [Design Principles](#design-principles) + - [How to contribute](#how-to-contribute) + - [Code of Conduct](#code-of-conduct) ## Getting started @@ -19,7 +78,7 @@ You can run the Airy Core Platform locally by running the following commands: ```sh $ git clone https://github.com/airyhq/airy $ cd airy -$ AIRY_VERSION=beta ./scripts/bootstrap.sh +$ ./scripts/bootstrap.sh ``` The bootstrap installation requires diff --git a/VERSION b/VERSION index 8f0916f768..a918a2aa18 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.0 +0.6.0 diff --git a/WORKSPACE b/WORKSPACE index 83dc45a9d7..fa8ef5d216 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -25,6 +25,8 @@ load("@rules_jvm_external//:defs.bzl", "maven_install") maven_install( artifacts = airy_jvm_deps + [ + "com.amazonaws:aws-java-sdk-core:1.11.933", + "com.amazonaws:aws-java-sdk-s3:1.11.933", "com.fasterxml.jackson.core:jackson-annotations:2.10.0", "com.fasterxml.jackson.core:jackson-core:2.10.0", "com.fasterxml.jackson.core:jackson-databind:2.10.0", @@ -60,6 +62,7 @@ maven_install( "org.apache.lucene:lucene-queryparser:8.7.0", "org.apache.lucene:lucene-analyzers-common:8.7.0", "org.apache.lucene:lucene-core:8.7.0", + "org.aspectj:aspectjweaver:1.8.10", "org.bouncycastle:bcpkix-jdk15on:1.63", "org.flywaydb:flyway-core:5.2.4", "org.hamcrest:hamcrest-library:2.1", @@ -84,6 +87,7 @@ maven_install( "org.springframework.boot:spring-boot-starter-web:2.3.1.RELEASE", "org.springframework.boot:spring-boot-starter-websocket:2.3.1.RELEASE", "org.springframework.boot:spring-boot-starter-security:2.3.1.RELEASE", + "org.springframework.retry:spring-retry:1.2.5.RELEASE", "org.springframework:spring-aop:4.1.4.RELEASE", "org.springframework:spring-jdbc:4.1.4.RELEASE", "org.springframework:spring-context-support:5.2.0.RELEASE", @@ -184,10 +188,10 @@ container_pull( container_pull( name = "nginx_base", - digest = "sha256:662a0c5a8677063c27b0ddd42f1c801be643b9502f7b1a4e2e727cb2bc3808a8", - registry = "index.docker.io", - repository = "nginx", - tag = "stable-alpine", + digest = "sha256:0340d329672fb3f0192754e4e1ccd7518ecc83f6644e8f0c317012bbc4d06d24", + registry = "ghcr.io", + repository = "airyhq/frontend/nginx-lua", + tag = "1.0.0", ) load( diff --git a/backend/api/admin/src/main/java/co/airy/core/api/admin/ChannelsController.java b/backend/api/admin/src/main/java/co/airy/core/api/admin/ChannelsController.java index 2f8b09bcdf..7f70c5e78f 100644 --- a/backend/api/admin/src/main/java/co/airy/core/api/admin/ChannelsController.java +++ b/backend/api/admin/src/main/java/co/airy/core/api/admin/ChannelsController.java @@ -80,7 +80,7 @@ ResponseEntity disconnect(@RequestBody @Valid ChannelDisconnectRequestPayload } if (channel.getConnectionState().equals(ChannelConnectionState.DISCONNECTED)) { - return ResponseEntity.accepted().build(); + return ResponseEntity.accepted().body(new EmptyResponsePayload()); } channel.setConnectionState(ChannelConnectionState.DISCONNECTED); diff --git a/backend/api/admin/src/main/java/co/airy/core/api/config/ClientConfigController.java b/backend/api/admin/src/main/java/co/airy/core/api/config/ClientConfigController.java new file mode 100644 index 0000000000..6977821c0c --- /dev/null +++ b/backend/api/admin/src/main/java/co/airy/core/api/config/ClientConfigController.java @@ -0,0 +1,25 @@ +package co.airy.core.api.config; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.HashMap; +import java.util.Map; + +@RestController +public class ClientConfigController { + private final ServiceDiscovery serviceDiscovery; + + public ClientConfigController(ServiceDiscovery serviceDiscovery) { + this.serviceDiscovery = serviceDiscovery; + } + + @PostMapping("/client.config") + public ResponseEntity getConfig() { + return ResponseEntity.ok(ClientConfigResponsePayload.builder() + .components(serviceDiscovery.getComponents()) + .features(Map.of()) + .build()); + } +} diff --git a/backend/api/admin/src/main/java/co/airy/core/api/config/ClientConfigResponsePayload.java b/backend/api/admin/src/main/java/co/airy/core/api/config/ClientConfigResponsePayload.java new file mode 100644 index 0000000000..bd11534e48 --- /dev/null +++ b/backend/api/admin/src/main/java/co/airy/core/api/config/ClientConfigResponsePayload.java @@ -0,0 +1,17 @@ +package co.airy.core.api.config; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +@Builder +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ClientConfigResponsePayload { + private Map> components; + private Map features; +} diff --git a/backend/api/admin/src/main/java/co/airy/core/api/config/ClientControllerConfig.java b/backend/api/admin/src/main/java/co/airy/core/api/config/ClientControllerConfig.java new file mode 100644 index 0000000000..c3f56961f1 --- /dev/null +++ b/backend/api/admin/src/main/java/co/airy/core/api/config/ClientControllerConfig.java @@ -0,0 +1,15 @@ +package co.airy.core.api.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.web.client.RestTemplate; + +@EnableScheduling +@Configuration +public class ClientControllerConfig { + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} diff --git a/backend/api/admin/src/main/java/co/airy/core/api/config/ServiceDiscovery.java b/backend/api/admin/src/main/java/co/airy/core/api/config/ServiceDiscovery.java new file mode 100644 index 0000000000..404921a692 --- /dev/null +++ b/backend/api/admin/src/main/java/co/airy/core/api/config/ServiceDiscovery.java @@ -0,0 +1,50 @@ +package co.airy.core.api.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Component +public class ServiceDiscovery { + private final String namespace; + private final RestTemplate restTemplate; + + private final Map> components = new ConcurrentHashMap<>(); + + private static final List services = List.of( + "sources-chatplugin", + "sources-facebook-connector", + "sources-twilio-connector", + "sources-google-connector" + ); + + public ServiceDiscovery(@Value("${kubernetes.namespace}") String namespace, RestTemplate restTemplate) { + this.namespace = namespace; + this.restTemplate = restTemplate; + } + + public Map> getComponents() { + return components; + } + + @Scheduled(fixedRate = 1_000) + private void updateComponentsStatus() { + for (String service : services) { + try { + ResponseEntity response = restTemplate.exchange(String.format("http://%s.%s/actuator/health", service, namespace), HttpMethod.GET, null, Object.class); + components.put(service.replace("-connector", ""), Map.of("enabled", response.getStatusCode().is2xxSuccessful())); + } catch (Exception e) { + components.put(service.replace("-connector", ""), Map.of("enabled",false)); + } + } + } +} diff --git a/backend/api/admin/src/main/resources/application.properties b/backend/api/admin/src/main/resources/application.properties index fbd90a5ed6..444ce06a02 100644 --- a/backend/api/admin/src/main/resources/application.properties +++ b/backend/api/admin/src/main/resources/application.properties @@ -2,7 +2,5 @@ kafka.brokers=${KAFKA_BROKERS} kafka.schema-registry-url=${KAFKA_SCHEMA_REGISTRY_URL} kafka.cleanup=${KAFKA_CLEANUP:false} kafka.commit-interval-ms=${KAFKA_COMMIT_INTERVAL_MS} - -facebook.app-id=${FACEBOOK_APP_ID} -facebook.app-secret=${FACEBOOK_APP_SECRET} auth.jwt-secret=${JWT_SECRET} +kubernetes.namespace=${KUBERNETES_NAMESPACE} \ No newline at end of file diff --git a/backend/api/admin/src/test/java/co/airy/core/api/config/ClientConfigControllerTest.java b/backend/api/admin/src/test/java/co/airy/core/api/config/ClientConfigControllerTest.java new file mode 100644 index 0000000000..f5cd36a103 --- /dev/null +++ b/backend/api/admin/src/test/java/co/airy/core/api/config/ClientConfigControllerTest.java @@ -0,0 +1,111 @@ +package co.airy.core.api.config; + +import co.airy.kafka.schema.application.ApplicationCommunicationChannels; +import co.airy.kafka.schema.application.ApplicationCommunicationTags; +import co.airy.kafka.schema.application.ApplicationCommunicationWebhooks; +import co.airy.kafka.test.KafkaTestHelper; +import co.airy.kafka.test.junit.SharedKafkaTestResource; +import co.airy.spring.core.AirySpringBootApplication; +import co.airy.spring.test.WebTestHelper; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.mockito.InjectMocks; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; + +import java.net.URI; + +import static co.airy.test.Timing.retryOnException; +import static org.hamcrest.CoreMatchers.everyItem; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.collection.IsCollectionWithSize.hasSize; +import static org.springframework.test.web.client.ExpectedCount.min; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = AirySpringBootApplication.class) +@TestPropertySource(value = "classpath:test.properties") +@AutoConfigureMockMvc +@ExtendWith(SpringExtension.class) +public class ClientConfigControllerTest { + @RegisterExtension + public static final SharedKafkaTestResource sharedKafkaTestResource = new SharedKafkaTestResource(); + private static KafkaTestHelper kafkaTestHelper; + + @Autowired + private RestTemplate restTemplate; + + private MockRestServiceServer mockServer; + + @InjectMocks + private ClientConfigController configController; + + @Autowired + private WebTestHelper webTestHelper; + + private static final ApplicationCommunicationChannels applicationCommunicationChannels = new ApplicationCommunicationChannels(); + private static final ApplicationCommunicationWebhooks applicationCommunicationWebhooks = new ApplicationCommunicationWebhooks(); + private static final ApplicationCommunicationTags applicationCommunicationTags = new ApplicationCommunicationTags(); + + @BeforeAll + static void beforeAll() throws Exception { + kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, + applicationCommunicationChannels, + applicationCommunicationWebhooks, + applicationCommunicationTags + ); + kafkaTestHelper.beforeAll(); + } + + @AfterAll + static void afterAll() throws Exception { + kafkaTestHelper.afterAll(); + } + + @BeforeEach + void beforeEach() throws Exception { + webTestHelper.waitUntilHealthy(); + + mockServer = MockRestServiceServer.createServer(restTemplate); + } + + @Test + public void canReturnConfig() throws Exception { + mockServer.expect(min(1), requestTo(new URI("http://sources-chatplugin.default/actuator/health"))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withStatus(HttpStatus.OK)); + + mockServer.expect(min(1), requestTo(new URI("http://sources-facebook-connector.default/actuator/health"))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withStatus(HttpStatus.OK)); + + mockServer.expect(min(1), requestTo(new URI("http://sources-twilio-connector.default/actuator/health"))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withStatus(HttpStatus.OK)); + + mockServer.expect(min(1), requestTo(new URI("http://sources-google-connector.default/actuator/health"))) + .andExpect(method(HttpMethod.GET)) + .andRespond(withStatus(HttpStatus.OK)); + + retryOnException(() -> webTestHelper.post("/client.config", "{}", "user-id") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.components.*", hasSize(4))) + .andExpect(jsonPath("$.components.*.enabled", everyItem(is(true)))), + "client.config call failed"); + } + +} diff --git a/backend/api/admin/src/test/resources/test.properties b/backend/api/admin/src/test/resources/test.properties index 1ad7ec68ba..4e9295c247 100644 --- a/backend/api/admin/src/test/resources/test.properties +++ b/backend/api/admin/src/test/resources/test.properties @@ -1,5 +1,4 @@ kafka.cleanup=true kafka.commit-interval-ms=100 -facebook.app-id=1234 -facebook.app-secret=secret auth.jwt-secret=42424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242 +kubernetes.namespace=default diff --git a/backend/api/communication/src/main/java/co/airy/core/api/communication/ConversationsController.java b/backend/api/communication/src/main/java/co/airy/core/api/communication/ConversationsController.java index eb9fb1e79d..a5c63448f5 100644 --- a/backend/api/communication/src/main/java/co/airy/core/api/communication/ConversationsController.java +++ b/backend/api/communication/src/main/java/co/airy/core/api/communication/ConversationsController.java @@ -1,6 +1,7 @@ package co.airy.core.api.communication; import co.airy.avro.communication.Metadata; +import co.airy.core.api.communication.lucene.ExtendedQueryParser; import co.airy.model.metadata.MetadataKeys; import co.airy.model.metadata.Subject; import co.airy.avro.communication.ReadReceipt; @@ -16,6 +17,7 @@ import co.airy.core.api.communication.payload.ResponseMetadata; import co.airy.pagination.Page; import co.airy.pagination.Paginator; +import co.airy.spring.web.payload.EmptyResponsePayload; import co.airy.spring.web.payload.RequestErrorResponsePayload; import org.apache.kafka.streams.state.KeyValueIterator; import org.apache.kafka.streams.state.ReadOnlyKeyValueStore; @@ -33,6 +35,7 @@ import java.time.Instant; import java.util.ArrayList; import java.util.List; +import java.util.Set; import static co.airy.model.metadata.MetadataRepository.newConversationTag; import static java.util.Comparator.comparing; @@ -42,10 +45,16 @@ public class ConversationsController { private final Stores stores; private final Mapper mapper; + private final ExtendedQueryParser queryParser; ConversationsController(Stores stores, Mapper mapper) { this.stores = stores; this.mapper = mapper; + this.queryParser = new ExtendedQueryParser(Set.of("unread_message_count"), + Set.of("created_at"), + "id", + new WhitespaceAnalyzer()); + this.queryParser.setAllowLeadingWildcard(true); } @PostMapping("/conversations.list") @@ -62,11 +71,9 @@ private ResponseEntity queryConversations(ConversationListRequestPayload requ final ReadOnlyLuceneStore conversationLuceneStore = stores.getConversationLuceneStore(); final ReadOnlyKeyValueStore conversationsStore = stores.getConversationsStore(); - final QueryParser simpleQueryParser = new QueryParser("id", new WhitespaceAnalyzer()); - final Query query; try { - query = simpleQueryParser.parse(requestPayload.getFilters()); + query = queryParser.parse(requestPayload.getFilters()); } catch (ParseException e) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(new RequestErrorResponsePayload("Failed to parse Lucene query: " + e.getMessage())); @@ -175,7 +182,7 @@ ResponseEntity conversationMarkRead(@RequestBody @Valid ConversationByIdReque return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new RequestErrorResponsePayload(e.getMessage())); } - return ResponseEntity.accepted().build(); + return ResponseEntity.accepted().body(new EmptyResponsePayload()); } @PostMapping("/conversations.tag") @@ -197,7 +204,7 @@ ResponseEntity conversationTag(@RequestBody @Valid ConversationTagRequestPayl return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new RequestErrorResponsePayload(e.getMessage())); } - return ResponseEntity.accepted().build(); + return ResponseEntity.accepted().body(new EmptyResponsePayload()); } @PostMapping("/conversations.untag") @@ -219,6 +226,6 @@ ResponseEntity conversationUntag(@RequestBody @Valid ConversationTagRequestPa return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new RequestErrorResponsePayload(e.getMessage())); } - return ResponseEntity.accepted().build(); + return ResponseEntity.accepted().body(new EmptyResponsePayload()); } } diff --git a/backend/api/communication/src/main/java/co/airy/core/api/communication/Mapper.java b/backend/api/communication/src/main/java/co/airy/core/api/communication/Mapper.java index a178e4e3b9..ec914f15bd 100644 --- a/backend/api/communication/src/main/java/co/airy/core/api/communication/Mapper.java +++ b/backend/api/communication/src/main/java/co/airy/core/api/communication/Mapper.java @@ -36,7 +36,7 @@ public ConversationResponsePayload fromConversation(Conversation conversation) { .source(conversation.getChannel().getSource()) .build()) .id(conversation.getId()) - .unreadMessageCount(conversation.getUnreadCount()) + .unreadMessageCount(conversation.getUnreadMessageCount()) .tags( MetadataRepository.filterPrefix(metadata, MetadataKeys.TAGS) .keySet() @@ -56,8 +56,7 @@ private ContactResponsePayload getContact(Conversation conversation) { return ContactResponsePayload.builder() .avatarUrl(metadata.get(MetadataKeys.Source.Contact.AVATAR_URL)) - .firstName(displayName.getFirstName()) - .lastName(displayName.getLastName()) + .displayName(displayName.toString()) .info(getConversationInfo(metadata)) .build(); } diff --git a/backend/api/communication/src/main/java/co/airy/core/api/communication/MessagesController.java b/backend/api/communication/src/main/java/co/airy/core/api/communication/MessagesController.java index adecebc019..452bbaca25 100644 --- a/backend/api/communication/src/main/java/co/airy/core/api/communication/MessagesController.java +++ b/backend/api/communication/src/main/java/co/airy/core/api/communication/MessagesController.java @@ -52,7 +52,7 @@ private MessageListResponsePayload fetchMessages(String conversationId, int page Page page = paginator.page(); return MessageListResponsePayload.builder() - .data(messages.stream().map(mapper::fromMessage).collect(toList())) + .data(page.getData().stream().map(mapper::fromMessage).collect(toList())) .responseMetadata(MessageListResponsePayload.ResponseMetadata.builder() .nextCursor(page.getNextCursor()) .previousCursor(cursor) diff --git a/backend/api/communication/src/main/java/co/airy/core/api/communication/Stores.java b/backend/api/communication/src/main/java/co/airy/core/api/communication/Stores.java index f96d48e0cd..15c56269ca 100644 --- a/backend/api/communication/src/main/java/co/airy/core/api/communication/Stores.java +++ b/backend/api/communication/src/main/java/co/airy/core/api/communication/Stores.java @@ -155,10 +155,8 @@ private void startStream() { return aggregate; }) - .join(channelTable, Conversation::getChannelId, (conversation, channel) -> { - conversation.setChannel(channel); - return conversation; - }) + .join(channelTable, Conversation::getChannelId, + (conversation, channel) -> conversation.toBuilder().channel(channel).build()) .leftJoin(metadataTable, (conversation, metadataMap) -> { if (metadataMap != null) { return conversation.toBuilder() @@ -169,7 +167,7 @@ private void startStream() { }) .leftJoin(unreadCountTable, (conversation, unreadCountState) -> { if (unreadCountState != null) { - conversation.setUnreadCount(unreadCountState.getUnreadCount()); + return conversation.toBuilder().unreadMessageCount(unreadCountState.getUnreadCount()).build(); } return conversation; }, Materialized.as(conversationsStore)) diff --git a/backend/api/communication/src/main/java/co/airy/core/api/communication/dto/Conversation.java b/backend/api/communication/src/main/java/co/airy/core/api/communication/dto/Conversation.java index 31f85edadf..4316714eda 100644 --- a/backend/api/communication/src/main/java/co/airy/core/api/communication/dto/Conversation.java +++ b/backend/api/communication/src/main/java/co/airy/core/api/communication/dto/Conversation.java @@ -25,7 +25,7 @@ public class Conversation implements Serializable { private String sourceConversationId; private Channel channel; - private Integer unreadCount; + private Integer unreadMessageCount; @Builder.Default private Map metadata = new HashMap<>(); diff --git a/backend/api/communication/src/main/java/co/airy/core/api/communication/dto/ConversationIndex.java b/backend/api/communication/src/main/java/co/airy/core/api/communication/dto/ConversationIndex.java index d766746c81..48f8133520 100644 --- a/backend/api/communication/src/main/java/co/airy/core/api/communication/dto/ConversationIndex.java +++ b/backend/api/communication/src/main/java/co/airy/core/api/communication/dto/ConversationIndex.java @@ -7,6 +7,7 @@ import java.io.Serializable; import java.util.HashMap; +import java.util.List; import java.util.Map; @Data @@ -19,7 +20,7 @@ public class ConversationIndex implements Serializable { private String channelId; private String source; private Long createdAt; - private Integer unreadCount; + private Integer unreadMessageCount; @Builder.Default private Map metadata = new HashMap<>(); @@ -32,7 +33,7 @@ public static ConversationIndex fromConversation(Conversation conversation) { .displayName(conversation.getDisplayNameOrDefault().toString()) .metadata(new HashMap<>(conversation.getMetadata())) .createdAt(conversation.getCreatedAt()) - .unreadCount(conversation.getUnreadCount()) + .unreadMessageCount(conversation.getUnreadMessageCount()) .build(); } } diff --git a/backend/api/communication/src/main/java/co/airy/core/api/communication/dto/MessagesTreeSet.java b/backend/api/communication/src/main/java/co/airy/core/api/communication/dto/MessagesTreeSet.java index 658a15b875..582d0f5340 100644 --- a/backend/api/communication/src/main/java/co/airy/core/api/communication/dto/MessagesTreeSet.java +++ b/backend/api/communication/src/main/java/co/airy/core/api/communication/dto/MessagesTreeSet.java @@ -9,6 +9,6 @@ public class MessagesTreeSet extends TreeSet { @JsonCreator public MessagesTreeSet() { - super(Comparator.comparing(Message::getSentAt)); + super(Comparator.comparing(Message::getSentAt).reversed()); } } diff --git a/backend/api/communication/src/main/java/co/airy/core/api/communication/lucene/DocumentMapper.java b/backend/api/communication/src/main/java/co/airy/core/api/communication/lucene/DocumentMapper.java index c0a0b057ec..ac2d50e0d0 100644 --- a/backend/api/communication/src/main/java/co/airy/core/api/communication/lucene/DocumentMapper.java +++ b/backend/api/communication/src/main/java/co/airy/core/api/communication/lucene/DocumentMapper.java @@ -1,7 +1,6 @@ package co.airy.core.api.communication.lucene; import co.airy.core.api.communication.dto.ConversationIndex; -import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.lucene.document.Document; import org.apache.lucene.document.Field; import org.apache.lucene.document.IntPoint; @@ -16,8 +15,6 @@ import static java.util.stream.Collectors.toMap; public class DocumentMapper { - final ObjectMapper objectMapper = new ObjectMapper(); - public Document fromConversationIndex(ConversationIndex conversation) { final Document document = new Document(); document.add(new StringField("id", conversation.getId(), Field.Store.YES)); @@ -27,10 +24,10 @@ public Document fromConversationIndex(ConversationIndex conversation) { document.add(new TextField("display_name", conversation.getDisplayName(), Field.Store.YES)); } - document.add(new LongPoint("createdAt", conversation.getCreatedAt())); - document.add(new StoredField("createdAt", conversation.getCreatedAt())); - document.add(new IntPoint("unreadCount", conversation.getUnreadCount())); - document.add(new StoredField("unreadCount", conversation.getUnreadCount())); + document.add(new LongPoint("created_at", conversation.getCreatedAt())); + document.add(new StoredField("created_at", conversation.getCreatedAt())); + document.add(new IntPoint("unread_message_count", conversation.getUnreadMessageCount())); + document.add(new StoredField("unread_message_count", conversation.getUnreadMessageCount())); for (Map.Entry entry : conversation.getMetadata().entrySet()) { document.add(new TextField("metadata." + entry.getKey(), entry.getValue(), Field.Store.YES)); @@ -40,9 +37,8 @@ public Document fromConversationIndex(ConversationIndex conversation) { } public ConversationIndex fromDocument(Document document) { - - final Long createdAt = document.getField("createdAt").numericValue().longValue(); - final Integer unreadCount = document.getField("unreadCount").numericValue().intValue(); + final Long createdAt = document.getField("created_at").numericValue().longValue(); + final Integer unreadCount = document.getField("unread_message_count").numericValue().intValue(); final Map metadata = document.getFields().stream() .filter((field) -> field.name().startsWith("metadata")) @@ -53,10 +49,10 @@ public ConversationIndex fromDocument(Document document) { return ConversationIndex.builder() .id(document.get("id")) - .unreadCount(unreadCount) + .unreadMessageCount(unreadCount) .createdAt(createdAt) .metadata(metadata) - .displayName(document.get("displayName")) + .displayName(document.get("display_name")) .build(); } } diff --git a/backend/api/communication/src/main/java/co/airy/core/api/communication/lucene/ExtendedQueryParser.java b/backend/api/communication/src/main/java/co/airy/core/api/communication/lucene/ExtendedQueryParser.java new file mode 100644 index 0000000000..b73c980025 --- /dev/null +++ b/backend/api/communication/src/main/java/co/airy/core/api/communication/lucene/ExtendedQueryParser.java @@ -0,0 +1,63 @@ +package co.airy.core.api.communication.lucene; + +import org.apache.lucene.analysis.Analyzer; +import org.apache.lucene.document.IntPoint; +import org.apache.lucene.document.LongPoint; +import org.apache.lucene.queryparser.classic.ParseException; +import org.apache.lucene.queryparser.classic.QueryParser; +import org.apache.lucene.search.Query; + +import java.util.Set; + +public class ExtendedQueryParser extends QueryParser { + private final Set intFields; + private final Set longFields; + + public ExtendedQueryParser(Set intFields, + Set longFields, + String field, + Analyzer analyzer) { + super(field, analyzer); + this.intFields = intFields; + this.longFields = longFields; + } + + protected Query getFieldQuery(String field, String queryText, boolean quoted) throws ParseException { + if (intFields.contains(field)) { + return IntPoint.newExactQuery(field, Integer.parseInt(queryText)); + } + if (longFields.contains(field)) { + return LongPoint.newExactQuery(field, Long.parseLong(queryText)); + } + + return super.getFieldQuery(field, queryText, quoted); + } + + protected Query newRangeQuery(String field, String part1, String part2, boolean startInclusive, + boolean endInclusive) { + if (intFields.contains(field)) { + return IntPoint.newRangeQuery(field, Integer.parseInt(part1), getUpperIntBound(part2)); + } + if (longFields.contains(field)) { + return LongPoint.newRangeQuery(field, Long.parseLong(part1), getUpperLongBound(part2)); + } + + return super.newRangeQuery(field, part1, part2, startInclusive, endInclusive); + } + + private int getUpperIntBound(String part2) { + if (part2 == null) { + return Integer.MAX_VALUE; + } + + return Integer.parseInt(part2); + } + + private long getUpperLongBound(String part2) { + if (part2 == null) { + return Long.MAX_VALUE; + } + + return Integer.parseInt(part2); + } +} diff --git a/backend/api/communication/src/main/java/co/airy/core/api/communication/payload/ContactResponsePayload.java b/backend/api/communication/src/main/java/co/airy/core/api/communication/payload/ContactResponsePayload.java index 25f6ed99a8..93b4d37a67 100644 --- a/backend/api/communication/src/main/java/co/airy/core/api/communication/payload/ContactResponsePayload.java +++ b/backend/api/communication/src/main/java/co/airy/core/api/communication/payload/ContactResponsePayload.java @@ -13,8 +13,7 @@ @Data public class ContactResponsePayload { private String avatarUrl; - private String firstName; - private String lastName; + private String displayName; private Map info; } diff --git a/backend/api/communication/src/main/java/co/airy/core/api/communication/payload/SendMessageRequestPayload.java b/backend/api/communication/src/main/java/co/airy/core/api/communication/payload/SendMessageRequestPayload.java index 42fea33633..b843e80bd6 100644 --- a/backend/api/communication/src/main/java/co/airy/core/api/communication/payload/SendMessageRequestPayload.java +++ b/backend/api/communication/src/main/java/co/airy/core/api/communication/payload/SendMessageRequestPayload.java @@ -1,5 +1,6 @@ package co.airy.core.api.communication.payload; +import co.airy.mapping.model.Content; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -16,12 +17,5 @@ public class SendMessageRequestPayload { private UUID conversationId; @Valid @NotNull - private MessagePayload message; - - @Data - @NoArgsConstructor - @AllArgsConstructor - public static class MessagePayload { - private String text; - } + private Content message; } diff --git a/backend/api/communication/src/test/java/co/airy/core/api/communication/ConversationsInfoTest.java b/backend/api/communication/src/test/java/co/airy/core/api/communication/ConversationsInfoTest.java index e735fd2a8c..9945a8050a 100644 --- a/backend/api/communication/src/test/java/co/airy/core/api/communication/ConversationsInfoTest.java +++ b/backend/api/communication/src/test/java/co/airy/core/api/communication/ConversationsInfoTest.java @@ -3,10 +3,6 @@ import co.airy.avro.communication.Channel; import co.airy.avro.communication.ChannelConnectionState; import co.airy.core.api.communication.util.TestConversation; -import co.airy.kafka.schema.application.ApplicationCommunicationChannels; -import co.airy.kafka.schema.application.ApplicationCommunicationMessages; -import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; -import co.airy.kafka.schema.application.ApplicationCommunicationReadReceipts; import co.airy.kafka.test.KafkaTestHelper; import co.airy.kafka.test.junit.SharedKafkaTestResource; import co.airy.spring.core.AirySpringBootApplication; @@ -26,6 +22,8 @@ import java.util.UUID; +import static co.airy.core.api.communication.util.Topics.getTopics; +import static co.airy.core.api.communication.util.Topics.applicationCommunicationChannels; import static co.airy.test.Timing.retryOnException; import static org.hamcrest.core.Is.is; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -44,18 +42,9 @@ class ConversationsInfoTest { @Autowired private WebTestHelper webTestHelper; - private static final ApplicationCommunicationMessages applicationCommunicationMessages = new ApplicationCommunicationMessages(); - private static final ApplicationCommunicationChannels applicationCommunicationChannels = new ApplicationCommunicationChannels(); - private static final ApplicationCommunicationMetadata applicationCommunicationMetadata = new ApplicationCommunicationMetadata(); - private static final ApplicationCommunicationReadReceipts applicationCommunicationReadReceipts = new ApplicationCommunicationReadReceipts(); - @BeforeAll static void beforeAll() throws Exception { - kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, - applicationCommunicationMessages, - applicationCommunicationChannels, - applicationCommunicationMetadata, - applicationCommunicationReadReceipts); + kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, getTopics()); kafkaTestHelper.beforeAll(); } diff --git a/backend/api/communication/src/test/java/co/airy/core/api/communication/ConversationsListTest.java b/backend/api/communication/src/test/java/co/airy/core/api/communication/ConversationsListTest.java index 1f486ff277..f315b51f3d 100644 --- a/backend/api/communication/src/test/java/co/airy/core/api/communication/ConversationsListTest.java +++ b/backend/api/communication/src/test/java/co/airy/core/api/communication/ConversationsListTest.java @@ -2,15 +2,11 @@ import co.airy.avro.communication.Channel; import co.airy.avro.communication.ChannelConnectionState; -import co.airy.model.metadata.MetadataKeys; import co.airy.core.api.communication.util.TestConversation; -import co.airy.kafka.schema.application.ApplicationCommunicationChannels; -import co.airy.kafka.schema.application.ApplicationCommunicationMessages; -import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; -import co.airy.kafka.schema.application.ApplicationCommunicationReadReceipts; +import co.airy.date.format.DateFormat; import co.airy.kafka.test.KafkaTestHelper; import co.airy.kafka.test.junit.SharedKafkaTestResource; -import co.airy.date.format.DateFormat; +import co.airy.model.metadata.MetadataKeys; import co.airy.spring.core.AirySpringBootApplication; import co.airy.spring.test.WebTestHelper; import com.fasterxml.jackson.databind.node.JsonNodeFactory; @@ -33,6 +29,8 @@ import java.util.Map; import java.util.UUID; +import static co.airy.core.api.communication.util.Topics.applicationCommunicationChannels; +import static co.airy.core.api.communication.util.Topics.getTopics; import static co.airy.test.Timing.retryOnException; import static java.util.Comparator.reverseOrder; import static java.util.stream.Collectors.toList; @@ -55,11 +53,6 @@ class ConversationsListTest { @Autowired private WebTestHelper webTestHelper; - private static final ApplicationCommunicationMessages applicationCommunicationMessages = new ApplicationCommunicationMessages(); - private static final ApplicationCommunicationChannels applicationCommunicationChannels = new ApplicationCommunicationChannels(); - private static final ApplicationCommunicationMetadata applicationCommunicationMetadata = new ApplicationCommunicationMetadata(); - private static final ApplicationCommunicationReadReceipts applicationCommunicationReadReceipts = new ApplicationCommunicationReadReceipts(); - private static final String firstNameToFind = "Grace"; private static final Channel defaultChannel = Channel.newBuilder() @@ -85,23 +78,17 @@ class ConversationsListTest { TestConversation.from(UUID.randomUUID().toString(), channelToFind, Map.of(MetadataKeys.Source.Contact.FIRST_NAME, firstNameToFind), 1), TestConversation.from(UUID.randomUUID().toString(), channelToFind, 1), TestConversation.from(conversationIdToFind, defaultChannel, 1), - TestConversation.from(UUID.randomUUID().toString(), defaultChannel, 1), - TestConversation.from(UUID.randomUUID().toString(), defaultChannel, 1) + TestConversation.from(UUID.randomUUID().toString(), defaultChannel, 2), + TestConversation.from(UUID.randomUUID().toString(), defaultChannel, 5) ); @BeforeAll static void beforeAll() throws Exception { - kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, - applicationCommunicationMessages, - applicationCommunicationChannels, - applicationCommunicationMetadata, - applicationCommunicationReadReceipts - ); + kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, getTopics()); kafkaTestHelper.beforeAll(); - kafkaTestHelper.produceRecord(new ProducerRecord<>(applicationCommunicationChannels.name(), defaultChannel.getId(), defaultChannel)); kafkaTestHelper.produceRecord(new ProducerRecord<>(applicationCommunicationChannels.name(), channelToFind.getId(), channelToFind)); @@ -150,6 +137,18 @@ void canFilterByDisplayName() throws Exception { checkConversationsFound(payload, 1); } + @Test + void canFilterByUnreadMessageCountRange() throws Exception { + String payload = "{\"filters\": \"unread_message_count:[2 TO *]\"}"; + checkConversationsFound(payload, 2); + } + + @Test + void canFilterByUnreadMessageCount() throws Exception { + String payload = "{\"filters\": \"unread_message_count:2\"}"; + checkConversationsFound(payload, 1); + } + @Test void canFilterByCombinedQueries() throws Exception { final JsonNodeFactory jsonNodeFactory = JsonNodeFactory.instance; @@ -182,6 +181,6 @@ private void checkConversationsFound(String payload, int count) throws Interrupt .andExpect(jsonPath("$.data", hasSize(count))) .andExpect(jsonPath("response_metadata.filtered_total", is(count))) .andExpect(jsonPath("response_metadata.total", is(conversations.size()))), - "Expected one conversation returned"); + String.format("Expected %d conversation returned", count)); } } diff --git a/backend/api/communication/src/test/java/co/airy/core/api/communication/ConversationsTagTest.java b/backend/api/communication/src/test/java/co/airy/core/api/communication/ConversationsTagTest.java index 6f93ab853d..368939368a 100644 --- a/backend/api/communication/src/test/java/co/airy/core/api/communication/ConversationsTagTest.java +++ b/backend/api/communication/src/test/java/co/airy/core/api/communication/ConversationsTagTest.java @@ -4,9 +4,6 @@ import co.airy.avro.communication.ChannelConnectionState; import co.airy.core.api.communication.util.TestConversation; import co.airy.kafka.schema.application.ApplicationCommunicationChannels; -import co.airy.kafka.schema.application.ApplicationCommunicationMessages; -import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; -import co.airy.kafka.schema.application.ApplicationCommunicationReadReceipts; import co.airy.kafka.test.KafkaTestHelper; import co.airy.kafka.test.junit.SharedKafkaTestResource; import co.airy.spring.core.AirySpringBootApplication; @@ -26,6 +23,8 @@ import java.util.UUID; +import static co.airy.core.api.communication.util.Topics.getTopics; +import static co.airy.core.api.communication.util.Topics.applicationCommunicationChannels; import static co.airy.test.Timing.retryOnException; import static org.hamcrest.collection.IsIterableContainingInAnyOrder.containsInAnyOrder; import static org.hamcrest.core.Is.is; @@ -45,19 +44,9 @@ class ConversationsTagTest { @Autowired private WebTestHelper webTestHelper; - private static final ApplicationCommunicationMessages applicationCommunicationMessages = new ApplicationCommunicationMessages(); - private static final ApplicationCommunicationChannels applicationCommunicationChannels = new ApplicationCommunicationChannels(); - private static final ApplicationCommunicationMetadata applicationCommunicationMetadata = new ApplicationCommunicationMetadata(); - private static final ApplicationCommunicationReadReceipts applicationCommunicationReadReceipts = new ApplicationCommunicationReadReceipts(); - @BeforeAll static void beforeAll() throws Exception { - kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, - applicationCommunicationMessages, - applicationCommunicationChannels, - applicationCommunicationMetadata, - applicationCommunicationReadReceipts - ); + kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, getTopics()); kafkaTestHelper.beforeAll(); } diff --git a/backend/api/communication/src/test/java/co/airy/core/api/communication/MessagesTest.java b/backend/api/communication/src/test/java/co/airy/core/api/communication/MessagesTest.java index c2f09d46a4..0fb81ccfd0 100644 --- a/backend/api/communication/src/test/java/co/airy/core/api/communication/MessagesTest.java +++ b/backend/api/communication/src/test/java/co/airy/core/api/communication/MessagesTest.java @@ -4,13 +4,9 @@ import co.airy.avro.communication.ChannelConnectionState; import co.airy.avro.communication.Message; import co.airy.core.api.communication.util.TestConversation; -import co.airy.kafka.schema.application.ApplicationCommunicationChannels; -import co.airy.kafka.schema.application.ApplicationCommunicationMessages; -import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; -import co.airy.kafka.schema.application.ApplicationCommunicationReadReceipts; +import co.airy.date.format.DateFormat; import co.airy.kafka.test.KafkaTestHelper; import co.airy.kafka.test.junit.SharedKafkaTestResource; -import co.airy.date.format.DateFormat; import co.airy.spring.core.AirySpringBootApplication; import co.airy.spring.test.WebTestHelper; import org.apache.avro.specific.SpecificRecordBase; @@ -27,10 +23,14 @@ import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.junit.jupiter.SpringExtension; +import java.util.Comparator; import java.util.List; import java.util.UUID; +import static co.airy.core.api.communication.util.Topics.getTopics; +import static co.airy.core.api.communication.util.Topics.applicationCommunicationChannels; import static co.airy.test.Timing.retryOnException; +import static java.util.Comparator.reverseOrder; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.hasSize; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -49,19 +49,9 @@ public class MessagesTest { @Autowired private WebTestHelper webTestHelper; - private static final ApplicationCommunicationMessages applicationCommunicationMessages = new ApplicationCommunicationMessages(); - private static final ApplicationCommunicationChannels applicationCommunicationChannels = new ApplicationCommunicationChannels(); - private static final ApplicationCommunicationMetadata applicationCommunicationMetadata = new ApplicationCommunicationMetadata(); - private static final ApplicationCommunicationReadReceipts applicationCommunicationReadReceipts = new ApplicationCommunicationReadReceipts(); - @BeforeAll static void beforeAll() throws Exception { - kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, - applicationCommunicationMessages, - applicationCommunicationChannels, - applicationCommunicationMetadata, - applicationCommunicationReadReceipts - ); + kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, getTopics()); kafkaTestHelper.beforeAll(); } @@ -106,7 +96,7 @@ void canFetchMessages() throws Exception { records.stream() .map((record) -> ((Message) record.value()).getSentAt()) .map(DateFormat::isoFromMillis) - .sorted().toArray()))), + .sorted(reverseOrder()).toArray()))), "/messages.list endpoint error"); } diff --git a/backend/api/communication/src/test/java/co/airy/core/api/communication/MetadataControllerTest.java b/backend/api/communication/src/test/java/co/airy/core/api/communication/MetadataControllerTest.java index daa4800552..74af0d44de 100644 --- a/backend/api/communication/src/test/java/co/airy/core/api/communication/MetadataControllerTest.java +++ b/backend/api/communication/src/test/java/co/airy/core/api/communication/MetadataControllerTest.java @@ -3,10 +3,6 @@ import co.airy.avro.communication.Channel; import co.airy.avro.communication.ChannelConnectionState; import co.airy.core.api.communication.util.TestConversation; -import co.airy.kafka.schema.application.ApplicationCommunicationChannels; -import co.airy.kafka.schema.application.ApplicationCommunicationMessages; -import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; -import co.airy.kafka.schema.application.ApplicationCommunicationReadReceipts; import co.airy.kafka.test.KafkaTestHelper; import co.airy.kafka.test.junit.SharedKafkaTestResource; import co.airy.spring.core.AirySpringBootApplication; @@ -26,6 +22,8 @@ import java.util.UUID; +import static co.airy.core.api.communication.util.Topics.getTopics; +import static co.airy.core.api.communication.util.Topics.applicationCommunicationChannels; import static co.airy.test.Timing.retryOnException; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -42,18 +40,9 @@ public class MetadataControllerTest { @Autowired private WebTestHelper webTestHelper; - private static final ApplicationCommunicationMessages applicationCommunicationMessages = new ApplicationCommunicationMessages(); - private static final ApplicationCommunicationChannels applicationCommunicationChannels = new ApplicationCommunicationChannels(); - private static final ApplicationCommunicationMetadata applicationCommunicationMetadata = new ApplicationCommunicationMetadata(); - private static final ApplicationCommunicationReadReceipts applicationCommunicationReadReceipts = new ApplicationCommunicationReadReceipts(); - @BeforeAll static void beforeAll() throws Exception { - kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, - applicationCommunicationMessages, - applicationCommunicationChannels, - applicationCommunicationMetadata, - applicationCommunicationReadReceipts); + kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, getTopics()); kafkaTestHelper.beforeAll(); } diff --git a/backend/api/communication/src/test/java/co/airy/core/api/communication/SendMessageControllerTest.java b/backend/api/communication/src/test/java/co/airy/core/api/communication/SendMessageControllerTest.java index 4672c2dc4b..3bb6539c35 100644 --- a/backend/api/communication/src/test/java/co/airy/core/api/communication/SendMessageControllerTest.java +++ b/backend/api/communication/src/test/java/co/airy/core/api/communication/SendMessageControllerTest.java @@ -5,14 +5,16 @@ import co.airy.avro.communication.Message; import co.airy.avro.communication.SenderType; import co.airy.core.api.communication.util.TestConversation; -import co.airy.kafka.schema.application.ApplicationCommunicationChannels; -import co.airy.kafka.schema.application.ApplicationCommunicationMessages; -import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; -import co.airy.kafka.schema.application.ApplicationCommunicationReadReceipts; import co.airy.kafka.test.KafkaTestHelper; import co.airy.kafka.test.junit.SharedKafkaTestResource; +import co.airy.mapping.ContentMapper; +import co.airy.mapping.model.Audio; +import co.airy.mapping.model.Content; +import co.airy.mapping.model.Text; import co.airy.spring.core.AirySpringBootApplication; import co.airy.spring.test.WebTestHelper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.kafka.clients.consumer.ConsumerRecord; import org.apache.kafka.clients.producer.ProducerRecord; import org.junit.jupiter.api.AfterAll; @@ -31,10 +33,15 @@ import java.util.Optional; import java.util.UUID; +import static co.airy.core.api.communication.util.Topics.applicationCommunicationChannels; +import static co.airy.core.api.communication.util.Topics.applicationCommunicationMessages; +import static co.airy.core.api.communication.util.Topics.getTopics; import static co.airy.test.Timing.retryOnException; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.everyItem; import static org.hamcrest.Matchers.is; import static org.hamcrest.collection.IsCollectionWithSize.hasSize; +import static org.hamcrest.core.Is.isA; import static org.junit.jupiter.api.Assertions.fail; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -47,11 +54,11 @@ public class SendMessageControllerTest { public static final SharedKafkaTestResource sharedKafkaTestResource = new SharedKafkaTestResource(); private static KafkaTestHelper kafkaTestHelper; - private static final String facebookConversationId = UUID.randomUUID().toString(); + private static final String conversationId = UUID.randomUUID().toString(); - private static final Channel facebookChannel = Channel.newBuilder() + private static final Channel channel = Channel.newBuilder() .setConnectionState(ChannelConnectionState.CONNECTED) - .setId("facebook-channel-id") + .setId("channel-id") .setName("channel-name") .setSource("facebook") .setSourceChannelId("ps-id") @@ -61,24 +68,17 @@ public class SendMessageControllerTest { @Autowired private WebTestHelper webTestHelper; - private static final ApplicationCommunicationMessages applicationCommunicationMessages = new ApplicationCommunicationMessages(); - private static final ApplicationCommunicationChannels applicationCommunicationChannels = new ApplicationCommunicationChannels(); - private static final ApplicationCommunicationMetadata applicationCommunicationMetadata = new ApplicationCommunicationMetadata(); - private static final ApplicationCommunicationReadReceipts applicationCommunicationReadReceipts = new ApplicationCommunicationReadReceipts(); + @Autowired + private ContentMapper contentMapper; @BeforeAll static void beforeAll() throws Exception { - kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, - applicationCommunicationMessages, - applicationCommunicationChannels, - applicationCommunicationMetadata, - applicationCommunicationReadReceipts - ); + kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, getTopics()); kafkaTestHelper.beforeAll(); - kafkaTestHelper.produceRecord(new ProducerRecord<>(applicationCommunicationChannels.name(), facebookChannel.getId(), facebookChannel)); - kafkaTestHelper.produceRecords(TestConversation.generateRecords(facebookConversationId, facebookChannel, 1)); + kafkaTestHelper.produceRecord(new ProducerRecord<>(applicationCommunicationChannels.name(), channel.getId(), channel)); + kafkaTestHelper.produceRecords(TestConversation.generateRecords(conversationId, channel, 1)); } @AfterAll @@ -91,19 +91,49 @@ void beforeEach() throws Exception { webTestHelper.waitUntilHealthy(); } + + @Test + void failsForUnknownContentSchema() throws Exception { + String payload = String.format("{\"conversation_id\":\"%s\"," + + "\"message\":{\"text\":\"answeris42\",\"type\":\"unknown\"}}", + conversationId); + final String userId = "user-id"; + + webTestHelper.post("/messages.send", payload, userId) + .andExpect(status().isBadRequest()); + } + @Test - void canSendMessages() throws Exception { - String payload = "{\"conversation_id\": \"" + facebookConversationId + "\", \"message\": { \"text\": \"answer is 42\" }}"; + void canSendTemplateMessages() throws Exception { + String payload = String.format("{\"conversation_id\":\"%s\"," + + "\"message\":{\"payload\":{\"a nested\":\"structure\"},\"type\":\"source.template\"}}", + conversationId); final String userId = "user-id"; - retryOnException(() -> webTestHelper.post("/messages.send", payload, userId).andExpect(status().isOk()), "Facebook Message was not sent"); + webTestHelper.post("/messages.send", payload, userId) + .andExpect(status().isOk()); + } + + @Test + void canSendTextMessages() throws Exception { + String payload = String.format("{\"conversation_id\":\"%s\"," + + "\"message\":{\"text\":\"answeris42\",\"type\":\"text\"}}", + conversationId); + final String userId = "user-id"; + + final String response = webTestHelper.post("/messages.send", payload, userId) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + final JsonNode responseNode = new ObjectMapper().readTree(response); + final String messageId = responseNode.get("id").textValue(); - List> records = kafkaTestHelper.consumeRecords(2, applicationCommunicationMessages.name()); - assertThat(records, hasSize(2)); + List> records = kafkaTestHelper.consumeRecords(3, applicationCommunicationMessages.name()); + assertThat(records, hasSize(3)); final Optional maybeMessage = records.stream() .map(ConsumerRecord::value) - .filter(m -> m.getSenderType().equals(SenderType.APP_USER)) + .filter(m -> m.getSenderType().equals(SenderType.APP_USER) && m.getId().equals(messageId)) .findFirst(); if (maybeMessage.isEmpty()) { @@ -111,7 +141,9 @@ void canSendMessages() throws Exception { } final Message message = maybeMessage.get(); - assertThat(message.getContent(), is("{\"text\":\"answer is 42\"}")); + final List contents = contentMapper.render(message); + assertThat(contents, hasSize(1)); + assertThat(contents, everyItem(isA(Text.class))); assertThat(message.getSenderId(), is(userId)); } } diff --git a/backend/api/communication/src/test/java/co/airy/core/api/communication/UnreadCountTest.java b/backend/api/communication/src/test/java/co/airy/core/api/communication/UnreadCountTest.java index 868ef90441..598ce5cbce 100644 --- a/backend/api/communication/src/test/java/co/airy/core/api/communication/UnreadCountTest.java +++ b/backend/api/communication/src/test/java/co/airy/core/api/communication/UnreadCountTest.java @@ -26,6 +26,8 @@ import java.util.UUID; +import static co.airy.core.api.communication.util.Topics.getTopics; +import static co.airy.core.api.communication.util.Topics.applicationCommunicationChannels; import static co.airy.test.Timing.retryOnException; import static org.hamcrest.core.IsEqual.equalTo; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -44,19 +46,9 @@ class UnreadCountTest { @Autowired private WebTestHelper webTestHelper; - private static final ApplicationCommunicationMessages applicationCommunicationMessages = new ApplicationCommunicationMessages(); - private static final ApplicationCommunicationChannels applicationCommunicationChannels = new ApplicationCommunicationChannels(); - private static final ApplicationCommunicationMetadata applicationCommunicationMetadata = new ApplicationCommunicationMetadata(); - private static final ApplicationCommunicationReadReceipts applicationCommunicationReadReceipts = new ApplicationCommunicationReadReceipts(); - @BeforeAll static void beforeAll() throws Exception { - kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, - applicationCommunicationMessages, - applicationCommunicationChannels, - applicationCommunicationMetadata, - applicationCommunicationReadReceipts - ); + kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, getTopics()); kafkaTestHelper.beforeAll(); } diff --git a/backend/api/communication/src/test/java/co/airy/core/api/communication/WebSocketControllerTest.java b/backend/api/communication/src/test/java/co/airy/core/api/communication/WebSocketControllerTest.java index 685dcfd57f..f037203f75 100644 --- a/backend/api/communication/src/test/java/co/airy/core/api/communication/WebSocketControllerTest.java +++ b/backend/api/communication/src/test/java/co/airy/core/api/communication/WebSocketControllerTest.java @@ -2,16 +2,12 @@ import co.airy.avro.communication.Channel; import co.airy.avro.communication.ChannelConnectionState; -import co.airy.model.channel.ChannelPayload; import co.airy.core.api.communication.payload.MessageUpsertPayload; import co.airy.core.api.communication.payload.UnreadCountPayload; import co.airy.core.api.communication.util.TestConversation; -import co.airy.kafka.schema.application.ApplicationCommunicationChannels; -import co.airy.kafka.schema.application.ApplicationCommunicationMessages; -import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; -import co.airy.kafka.schema.application.ApplicationCommunicationReadReceipts; import co.airy.kafka.test.KafkaTestHelper; import co.airy.kafka.test.junit.SharedKafkaTestResource; +import co.airy.model.channel.ChannelPayload; import co.airy.spring.core.AirySpringBootApplication; import co.airy.spring.jwt.Jwt; import com.fasterxml.jackson.databind.ObjectMapper; @@ -46,6 +42,8 @@ import static co.airy.core.api.communication.WebSocketController.QUEUE_CHANNEL_CONNECTED; import static co.airy.core.api.communication.WebSocketController.QUEUE_MESSAGE; import static co.airy.core.api.communication.WebSocketController.QUEUE_UNREAD_COUNT; +import static co.airy.core.api.communication.util.Topics.getTopics; +import static co.airy.core.api.communication.util.Topics.applicationCommunicationChannels; import static co.airy.test.Timing.retryOnException; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; @@ -61,12 +59,6 @@ public class WebSocketControllerTest { @RegisterExtension public static final SharedKafkaTestResource sharedKafkaTestResource = new SharedKafkaTestResource(); - - private static final ApplicationCommunicationChannels applicationCommunicationChannels = new ApplicationCommunicationChannels(); - private static final ApplicationCommunicationMessages applicationCommunicationMessages = new ApplicationCommunicationMessages(); - private static final ApplicationCommunicationMetadata applicationCommunicationMetadata = new ApplicationCommunicationMetadata(); - private static final ApplicationCommunicationReadReceipts applicationCommunicationReadReceipts = new ApplicationCommunicationReadReceipts(); - private static KafkaTestHelper kafkaTestHelper; private static boolean testDataInitialized = false; @@ -93,12 +85,7 @@ public class WebSocketControllerTest { @BeforeAll static void beforeAll() throws Exception { - kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, - applicationCommunicationMetadata, - applicationCommunicationMessages, - applicationCommunicationChannels, - applicationCommunicationReadReceipts - ); + kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, getTopics()); kafkaTestHelper.beforeAll(); } diff --git a/backend/api/communication/src/test/java/co/airy/core/api/communication/util/Topics.java b/backend/api/communication/src/test/java/co/airy/core/api/communication/util/Topics.java new file mode 100644 index 0000000000..c849fc7591 --- /dev/null +++ b/backend/api/communication/src/test/java/co/airy/core/api/communication/util/Topics.java @@ -0,0 +1,18 @@ +package co.airy.core.api.communication.util; + +import co.airy.kafka.schema.Topic; +import co.airy.kafka.schema.application.ApplicationCommunicationChannels; +import co.airy.kafka.schema.application.ApplicationCommunicationMessages; +import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; +import co.airy.kafka.schema.application.ApplicationCommunicationReadReceipts; + +public class Topics { + public static final ApplicationCommunicationMessages applicationCommunicationMessages = new ApplicationCommunicationMessages(); + public static final ApplicationCommunicationChannels applicationCommunicationChannels = new ApplicationCommunicationChannels(); + public static final ApplicationCommunicationMetadata applicationCommunicationMetadata = new ApplicationCommunicationMetadata(); + public static final ApplicationCommunicationReadReceipts applicationCommunicationReadReceipts = new ApplicationCommunicationReadReceipts(); + + public static Topic[] getTopics() { + return new Topic[]{applicationCommunicationMessages, applicationCommunicationChannels, applicationCommunicationMetadata, applicationCommunicationReadReceipts}; + } +} diff --git a/backend/media/BUILD b/backend/media/BUILD index 0b7ed227b6..d6e8aa0d8a 100644 --- a/backend/media/BUILD +++ b/backend/media/BUILD @@ -6,12 +6,15 @@ app_deps = [ "//backend:base_app", "//backend/model/message", "//backend/model/metadata", - "//lib/java/uuid", "//lib/java/mapping", + "//lib/java/url", "//lib/java/spring/kafka/core:spring-kafka-core", "//lib/java/spring/kafka/streams:spring-kafka-streams", - "@maven//:io_lettuce_lettuce_core", - "@maven//:org_springframework_data_spring_data_redis", + "@maven//:javax_xml_bind_jaxb_api", + "@maven//:org_springframework_retry_spring_retry", + "@maven//:org_aspectj_aspectjweaver", + "@maven//:com_amazonaws_aws_java_sdk_core", + "@maven//:com_amazonaws_aws_java_sdk_s3", ] springboot( diff --git a/backend/media/src/main/java/co/airy/core/media/MessageMediaResolver.java b/backend/media/src/main/java/co/airy/core/media/MessageMediaResolver.java new file mode 100644 index 0000000000..198f7f985e --- /dev/null +++ b/backend/media/src/main/java/co/airy/core/media/MessageMediaResolver.java @@ -0,0 +1,111 @@ +package co.airy.core.media; + +import co.airy.avro.communication.Message; +import co.airy.avro.communication.Metadata; +import co.airy.core.media.dto.MessageMediaRequest; +import co.airy.core.media.services.MediaUpload; +import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; +import co.airy.log.AiryLoggerFactory; +import co.airy.mapping.ContentMapper; +import co.airy.mapping.model.Content; +import co.airy.mapping.model.DataUrl; +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.slf4j.Logger; +import org.springframework.stereotype.Component; + +import javax.xml.bind.DatatypeConverter; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static co.airy.model.metadata.MetadataRepository.getId; +import static co.airy.model.metadata.MetadataRepository.newMessageMetadata; + +@Component +public class MessageMediaResolver { + private final Logger log = AiryLoggerFactory.getLogger(MessageMediaResolver.class); + private final String applicationCommunicationMetadata = new ApplicationCommunicationMetadata().name(); + private final KafkaProducer producer; + private final MediaUpload mediaUpload; + private final ContentMapper mapper; + private final ExecutorService executor; + + public MessageMediaResolver(KafkaProducer producer, + MediaUpload mediaUpload, + ContentMapper mapper) { + this.producer = producer; + this.mediaUpload = mediaUpload; + this.mapper = mapper; + this.executor = Executors.newSingleThreadExecutor(); + } + + public void onMessageMediaRequest(String messageId, MessageMediaRequest messageMediaRequest) { + executor.submit(() -> processMessageMediaRequests(messageId, messageMediaRequest)); + } + + private void processMessageMediaRequests(String messageId, MessageMediaRequest messageMediaRequest) { + final Message message = messageMediaRequest.getMessage(); + final Map metadataMap = Optional.ofNullable(messageMediaRequest.getMetadata()).orElse(new HashMap<>()); + + final List contentList = mapper.renderWithDefaultAndLog(message, metadataMap); + + for (Content content : contentList) { + if (!(content instanceof DataUrl)) { + continue; + } + + final String sourceUrl = ((DataUrl) content).getUrl(); + + try { + final URL url = new URL(sourceUrl); + + if (!mediaUpload.isUserStorageUrl(url) && !hasPersistentUrl(metadataMap, sourceUrl)) { + final String persistentUrl = mediaUpload.uploadMedia(url.openStream(), getFileName(sourceUrl)); + + final Metadata metadata = newMessageMetadata(messageId, getMessageKey(sourceUrl), persistentUrl); + storeMetadata(metadata); + } + } catch (ExecutionException | InterruptedException exception) { + throw new RuntimeException(exception); + } catch (MalformedURLException exception) { + // If it's not a URL, this is an error on the source side + log.warn("Source data url field is not a URL", exception); + } catch (Exception exception) { + log.error("Fetching message source content failed {}", messageMediaRequest); + } + } + } + + private void storeMetadata(Metadata metadata) throws ExecutionException, InterruptedException { + final String metadataKey = getId(metadata).toString(); + producer.send(new ProducerRecord<>(applicationCommunicationMetadata, metadataKey, metadata)).get(); + } + + private String getFileName(String sourceUrl) { + try { + final MessageDigest digest = MessageDigest.getInstance("SHA-256"); + final String urlHash = DatatypeConverter.printHexBinary(digest.digest(sourceUrl.getBytes(StandardCharsets.UTF_8))); + return String.format("data_%s", urlHash.toLowerCase()); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + private boolean hasPersistentUrl(Map metadataMap, String sourceUrl) { + return metadataMap.containsKey(getMessageKey(sourceUrl)); + } + + private String getMessageKey(String sourceUrl) { + return String.format("data_%s", sourceUrl); + } +} diff --git a/backend/media/src/main/java/co/airy/core/media/MetadataResolver.java b/backend/media/src/main/java/co/airy/core/media/MetadataResolver.java new file mode 100644 index 0000000000..ffae659dd4 --- /dev/null +++ b/backend/media/src/main/java/co/airy/core/media/MetadataResolver.java @@ -0,0 +1,96 @@ +package co.airy.core.media; + +import co.airy.avro.communication.Metadata; +import co.airy.core.media.services.MediaUpload; +import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; +import co.airy.log.AiryLoggerFactory; +import co.airy.model.metadata.MetadataKeys; +import co.airy.model.metadata.Subject; +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.slf4j.Logger; +import org.springframework.stereotype.Component; + +import java.net.URL; +import java.time.Instant; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static co.airy.model.metadata.MetadataRepository.getId; +import static co.airy.model.metadata.MetadataRepository.getSubject; +import static co.airy.model.metadata.MetadataRepository.isConversationMetadata; + +@Component +public class MetadataResolver { + private final Logger log = AiryLoggerFactory.getLogger(MetadataResolver.class); + private final String applicationCommunicationMetadata = new ApplicationCommunicationMetadata().name(); + private final KafkaProducer producer; + private final MediaUpload mediaUpload; + private final ExecutorService executor; + + public MetadataResolver( + KafkaProducer producer, + MediaUpload mediaUpload) { + this.producer = producer; + this.mediaUpload = mediaUpload; + this.executor = Executors.newSingleThreadExecutor(); + } + + public boolean shouldResolve(Metadata metadata) { + if (metadata == null) { + return false; + } + + URL dataUrl; + + try { + dataUrl = new URL(metadata.getValue()); + } catch (Exception ignored) { + return false; + } + + return isConversationMetadata(metadata) + && metadata.getKey().equals(MetadataKeys.Source.Contact.AVATAR_URL) + && !mediaUpload.isUserStorageUrl(dataUrl); + } + + public void onMetadata(Metadata metadata) { + executor.submit(() -> processMetadataMediaRequest(metadata)); + } + + public void processMetadataMediaRequest(Metadata metadata) { + URL dataUrl; + + try { + dataUrl = new URL(metadata.getValue()); + } catch (Exception exception) { + log.error("Metadata value not a valid url despite filtering {}", metadata, exception); + return; + } + + final String resolvedKey = metadata.getKey() + ".resolved"; + final Subject subject = getSubject(metadata); + final String fileName = String.format("%s/%s", subject.getIdentifier(), resolvedKey); + + try { + final String userStorageUrl = mediaUpload.uploadMedia(dataUrl.openStream(), fileName); + + storeMetadata(Metadata.newBuilder() + .setSubject(subject.toString()) + .setKey(resolvedKey) + .setValue(userStorageUrl) + .setTimestamp(Instant.now().toEpochMilli()) + .build()); + } catch (ExecutionException | InterruptedException exception) { + throw new RuntimeException(exception); + } catch (Exception exception) { + log.error("Failed to upload metadata data url {}", metadata, exception); + } + } + + private void storeMetadata(Metadata metadata) throws ExecutionException, InterruptedException { + final String metadataKey = getId(metadata).toString(); + producer.send(new ProducerRecord<>(applicationCommunicationMetadata, metadataKey, metadata)).get(); + } +} diff --git a/backend/media/src/main/java/co/airy/core/media/Stores.java b/backend/media/src/main/java/co/airy/core/media/Stores.java new file mode 100644 index 0000000000..f867ab5def --- /dev/null +++ b/backend/media/src/main/java/co/airy/core/media/Stores.java @@ -0,0 +1,87 @@ +package co.airy.core.media; + +import co.airy.avro.communication.Message; +import co.airy.avro.communication.Metadata; +import co.airy.core.media.dto.MessageMediaRequest; +import co.airy.kafka.schema.application.ApplicationCommunicationMessages; +import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; +import co.airy.kafka.streams.KafkaStreamsWrapper; +import co.airy.log.AiryLoggerFactory; +import org.apache.kafka.streams.KafkaStreams; +import org.apache.kafka.streams.KeyValue; +import org.apache.kafka.streams.StreamsBuilder; +import org.apache.kafka.streams.kstream.KStream; +import org.apache.kafka.streams.kstream.KTable; +import org.slf4j.Logger; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.boot.context.event.ApplicationStartedEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; + +import static co.airy.model.message.MessageRepository.isNewMessage; +import static co.airy.model.metadata.MetadataRepository.getSubject; +import static co.airy.model.metadata.MetadataRepository.isMessageMetadata; + +@Component +public class Stores implements ApplicationListener, DisposableBean { + private final Logger log = AiryLoggerFactory.getLogger(Stores.class); + + private static final String appId = "media.Resolver"; + private final KafkaStreamsWrapper streams; + private final MetadataResolver metadataResolver; + private final MessageMediaResolver messageResolver; + + public Stores(KafkaStreamsWrapper streams, + MetadataResolver metadataResolver, + MessageMediaResolver messageResolver) { + this.streams = streams; + this.metadataResolver = metadataResolver; + this.messageResolver = messageResolver; + } + + @Override + public void onApplicationEvent(ApplicationStartedEvent event) { + final StreamsBuilder builder = new StreamsBuilder(); + + final KStream metadataTable = builder.stream(new ApplicationCommunicationMetadata().name()); + + final KTable> messageMetadataTable = metadataTable.toTable() + .filter((metadataId, metadata) -> isMessageMetadata(metadata)) + .groupBy((metadataId, metadata) -> KeyValue.pair(getSubject(metadata).getIdentifier(), metadata)) + .aggregate(HashMap::new, (metadataId, metadata, metadataMap) -> { + metadataMap.put(metadata.getKey(), metadata.getValue()); + return metadataMap; + }, (metadataId, metadata, metadataMap) -> { + metadataMap.remove(metadata.getKey()); + return metadataMap; + }); + + metadataTable + .filter((metadataId, metadata) -> metadataResolver.shouldResolve(metadata)) + .foreach((metadataId, metadata) -> metadataResolver.onMetadata(metadata)); + + builder.stream(new ApplicationCommunicationMessages().name()) + // Since the message content is immutable we only have to fetch + // the media for new messages + .filter((messageId, message) -> isNewMessage(message)) + .leftJoin(messageMetadataTable, MessageMediaRequest::new) + .foreach(messageResolver::onMessageMediaRequest); + + streams.start(builder.build(), appId); + } + + @Override + public void destroy() { + if (streams != null) { + streams.close(); + } + } + + // visible for testing + KafkaStreams.State getStreamState() { + return streams.state(); + } +} diff --git a/backend/media/src/main/java/co/airy/core/media/config/AwsConfig.java b/backend/media/src/main/java/co/airy/core/media/config/AwsConfig.java new file mode 100644 index 0000000000..f718d2be19 --- /dev/null +++ b/backend/media/src/main/java/co/airy/core/media/config/AwsConfig.java @@ -0,0 +1,31 @@ +package co.airy.core.media.config; + +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.regions.Regions; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class AwsConfig { + + @Bean + public AmazonS3 amazonS3Client(@Value("${storage.s3.key}") final String mediaS3Key, + @Value("${storage.s3.secret}") final String mediaS3Secret, + @Value("${storage.s3.region}") final String region) { + AWSCredentials credentials = new BasicAWSCredentials( + mediaS3Key, + mediaS3Secret + ); + + return AmazonS3ClientBuilder + .standard() + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .withRegion(region) + .build(); + } +} diff --git a/backend/media/src/main/java/co/airy/core/media/dto/MessageMediaRequest.java b/backend/media/src/main/java/co/airy/core/media/dto/MessageMediaRequest.java new file mode 100644 index 0000000000..2e3a55b4d8 --- /dev/null +++ b/backend/media/src/main/java/co/airy/core/media/dto/MessageMediaRequest.java @@ -0,0 +1,17 @@ +package co.airy.core.media.dto; + +import co.airy.avro.communication.Message; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.util.Map; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class MessageMediaRequest implements Serializable { + private Message message; + private Map metadata; +} diff --git a/backend/media/src/main/java/co/airy/core/media/services/MediaUpload.java b/backend/media/src/main/java/co/airy/core/media/services/MediaUpload.java new file mode 100644 index 0000000000..9ab3fa6c27 --- /dev/null +++ b/backend/media/src/main/java/co/airy/core/media/services/MediaUpload.java @@ -0,0 +1,60 @@ +package co.airy.core.media.services; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.CannedAccessControlList; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.PutObjectRequest; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.retry.annotation.EnableRetry; +import org.springframework.retry.annotation.Retryable; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLConnection; + +@Service +@EnableRetry +public class MediaUpload { + private final AmazonS3 amazonS3Client; + private final String bucket; + private final URL host; + + public MediaUpload(AmazonS3 amazonS3Client, + @Value("${storage.s3.bucket}") String bucket, + @Value("${storage.s3.path}") String path) throws MalformedURLException { + this.amazonS3Client = amazonS3Client; + this.bucket = bucket; + URL bucketHost = new URL(String.format("https://%s.s3.amazonaws.com/", bucket)); + this.host = new URL(bucketHost, path); + } + + public boolean isUserStorageUrl(URL dataUrl) { + return dataUrl.getHost().equals(host.getHost()); + } + + @Retryable + public String uploadMedia(final InputStream is, final String fileName) throws Exception { + final String contentType = resolveContentType(fileName, is); + + final ObjectMetadata objectMetadata = new ObjectMetadata(); + objectMetadata.setContentType(contentType); + + final PutObjectRequest putObjectRequest = new PutObjectRequest( + bucket, + fileName, + is, + objectMetadata + ).withCannedAcl(CannedAccessControlList.PublicRead); + amazonS3Client.putObject(putObjectRequest); + + return new URL(host, fileName).toString(); + } + + private String resolveContentType(final String fileName, final InputStream is) throws IOException { + final String contentType = URLConnection.guessContentTypeFromStream(is); + return contentType == null ? URLConnection.guessContentTypeFromName(fileName) : contentType; + } +} diff --git a/backend/media/src/main/resources/application.properties b/backend/media/src/main/resources/application.properties index e456a64de1..d7e3c82c0f 100644 --- a/backend/media/src/main/resources/application.properties +++ b/backend/media/src/main/resources/application.properties @@ -1,4 +1,10 @@ kafka.brokers=${KAFKA_BROKERS} kafka.schema-registry-url=${KAFKA_SCHEMA_REGISTRY_URL} -storage.host=${STORAGE_HOST} +kafka.suppress-interval-ms=${KAFKA_SUPPRESS_INTERVAL_MS:3000} + +storage.s3.key=${STORAGE_S3_KEY} +storage.s3.secret=${STORAGE_S3_SECRET} +storage.s3.bucket=${STORAGE_S3_BUCKET} +storage.s3.region=${STORAGE_S3_REGION} +storage.s3.path=${STORAGE_S3_PATH:/} diff --git a/backend/media/src/test/java/co/airy/core/media/MessagesTest.java b/backend/media/src/test/java/co/airy/core/media/MessagesTest.java new file mode 100644 index 0000000000..dbe108ec69 --- /dev/null +++ b/backend/media/src/test/java/co/airy/core/media/MessagesTest.java @@ -0,0 +1,150 @@ +package co.airy.core.media; + +import co.airy.avro.communication.DeliveryState; +import co.airy.avro.communication.Message; +import co.airy.avro.communication.Metadata; +import co.airy.avro.communication.SenderType; +import co.airy.core.media.services.MediaUpload; +import co.airy.kafka.schema.application.ApplicationCommunicationMessages; +import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; +import co.airy.kafka.test.KafkaTestHelper; +import co.airy.kafka.test.junit.SharedKafkaTestResource; +import co.airy.mapping.ContentMapper; +import co.airy.mapping.model.Audio; +import co.airy.spring.core.AirySpringBootApplication; +import com.amazonaws.AmazonServiceException; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.PutObjectRequest; +import com.amazonaws.services.s3.model.PutObjectResult; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.hamcrest.core.StringEndsWith; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import static co.airy.test.Timing.retryOnException; +import static org.apache.kafka.streams.KafkaStreams.State.RUNNING; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.IsEqual.equalTo; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringBootTest(classes = AirySpringBootApplication.class) +@TestPropertySource(value = "classpath:test.properties") +@ExtendWith(SpringExtension.class) +public class MessagesTest { + @RegisterExtension + public static final SharedKafkaTestResource sharedKafkaTestResource = new SharedKafkaTestResource(); + private static KafkaTestHelper kafkaTestHelper; + private static final ApplicationCommunicationMessages applicationCommunicationMessages = new ApplicationCommunicationMessages(); + private static final ApplicationCommunicationMetadata applicationCommunicationMetadata = new ApplicationCommunicationMetadata(); + + @BeforeAll + static void beforeAll() throws Exception { + kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, + applicationCommunicationMetadata, + applicationCommunicationMessages + ); + + kafkaTestHelper.beforeAll(); + } + + @AfterAll + static void afterAll() throws Exception { + kafkaTestHelper.afterAll(); + } + + @Autowired + Stores stores; + + MediaUpload mediaUpload; + + @MockBean + private AmazonS3 amazonS3; + + @MockBean + private ContentMapper mapper; + + @Value("${storage.s3.bucket}") + private String bucket; + + @Value("${storage.s3.path}") + private String path; + + @BeforeEach + void beforeEach() throws Exception { + MockitoAnnotations.initMocks(this); + mediaUpload = new MediaUpload(amazonS3, bucket, path); + retryOnException(() -> assertEquals(stores.getStreamState(), RUNNING), "Failed to reach RUNNING state."); + } + + @Test + void storesMessageUrlsWithRetries() throws Exception { + final String originalUrl = "https://picsum.photos/1/1"; + final String urlHash = "64ff04d5ca0ad5951e64e3669c3dbd9159675e177a2ba237bf334495f4778da5"; + final String messageId = UUID.randomUUID().toString(); + + final String expectedUrl = String.format("https://%s.s3.amazonaws.com%sdata_%s", + bucket, path, urlHash); + + final ArgumentCaptor s3PutCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); + when(mapper.renderWithDefaultAndLog(Mockito.any(), Mockito.any())).thenReturn(List.of(new Audio(originalUrl))); + + // Simulate a failure to trigger one retry + when(amazonS3.putObject(s3PutCaptor.capture())) + .thenThrow(AmazonServiceException.class) + .thenReturn(new PutObjectResult()); + + kafkaTestHelper.produceRecord( + new ProducerRecord<>(applicationCommunicationMessages.name(), messageId, + Message.newBuilder() + .setId(messageId) + .setSource("fakesource") + .setSentAt(Instant.now().toEpochMilli()) + .setUpdatedAt(null) + .setSenderId("sourceConversationId") + .setSenderType(SenderType.SOURCE_CONTACT) + .setDeliveryState(DeliveryState.DELIVERED) + .setConversationId("conversationId") + .setChannelId("channelId") + .setContent("mocked") + .build() + )); + + TimeUnit.SECONDS.sleep(10); + + List> metadataRecords = kafkaTestHelper.consumeRecords(2, applicationCommunicationMetadata.name()); + Metadata metadata = metadataRecords.stream() + .filter((record) -> record.value().getKey().equals(String.format("data_%s", originalUrl))) + .findFirst().get().value(); + + assertThat(metadata.getValue(), equalTo(expectedUrl)); + + verify(amazonS3, times(2)).putObject(Mockito.any(PutObjectRequest.class)); + final PutObjectRequest putObjectRequest = s3PutCaptor.getValue(); + assertThat(putObjectRequest.getBucketName(), equalTo(bucket)); + // The filename we wrote to the metadata has to match the file key we write to S3 + assertThat(metadata.getValue(), StringEndsWith.endsWith(putObjectRequest.getKey())); + } +} diff --git a/backend/media/src/test/java/co/airy/core/media/MetadataTest.java b/backend/media/src/test/java/co/airy/core/media/MetadataTest.java new file mode 100644 index 0000000000..0c0bf154e3 --- /dev/null +++ b/backend/media/src/test/java/co/airy/core/media/MetadataTest.java @@ -0,0 +1,135 @@ +package co.airy.core.media; + +import co.airy.avro.communication.Metadata; +import co.airy.core.media.services.MediaUpload; +import co.airy.kafka.schema.application.ApplicationCommunicationMessages; +import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; +import co.airy.kafka.test.KafkaTestHelper; +import co.airy.kafka.test.junit.SharedKafkaTestResource; +import co.airy.model.metadata.MetadataKeys; +import co.airy.spring.core.AirySpringBootApplication; +import com.amazonaws.AmazonServiceException; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.PutObjectRequest; +import com.amazonaws.services.s3.model.PutObjectResult; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.util.List; +import java.util.UUID; + +import static co.airy.model.metadata.MetadataRepository.newConversationMetadata; +import static co.airy.test.Timing.retryOnException; +import static org.apache.kafka.streams.KafkaStreams.State.RUNNING; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.IsEqual.equalTo; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.isNotNull; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringBootTest(classes = AirySpringBootApplication.class) +@TestPropertySource(value = "classpath:test.properties") +@ExtendWith(SpringExtension.class) +public class MetadataTest { + @RegisterExtension + public static final SharedKafkaTestResource sharedKafkaTestResource = new SharedKafkaTestResource(); + private static KafkaTestHelper kafkaTestHelper; + private static final ApplicationCommunicationMessages applicationCommunicationMessages = new ApplicationCommunicationMessages(); + private static final ApplicationCommunicationMetadata applicationCommunicationMetadata = new ApplicationCommunicationMetadata(); + + @BeforeAll + static void beforeAll() throws Exception { + kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, + applicationCommunicationMetadata, + applicationCommunicationMessages + ); + + kafkaTestHelper.beforeAll(); + } + + @AfterAll + static void afterAll() throws Exception { + kafkaTestHelper.afterAll(); + } + + @Autowired + Stores stores; + + @Autowired + + MediaUpload mediaUpload; + + @MockBean + private AmazonS3 amazonS3; + + @Value("${storage.s3.bucket}") + private String bucket; + + @Value("${storage.s3.path}") + private String path; + + @BeforeEach + void beforeEach() throws Exception { + MockitoAnnotations.initMocks(this); + mediaUpload = new MediaUpload(amazonS3, bucket, path); + retryOnException(() -> assertEquals(stores.getStreamState(), RUNNING), "Failed to reach RUNNING state."); + } + + @Test + void storesMetadataUrlsWithRetries() throws Exception { + final String conversationId = UUID.randomUUID().toString(); + final String originalUrl = "https://picsum.photos/1/1"; + final String metadataId = UUID.randomUUID().toString(); + final String expectedUrl = String.format("https://%s.s3.amazonaws.com%s%s/%s.resolved", + bucket, + path, + conversationId, + MetadataKeys.Source.Contact.AVATAR_URL); + + final ArgumentCaptor s3PutCaptor = ArgumentCaptor.forClass(PutObjectRequest.class); + + // Simulate a failure to trigger one retry + when(amazonS3.putObject(s3PutCaptor.capture())) + .thenThrow(AmazonServiceException.class) + .thenReturn(new PutObjectResult()); + + kafkaTestHelper.produceRecord( + new ProducerRecord<>(applicationCommunicationMetadata.name(), metadataId, + newConversationMetadata(conversationId, + MetadataKeys.Source.Contact.AVATAR_URL, + originalUrl) + )); + + List> metadataRecords = kafkaTestHelper.consumeRecords(2, applicationCommunicationMetadata.name()); + Metadata metadata = metadataRecords.stream() + .filter((record) -> !record.key().equals(metadataId)) + .findFirst().get().value(); + + assertThat(metadata.getValue(), equalTo(expectedUrl)); + + verify(amazonS3, times(2)).putObject(Mockito.any(PutObjectRequest.class)); + final PutObjectRequest putObjectRequest = s3PutCaptor.getValue(); + assertThat(putObjectRequest.getBucketName(), equalTo(bucket)); + assertThat(putObjectRequest.getKey(), + equalTo(String.format("%s/%s", conversationId, MetadataKeys.Source.Contact.AVATAR_URL + ".resolved"))); + } +} diff --git a/backend/media/src/test/resources/test.properties b/backend/media/src/test/resources/test.properties new file mode 100644 index 0000000000..86dde47a14 --- /dev/null +++ b/backend/media/src/test/resources/test.properties @@ -0,0 +1,10 @@ +kafka.cleanup=true +kafka.cache.max.bytes=0 +kafka.commit-interval-ms=100 +kafka.suppress-interval-ms=0 + +storage.s3.key=no +storage.s3.secret=no +storage.s3.bucket=mybucket +storage.s3.region=us-east-1 +storage.s3.path=/core-media/ diff --git a/backend/model/message/src/main/java/co/airy/model/message/MessageRepository.java b/backend/model/message/src/main/java/co/airy/model/message/MessageRepository.java index fa9cabe23d..6166a7ad4f 100644 --- a/backend/model/message/src/main/java/co/airy/model/message/MessageRepository.java +++ b/backend/model/message/src/main/java/co/airy/model/message/MessageRepository.java @@ -11,4 +11,8 @@ public static Message updateDeliveryState(Message message, DeliveryState state) message.setUpdatedAt(Instant.now().toEpochMilli()); return message; } + + public static boolean isNewMessage(Message message) { + return message.getUpdatedAt() == null; + } } diff --git a/backend/model/metadata/src/main/java/co/airy/model/metadata/MetadataRepository.java b/backend/model/metadata/src/main/java/co/airy/model/metadata/MetadataRepository.java index f11599dedc..48517d752e 100644 --- a/backend/model/metadata/src/main/java/co/airy/model/metadata/MetadataRepository.java +++ b/backend/model/metadata/src/main/java/co/airy/model/metadata/MetadataRepository.java @@ -21,7 +21,16 @@ public static Map filterPrefix(Map metadataMap, public static Metadata newConversationMetadata(String conversationId, String key, String value) { return Metadata.newBuilder() - .setSubject(new Subject("conversation",conversationId).toString()) + .setSubject(new Subject("conversation", conversationId).toString()) + .setKey(key) + .setValue(value) + .setTimestamp(Instant.now().toEpochMilli()) + .build(); + } + + public static Metadata newMessageMetadata(String messageId, String key, String value) { + return Metadata.newBuilder() + .setSubject(new Subject("message", messageId).toString()) .setKey(key) .setValue(value) .setTimestamp(Instant.now().toEpochMilli()) @@ -32,6 +41,10 @@ public static boolean isConversationMetadata(Metadata metadata) { return metadata.getSubject().startsWith("conversation:"); } + public static boolean isMessageMetadata(Metadata metadata) { + return metadata.getSubject().startsWith("message:"); + } + public static Map getConversationInfo(Map metadataMap) { return filterPrefix(metadataMap, PUBLIC); } diff --git a/backend/sources/chat-plugin/src/main/java/co/airy/core/chat_plugin/payload/SendMessageRequestPayload.java b/backend/sources/chat-plugin/src/main/java/co/airy/core/chat_plugin/payload/SendMessageRequestPayload.java index 4252796e02..6904a5f65a 100644 --- a/backend/sources/chat-plugin/src/main/java/co/airy/core/chat_plugin/payload/SendMessageRequestPayload.java +++ b/backend/sources/chat-plugin/src/main/java/co/airy/core/chat_plugin/payload/SendMessageRequestPayload.java @@ -1,5 +1,6 @@ package co.airy.core.chat_plugin.payload; +import co.airy.mapping.model.Text; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -11,13 +12,5 @@ @NoArgsConstructor public class SendMessageRequestPayload { @NotNull - private MessagePayload message; - - @Data - @NoArgsConstructor - @AllArgsConstructor - public static class MessagePayload { - @NotBlank - private String text; - } + private Text message; } diff --git a/backend/sources/chat-plugin/src/test/java/co/airy/core/chat_plugin/ChatControllerTest.java b/backend/sources/chat-plugin/src/test/java/co/airy/core/chat_plugin/ChatControllerTest.java index 0f454858c6..267ec3c780 100644 --- a/backend/sources/chat-plugin/src/test/java/co/airy/core/chat_plugin/ChatControllerTest.java +++ b/backend/sources/chat-plugin/src/test/java/co/airy/core/chat_plugin/ChatControllerTest.java @@ -121,7 +121,7 @@ void authenticateSendAndReceive() throws Exception { final CompletableFuture messageFuture = subscribe(token, port, MessageUpsertPayload.class, QUEUE_MESSAGE); final String messageText = "answer is 42"; - String sendMessagePayload = "{\"message\": { \"text\": \"" + messageText + "\" }}"; + String sendMessagePayload = "{\"message\": { \"text\": \"" + messageText + "\", \"type\":\"text\" }}"; retryOnException(() -> mvc.perform(post("/chatplugin.send") .headers(buildHeaders(token)) .content(sendMessagePayload)) @@ -150,7 +150,7 @@ void canResumeConversation() throws Exception { final String authToken = jsonNode.get("token").textValue(); final String messageText = "Talk to you later!"; - final String sendMessagePayload = "{\"message\": { \"text\": \"" + messageText + "\" }}"; + final String sendMessagePayload = "{\"message\": { \"text\": \"" + messageText + "\", \"type\":\"text\" }}"; mvc.perform(post("/chatplugin.send") .headers(buildHeaders(authToken)) .content(sendMessagePayload)) diff --git a/backend/sources/facebook/connector/BUILD b/backend/sources/facebook/connector/BUILD index 0be43cf3bb..a03e6ad0d3 100644 --- a/backend/sources/facebook/connector/BUILD +++ b/backend/sources/facebook/connector/BUILD @@ -4,11 +4,13 @@ load("//tools/build:container_push.bzl", "container_push") app_deps = [ "//backend:base_app", + "//:springboot_actuator", "//backend/model/channel", "//backend/model/message", "//backend/model/metadata", "//lib/java/uuid", "//lib/java/log", + "//lib/java/mapping", "//lib/java/spring/web:spring-web", "//lib/java/spring/auth:spring-auth", "//lib/java/spring/kafka/core:spring-kafka-core", diff --git a/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/ChannelsController.java b/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/ChannelsController.java index 07f505d7c9..a5bf43b2e1 100644 --- a/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/ChannelsController.java +++ b/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/ChannelsController.java @@ -2,13 +2,13 @@ import co.airy.avro.communication.Channel; import co.airy.avro.communication.ChannelConnectionState; -import co.airy.core.sources.facebook.payload.PageInfoResponsePayload; -import co.airy.core.sources.facebook.payload.ConnectRequestPayload; -import co.airy.core.sources.facebook.payload.ExploreRequestPayload; -import co.airy.core.sources.facebook.payload.ExploreResponsePayload; import co.airy.core.sources.facebook.api.Api; import co.airy.core.sources.facebook.api.ApiException; import co.airy.core.sources.facebook.api.model.PageWithConnectInfo; +import co.airy.core.sources.facebook.payload.ConnectRequestPayload; +import co.airy.core.sources.facebook.payload.ExploreRequestPayload; +import co.airy.core.sources.facebook.payload.ExploreResponsePayload; +import co.airy.core.sources.facebook.payload.PageInfoResponsePayload; import co.airy.spring.web.payload.RequestErrorResponsePayload; import co.airy.uuid.UUIDv5; import org.apache.kafka.streams.state.KeyValueIterator; @@ -104,4 +104,5 @@ ResponseEntity connect(@RequestBody @Valid ConnectRequestPayload requestPaylo return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build(); } } + } diff --git a/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/Stores.java b/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/Stores.java index 5aaf241f4e..ea6cb8e91e 100644 --- a/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/Stores.java +++ b/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/Stores.java @@ -12,6 +12,7 @@ import co.airy.kafka.schema.application.ApplicationCommunicationMessages; import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; import co.airy.kafka.streams.KafkaStreamsWrapper; +import co.airy.log.AiryLoggerFactory; import org.apache.avro.specific.SpecificRecordBase; import org.apache.kafka.clients.producer.KafkaProducer; import org.apache.kafka.clients.producer.ProducerRecord; @@ -23,7 +24,10 @@ import org.apache.kafka.streams.kstream.Materialized; import org.apache.kafka.streams.kstream.Suppressed; import org.apache.kafka.streams.state.ReadOnlyKeyValueStore; +import org.slf4j.Logger; import org.springframework.beans.factory.DisposableBean; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; import org.springframework.boot.context.event.ApplicationStartedEvent; import org.springframework.context.ApplicationListener; import org.springframework.stereotype.Service; @@ -38,7 +42,8 @@ import static co.airy.model.metadata.MetadataRepository.isConversationMetadata; @Service -public class Stores implements ApplicationListener, DisposableBean { +public class Stores implements ApplicationListener, DisposableBean, HealthIndicator { + private static final Logger log = AiryLoggerFactory.getLogger(Stores.class); private static final String appId = "sources.facebook.ConnectorStores"; private final KafkaStreamsWrapper streams; @@ -87,18 +92,21 @@ public void onApplicationEvent(ApplicationStartedEvent applicationStartedEvent) final KTable conversationTable = messageStream .groupByKey() .aggregate(Conversation::new, - (conversationId, message, aggregate) -> { + (conversationId, message, conversation) -> { + final Conversation.ConversationBuilder conversationBuilder = conversation.toBuilder(); if (SenderType.SOURCE_CONTACT.equals(message.getSenderType())) { - aggregate.setSourceConversationId(message.getSenderId()); + conversationBuilder.sourceConversationId(message.getSenderId()); } + conversationBuilder.channelId(message.getChannelId()); - aggregate.setChannelId(message.getChannelId()); - - return aggregate; + return conversationBuilder.build(); }) - .join(channelsTable, Conversation::getChannelId, (aggregate, channel) -> { - aggregate.setChannel(channel); - return aggregate; + .join(channelsTable, Conversation::getChannelId, (conversation, channel) -> { + return conversation.toBuilder() + .channelId(conversation.getChannelId()) + .channel(channel) + .sourceConversationId(conversation.getSourceConversationId()) + .build(); }); // Send outbound messages @@ -109,9 +117,6 @@ public void onApplicationEvent(ApplicationStartedEvent applicationStartedEvent) // Fetch missing metadata conversationTable - // To avoid any redundant fetch contact operations the suppression interval should - // be higher than the timeout of the Facebook API - .suppress(Suppressed.untilTimeLimit(Duration.ofMillis(streams.getSuppressIntervalInMs()), Suppressed.BufferConfig.unbounded())) .toStream() .leftJoin(metadataTable, (conversation, metadataMap) -> conversation .toBuilder() @@ -139,6 +144,12 @@ public void destroy() { } } + @Override + public Health health() { + getChannelsStore(); + return Health.up().build(); + } + // visible for testing KafkaStreams.State getStreamState() { return streams.state(); diff --git a/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/api/Api.java b/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/api/Api.java index 2112768cbe..9b4ddd1cc6 100644 --- a/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/api/Api.java +++ b/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/api/Api.java @@ -60,8 +60,7 @@ public class Api implements ApplicationListener { public Api(ObjectMapper objectMapper, RestTemplateBuilder restTemplateBuilder, @Value("${facebook.app-id}") String appId, - @Value("${facebook.app-secret}") String apiSecret - ) { + @Value("${facebook.app-secret}") String apiSecret) { httpHeaders.setContentType(MediaType.APPLICATION_JSON); this.objectMapper = objectMapper; this.restTemplateBuilder = restTemplateBuilder; @@ -71,7 +70,6 @@ public Api(ObjectMapper objectMapper, RestTemplateBuilder restTemplateBuilder, public void sendMessage(final String pageToken, SendMessagePayload sendMessagePayload) { String fbReqUrl = String.format(requestTemplate, pageToken); - restTemplate.postForEntity(fbReqUrl, new HttpEntity<>(sendMessagePayload, httpHeaders), FbSendMessageResponse.class); } @@ -94,7 +92,6 @@ public List getPagesInfo(String accessToken) throws Excepti private T apiResponse(String url, HttpMethod method, Class clazz) throws Exception { ResponseEntity responseEntity = restTemplate.exchange(url, method, null, String.class); - return objectMapper.readValue(responseEntity.getBody(), clazz); } diff --git a/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/api/Mapper.java b/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/api/Mapper.java index a120d7d9ff..5a41f71176 100644 --- a/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/api/Mapper.java +++ b/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/api/Mapper.java @@ -3,18 +3,23 @@ import co.airy.avro.communication.Message; import co.airy.core.sources.facebook.api.model.SendMessagePayload; import co.airy.core.sources.facebook.dto.SendMessageRequest; +import co.airy.mapping.ContentMapper; +import co.airy.mapping.model.Content; +import co.airy.mapping.model.SourceTemplate; +import co.airy.mapping.model.Text; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.stereotype.Service; +import java.util.List; + @Service public class Mapper { + private final ContentMapper mapper; - private final ObjectMapper objectMapper; - - Mapper(ObjectMapper objectMapper) { - this.objectMapper = objectMapper; + Mapper(ContentMapper mapper) { + this.mapper = mapper; } public SendMessagePayload fromSendMessageRequest(SendMessageRequest sendMessageRequest) throws Exception { @@ -22,9 +27,19 @@ public SendMessagePayload fromSendMessageRequest(SendMessageRequest sendMessageR final SendMessagePayload.MessagePayload messagePayload = new SendMessagePayload.MessagePayload(); - final JsonNode messageRequest = objectMapper.readTree(message.getContent()); - - messagePayload.setText(messageRequest.get("text").textValue()); + final Content content = mapper.render(message) + .stream() + .findFirst() + .orElseThrow(() -> new Exception("Message is empty")); + + if (content instanceof Text) { + messagePayload.setText(((Text) content).getText()); + } else if (content instanceof SourceTemplate) { + messagePayload.setAttachment(SendMessagePayload.AttachmentPayload.builder() + .type("template") + .payload(((SourceTemplate) content).getPayload()) + .build()); + } SendMessagePayload.SendMessagePayloadBuilder builder = SendMessagePayload.builder() .recipient(SendMessagePayload.MessageRecipient.builder() diff --git a/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/api/model/SendMessagePayload.java b/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/api/model/SendMessagePayload.java index 48e8f0c850..f8a73e8f84 100644 --- a/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/api/model/SendMessagePayload.java +++ b/backend/sources/facebook/connector/src/main/java/co/airy/core/sources/facebook/api/model/SendMessagePayload.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -15,36 +16,33 @@ @AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_EMPTY) public class SendMessagePayload { - - @JsonProperty("messaging_type") private String messagingType; - - @JsonProperty("recipient") private MessageRecipient recipient; - - @JsonProperty("message") private MessagePayload message; - - @JsonProperty("tag") private String tag; @Data @Builder @NoArgsConstructor @AllArgsConstructor - @JsonInclude(JsonInclude.Include.NON_EMPTY) public static class MessageRecipient { - - @JsonProperty("id") private String id; } @Data @NoArgsConstructor @AllArgsConstructor - @JsonInclude(JsonInclude.Include.NON_EMPTY) public static class MessagePayload implements Serializable { - @JsonProperty("text") - public String text; + private String text; + private AttachmentPayload attachment; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class AttachmentPayload implements Serializable { + private String type; + private JsonNode payload; } } diff --git a/backend/sources/facebook/connector/src/test/java/co/airy/core/sources/facebook/SendMessageTest.java b/backend/sources/facebook/connector/src/test/java/co/airy/core/sources/facebook/SendMessageTest.java index a0a9e9b08d..6d01a9f31f 100644 --- a/backend/sources/facebook/connector/src/test/java/co/airy/core/sources/facebook/SendMessageTest.java +++ b/backend/sources/facebook/connector/src/test/java/co/airy/core/sources/facebook/SendMessageTest.java @@ -13,7 +13,11 @@ import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; import co.airy.kafka.test.KafkaTestHelper; import co.airy.kafka.test.junit.SharedKafkaTestResource; +import co.airy.mapping.model.SourceTemplate; import co.airy.spring.core.AirySpringBootApplication; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import org.apache.kafka.clients.producer.ProducerRecord; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; @@ -125,6 +129,9 @@ void canSendMessageViaTheFacebookApi() throws Exception { TimeUnit.SECONDS.sleep(5); + final ObjectMapper objectMapper = new ObjectMapper(); + final JsonNode attachmentPayload = objectMapper.readTree("{\"a\":\"template payload\"}"); + kafkaTestHelper.produceRecord(new ProducerRecord<>(applicationCommunicationMessages.name(), messageId, Message.newBuilder() .setId(messageId) @@ -135,14 +142,14 @@ void canSendMessageViaTheFacebookApi() throws Exception { .setConversationId(conversationId) .setChannelId(channelId) .setSource("facebook") - .setContent("{\"text\":\"" + text + "\"}") + .setContent(objectMapper.writeValueAsString(new SourceTemplate(attachmentPayload))) .build()) ); retryOnException(() -> { final SendMessagePayload sendMessagePayload = payloadCaptor.getValue(); assertThat(sendMessagePayload.getRecipient().getId(), equalTo(sourceConversationId)); - assertThat(sendMessagePayload.getMessage().getText(), equalTo(text)); + assertThat(sendMessagePayload.getMessage().getAttachment().getPayload(), equalTo(attachmentPayload)); assertThat(tokenCaptor.getValue(), equalTo(token)); }, "Facebook API was not called"); diff --git a/backend/sources/google/connector/BUILD b/backend/sources/google/connector/BUILD index 46e8dce898..228830b2e0 100644 --- a/backend/sources/google/connector/BUILD +++ b/backend/sources/google/connector/BUILD @@ -4,11 +4,13 @@ load("//tools/build:container_push.bzl", "container_push") app_deps = [ "//backend:base_app", + "//:springboot_actuator", "//backend/model/channel", "//backend/model/message", "//lib/java/spring/kafka/core:spring-kafka-core", "//lib/java/spring/kafka/streams:spring-kafka-streams", "//lib/java/uuid", + "//lib/java/mapping", "//lib/java/kafka/schema:source-google-events", "//lib/java/spring/web:spring-web", "//lib/java/spring/auth:spring-auth", diff --git a/backend/sources/google/connector/src/main/java/co/airy/core/sources/google/ChannelsController.java b/backend/sources/google/connector/src/main/java/co/airy/core/sources/google/ChannelsController.java index 6fedf6398b..3407bbe0a9 100644 --- a/backend/sources/google/connector/src/main/java/co/airy/core/sources/google/ChannelsController.java +++ b/backend/sources/google/connector/src/main/java/co/airy/core/sources/google/ChannelsController.java @@ -70,7 +70,7 @@ ResponseEntity disconnect(@RequestBody @Valid DisconnectChannelRequestPayload } if (channel.getConnectionState().equals(ChannelConnectionState.DISCONNECTED)) { - return ResponseEntity.accepted().build(); + return ResponseEntity.accepted().body(new EmptyResponsePayload()); } channel.setConnectionState(ChannelConnectionState.DISCONNECTED); diff --git a/backend/sources/google/connector/src/main/java/co/airy/core/sources/google/Connector.java b/backend/sources/google/connector/src/main/java/co/airy/core/sources/google/Connector.java index fcaef49dbc..2c597adb38 100644 --- a/backend/sources/google/connector/src/main/java/co/airy/core/sources/google/Connector.java +++ b/backend/sources/google/connector/src/main/java/co/airy/core/sources/google/Connector.java @@ -40,9 +40,9 @@ public Message sendMessage(SendMessageRequest sendMessageRequest) { updateDeliveryState(message, DeliveryState.DELIVERED); return message; } catch (ApiException e) { - log.error(String.format("Failed to send a message to Google \n SendMessageRequest: %s \n Error Message: %s \n", sendMessageRequest, e.getMessage()), e); + log.error(String.format("Google Api Exception for SendMessageRequest:\n%s", sendMessageRequest), e); } catch (Exception e) { - log.error(String.format("Failed to send a message to Google \n SendMessageRequest: %s", sendMessageRequest), e); + log.error(String.format("Failed to send a message to Google \nSendMessageRequest: %s", sendMessageRequest), e); } updateDeliveryState(message, DeliveryState.FAILED); diff --git a/backend/sources/google/connector/src/main/java/co/airy/core/sources/google/Stores.java b/backend/sources/google/connector/src/main/java/co/airy/core/sources/google/Stores.java index c329992b68..b0ce5eb282 100644 --- a/backend/sources/google/connector/src/main/java/co/airy/core/sources/google/Stores.java +++ b/backend/sources/google/connector/src/main/java/co/airy/core/sources/google/Stores.java @@ -15,12 +15,14 @@ import org.apache.kafka.streams.kstream.Materialized; import org.apache.kafka.streams.state.ReadOnlyKeyValueStore; import org.springframework.beans.factory.DisposableBean; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.ApplicationListener; import org.springframework.stereotype.Component; @Component -public class Stores implements DisposableBean, ApplicationListener { +public class Stores implements ApplicationListener, DisposableBean, HealthIndicator { private static final String appId = "sources.google.ConnectorStores"; private final String channelsStore = "channels-store"; private static final String applicationCommunicationChannels = new ApplicationCommunicationChannels().name(); @@ -71,6 +73,14 @@ public void destroy() { } } + + @Override + public Health health() { + getChannelsStore(); + + return Health.up().build(); + } + // visible for testing KafkaStreams.State getStreamState() { return streams.state(); diff --git a/backend/sources/google/connector/src/main/java/co/airy/core/sources/google/services/Mapper.java b/backend/sources/google/connector/src/main/java/co/airy/core/sources/google/services/Mapper.java index 8e156c1722..6b206f9bc7 100644 --- a/backend/sources/google/connector/src/main/java/co/airy/core/sources/google/services/Mapper.java +++ b/backend/sources/google/connector/src/main/java/co/airy/core/sources/google/services/Mapper.java @@ -3,25 +3,29 @@ import co.airy.avro.communication.Message; import co.airy.core.sources.google.model.SendMessagePayload; import co.airy.core.sources.google.model.SendMessageRequest; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import co.airy.mapping.ContentMapper; +import co.airy.mapping.model.Text; import org.springframework.stereotype.Service; @Service public class Mapper { - private final ObjectMapper objectMapper; - Mapper(ObjectMapper objectMapper) { - this.objectMapper = objectMapper; + private final ContentMapper mapper; + Mapper(ContentMapper mapper) { + this.mapper = mapper; } public SendMessagePayload fromSendMessageRequest(SendMessageRequest sendMessageRequest) throws Exception { final Message message = sendMessageRequest.getMessage(); - final JsonNode messageRequest = objectMapper.readTree(message.getContent()); + final Text text = (Text) mapper.render(message) + .stream() + .filter(c -> c instanceof Text) + .findFirst() + .orElseThrow(() -> new Exception("google only supports text messages")); return SendMessagePayload.builder() .messageId(message.getId()) .representative(new SendMessagePayload.Representative("HUMAN")) - .text(messageRequest.get("text").textValue()) + .text(text.getText()) .build(); } } diff --git a/backend/sources/google/connector/src/test/java/co/airy/core/sources/google/SendMessageTest.java b/backend/sources/google/connector/src/test/java/co/airy/core/sources/google/SendMessageTest.java index 5ed3139880..935066ea22 100644 --- a/backend/sources/google/connector/src/test/java/co/airy/core/sources/google/SendMessageTest.java +++ b/backend/sources/google/connector/src/test/java/co/airy/core/sources/google/SendMessageTest.java @@ -10,7 +10,9 @@ import co.airy.kafka.schema.application.ApplicationCommunicationMessages; import co.airy.kafka.test.KafkaTestHelper; import co.airy.kafka.test.junit.SharedKafkaTestResource; +import co.airy.mapping.model.Text; import co.airy.spring.core.AirySpringBootApplication; +import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.kafka.clients.producer.ProducerRecord; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; @@ -110,6 +112,7 @@ void canSendMessageViaGoogleApi() throws Exception { TimeUnit.SECONDS.sleep(5); + final ObjectMapper objectMapper = new ObjectMapper(); kafkaTestHelper.produceRecord(new ProducerRecord<>(applicationCommunicationMessages.name(), messageId, Message.newBuilder() .setId(messageId) @@ -120,7 +123,7 @@ void canSendMessageViaGoogleApi() throws Exception { .setConversationId(conversationId) .setChannelId(channelId) .setSource("google") - .setContent("{\"text\":\"" + text + "\"}") + .setContent(objectMapper.writeValueAsString(new Text(text))) .build()) ); diff --git a/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/EventInfo.java b/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/EventInfo.java index 894379ac13..0fabc1fe7b 100644 --- a/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/EventInfo.java +++ b/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/EventInfo.java @@ -1,12 +1,16 @@ package co.airy.core.sources.google; import co.airy.avro.communication.Channel; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.databind.JsonNode; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.io.Serializable; +import java.util.HashMap; +import java.util.Map; @Data @AllArgsConstructor @@ -19,4 +23,20 @@ public class EventInfo implements Serializable { private Channel channel; private Long timestamp; private boolean isMessage; + + @JsonIgnore + public Map getMessageHeaders() { + final Map headers = new HashMap<>(); + + final JsonNode suggestionResponse = event.getSuggestionResponse(); + if (suggestionResponse != null) { + if (suggestionResponse.get("postbackData") != null) { + headers.put("postback.payload", suggestionResponse.get("postbackData").textValue()); + } else { + headers.put("postback.payload", "__empty__"); + } + } + + return headers; + } } diff --git a/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/EventsRouter.java b/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/EventsRouter.java index fc5e4f9e73..6f8c969aa9 100644 --- a/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/EventsRouter.java +++ b/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/EventsRouter.java @@ -29,7 +29,6 @@ import java.time.Instant; import java.util.ArrayList; import java.util.List; -import java.util.Map; import static co.airy.model.metadata.MetadataRepository.getId; import static co.airy.core.sources.google.InfoExtractor.getMetadataFromContext; @@ -112,8 +111,8 @@ private void startStream() { final String conversationId = UUIDv5.fromNamespaceAndName(channel.getId(), sourceConversationId).toString(); final List> records = new ArrayList<>(); + final String messageId = UUIDv5.fromNamespaceAndName(channel.getId(), payload).toString(); if (webhookEvent.hasMessage()) { - final String messageId = UUIDv5.fromNamespaceAndName(channel.getId(), payload).toString(); records.add(KeyValue.pair(messageId, Message.newBuilder() .setSource(channel.getSource()) @@ -124,7 +123,7 @@ private void startStream() { .setSenderType(SenderType.SOURCE_CONTACT) .setContent(payload) .setSenderId(sourceConversationId) - .setHeaders(Map.of()) + .setHeaders(event.getMessageHeaders()) .setSentAt(event.getTimestamp()) .setUpdatedAt(null) .build() diff --git a/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/InfoExtractor.java b/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/InfoExtractor.java index e1ddab2987..33d111d144 100644 --- a/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/InfoExtractor.java +++ b/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/InfoExtractor.java @@ -8,6 +8,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; diff --git a/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/WebhookEvent.java b/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/WebhookEvent.java index 40abcba871..65aa480567 100644 --- a/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/WebhookEvent.java +++ b/backend/sources/google/events-router/src/main/java/co/airy/core/sources/google/WebhookEvent.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.databind.JsonNode; +import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -10,6 +11,7 @@ @Data @NoArgsConstructor +@AllArgsConstructor public class WebhookEvent { private String agent; @@ -36,7 +38,8 @@ public JsonNode getPayload() { @JsonIgnore public boolean hasMessage() { - return this.message != null; + // since suggestion responses can be rendered, we consider them messages + return this.message != null || this.suggestionResponse != null; } @JsonIgnore diff --git a/backend/sources/google/events-router/src/test/java/co/airy/core/sources/google/EventsRouterTest.java b/backend/sources/google/events-router/src/test/java/co/airy/core/sources/google/EventsRouterTest.java index 04b303838c..bb62059cc6 100644 --- a/backend/sources/google/events-router/src/test/java/co/airy/core/sources/google/EventsRouterTest.java +++ b/backend/sources/google/events-router/src/test/java/co/airy/core/sources/google/EventsRouterTest.java @@ -118,12 +118,12 @@ void canRouteGoogleMetadata() throws Exception { final String singleName = "hal9000"; // Two different event types that both carry context - final String messagePayload = "{ \"agent\": \"brands/somebrand/agents/%s\", \"conversationId\": \"CONVERSATION_ID\"," + - " \"customAgentId\": \"CUSTOM_AGENT_ID\", \"message\": { \"messageId\": \"MESSAGE_ID\", \"name\": \"conversations/CONVERSATION_ID/messages/MESSAGE_ID\", \"text\": \"MESSAGE_TEXT\", \"createTime\": \"MESSAGE_CREATE_TIME\" }," + - " \"context\": { \"userInfo\": { \"displayName\": \"%s\" } }, \"sendTime\": \"2014-10-02T15:01:23.045123456Z\" }"; - final String userStatusPayload = "{ \"agent\": \"brands/somebrand/agents/%s\", \"conversationId\": \"CONVERSATION_ID\", " + - "\"customAgentId\": \"CUSTOM_AGENT_ID\", \"userStatus\": { \"isTyping\": true }, " + - "\"context\": { \"userInfo\": { \"displayName\": \"%s\" } }, \"sendTime\": \"2014-10-02T15:01:23.045123456Z\" }"; + final String messagePayload = "{\"agent\":\"brands/somebrand/agents/%s\",\"conversationId\":\"CONVERSATION_ID\"," + + "\"customAgentId\":\"CUSTOM_AGENT_ID\",\"message\":{\"messageId\":\"MESSAGE_ID\",\"name\":\"conversations/CONVERSATION_ID/messages/MESSAGE_ID\",\"text\":\"MESSAGE_TEXT\",\"createTime\":\"MESSAGE_CREATE_TIME\"}," + + "\"context\":{\"userInfo\":{\"displayName\":\"%s\"}},\"sendTime\":\"2014-10-02T15:01:23.045123456Z\"}"; + final String userStatusPayload = "{\"agent\":\"brands/somebrand/agents/%s\",\"conversationId\":\"CONVERSATION_ID\"," + + "\"customAgentId\":\"CUSTOM_AGENT_ID\",\"userStatus\":{\"isTyping\":true}," + + "\"context\":{\"userInfo\":{\"displayName\":\"%s\"}},\"sendTime\":\"2014-10-02T15:01:23.045123456Z\"}"; List> events = List.of( new ProducerRecord<>(sourceGoogleEvents.name(), UUID.randomUUID().toString(), String.format(messagePayload, agentId, displayName)), diff --git a/backend/sources/twilio/connector/BUILD b/backend/sources/twilio/connector/BUILD index 85edc045c8..1ab494184b 100644 --- a/backend/sources/twilio/connector/BUILD +++ b/backend/sources/twilio/connector/BUILD @@ -4,6 +4,7 @@ load("//tools/build:container_push.bzl", "container_push") app_deps = [ "//backend:base_app", + "//:springboot_actuator", "//backend/model/channel", "//backend/model/message", "//lib/java/log", diff --git a/backend/sources/twilio/connector/src/main/java/co/airy/core/sources/twilio/ChannelsController.java b/backend/sources/twilio/connector/src/main/java/co/airy/core/sources/twilio/ChannelsController.java index 58cb944c6a..7b7d9e04f0 100644 --- a/backend/sources/twilio/connector/src/main/java/co/airy/core/sources/twilio/ChannelsController.java +++ b/backend/sources/twilio/connector/src/main/java/co/airy/core/sources/twilio/ChannelsController.java @@ -10,6 +10,8 @@ import lombok.NoArgsConstructor; import org.apache.kafka.clients.producer.KafkaProducer; import org.apache.kafka.clients.producer.ProducerRecord; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; @@ -97,7 +99,7 @@ private ResponseEntity disconnect(@RequestBody @Valid DisconnectChannelReques } if (channel.getConnectionState().equals(ChannelConnectionState.DISCONNECTED)) { - return ResponseEntity.accepted().build(); + return ResponseEntity.accepted().body(new EmptyResponsePayload()); } channel.setConnectionState(ChannelConnectionState.DISCONNECTED); @@ -111,6 +113,7 @@ private ResponseEntity disconnect(@RequestBody @Valid DisconnectChannelReques return ResponseEntity.ok(new EmptyResponsePayload()); } + } @Data diff --git a/backend/sources/twilio/connector/src/main/java/co/airy/core/sources/twilio/Connector.java b/backend/sources/twilio/connector/src/main/java/co/airy/core/sources/twilio/Connector.java index 69107ca5ff..563f5589de 100644 --- a/backend/sources/twilio/connector/src/main/java/co/airy/core/sources/twilio/Connector.java +++ b/backend/sources/twilio/connector/src/main/java/co/airy/core/sources/twilio/Connector.java @@ -40,14 +40,14 @@ public Message sendMessage(SendMessageRequest sendMessageRequest) { .stream() .filter(c -> c instanceof Text) .findFirst() - .orElse(null); + .orElseThrow(() -> new Exception("twilio only supports text messages")); api.sendMessage(from, to, text.getText()); updateDeliveryState(message, DeliveryState.DELIVERED); return message; } catch (ApiException e) { - log.error(String.format("Failed to send a message to Twilio \n SendMessageRequest: %s \n Error Message: %s \n", sendMessageRequest, e.getMessage()), e); + log.error(String.format("Twilio Api Exception for SendMessageRequest:\n%s", sendMessageRequest), e); } catch (Exception e) { log.error(String.format("Failed to send a message to Twilio \n SendMessageRequest: %s", sendMessageRequest), e); } diff --git a/backend/sources/twilio/connector/src/main/java/co/airy/core/sources/twilio/Stores.java b/backend/sources/twilio/connector/src/main/java/co/airy/core/sources/twilio/Stores.java index afcb3b0d1f..ba2746be62 100644 --- a/backend/sources/twilio/connector/src/main/java/co/airy/core/sources/twilio/Stores.java +++ b/backend/sources/twilio/connector/src/main/java/co/airy/core/sources/twilio/Stores.java @@ -16,12 +16,14 @@ import org.apache.kafka.streams.kstream.Materialized; import org.apache.kafka.streams.state.ReadOnlyKeyValueStore; import org.springframework.beans.factory.DisposableBean; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.ApplicationListener; import org.springframework.stereotype.Component; @Component -public class Stores implements DisposableBean, ApplicationListener { +public class Stores implements ApplicationListener, DisposableBean, HealthIndicator { private static final String applicationCommunicationChannels = new ApplicationCommunicationChannels().name(); @@ -57,7 +59,7 @@ public void onApplicationEvent(ApplicationReadyEvent applicationReadyEvent) { .groupByKey() .aggregate(SendMessageRequest::new, (conversationId, message, aggregate) -> { - SendMessageRequest.SendMessageRequestBuilder sendMessageRequestBuilder = aggregate.toBuilder(); + SendMessageRequest.SendMessageRequestBuilder sendMessageRequestBuilder = aggregate.toBuilder(); if (SenderType.SOURCE_CONTACT.equals(message.getSenderType())) { sendMessageRequestBuilder.sourceConversationId(message.getSenderId()); } @@ -88,6 +90,13 @@ public void destroy() { } } + @Override + public Health health() { + getChannelsStore(); + + return Health.up().build(); + } + // visible for testing KafkaStreams.State getStreamState() { return streams.state(); diff --git a/backend/sources/twilio/connector/src/test/java/co/airy/core/sources/twilio/SendMessageTest.java b/backend/sources/twilio/connector/src/test/java/co/airy/core/sources/twilio/SendMessageTest.java index 5e1c2919e2..68a07a6217 100644 --- a/backend/sources/twilio/connector/src/test/java/co/airy/core/sources/twilio/SendMessageTest.java +++ b/backend/sources/twilio/connector/src/test/java/co/airy/core/sources/twilio/SendMessageTest.java @@ -11,7 +11,9 @@ import co.airy.kafka.schema.application.ApplicationCommunicationMessages; import co.airy.kafka.test.KafkaTestHelper; import co.airy.kafka.test.junit.SharedKafkaTestResource; +import co.airy.mapping.model.Text; import co.airy.spring.core.AirySpringBootApplication; +import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.kafka.clients.producer.ProducerRecord; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; @@ -122,6 +124,7 @@ void canSendMessageViaTheTwilioApi() throws Exception { TimeUnit.SECONDS.sleep(5); + final ObjectMapper objectMapper = new ObjectMapper(); kafkaTestHelper.produceRecord(new ProducerRecord<>(applicationCommunicationMessages.name(), messageId, Message.newBuilder() .setId(messageId) @@ -132,7 +135,7 @@ void canSendMessageViaTheTwilioApi() throws Exception { .setConversationId(conversationId) .setChannelId(channelId) .setSource("twilio.sms") - .setContent("{\"text\":\"" + text + "\"}") + .setContent(objectMapper.writeValueAsString(new Text(text))) .build()) ); diff --git a/backend/sources/twilio/events-router/BUILD b/backend/sources/twilio/events-router/BUILD index a1b92003fa..63e98d23b1 100644 --- a/backend/sources/twilio/events-router/BUILD +++ b/backend/sources/twilio/events-router/BUILD @@ -8,6 +8,7 @@ app_deps = [ "//backend/model/message", "//lib/java/uuid", "//lib/java/log", + "//lib/java/url", "//lib/java/kafka/schema:source-twilio-events", "//lib/java/spring/kafka/core:spring-kafka-core", "//lib/java/spring/kafka/streams:spring-kafka-streams", diff --git a/backend/sources/twilio/events-router/src/main/java/co/airy/core/sources/twilio/TwilioInfoExtractor.java b/backend/sources/twilio/events-router/src/main/java/co/airy/core/sources/twilio/TwilioInfoExtractor.java index fbedc96a9b..c90e748a8c 100644 --- a/backend/sources/twilio/events-router/src/main/java/co/airy/core/sources/twilio/TwilioInfoExtractor.java +++ b/backend/sources/twilio/events-router/src/main/java/co/airy/core/sources/twilio/TwilioInfoExtractor.java @@ -1,14 +1,8 @@ package co.airy.core.sources.twilio; -import java.net.URLDecoder; -import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import java.util.List; import java.util.Map; -import java.util.Objects; - -import static java.util.stream.Collectors.toMap; +import static co.airy.url.UrlUtil.parseUrlEncoded; public class TwilioInfoExtractor { @@ -21,24 +15,4 @@ static TwilioEventInfo extract(String payload) { .payload(payload) .build(); } - - static Map parseUrlEncoded(String payload) { - List kvPairs = Arrays.asList(payload.split("&")); - - return kvPairs.stream() - .map((kvPair) -> { - String[] fields = kvPair.split("="); - - if (fields.length != 2) { - return null; - } - - String name = URLDecoder.decode(fields[0], StandardCharsets.UTF_8); - String value = URLDecoder.decode(fields[1], StandardCharsets.UTF_8); - - return List.of(name, value); - }) - .filter(Objects::nonNull) - .collect(toMap((tuple) -> tuple.get(0), (tuple) -> tuple.get(1))); - } } diff --git a/docs/docs/api/http.md b/docs/docs/api/http.md index 7e14f9814d..a451960c20 100644 --- a/docs/docs/api/http.md +++ b/docs/docs/api/http.md @@ -11,7 +11,7 @@ compose the Airy API. The HTTP endpoints adhere to the following conventions: - Endpoints only accept `POST` JSON requests. -- Communication always requires a valid [JWT token](#authorization), except for +- Communication always requires a valid [JWT token](#authentication), except for `/users.login` and `/users.signup` endpoints. - We use dots for namespacing URLs (eg `/things.add`). @@ -144,9 +144,7 @@ Find users whose name ends with "Lovelace": "contact": { // Additional data on the contact "avatar_url": "https://assets.airy.co/AirySupportIcon.jpg", - "first_name": "Airy Support", - "last_name": null, - "id": "36d07b7b-e242-4612-a82c-76832cfd1026" + "display_name": "Airy Support" }, "tags": ["f339c325-8614-43cb-a70a-e83d81bf56fc"], "last_message": { @@ -202,13 +200,8 @@ Find users whose name ends with "Lovelace": }, "created_at": "2019-01-07T09:01:44.000Z", "contact": { - "avatar_url": "https://assets.airy.co/AirySupportIcon.jpg", - // optional - "first_name": "Airy Support", - // optional - "last_name": null, - // optional - "id": "36d07b7b-e242-4612-a82c-76832cfd1026" + "avatar_url": "https://assets.airy.co/AirySupportIcon.jpg", // optional + "display_name": "Airy Support" // optional }, "tags": ["f339c325-8614-43cb-a70a-e83d81bf56fc"], "last_message": { @@ -352,13 +345,45 @@ This is a [paginated](#pagination) endpoint. Messages are sorted from oldest to Sends a message to a conversation and returns a payload. -**Sample request** +**Sending a text message** ```json5 { "conversation_id": "a688d36c-a85e-44af-bc02-4248c2c97622", "message": { - "text": "{String}" + "text": "Hello World", + "type": "text" + } +} +``` + +**Sending an attachment message** + +```json5 +{ + "conversation_id": "a688d36c-a85e-44af-bc02-4248c2c97622", + "message": { + "url": "http://example.org/myfile", + "type": "image|video|audio|file" + } +} +``` + +**Sending source templates** + +Some sources support sending templates, which can be used to display rich content such as buttons or cards. You +can send source templates by setting the type to `source.template`. Please refer to the source documentation +to see the expected values for the `payload` field. + +```json5 +{ + "conversation_id": "a688d36c-a85e-44af-bc02-4248c2c97622", + "message": { + "payload": { + "template_type": "buttons", + "buttons": ["Welcome to our shop"] + }, + "type": "source.template" } } ``` @@ -372,12 +397,9 @@ Sends a message to a conversation and returns a payload. { "text": "{String}", "type": "text" - // Determines the schema of the content } ], - // typed source message model - "state": "{String}", - // delivery state of message, one of PENDING, FAILED, DELIVERED + "state": "pending|failed|delivered", "sender_type": "{string/enum}", // See glossary "sent_at": "{string}" diff --git a/docs/docs/api/webhook.md b/docs/docs/api/webhook.md index c986850f32..519c11e2c4 100644 --- a/docs/docs/api/webhook.md +++ b/docs/docs/api/webhook.md @@ -80,7 +80,7 @@ Subscribes the webhook for the first time or update its parameters. ## Event Payload -After [subscribing](#subscribing-to-a-webhook) to an Airy webhook, you will +After [subscribing](#subscribing) to an Airy webhook, you will start receiving events on your URL of choice. The event will _always_ be a POST request with the following structure: @@ -92,7 +92,7 @@ request with the following structure: "id": "adac9220-fe7b-40a8-98e5-2fcfaf4a53b5", "type": "source_contact" }, - "source": "FACEBOOK", + "source": "facebook", "sent_at": "2020-07-20T14:18:08.584Z", "text": "Message to be sent" } diff --git a/docs/docs/api/websocket.md b/docs/docs/api/websocket.md index 1c16f48a73..9145c1b591 100644 --- a/docs/docs/api/websocket.md +++ b/docs/docs/api/websocket.md @@ -11,6 +11,9 @@ uses the [STOMP](https://en.wikipedia.org/wiki/Streaming_Text_Oriented_Messaging_Protocol) protocol endpoint at `/ws.communication`. +To execute the handshake with `/ws.communicaiton` you need to set an `Authorization` header where the +value is the authorization token obtained [from the API](http.md#authentication). + ## Outbound Queues Outbound queues follow the pattern `/queue/:event_type[/:action}]` and @@ -36,7 +39,7 @@ Incoming payloads notify connected clients that a message was created or updated // Determines the schema of the content }, // typed source message model - "state": "{String}", + "delivery_state": "{String}", // delivery state of message, one of PENDING, FAILED, DELIVERED "sender_type": "{string/enum}", // See glossary diff --git a/docs/docs/guides/airy-core-in-production.md b/docs/docs/guides/airy-core-in-production.md index 0e0d38680f..989c8bedad 100644 --- a/docs/docs/guides/airy-core-in-production.md +++ b/docs/docs/guides/airy-core-in-production.md @@ -141,6 +141,10 @@ features. In order to proceed with deploying the apps, we assume that you have a running Kubernetes cluster, properly configured KUBECONF file and properly set context. +The Airy Core Platform ships with a Kubernetes controller, which is responsible for +starting and reloading the appropriate Airy apps based on the provided configuration. +The controller as a deployment named `airy-controller`. + ### Configuration After the [required services](#requirements) are deployed, you're ready to start @@ -149,79 +153,71 @@ Kafka cluster, PostgreSQL and Redis can be done by creating a configuration file, prior to deploying the apps. Make sure that the `Airy apps` also have network connectivity to the required services. -The file `infrastructure/airy.conf.all` contains an example of all possible -configuration parameters. This file should be copied to `airy.conf` and edited +The file `infrastructure/airy.tpl.yaml` contains an example of all possible +configuration parameters. This file should be copied to `airy.yaml` and edited according to your environment: ```sh cd infrastructure -cp airy.conf.all airy.conf +cp airy.tpl.yaml airy.yaml ``` -Edit the file to configure connections to the base services. Make sure that the -following sections are configured correctly, so that the `Airy apps` to start -properly: +Edit the file to configure connections to the base services. Make sure to configure the +following sections correctly, so that the `Airy apps` start properly: -``` +```yaml apps: - kafka: - ... - redis: - ... - postgresql: - ... + kafka: ... + redis: ... + postgresql: ... ``` -We recommend to create a new database if you are reusing a PostgreSQL server to avoid name collisions. +We recommend that you create a new database if you are reusing a PostgreSQL server to avoid name collisions. -### Deployment +## Source media storage -We provided a Helm chart to deploy the `Airy apps`. Before you can run helm, you -must configure the system via the `airy.conf` file, then you can proceed: +Most message sources allow users to send rich data such as images, videos and audio files. For some sources +the Urls that host this data expire which is why after some time you may find that conversations have inaccessible +content. -```sh -cp airy.conf ./helm-chart/charts/apps/values.yaml -helm install core ./helm-chart/charts/apps/ --timeout 1000s -``` - -By default, the `Airy apps` deployments start with `replicas=0` so to scale them up, run: +The Airy Core Platform allows you to persist this data to a storage of your choice. To take advantage of this +you must provide access credentials to your storage. The platform currently supports [s3](https://aws.amazon.com/s3/): -```sh -kubectl scale deployment -l type=api --replicas=1 -kubectl scale deployment -l type=frontend --replicas=1 -kubectl scale deployment -l type=webhook --replicas=1 -kubectl scale deployment -l type=sources-chatplugin --replicas=1 -kubectl scale deployment -l type=sources-facebook --replicas=1 -kubectl scale deployment -l type=sources-google --replicas=1 -kubectl scale deployment -l type=sources-twilio --replicas=1 +```yaml +apps: + storage: + s3: + key: + secret: + bucket: + region: + path: <(optional) defaults to the bucket root> ``` -At this point you should have a running `Airy Core Platform` in your environment 🎉. +### Deployment -To deploy with a different `image tag` (for example `beta` from the `develop` -branch), you can run: +We provided a Helm chart to deploy the `Airy apps`. Before you can run helm, you +must configure the system via the `airy.yaml` file, then you can proceed: ```sh -export AIRY_VERSION=beta -helm install core ./helm-chart/charts/apps/ --set global.appImageTag=${AIRY_VERSION} --timeout 1000s +helm install core ./helm-chart/charts/apps/ --values ./airy.yaml --timeout 1000s ``` +The API `Airy apps`, the Frontend UI and the Frontend Chatplugin start by default, while all the other apps are optional and are started if there is provided configuration for them in the `airy.yaml` file. + +At this point you should have a running `Airy Core Platform` in your environment 🎉. + If afterwards you need to modify or add other config parameters in the -`airy.conf` file, after editing the file run: +`airy.yaml` file, after editing the file run: ```sh -cp airy.conf ./helm-chart/charts/apps/values.yaml -helm upgrade core ./helm-chart/charts/apps/ --timeout 1000s +airy config apply --config ./airy.yaml --kube-config /path/to/your/kube.conf ``` -If you deploy the Airy Core Platform with a specific version tag, you must -export the `AIRY_VERSION` variable before running `helm upgrade`: +Make sure you point the `--kube-config` flag to your Kubernetes configuration file. -```sh -cp airy.conf ./helm-chart/charts/apps/values.yaml -export AIRY_VERSION=beta -helm upgrade core ./helm-chart/charts/apps/ --set global.appImageTag=${AIRY_VERSION} --timeout 1000s -``` +If you want to deploy the Airy Core Platform with a specific version, you must set the version in your +`airy.yaml` file, under the `global.appImageTag` configuration key. ## Network @@ -260,21 +256,20 @@ Ingress resources. You can choose an [Kubernetes ingress controller](https://kubernetes.io/docs/concepts/services-networking/ingress-controllers/) in accordance to your needs or preferences. If you are using the [Traefik](https://traefik.io/) ingress controller, you can edit the -`infrastructure/network/ingress.yaml` file to modify the `host` records and -directly apply it to your Kubernetes cluster. +`infrastructure/helm-chart/charts/ingress/templates/ingress.yaml` file to modify the `host` records and apply the ingress helm chart, which is already included in the repository: ```sh -kubectl apply -f infrastructure/network/ingress.yaml +helm install ingress infrastructure/helm-chart/charts/ingress/ ``` -You must set different `host` attributes for the following: +You must set appropriate `host` attributes in the rules for: - API endpoints (defaults to `api.airy`) - Demo (defaults to `demo.airy`) - Chat plugin (defaults to `chatplugin.airy`) If you are not using Traefik, you can use the -`infrastructure/network/ingress.yaml` file as a guide to create your own +`infrastructure/helm-chart/charts/ingress/templates/ingress.yaml` file as a guide to create your own Kubernetes manifest for your preferred ingress controller. If your Kubernetes cluster is not directly reachable on the Internet, you will diff --git a/docs/docs/guides/airy-core-in-test-env.md b/docs/docs/guides/airy-core-in-test-env.md index 96b1873287..7f38ee4162 100644 --- a/docs/docs/guides/airy-core-in-test-env.md +++ b/docs/docs/guides/airy-core-in-test-env.md @@ -98,7 +98,8 @@ local machine. You can see an example request to the API by running the The frontend UI for the demo app can be accessed through http://demo.airy. -The frontend UI for the Airy chat plugin can be accessed through http://chatplugin.airy/example.html. +The frontend UI for the Airy chat plugin can be accessed through +http://chatplugin.airy/example. ## Public webhooks @@ -138,9 +139,9 @@ document The bootstrap process creates a random URL which is then provisioned inside the Helm chart. To configure these URLs, you can specify them in the -`infrastructure/helm-chart/charts/apps/charts/airy-co)fig/values.yaml` document. -Alternatively you can edit the `airy.conf` file by setting the following -parameter (see `airy.conf.all` for more examples): +`infrastructure/helm-chart/charts/apps/charts/airy-config/values.yaml` document. +Alternatively you can edit the `airy.yaml` file by setting the following +parameter (see `airy.tpl.yaml` for more examples): ``` sources: @@ -154,23 +155,32 @@ After preparing the configuration, run the following commands to apply the chang cd infrastructure vagrant ssh sudo -i -cp /vagrant/airy.conf ~/airy-core/helm-chart/charts/apps/values.yaml -helm upgrade core ~/airy-core/helm-chart/charts/apps/ --timeout 1000s +helm upgrade core ~/airy-core/helm-chart/charts/apps/ --values /vagrant/airy.yaml --timeout 1000s ``` ## Connect sources Integrating sources into the `Airy Core Platform` often requires specific configuration settings, refer to the source specific docs for details. You must -provide the settings in `infrastructure/airy.conf` configuration file. An -example of the configuration can be found in `airy.conf.all`. +provide the settings in `infrastructure/airy.yaml` configuration file. An +example of the configuration can be found in `airy.tpl.yaml`. -After setting the configuration run: +After setting the configuration, you need the Airy command line binary (Airy CLI), to communicate with the core installation and apply the installation. +Building and releasing the Airy CLI is part of the regular release process of the Airy Core Platform. +You can download the Airy CLI from the releases page on Github https://github.com/airyhq/airy/releases. + +After downloading, run the following commands: ```sh -vagrant provision --provision-with=conf +airy init +airy apply config --config ./airy.yaml ``` +Make sure that the argument `` points to your `airy.yaml` configuration file. + +The Airy CLI considers that the kubernetes configuration file is located under `~/.airy/kube.conf`. +If you modified the location of the file, make sure to set the appropriate path with the `--kube-config` flag. + ## Uninstall the Airy Core Platform You can remove the Airy Core Platform Box from your machine completely running diff --git a/docs/docs/index.md b/docs/docs/index.md index d654af9b53..c01ac13125 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -57,6 +57,14 @@ hosts file yourself. 192.168.50.5 chatplugin.airy ``` +After the bootstrap process finishes, it will download the Kubernetes configiration file to the local host machine under `~/.airy/kube.conf`. +That file is required for the Airy Command Line tool (Airy CLI), in order to access the Kubernetes cluster where the Airy Core Platform is running. +You can also use that configuration file with the `kubectl` utility, for example: + +```sh +kubectl --kubeconfig ~/.airy/kube.conf get pods +``` + Check out our [guide for running in test environment](guides/airy-core-in-test-env.md) for detailed information. ## Connect a Chat Plugin source @@ -91,7 +99,7 @@ browser. This authenticates the chat plugin and enables you to send messages immediately: ``` -http://chatplugin.airy/example.html?channel_id= +http://chatplugin.airy/example?channel_id= ``` You can now type a message in the text box and send it 🎉 diff --git a/docs/docs/overview/architecture.md b/docs/docs/overview/architecture.md index a7a6e4b20f..d68ccd1eb9 100644 --- a/docs/docs/overview/architecture.md +++ b/docs/docs/overview/architecture.md @@ -43,3 +43,13 @@ which run as part of the Airy Core Platform: - frontend-demo - Web application for viewing messages - frontend-chat-plugin - Web chat plugin + +## Airy Controller + +The Airy Core Platform ships with a Kubernetes controller, which is responsible for starting and reloading the appropriate Airy apps based on the provided configuration. +The controller as a deployment named `airy-controller`. + +## Airy CLI + +Every release features a command line binary, used to configure and fetch status information from the Airy Core Platform. +This tool is referred to as the `Airy CLI` throughout the documentation. diff --git a/docs/docs/overview/release-process.md b/docs/docs/overview/release-process.md index 6f70200d67..7f11157026 100644 --- a/docs/docs/overview/release-process.md +++ b/docs/docs/overview/release-process.md @@ -10,32 +10,16 @@ users in a timely manner. Here's an outline of the process: -- We branch from `develop` unless it's a hot-fix (we'd use `main` in that case) +- We need a `GITHUB_TOKEN` environment variable with write permission to the org +- We run `./scripts/release.sh start x.y.z` - Once release days comes, we execute the following steps: - - We create an issue "Release x.y.z" - - We create a release branch `release/x.y.z` from the latest `develop` and push it: - - `git checkout develop` - - `git pull origin develop` - - `git checkout -b release/x.y.z` - - `git push origin release/x.y.z` - - We test our release (`AIRY_VERSION=release ./scripts/bootstrap.sh`) and any + - We test our release (`./scripts/bootstrap.sh`) and any additional hot-fix is committed directly to the release branch - - Once we're satisfied with the release, we update the `VERSION` file with the - current release number. The commit message must be `Fixes #issue-number` - where `issue-number` is the number of the current release issue - - We merge the release branch into `main`, tag `main` with `x.y.z`and push to `main`: - - `git checkout main` - - `git pull origin main` - - `git merge --no-ff release/x.y.z` - - `git tag x.y.z` - - `git push origin main` - - `git push origin x.y.z` - - We merge the release branch back into `develop`: - - `git checkout develop` - - `git merge --no-ff release/x.y.z` - - `git push origin develop` + - Once we're satisfied with the release, we finish the release by running `./scripts/release.sh finish x.y.z` - We archive cards in the done column of the [work in progress](https://github.com/airyhq/airy/projects/1) board - We rename the current draft release to `x.y.z` and publish it - We announce the release! +As part of the release process we are also releasing a command line client - the `Airy CLI`. + You can check out existing releases on [GitHub](https://github.com/airyhq/airy/releases). diff --git a/docs/docs/sources/chat-plugin.md b/docs/docs/sources/chat-plugin.md index 6b2c9d4648..b58220dd99 100644 --- a/docs/docs/sources/chat-plugin.md +++ b/docs/docs/sources/chat-plugin.md @@ -83,7 +83,7 @@ of your Chat Plugin server. When using the local vagrant environment ::: To test the setup, replace the `CHANNEL_ID` in the URL -`http://chatplugin.airy/example.html?channel_id=CHANNEL_ID` and open it in your +`http://chatplugin.airy/example?channel_id=CHANNEL_ID` and open it in your browser. ## HTTP API @@ -179,6 +179,7 @@ header. { "message": { "text": "{String}" + "type": "text" } } ``` diff --git a/docs/docs/sources/facebook.md b/docs/docs/sources/facebook.md index ba6ae36229..e26e3bda09 100644 --- a/docs/docs/sources/facebook.md +++ b/docs/docs/sources/facebook.md @@ -170,3 +170,43 @@ the nature of the request, the response time may vary. ] } ``` + +## Send a template message + +With Facebook messenger you can send [templates](https://developers.facebook.com/docs/messenger-platform/send-messages/templates) to your contacts, +which is useful for automations, FAQs, receipts and many more use cases. To send Facebook templates using the [send message API](/api/http.md#send-a-message) you have to +first build an attachment for Facebook using their template model. Quoting from the docs: + +> The body of the request follows a standard format for all template types, with the `message.attachment.payload` property containing the type and content details that are specific to each template type: +> +> ```json5 +> { +> "recipient": { +> "id": "" +> }, +> "message": { +> "attachment": { +> "type": "template", +> "payload": { +> "template_type": "" +> // ... +> } +> } +> } +> } +> ``` + +Next take the `message.attachment.payload` field and add it to the `message` field on the API request like so: + +```json5 +{ + "conversation_id": "a688d36c-a85e-44af-bc02-4248c2c97622", + "message": { + "payload": { + "template_type": "" + // ... + }, + "type": "source.template" + } +} +``` diff --git a/docs/docs/sources/google.md b/docs/docs/sources/google.md index bb29a5715b..fc23eeb091 100644 --- a/docs/docs/sources/google.md +++ b/docs/docs/sources/google.md @@ -16,7 +16,7 @@ Business Location and your running instance of the Airy Core Platform. ## Configuration The first step is to copy the Google Service Account file provided by Google to -`infrastructure/airy.conf` as a one line string +`infrastructure/airy.yaml` as a one line string ``` GOOGLE_SA_FILE= diff --git a/docs/docs/sources/twilio-source.mdx b/docs/docs/sources/twilio-source.mdx index addc708a0a..c6bf5389f1 100644 --- a/docs/docs/sources/twilio-source.mdx +++ b/docs/docs/sources/twilio-source.mdx @@ -1,6 +1,6 @@ You must create a [Twilio auth token](https://support.twilio.com/hc/en-us/articles/223136027-Auth-Tokens-and-How-to-Change-Them) -and add it to `infrastructure/airy.conf` together with your account SID: +and add it to `infrastructure/airy.yaml` together with your account SID: ``` authToken= diff --git a/frontend/chat-plugin/BUILD b/frontend/chat-plugin/BUILD index d71937cd45..790af5355d 100644 --- a/frontend/chat-plugin/BUILD +++ b/frontend/chat-plugin/BUILD @@ -6,17 +6,23 @@ load("@rules_pkg//:pkg.bzl", "pkg_tar") load("//tools/build:container_push.bzl", "container_push") load("@io_bazel_rules_docker//container:container.bzl", "container_image") +module_deps = [ + "//lib/typescript/types", +] + web_app( name = "bundle", app_lib = ":app", entry = "frontend/chat-plugin/src/iframe.js", - index = ":index.html", + index = ":development.html", + module_deps = module_deps, ) web_library( name = "library", app_lib = ":app", entry = "frontend/chat-plugin/src/defaultScript.js", + module_deps = module_deps, output = { "libraryExport": "AiryWidget", "publicPath": "/", @@ -27,8 +33,7 @@ web_library( ts_library( name = "app", tsconfig = ":widget_tsconfig", - deps = [ - "//lib/typescript/types", + deps = module_deps + [ "@npm//@stomp/stompjs", "@npm//@types/node", "@npm//linkifyjs", diff --git a/frontend/chat-plugin/index.html b/frontend/chat-plugin/development.html similarity index 100% rename from frontend/chat-plugin/index.html rename to frontend/chat-plugin/development.html diff --git a/frontend/chat-plugin/example.html b/frontend/chat-plugin/example.html index bb822ae59c..c0f2f7f4f6 100644 --- a/frontend/chat-plugin/example.html +++ b/frontend/chat-plugin/example.html @@ -207,7 +207,7 @@ (function (w, d, s, n) { w[n] = w[n] || {}; w[n].cid = search.get("channel_id"); - w[n].h = "chatplugin.airy"; + w[n].h = "{{API_HOST}}"; // Only to be used for local installations w[n].no_tls = true; var f = d.getElementsByTagName(s)[0], diff --git a/frontend/chat-plugin/nginx.conf b/frontend/chat-plugin/nginx.conf index 1541330a74..e5859392ac 100644 --- a/frontend/chat-plugin/nginx.conf +++ b/frontend/chat-plugin/nginx.conf @@ -8,6 +8,9 @@ events { worker_connections 1024; } +# By default nginx does not make any env variables accessible to lua +# http://nginx.org/en/docs/ngx_core_module.html#env +env API_HOST; http { include /etc/nginx/mime.types; @@ -29,7 +32,16 @@ http { listen 80; root /usr/share/nginx/html; - location / { + location = /example { + default_type text/html; + + content_by_lua_block { + local template = require("resty.template") + local template_string = ngx.location.capture("/example.html") + template.render(template_string.body, { + API_HOST = os.getenv("API_HOST") + }) + } } location /health { diff --git a/frontend/chat-plugin/src/components/api/index.tsx b/frontend/chat-plugin/src/components/api/index.tsx new file mode 100644 index 0000000000..d63de03c9b --- /dev/null +++ b/frontend/chat-plugin/src/components/api/index.tsx @@ -0,0 +1,59 @@ +import {Text} from 'types'; + +declare const window: { + airy: { + h: string; + cid: string; + no_tls: boolean; + }; +}; + +const API_HOST = window.airy ? window.airy.h : 'chatplugin.airy'; +const TLS_PREFIX = window.airy ? (window.airy.no_tls === true ? '' : 's') : ''; + +export const sendMessage = (message: Text, token: string) => { + return fetch(`http${TLS_PREFIX}://${API_HOST}/chatplugin.send`, { + method: 'POST', + body: JSON.stringify({ + message, + }), + headers: { + 'Content-Type': 'application/json', + Authorization: token, + }, + }); +}; + +export const getResumeToken = async (token: string) => { + const resumeChat = await fetch(`http${TLS_PREFIX}://${API_HOST}/chatplugin.resumeToken`, { + method: 'POST', + body: JSON.stringify({}), + headers: { + 'Content-Type': 'application/json', + Authorization: token, + }, + }); + const jsonResumeToken = await resumeChat.json(); + localStorage.setItem('resume_token', jsonResumeToken.resume_token); +}; + +export const start = async (channel_id: string, resume_token: string) => { + try { + const response = await fetch(`http${TLS_PREFIX}://${API_HOST}/chatplugin.authenticate`, { + method: 'POST', + body: JSON.stringify({ + channel_id: channel_id, + ...(resume_token && { + resume_token, + }), + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + + return await response.json(); + } catch (e) { + return Promise.reject(new Error('Widget authorization failed. Please check your installation.')); + } +}; diff --git a/frontend/chat-plugin/src/components/chat/index.tsx b/frontend/chat-plugin/src/components/chat/index.tsx index 8137d929c4..08015e82d0 100644 --- a/frontend/chat-plugin/src/components/chat/index.tsx +++ b/frontend/chat-plugin/src/components/chat/index.tsx @@ -2,7 +2,7 @@ import {h} from 'preact'; import {useState, useEffect} from 'preact/hooks'; import {IMessage} from '@stomp/stompjs'; -import Websocket from '../../components/websocket'; +import WebSocket from '../../components/websocket'; import MessageProp from '../../components/message'; import InputBarProp from '../../components/inputBar'; import AiryInputBar from '../../airyRenderProps/AiryInputBar'; @@ -16,7 +16,7 @@ import {RoutableProps} from 'preact-router'; import BubbleProp from '../bubble'; import AiryBubble from '../../airyRenderProps/AiryBubble'; -let ws: Websocket; +let ws: WebSocket; const welcomeMessage = { id: '19527d24-9b47-4e18-9f79-fd1998b95059', @@ -40,8 +40,17 @@ const Chat = (props: Props) => { const [isChatHidden, setIsChatHidden] = useState(true); const [messages, setMessages] = useState([welcomeMessage]); + const getResumeToken = () => { + const queryParams = new URLSearchParams(window.location.search); + if (queryParams.has('resume_token')) { + localStorage.setItem('resume_token', queryParams.get('resume_token')); + } + + return queryParams.get('resume_token') || localStorage.getItem('resume_token'); + }; + useEffect(() => { - ws = new Websocket(props.channel_id, onReceive); + ws = new WebSocket(props.channel_id, onReceive, getResumeToken()); ws.start().catch(error => { console.error(error); setInstallError(error.message); @@ -69,13 +78,10 @@ const Chat = (props: Props) => { } }, sendMessage: (text: string) => { - ws.onSend( - JSON.stringify({ - message: { - text, - }, - }) - ); + ws.onSend({ + text, + type: 'text', + }); }, }; diff --git a/frontend/chat-plugin/src/components/websocket/index.ts b/frontend/chat-plugin/src/components/websocket/index.ts index 8c2b9991d1..1c3a6c53f9 100644 --- a/frontend/chat-plugin/src/components/websocket/index.ts +++ b/frontend/chat-plugin/src/components/websocket/index.ts @@ -1,5 +1,7 @@ import {Client, messageCallbackType, IFrame} from '@stomp/stompjs'; import 'regenerator-runtime/runtime'; +import {start, getResumeToken, sendMessage} from '../api'; +import {Text} from 'types'; declare const window: { airy: { @@ -12,15 +14,17 @@ declare const window: { const API_HOST = window.airy ? window.airy.h : 'chatplugin.airy'; const TLS_PREFIX = window.airy ? (window.airy.no_tls === true ? '' : 's') : ''; -class Websocket { +class WebSocket { client: Client; channel_id: string; token: string; + resume_token: string; onReceive: messageCallbackType; - constructor(channel_id: string, onReceive: messageCallbackType) { + constructor(channel_id: string, onReceive: messageCallbackType, resume_token?: string) { this.channel_id = channel_id; this.onReceive = onReceive; + this.resume_token = resume_token; } connect = (token: string) => { @@ -49,39 +53,19 @@ class Websocket { this.client.activate(); }; - onConnect = () => { - this.client.subscribe('/user/queue/message', this.onReceive); - }; + onSend = (message: Text) => sendMessage(message, this.token); - onSend = (message: string) => { - return fetch(`http${TLS_PREFIX}://${API_HOST}/chatplugin.send`, { - method: 'POST', - body: message, - headers: { - 'Content-Type': 'application/json', - Authorization: this.token, - }, - }); + start = async () => { + this.token = (await start(this.channel_id, this.resume_token)).token; + this.connect(this.token); + if (!this.resume_token) { + await getResumeToken(this.token); + } }; - async start() { - try { - const response = await fetch(`http${TLS_PREFIX}://${API_HOST}/chatplugin.authenticate`, { - method: 'POST', - body: JSON.stringify({ - channel_id: this.channel_id, - }), - headers: { - 'Content-Type': 'application/json', - }, - }); - - const jsonResponse = await response.json(); - this.connect(jsonResponse.token); - } catch (e) { - return Promise.reject(new Error('Widget authorization failed. Please check your installation.')); - } - } + onConnect = () => { + this.client.subscribe('/user/queue/message', this.onReceive); + }; } -export default Websocket; +export default WebSocket; diff --git a/frontend/demo/BUILD b/frontend/demo/BUILD index 870ee81976..a3051e3755 100644 --- a/frontend/demo/BUILD +++ b/frontend/demo/BUILD @@ -19,6 +19,7 @@ ts_library( "@npm//@types/node", "@npm//@types/prop-types", "@npm//@types/react", + "@npm//@types/react-dom", "@npm//@types/react-redux", "@npm//emoji-mart", "@npm//lodash-es", diff --git a/frontend/demo/README.md b/frontend/demo/README.md index 5fe00df59c..4233270460 100644 --- a/frontend/demo/README.md +++ b/frontend/demo/README.md @@ -9,11 +9,13 @@ The Airy Demo UI is a minimal user interactive frontend project that showcases the Airy Core Platform API.It enables users to experience the functionalities of our Airy Core Platform. -- [Prerequities](#prerequities) +- [Airy Demo UI](#airy-demo-ui) +- [Prerequisites](#prerequisites) - [Building Airy Demo UI](#building-airy-demo-ui) - [Installation](#installation) - [Authentication](#authentication) - [Endpoints](#endpoints) +- [Development](#development) ### Prerequisites @@ -29,14 +31,14 @@ You can run the Airy Demo UI locally by running the following commands: ``` $ git clone https://github.com/airyhq/airy $ cd airy -$ AIRY_VERSION=beta ./scripts/bootstrap.sh (Takes a few minutes) +$ ./scripts/bootstrap.sh (Takes a few minutes) ``` When the bootstrap process finishes, open another terminal and run ``` $ ibazel run //frontend/demo:bundle_server ``` Then open `http://localhost:8080/` in a web browser to access the Airy Demo UI ### Installation The bootstrap installation requires [Vagrant](https://www.vagrantup.com/downloads) and [VirtualBox](https://www.virtualbox.org/wiki/Downloads). If they are not -found, the script ```$ AIRY_VERSION=beta ./scripts/bootstrap.sh``` will attempt to install them for you. Check out our [test deployment guide](/docs/docs/guides/airy-core-in-test-env.md) for detailed information. +found, the script ```$ ./scripts/bootstrap.sh``` will attempt to install them for you. Check out our [test deployment guide](/docs/docs/guides/airy-core-in-test-env.md) for detailed information. ### Authentication @@ -49,4 +51,13 @@ In order to communicate with our API endpoints, you need a valid [JWT](https://j Aside from Curl, [PostMan](https://www.postman.com/downloads/) and other API testing tools could also be used to access the endpoints. +### Development +To start the app in development mode, run these commands: + +``` +yarn +yarn ibazel run //frontend/demo:bundle_server +``` + +After it started, open a web browser to [`localhost:8080`](http://localhost:8080). Login with the user you created above. \ No newline at end of file diff --git a/frontend/demo/index.html b/frontend/demo/index.html index e9bda5d610..f85c9a66c6 100644 --- a/frontend/demo/index.html +++ b/frontend/demo/index.html @@ -1,6 +1,7 @@ + Airy UI - - Airy Inbox Demo + -
diff --git a/frontend/demo/nginx.conf b/frontend/demo/nginx.conf index fbbe8ae352..bcf8949727 100644 --- a/frontend/demo/nginx.conf +++ b/frontend/demo/nginx.conf @@ -8,6 +8,9 @@ events { worker_connections 1024; } +# By default nginx does not make any env variables accessible to lua +# http://nginx.org/en/docs/ngx_core_module.html#env +env API_HOST; http { include /etc/nginx/mime.types; @@ -30,11 +33,23 @@ http { root /usr/share/nginx/html; location / { - try_files $uri @rewrites; + try_files $uri @rewrites @lua_index; } location @rewrites { - rewrite ^(.+)$ /index.html last; + rewrite ^(.+)$ @lua_index last; + } + + location @lua_index { + default_type text/html; + + content_by_lua_block { + local template = require("resty.template") + local template_string = ngx.location.capture("/index.html") + template.render(template_string.body, { + API_HOST = os.getenv("API_HOST") + }) + } } location /health { diff --git a/frontend/demo/src/AiryConfig.ts b/frontend/demo/src/AiryConfig.ts new file mode 100644 index 0000000000..7abde61ad7 --- /dev/null +++ b/frontend/demo/src/AiryConfig.ts @@ -0,0 +1,4 @@ +export class AiryConfig { + static NODE_ENV = process.env.NODE_ENV; + static FACEBOOK_APP_ID = 'CHANGE_ME'; +} diff --git a/frontend/demo/src/App.module.scss b/frontend/demo/src/App.module.scss index c47a1ee7ac..4ec34471b1 100644 --- a/frontend/demo/src/App.module.scss +++ b/frontend/demo/src/App.module.scss @@ -5,7 +5,7 @@ text-align: center; flex-grow: 1; width: 100vw; - min-height: 100vh; + height: 100vh; margin-left: 250px; overflow: visible; background: var(--color-blue-white); @@ -29,8 +29,8 @@ @include font-base; display: flex; width: 100vw; + height: 100vh; min-width: 550px; - min-height: 100vh; justify-content: center; flex-direction: column; } diff --git a/frontend/demo/src/App.tsx b/frontend/demo/src/App.tsx index 9dac7583b1..a00b2b3e0c 100644 --- a/frontend/demo/src/App.tsx +++ b/frontend/demo/src/App.tsx @@ -11,13 +11,27 @@ import Tags from './pages/Tags'; import Logout from './pages/Logout'; import NotFound from './pages/NotFound'; import Sidebar from './components/Sidebar'; - +import {fakeSettingsAPICall} from './actions/settings'; import {StateModel} from './reducers'; import {INBOX_ROUTE, CHANNELS_ROUTE, LOGIN_ROUTE, LOGOUT_ROUTE, ROOT_ROUTE, TAGS_ROUTE} from './routes/routes'; import styles from './App.module.scss'; +const mapStateToProps = (state: StateModel, ownProps: RouteComponentProps) => { + return { + user: state.data.user, + pathname: ownProps.location.pathname, + token: state.data.user.token, + }; +}; + +const mapDispatchToProps = { + fakeSettingsAPICall, +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); + const publicRoutes = [LOGIN_ROUTE]; const shouldRedirect = (path: string) => @@ -27,6 +41,9 @@ class App extends Component & RouteComponentPro constructor(props: ConnectedProps & RouteComponentProps) { super(props); } + componentDidMount() { + this.props.fakeSettingsAPICall(); + } get isAuthSuccess() { return this.props.user.token && this.props.user.token !== ''; @@ -62,7 +79,7 @@ class App extends Component & RouteComponentPro - + @@ -73,14 +90,4 @@ class App extends Component & RouteComponentPro } } -const mapStateToProps = (state: StateModel, ownProps: RouteComponentProps) => { - return { - user: state.data.user, - pathname: ownProps.location.pathname, - token: state.data.user.token, - }; -}; - -const connector = connect(mapStateToProps, null); - export default withRouter(connector(App)); diff --git a/frontend/demo/src/InitializeAiryApi.ts b/frontend/demo/src/InitializeAiryApi.ts new file mode 100644 index 0000000000..53a92df676 --- /dev/null +++ b/frontend/demo/src/InitializeAiryApi.ts @@ -0,0 +1,7 @@ +import {HttpClient} from 'httpclient'; +import {getAuthToken} from './cookies'; +import {env} from './env'; + +const authToken = getAuthToken(); + +export const HttpClientInstance = new HttpClient(authToken, `//${env.API_HOST}`); diff --git a/frontend/demo/src/actions/channel/index.ts b/frontend/demo/src/actions/channel/index.ts index 89d0482f64..ff3109a4e1 100644 --- a/frontend/demo/src/actions/channel/index.ts +++ b/frontend/demo/src/actions/channel/index.ts @@ -6,8 +6,8 @@ import { ConnectChannelRequestPayload, ExploreChannelRequestPayload, DisconnectChannelRequestPayload, - HttpClient, } from 'httpclient'; +import {HttpClientInstance} from '../../InitializeAiryApi'; const SET_CURRENT_CHANNELS = '@@channel/SET_CHANNELS'; const ADD_CHANNELS = '@@channel/ADD_CHANNELS'; @@ -20,7 +20,7 @@ export const addChannelsAction = createAction(ADD_CHANNELS, resolve => (channels export function listChannels() { return async (dispatch: Dispatch) => { - return HttpClient.listChannels() + return HttpClientInstance.listChannels() .then((response: Channel[]) => { dispatch(setCurrentChannelsAction(response)); return Promise.resolve(response); @@ -33,7 +33,7 @@ export function listChannels() { export function exploreChannels(requestPayload: ExploreChannelRequestPayload) { return async (dispatch: Dispatch) => { - return HttpClient.exploreChannels(requestPayload) + return HttpClientInstance.exploreChannels(requestPayload) .then((response: Channel[]) => { dispatch(addChannelsAction(response)); return Promise.resolve(response); @@ -46,7 +46,7 @@ export function exploreChannels(requestPayload: ExploreChannelRequestPayload) { export function connectChannel(requestPayload: ConnectChannelRequestPayload) { return async (dispatch: Dispatch) => { - return HttpClient.connectChannel(requestPayload) + return HttpClientInstance.connectChannel(requestPayload) .then((response: Channel) => { dispatch(addChannelsAction([response])); return Promise.resolve(response); @@ -59,7 +59,7 @@ export function connectChannel(requestPayload: ConnectChannelRequestPayload) { export function disconnectChannel(requestPayload: DisconnectChannelRequestPayload) { return async (dispatch: Dispatch) => { - return HttpClient.disconnectChannel(requestPayload) + return HttpClientInstance.disconnectChannel(requestPayload) .then((response: Channel[]) => { dispatch(setCurrentChannelsAction(response)); return Promise.resolve(response); diff --git a/frontend/demo/src/actions/conversations/index.ts b/frontend/demo/src/actions/conversations/index.ts index 8395c8608e..6edca80f17 100644 --- a/frontend/demo/src/actions/conversations/index.ts +++ b/frontend/demo/src/actions/conversations/index.ts @@ -1,7 +1,8 @@ import {Dispatch} from 'redux'; import {createAction} from 'typesafe-actions'; -import {HttpClient, Conversation} from 'httpclient'; +import {Conversation} from 'httpclient'; import {ResponseMetadataPayload} from 'httpclient/payload/ResponseMetadataPayload'; +import {HttpClientInstance} from '../../InitializeAiryApi'; import {StateModel} from '../../reducers'; export const CONVERSATION_LOADING = '@@conversation/LOADING'; @@ -9,6 +10,9 @@ export const CONVERSATIONS_LOADING = '@@conversations/LOADING'; export const CONVERSATIONS_MERGE = '@@conversations/MERGE'; export const CONVERSATION_ADD_ERROR = '@@conversations/ADD_ERROR_TO_CONVERSATION'; export const CONVERSATION_REMOVE_ERROR = '@@conversations/REMOVE_ERROR_FROM_CONVERSATION'; +export const CONVERSATION_READ = '@@conversations/CONVERSATION_READ'; +export const CONVERSATION_ADD_TAG = '@@conversations/CONVERSATION_ADD_TAG'; +export const CONVERSATION_REMOVE_TAG = '@@conversations/CONVERSATION_REMOVE_TAG'; export const loadingConversationAction = createAction(CONVERSATION_LOADING, resolve => (conversationId: string) => resolve(conversationId) @@ -22,6 +26,10 @@ export const mergeConversationsAction = createAction( resolve({conversations, responseMetadata}) ); +export const readConversationsAction = createAction(CONVERSATION_READ, resolve => (conversationId: string) => + resolve({conversationId}) +); + export const addErrorToConversationAction = createAction( CONVERSATION_ADD_ERROR, resolve => (conversationId: string, errorMessage: string) => resolve({conversationId, errorMessage}) @@ -32,10 +40,20 @@ export const removeErrorFromConversationAction = createAction( resolve => (conversationId: string) => resolve({conversationId}) ); +export const addTagToConversationAction = createAction( + CONVERSATION_ADD_TAG, + resolve => (conversationId: string, tagId: string) => resolve({conversationId, tagId}) +); + +export const removeTagFromConversationAction = createAction( + CONVERSATION_REMOVE_TAG, + resolve => (conversationId: string, tagId: string) => resolve({conversationId, tagId}) +); + export function listConversations() { return async (dispatch: Dispatch) => { dispatch(loadingConversationsAction()); - return HttpClient.listConversations({page_size: 10}) + return HttpClientInstance.listConversations({page_size: 10}) .then((response: {data: Conversation[]; metadata: ResponseMetadataPayload}) => { dispatch(mergeConversationsAction(response.data, response.metadata)); return Promise.resolve(true); @@ -50,7 +68,7 @@ export function listNextConversations() { return async (dispatch: Dispatch, state: StateModel) => { const cursor = state.data.conversations.all.metadata.nextCursor; dispatch(loadingConversationsAction()); - return HttpClient.listConversations({cursor: cursor}) + return HttpClientInstance.listConversations({cursor: cursor}) .then((response: {data: Conversation[]; metadata: ResponseMetadataPayload}) => { dispatch(mergeConversationsAction(response.data, response.metadata)); return Promise.resolve(true); @@ -60,3 +78,25 @@ export function listNextConversations() { }); }; } + +export function readConversations(conversationId: string) { + return function(dispatch: Dispatch) { + HttpClientInstance.readConversations(conversationId).then(() => dispatch(readConversationsAction(conversationId))); + }; +} + +export function addTagToConversation(conversationId: string, tagId: string) { + return function(dispatch: Dispatch) { + HttpClientInstance.tagConversation({conversationId, tagId}).then(() => + dispatch(addTagToConversationAction(conversationId, tagId)) + ); + }; +} + +export function removeTagFromConversation(conversationId: string, tagId: string) { + return function(dispatch: Dispatch) { + HttpClientInstance.untagConversation({conversationId, tagId}).then(() => + dispatch(removeTagFromConversationAction(conversationId, tagId)) + ); + }; +} diff --git a/frontend/demo/src/actions/messages/index.ts b/frontend/demo/src/actions/messages/index.ts new file mode 100644 index 0000000000..5ad971f9b6 --- /dev/null +++ b/frontend/demo/src/actions/messages/index.ts @@ -0,0 +1,32 @@ +import {Dispatch} from 'redux'; +import {createAction} from 'typesafe-actions'; +import {Message, ResponseMetadataPayload} from 'httpclient'; +import {HttpClientInstance} from '../../InitializeAiryApi'; + +export const MESSAGES_LOADING = '@@messages/LOADING'; + +export const loadingMessagesAction = createAction( + MESSAGES_LOADING, + resolve => (messagesInfo: {conversationId: string; messages: Message[]}) => resolve(messagesInfo) +); + +export function listMessages(conversationId: string) { + return async (dispatch: Dispatch) => { + return HttpClientInstance.listMessages({ + conversationId, + pageSize: 10, + }) + .then((response: {data: Message[]; metadata: ResponseMetadataPayload}) => { + dispatch( + loadingMessagesAction({ + conversationId, + messages: response.data, + }) + ); + return Promise.resolve(true); + }) + .catch((error: Error) => { + return Promise.reject(error); + }); + }; +} diff --git a/frontend/demo/src/actions/tags/index.tsx b/frontend/demo/src/actions/tags/index.tsx index da4b57a8c6..a096ecec99 100644 --- a/frontend/demo/src/actions/tags/index.tsx +++ b/frontend/demo/src/actions/tags/index.tsx @@ -1,7 +1,8 @@ import _, {Dispatch} from 'redux'; import {createAction} from 'typesafe-actions'; -import {HttpClient, Tag, CreateTagRequestPayload} from 'httpclient'; +import {Tag, CreateTagRequestPayload} from 'httpclient'; +import {HttpClientInstance} from '../../InitializeAiryApi'; const UPSERT_TAG = 'UPSERT_TAG'; const DELETE_TAG = 'DELETE_TAG'; @@ -19,7 +20,7 @@ export const errorTagAction = createAction(ERROR_TAG, resolve => (status: string export function listTags() { return function(dispatch: Dispatch) { - return HttpClient.listTags().then((response: Tag[]) => { + return HttpClientInstance.listTags().then((response: Tag[]) => { dispatch(fetchTagAction(response)); }); }; @@ -27,10 +28,10 @@ export function listTags() { export function createTag(requestPayload: CreateTagRequestPayload) { return async (dispatch: Dispatch) => { - return HttpClient.createTag(requestPayload) + return HttpClientInstance.createTag(requestPayload) .then((response: Tag) => { dispatch(addTagAction(response)); - return Promise.resolve(true); + return Promise.resolve(response); }) .catch((error: string) => { dispatch(errorTagAction(error)); @@ -41,13 +42,13 @@ export function createTag(requestPayload: CreateTagRequestPayload) { export function updateTag(tag: Tag) { return function(dispatch: Dispatch) { - HttpClient.updateTag(tag).then(() => dispatch(editTagAction(tag))); + HttpClientInstance.updateTag(tag).then(() => dispatch(editTagAction(tag))); }; } export function deleteTag(id: string) { return function(dispatch: Dispatch) { - HttpClient.deleteTag(id).then(() => { + HttpClientInstance.deleteTag(id).then(() => { dispatch(deleteTagAction(id)); }); }; diff --git a/frontend/demo/src/actions/user/index.ts b/frontend/demo/src/actions/user/index.ts index 614d0d21ba..bc36ebe78a 100644 --- a/frontend/demo/src/actions/user/index.ts +++ b/frontend/demo/src/actions/user/index.ts @@ -1,7 +1,8 @@ import {createAction} from 'typesafe-actions'; import _, {Dispatch} from 'redux'; -import {User, HttpClient, LoginViaEmailRequestPayload} from 'httpclient'; +import {User, LoginViaEmailRequestPayload} from 'httpclient'; +import {HttpClientInstance} from '../../InitializeAiryApi'; const SET_CURRENT_USER = '@@auth/SET_CURRENT_USER'; const USER_AUTH_ERROR = '@@auth/ERROR'; @@ -13,7 +14,7 @@ export const logoutUserAction = createAction(USER_LOGOUT); export function loginViaEmail(requestPayload: LoginViaEmailRequestPayload) { return async (dispatch: Dispatch) => { - return HttpClient.loginViaEmail(requestPayload) + return HttpClientInstance.loginViaEmail(requestPayload) .then((response: User) => { dispatch(setCurrentUserAction(response)); return Promise.resolve(true); diff --git a/frontend/demo/src/assets/images/empty-state/inbox-empty-state.svg b/frontend/demo/src/assets/images/empty-state/inbox-empty-state.svg new file mode 100644 index 0000000000..5cb6c9669b --- /dev/null +++ b/frontend/demo/src/assets/images/empty-state/inbox-empty-state.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/demo/src/components/AvatarImage/index.module.scss b/frontend/demo/src/components/AvatarImage/index.module.scss new file mode 100644 index 0000000000..49c278ac39 --- /dev/null +++ b/frontend/demo/src/components/AvatarImage/index.module.scss @@ -0,0 +1,9 @@ +.avatar { + display: flex; +} + +.avatarImage { + border-radius: 50%; + width: 100%; + height: 100%; +} diff --git a/frontend/demo/src/components/AvatarImage/index.tsx b/frontend/demo/src/components/AvatarImage/index.tsx new file mode 100644 index 0000000000..5be93c6104 --- /dev/null +++ b/frontend/demo/src/components/AvatarImage/index.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +import {Contact} from 'httpclient'; +import styles from './index.module.scss'; + +const fallbackAvatar = 'https://s3.amazonaws.com/assets.airy.co/unknown.png'; + +type AvatarProps = { + contact: Contact; +}; + +const AvatarImage = (props: AvatarProps) => { + const {contact} = props; + + return ( +
+ +
+ ); +}; + +export default AvatarImage; diff --git a/frontend/demo/src/components/ColorSelector.tsx b/frontend/demo/src/components/ColorSelector.tsx index eeb6b6e32a..e9a6b87c5e 100644 --- a/frontend/demo/src/components/ColorSelector.tsx +++ b/frontend/demo/src/components/ColorSelector.tsx @@ -1,7 +1,7 @@ import React, {useCallback} from 'react'; import {connect} from 'react-redux'; import {RootState} from '../reducers'; -import {TagSettings} from '../types'; +import {Settings} from '../reducers/data/settings'; import styles from './ColorSelector.module.scss'; @@ -13,12 +13,12 @@ type ColorSelectorProps = { }; type ColorSelectorState = { - tagSettings: TagSettings; + settings: Settings; }; -const ColorSelector = ({handleUpdate, color, editing, id, tagSettings}: ColorSelectorProps & ColorSelectorState) => { - const getColorValue = useCallback((color: string) => (tagSettings && tagSettings.colors[color].default) || '1578D4', [ - tagSettings, +const ColorSelector = ({handleUpdate, color, editing, id, settings}: ColorSelectorProps & ColorSelectorState) => { + const getColorValue = useCallback((color: string) => (settings && settings.colors[color].default) || '1578D4', [ + settings, ]); return ( @@ -85,7 +85,7 @@ const ColorSelector = ({handleUpdate, color, editing, id, tagSettings}: ColorSel const mapStateToProps = (state: RootState) => { return { - tagSettings: state.data.settings, + settings: state.data.settings, }; }; diff --git a/frontend/demo/src/components/Sidebar/index.tsx b/frontend/demo/src/components/Sidebar/index.tsx index 924224c9bd..a3e3935ad5 100644 --- a/frontend/demo/src/components/Sidebar/index.tsx +++ b/frontend/demo/src/components/Sidebar/index.tsx @@ -19,19 +19,19 @@ const Sidebar = (props: RouteProps) => {
- + Inbox
- + Channels
- + Tags
diff --git a/frontend/demo/src/pages/Tags/Tag.module.scss b/frontend/demo/src/components/Tag/index.module.scss similarity index 91% rename from frontend/demo/src/pages/Tags/Tag.module.scss rename to frontend/demo/src/components/Tag/index.module.scss index b18c1dcd24..94c42ce3dc 100644 --- a/frontend/demo/src/pages/Tags/Tag.module.scss +++ b/frontend/demo/src/components/Tag/index.module.scss @@ -34,13 +34,9 @@ } .closeButton { - display: inline-block; - padding-left: 4px; - - svg { - path { - fill: #fff; - } + margin-left: 4px; + path { + fill: #fff; } } diff --git a/frontend/demo/src/pages/Tags/Tag.tsx b/frontend/demo/src/components/Tag/index.tsx similarity index 61% rename from frontend/demo/src/pages/Tags/Tag.tsx rename to frontend/demo/src/components/Tag/index.tsx index fbc6495481..c951c78f88 100644 --- a/frontend/demo/src/pages/Tags/Tag.tsx +++ b/frontend/demo/src/components/Tag/index.tsx @@ -1,34 +1,27 @@ import React from 'react'; import {connect} from 'react-redux'; import {Tag as TagModel} from 'httpclient'; -import {TagSettings} from '../../types'; +import {Settings} from '../../reducers/data/settings'; -import close from '../../assets/images/icons/close.svg'; -import styles from './Tag.module.scss'; +import {ReactComponent as Close} from '../../assets/images/icons/close.svg'; +import styles from './index.module.scss'; import {RootState} from '../../reducers'; type TagProps = { tag: TagModel; expanded?: boolean; onClick?: () => void; - removeTagFromContact?: () => void; + removeTag?: () => void; variant?: 'default' | 'light'; type?: string; }; type tagState = { - tagSettings: TagSettings; + settings: Settings; }; -export const Tag = ({ - tag, - expanded, - variant, - onClick, - removeTagFromContact, - tagSettings, -}: TagProps & tagState): JSX.Element => { - const tagColor = (tagSettings && tagSettings.colors[tag.color]) || { +export const Tag = ({tag, expanded, variant, onClick, removeTag, settings}: TagProps & tagState): JSX.Element => { + const tagColor = (settings && settings.colors[tag.color]) || { background: 'F1FAFF', border: '1578D4', default: '1578D4', @@ -50,14 +43,12 @@ export const Tag = ({ return (
{tag.name} - {removeTagFromContact && ( - - + {removeTag && ( + + )}
@@ -67,7 +58,7 @@ export const Tag = ({ const mapStateToProps = (state: RootState) => { return { - tagSettings: state.data.settings, + settings: state.data.settings, }; }; diff --git a/lib/typescript/httpclient/api/cookie.ts b/frontend/demo/src/cookies/cookie.ts similarity index 100% rename from lib/typescript/httpclient/api/cookie.ts rename to frontend/demo/src/cookies/cookie.ts diff --git a/lib/typescript/httpclient/api/index.ts b/frontend/demo/src/cookies/index.ts similarity index 64% rename from lib/typescript/httpclient/api/index.ts rename to frontend/demo/src/cookies/index.ts index a5b1f40baf..edadca8f5e 100644 --- a/lib/typescript/httpclient/api/index.ts +++ b/frontend/demo/src/cookies/index.ts @@ -1,3 +1,2 @@ -export * from './airyConfig'; export * from './cookie'; export * from './webStore'; diff --git a/lib/typescript/httpclient/api/webStore.ts b/frontend/demo/src/cookies/webStore.ts similarity index 97% rename from lib/typescript/httpclient/api/webStore.ts rename to frontend/demo/src/cookies/webStore.ts index 24e5b10f43..012f7d6e59 100644 --- a/lib/typescript/httpclient/api/webStore.ts +++ b/frontend/demo/src/cookies/webStore.ts @@ -1,5 +1,5 @@ import {getCookie, setCookie} from './cookie'; -import {User} from '../model/User'; +import {User} from 'httpclient'; export const storeDomainCookie = (key: string) => (token: string) => { setCookie(key, token, document.domain); diff --git a/frontend/demo/src/env.ts b/frontend/demo/src/env.ts new file mode 100644 index 0000000000..d53a86c6f4 --- /dev/null +++ b/frontend/demo/src/env.ts @@ -0,0 +1,9 @@ +export interface Env { + API_HOST?: string; +} + +const templatedState: Env = (window as any).AIRY_TEMPLATED_STATE || {}; + +export const env: Env = { + API_HOST: templatedState.API_HOST || 'api.airy', +}; diff --git a/frontend/demo/src/pages/Channels/index.tsx b/frontend/demo/src/pages/Channels/index.tsx index f345a5310e..cc6f62b26f 100644 --- a/frontend/demo/src/pages/Channels/index.tsx +++ b/frontend/demo/src/pages/Channels/index.tsx @@ -5,7 +5,8 @@ import {RouteComponentProps} from 'react-router-dom'; import FacebookLogin from 'react-facebook-login'; import {Button} from '@airyhq/components'; -import {AiryConfig, Channel} from 'httpclient'; +import {Channel} from 'httpclient'; +import {AiryConfig} from '../../AiryConfig'; import {listChannels, exploreChannels, connectChannel, disconnectChannel} from '../../actions/channel'; import {StateModel} from '../../reducers/index'; diff --git a/frontend/demo/src/pages/Inbox/ConversationListItem/index.tsx b/frontend/demo/src/pages/Inbox/ConversationListItem/index.tsx index 697d3cad91..e6d35dc8eb 100644 --- a/frontend/demo/src/pages/Inbox/ConversationListItem/index.tsx +++ b/frontend/demo/src/pages/Inbox/ConversationListItem/index.tsx @@ -3,12 +3,14 @@ import {Link} from 'react-router-dom'; import _, {connect, ConnectedProps} from 'react-redux'; import IconChannel from '../../../components/IconChannel'; +import AvatarImage from '../../../components/AvatarImage'; import {formatTimeOfMessage} from '../../../services/format/date'; import {Conversation, Message} from 'httpclient'; import {StateModel} from '../../../reducers'; import {INBOX_CONVERSATIONS_ROUTE} from '../../../routes/routes'; +import {readConversations} from '../../../actions/conversations'; import styles from './index.module.scss'; @@ -28,34 +30,35 @@ const mapStateToProps = (state: StateModel) => { }; }; -const connector = connect(mapStateToProps, null); +const mapDispatchToProps = { + readConversations, +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); const FormattedMessage = ({message}: FormattedMessageProps) => { - if (message && message.content) { - return <>{message.content.text}; + if (message && message.content[0]) { + return <>{message.content[0].text}; } return
; }; const ConversationListItem = (props: ConversationListItemProps) => { - const {conversation, active, style} = props; + const {conversation, active, style, readConversations} = props; const participant = conversation.contact; - const fallbackAvatar = 'https://s3.amazonaws.com/assets.airy.co/unknown.png'; - const unread = conversation.unreadMessageCount > 0; return ( -
+
readConversations(conversation.id)}>
-
+
+ +
diff --git a/frontend/demo/src/pages/Inbox/Messenger/ConversationMetadata/index.module.scss b/frontend/demo/src/pages/Inbox/Messenger/ConversationMetadata/index.module.scss new file mode 100644 index 0000000000..c81b76f9cd --- /dev/null +++ b/frontend/demo/src/pages/Inbox/Messenger/ConversationMetadata/index.module.scss @@ -0,0 +1,66 @@ +@import '../../../../assets/scss/colors.scss'; +@import '../../../../assets/scss/fonts.scss'; + +.content { + display: flex; + width: 290px; + height: auto; + flex-direction: column; + overflow: hidden; + background-color: #fff; + margin: 16px 8px 0 8px; + padding: 16px; + border-top-left-radius: 8px; + border-top-right-radius: 8px; +} + +.contact { + border-bottom: 1px solid var(--color-light-gray); + padding-bottom: 16px; +} + +.avatarImage { + width: 80px; + height: 80px; + margin: 0 auto 16px auto; +} + +.displayName { + @include font-m; + text-align: center; + color: var(--color-text-contrast); +} + +.tags { + border-bottom: 1px solid var(--color-light-gray); + padding-bottom: 16px; +} + +.tagsHeader { + display: flex; + margin: 8px 0; + justify-content: space-between; +} + +.tagsHeaderTitle { + font-weight: bold; +} + +.addTags { + padding: 8px; + width: 260px; +} + +.addTagsRow { + display: flex; + margin: 4px 0; + justify-content: space-between; +} + +.addTagsDescription { + margin: 8px 0; +} + +.addTagsButtonRow { + margin: 8px 0; +} diff --git a/frontend/demo/src/pages/Inbox/Messenger/ConversationMetadata/index.tsx b/frontend/demo/src/pages/Inbox/Messenger/ConversationMetadata/index.tsx new file mode 100644 index 0000000000..12297b3b66 --- /dev/null +++ b/frontend/demo/src/pages/Inbox/Messenger/ConversationMetadata/index.tsx @@ -0,0 +1,206 @@ +import React, {FormEvent, useEffect, useState} from 'react'; +import _, {connect, ConnectedProps} from 'react-redux'; +import {Conversation, Tag as TagModel, TagColor} from 'httpclient'; + +import {createTag, listTags} from '../../../../actions/tags'; +import {addTagToConversation, removeTagFromConversation} from '../../../../actions/conversations'; +import AvatarImage from '../../../../components/AvatarImage'; +import ColorSelector from '../../../../components/ColorSelector'; +import Dialog from '../../../../components/Dialog'; +import {StateModel} from '../../../../reducers'; + +import styles from './index.module.scss'; +import Tag from '../../../../components/Tag'; +import {Button, Input, LinkButton} from '@airyhq/components'; + +type ConversationMetadataProps = {conversation: Conversation} & ConnectedProps; + +const mapStateToProps = (state: StateModel) => { + return { + tags: state.data.tags.all, + }; +}; + +const mapDispatchToProps = { + createTag, + listTags, + addTagToConversation, + removeTagFromConversation, +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); + +const ConversationMetadata = (props: ConversationMetadataProps) => { + const {tags, createTag, conversation, listTags, addTagToConversation, removeTagFromConversation} = props; + const [showTagsDialog, setShowTagsDialog] = useState(false); + const [color, setColor] = useState('tag-blue'); + const [tagName, setTagName] = useState(''); + + useEffect(() => { + if (tags.length == 0) { + listTags(); + } + }, []); + + const showAddTags = () => { + setTagName(''); + setShowTagsDialog(true); + }; + + const addTag = (tag: TagModel) => { + addTagToConversation(conversation.id, tag.id); + setShowTagsDialog(false); + }; + + const removeTag = (tag: TagModel) => { + removeTagFromConversation(conversation.id, tag.id); + }; + + const filterForUnusedTags = (tags: TagModel[]): TagModel[] => { + return tags.filter(tag => !conversation.tags.includes(tag.id)); + }; + + const filterForUsedTags = (tags: TagModel[]): TagModel[] => { + return tags.filter(tag => conversation.tags.includes(tag.id)); + }; + + const tagSorter = (tagA: TagModel, tagB: TagModel) => { + if (tagA.name < tagB.name) { + return -1; + } + if (tagA.name > tagB.name) { + return 1; + } + + return 0; + }; + + const checkIfExists = (value: string) => { + const usedTags = filterForUsedTags(tags); + if (value.length == 0) { + return true; + } + if (usedTags.find(tag => tag.name === value)) { + return 'Tag already added'; + } + + return true; + }; + + const getFilterdTags = (): TagModel[] => { + return filterForUnusedTags(tags) + .sort(tagSorter) + .filter(tag => tag.name.startsWith(tagName)); + }; + + const submitForm = (event: FormEvent) => { + event.preventDefault(); + const filteredTags = getFilterdTags(); + + if (filteredTags.length == 1) { + addTag(filteredTags[0]); + } else if (filteredTags.length == 0 && tagName.trim().length > 0) { + createTag({name: tagName.trim(), color}).then((tag: TagModel) => { + if (tag) { + addTag(tag); + } + }); + } + }; + + const renderTagsDialog = () => { + const filteredTags = getFilterdTags(); + + return ( + setShowTagsDialog(false)}> +
+
Add a tag
+ ) => { + setTagName(e.target.value); + }} + height={32} + value={tagName} + name="tag_name" + placeholder="Please enter a tag name" + autoComplete="off" + autoFocus={true} + fontClass="font-s" + minLength={1} + maxLength={50} + validation={checkIfExists} + showErrors={true} + /> + {filteredTags.length > 0 ? ( + filteredTags.map(tag => { + return ( +
+ + addTag(tag)}> + Add + +
+ ); + }) + ) : ( +
+
+ +
+

Pick a color

+ ) => setColor(e.target.value as TagColor)} + color={color} + editing={true} + /> +
+ +
+
+ )} +
+
+ ); + }; + + const findTag = (tagId: string): TagModel => { + return tags.find(tag => tag.id === tagId); + }; + + return ( +
+ {conversation && ( +
+
+
+ +
+ +
{conversation.contact.displayName}
+
+
+
+

Tags

+ {showTagsDialog ? 'Close' : '+ Add Tag'} +
+ + {showTagsDialog && renderTagsDialog()} + +
+ {tags && + conversation.tags + .map(tagId => findTag(tagId)) + .sort(tagSorter) + .map(tag => tag && removeTag(tag)} />)} +
+
+
+ )} +
+ ); +}; + +export default connector(ConversationMetadata); diff --git a/frontend/demo/src/pages/Inbox/Messenger/MessageList/index.module.scss b/frontend/demo/src/pages/Inbox/Messenger/MessageList/index.module.scss new file mode 100644 index 0000000000..c457fed0d1 --- /dev/null +++ b/frontend/demo/src/pages/Inbox/Messenger/MessageList/index.module.scss @@ -0,0 +1,22 @@ +@import '../../../../assets/scss/colors.scss'; +@import '../../../../assets/scss/fonts.scss'; + +.messageList { + display: flex; + flex-direction: column; + padding: 16px; + height: 100%; + overflow-y: scroll; + flex-grow: 1; + overflow-x: hidden; +} + +.dateHeader { + @include font-s; + margin: 8px auto; + padding: 4px 8px; + border-radius: 4px; + background-color: var(--color-background-gray); + color: var(--color-text-gray); + width: max-content; +} diff --git a/frontend/demo/src/pages/Inbox/Messenger/MessageList/index.tsx b/frontend/demo/src/pages/Inbox/Messenger/MessageList/index.tsx new file mode 100644 index 0000000000..ff781e0a90 --- /dev/null +++ b/frontend/demo/src/pages/Inbox/Messenger/MessageList/index.tsx @@ -0,0 +1,97 @@ +import React, {useEffect, createRef} from 'react'; +import _, {connect, ConnectedProps} from 'react-redux'; +import _redux from 'redux'; + +import {Conversation, Message, SenderType} from 'httpclient'; + +import {StateModel} from '../../../../reducers'; +import {MessageById} from '../../../../reducers/data/messages'; + +import MessageListItem from '../MessengerListItem'; + +import {listMessages} from '../../../../actions/messages'; + +import styles from './index.module.scss'; +import {formatDateOfMessage} from '../../../../services/format/date'; + +type MessageListProps = {conversation: Conversation} & ConnectedProps; + +const messagesMapToArray = ( + messageInfo: {[conversationId: string]: MessageById}, + conversationId: string +): Message[] => { + const messageById = messageInfo[conversationId]; + if (messageById) { + return Object.keys(messageById).map((cId: string) => ({...messageById[cId]})); + } + return []; +}; + +const mapStateToProps = (state: StateModel, ownProps: {conversation: Conversation}) => { + return { + messages: messagesMapToArray(state.data.messages.all, ownProps.conversation && ownProps.conversation.id), + }; +}; + +const mapDispatchToProps = { + listMessages, +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); + +const MessageList = (props: MessageListProps) => { + const {listMessages, messages, conversation} = props; + const messageListRef = createRef(); + + useEffect(() => { + conversation && listMessages(conversation.id); + scrollBottom(); + }, [conversation && conversation.id]); + + const scrollBottom = () => { + messageListRef.current.scrollTop = messageListRef.current.scrollHeight; + }; + + const isContact = (message: Message) => message.senderType !== SenderType.appUser; + + const hasDateChanged = (prevMessage: Message, message: Message) => { + if (prevMessage == null) { + return true; + } + + return !isSameDay(prevMessage.sentAt, message.sentAt); + }; + + const isSameDay = (firstDate: Date, secondDate: Date) => { + return new Date(firstDate).setHours(0, 0, 0, 0) === new Date(secondDate).setHours(0, 0, 0, 0); + }; + + return ( +
+ {messages.map((message: Message, index: number) => { + const prevMessage = messages[index - 1]; + const nextMessage = messages[index + 1]; + const prevWasContact = prevMessage ? isContact(prevMessage) : false; + const nextIsSameUser = nextMessage ? isContact(message) == isContact(nextMessage) : false; + + return ( +
+ {hasDateChanged(prevMessage, message) && ( +
+ {formatDateOfMessage(message)} +
+ )} + +
+ ); + })} +
+ ); +}; + +export default connector(MessageList); diff --git a/frontend/demo/src/pages/Inbox/Messenger/MessengerContainer/index.module.scss b/frontend/demo/src/pages/Inbox/Messenger/MessengerContainer/index.module.scss new file mode 100644 index 0000000000..49e1aa191e --- /dev/null +++ b/frontend/demo/src/pages/Inbox/Messenger/MessengerContainer/index.module.scss @@ -0,0 +1,38 @@ +.messengerContainer { + display: flex; + flex: 1; + height: auto; + flex-direction: column; + overflow: hidden; + background-color: #fff; + margin: 16px 8px 0 8px; + border-top-left-radius: 8px; + border-top-right-radius: 8px; +} + +.emptyState { + align-self: center; + margin-top: 8%; + margin-bottom: auto; + max-width: 440px; + h1 { + font-size: 20px; + line-height: 24px; + font-weight: bold; + color: var(--color-airy-blue); + margin-bottom: 8px; + } + p { + font-size: 16px; + line-height: 24px; + font-weight: 400; + color: var(--color-text-gray); + margin-bottom: 32px; + } + svg { + margin-left: 50px; + } +} + +.notSelectedState { +} diff --git a/frontend/demo/src/pages/Inbox/Messenger/MessengerContainer/index.tsx b/frontend/demo/src/pages/Inbox/Messenger/MessengerContainer/index.tsx new file mode 100644 index 0000000000..b238fa13d3 --- /dev/null +++ b/frontend/demo/src/pages/Inbox/Messenger/MessengerContainer/index.tsx @@ -0,0 +1,48 @@ +import React, {useEffect, useState} from 'react'; +import _, {connect, ConnectedProps} from 'react-redux'; +import {useParams} from 'react-router-dom'; + +import {StateModel} from '../../../../reducers'; +import MessageList from '../MessageList'; +import {ReactComponent as EmptyStateImage} from '../../../../assets/images/empty-state/inbox-empty-state.svg'; +import styles from './index.module.scss'; +import ConversationMetadata from '../ConversationMetadata'; + +const mapStateToProps = (state: StateModel) => { + return { + conversations: state.data.conversations.all.items, + }; +}; + +const connector = connect(mapStateToProps, null); + +type MessengerContainerProps = ConnectedProps; + +const MessengerContainer = (props: MessengerContainerProps) => { + const {conversations} = props; + const {conversationId} = useParams<{conversationId: string}>(); + const [currentConversation, setCurrentConversation] = useState(null); + + useEffect(() => { + setCurrentConversation(conversations[conversationId]); + }, [conversationId, conversations]); + + return ( + <> +
+ {!conversations ? ( +
+

Your conversations will appear here as soon as a contact messages you.

+

Airy Messenger only shows new conversations from the moment you connect at least one channel.

+ +
+ ) : ( + + )} +
+ + + ); +}; + +export default connector(MessengerContainer); diff --git a/frontend/demo/src/pages/Inbox/Messenger/MessengerListItem/index.module.scss b/frontend/demo/src/pages/Inbox/Messenger/MessengerListItem/index.module.scss new file mode 100644 index 0000000000..eac1368a90 --- /dev/null +++ b/frontend/demo/src/pages/Inbox/Messenger/MessengerListItem/index.module.scss @@ -0,0 +1,65 @@ +@import '../../../../assets/scss/colors.scss'; +@import '../../../../assets/scss/fonts.scss'; + +.messageListItemContainer { + display: flex; + flex: none; +} + +.messageListItem { + display: flex; + align-self: flex-end; + width: 100%; + overflow-wrap: break-word; + word-break: break-word; +} + +.messageAvatar { + width: 40px; + height: 40px; + margin: 6px 8px 0 0; +} + +.messageListUserContainer { + display: flex; + flex-direction: row; +} + +.messageListItemUser { + align-self: flex-start; + text-align: left; + position: relative; +} + +.messageListItemUserText { + display: inline-flex; + padding: 10px; + margin-top: 5px; + background: var(--color-background-blue); + color: #212428; + border-radius: 8px; +} + +.messageListItemMember { + margin-top: 5px; + justify-content: flex-end; + width: 100%; + text-align: right; +} + +.messageListItemMemberText { + display: inline-flex; + padding: 10px; + position: relative; + background: var(--color-airy-blue); + color: white; + position: relative; + text-align: left; + border-radius: 8px; +} + +.messageTime { + @include font-s; + color: var(--color-text-gray); + margin: 4px 10px; +} diff --git a/frontend/demo/src/pages/Inbox/Messenger/MessengerListItem/index.tsx b/frontend/demo/src/pages/Inbox/Messenger/MessengerListItem/index.tsx new file mode 100644 index 0000000000..fc270daa9f --- /dev/null +++ b/frontend/demo/src/pages/Inbox/Messenger/MessengerListItem/index.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import _ from 'redux'; +import {Message, Conversation, SenderType} from 'httpclient'; +import AvatarImage from '../../../../components/AvatarImage'; + +import styles from './index.module.scss'; +import {formatTimeOfMessage} from '../../../../services/format/date'; + +type MessengerListItemProps = { + message: Message; + conversation: Conversation; + showAvatar: boolean; + showSentAt: boolean; +}; + +const MessengerListItem = (props: MessengerListItemProps) => { + const {conversation, showAvatar, showSentAt, message} = props; + const isUser = message.senderType !== SenderType.appUser; + + const messageAvatar = () => { + return conversation && ; + }; + + const messageText = message.content[0].text; + + return ( +
+
+ {!isUser ? ( +
+
{messageText}
+ {showSentAt &&
{formatTimeOfMessage(message)}
} +
+ ) : ( +
+
{showAvatar && messageAvatar()}
+
+
{messageText}
+ {showSentAt &&
{formatTimeOfMessage(message)}
} +
+
+ )} +
+
+ ); +}; + +export default MessengerListItem; diff --git a/frontend/demo/src/pages/Inbox/Messenger/index.scss b/frontend/demo/src/pages/Inbox/Messenger/index.module.scss similarity index 94% rename from frontend/demo/src/pages/Inbox/Messenger/index.scss rename to frontend/demo/src/pages/Inbox/Messenger/index.module.scss index e333f9d510..6c59177b52 100644 --- a/frontend/demo/src/pages/Inbox/Messenger/index.scss +++ b/frontend/demo/src/pages/Inbox/Messenger/index.module.scss @@ -6,7 +6,7 @@ width: 100%; align-items: stretch; overflow: hidden; - margin: 72px 90px 0; + margin: 72px 8px 0 90px; } .messengerContainerMiddlePanel { diff --git a/frontend/demo/src/pages/Inbox/Messenger/index.tsx b/frontend/demo/src/pages/Inbox/Messenger/index.tsx index a5612743e9..0024878342 100644 --- a/frontend/demo/src/pages/Inbox/Messenger/index.tsx +++ b/frontend/demo/src/pages/Inbox/Messenger/index.tsx @@ -1,4 +1,4 @@ -import React, {Fragment} from 'react'; +import React from 'react'; import {Route, withRouter, Redirect, RouteComponentProps} from 'react-router-dom'; import _, {connect, ConnectedProps} from 'react-redux'; @@ -7,7 +7,8 @@ import ConversationList from '../ConversationList'; import {StateModel} from '../../../reducers'; import {AllConversationsState} from '../../../reducers/data/conversations'; -import './index.scss'; +import styles from './index.module.scss'; +import MessengerContainer from './MessengerContainer'; const mapStateToProps = (state: StateModel) => { return { @@ -34,16 +35,16 @@ const Messenger = (props: ConnectedProps & RouteComponentProps } return ( -
+
{!!conversations.items && ( -
+
)} } + render={props => } />
); diff --git a/frontend/demo/src/pages/Inbox/index.tsx b/frontend/demo/src/pages/Inbox/index.tsx index ff07645e13..0d2d32ccd4 100644 --- a/frontend/demo/src/pages/Inbox/index.tsx +++ b/frontend/demo/src/pages/Inbox/index.tsx @@ -23,7 +23,7 @@ const mapDispatchToProps = { const connector = connect(mapStateToProps, mapDispatchToProps); -const MessengerContainer = (props: InboxProps & ConnectedProps) => { +const ConversationContainer = (props: InboxProps & ConnectedProps) => { useEffect(() => { props.listConversations(); }); @@ -31,4 +31,4 @@ const MessengerContainer = (props: InboxProps & ConnectedProps return ; }; -export default connector(MessengerContainer); +export default connector(ConversationContainer); diff --git a/frontend/demo/src/pages/Tags/FAKESETTINGS.ts b/frontend/demo/src/pages/Tags/FAKESETTINGS.ts index 05a31a2619..4442236d92 100644 --- a/frontend/demo/src/pages/Tags/FAKESETTINGS.ts +++ b/frontend/demo/src/pages/Tags/FAKESETTINGS.ts @@ -1,11 +1,12 @@ -export const fakeData = () => { +import {Settings} from '../../reducers/data/settings'; + +export const fakeData = (): Settings => { return { colors: { 'tag-green': {default: '0E764F', background: 'F5FFFB', font: '0E764F', position: 3, border: '0E764F'}, 'tag-blue': {default: '1578D4', background: 'F1FAFF', font: '1578D4', position: 1, border: '1578D4'}, 'tag-red': {default: 'E0243A', background: 'FFF7F9', font: 'E0243A', position: 2, border: 'E0243A'}, 'tag-purple': {default: '730A80', background: 'FEF7FF', font: '730A80', position: 4, border: '730A80'}, - enabled: true, }, }; }; diff --git a/frontend/demo/src/pages/Tags/SimpleTagForm.tsx b/frontend/demo/src/pages/Tags/SimpleTagForm.tsx index 38da9472d4..4941ca39c3 100644 --- a/frontend/demo/src/pages/Tags/SimpleTagForm.tsx +++ b/frontend/demo/src/pages/Tags/SimpleTagForm.tsx @@ -8,7 +8,7 @@ import {Button, Input} from '@airyhq/components'; import Dialog from '../../components/Dialog'; import ColorSelector from '../../components/ColorSelector'; -import Tag from '../../pages/Tags/Tag'; +import Tag from '../../components/Tag'; import {Tag as TagModel, TagColor} from 'httpclient'; import styles from './SimpleTagForm.module.scss'; diff --git a/frontend/demo/src/pages/Tags/TableRow.tsx b/frontend/demo/src/pages/Tags/TableRow.tsx index 350ee6e2e4..e786fc3664 100644 --- a/frontend/demo/src/pages/Tags/TableRow.tsx +++ b/frontend/demo/src/pages/Tags/TableRow.tsx @@ -7,19 +7,19 @@ import {Button, LinkButton} from '@airyhq/components'; import {ReactComponent as EditIcon} from '../../assets/images/icons/edit.svg'; import {ReactComponent as TrashIcon} from '../../assets/images/icons/trash.svg'; import ColorSelector from '../../components/ColorSelector'; -import Tag from './Tag'; +import Tag from '../../components/Tag'; import {Tag as TagModel, TagColor} from 'httpclient'; -import {TagSettings} from '../../types'; +import {Settings} from '../../reducers/data/settings'; import {RootState} from '../../reducers'; type TableRowProps = { tag: TagModel; - tagSettings: TagSettings; + settings: Settings; showModal(label: string, id: string, name: string): void; } & ConnectedProps; const TableRowComponent = (props: TableRowProps) => { - const {tag, updateTag, tagSettings, showModal} = props; + const {tag, updateTag, settings, showModal} = props; const [tagState, setTagState] = useState({ edit: false, @@ -81,9 +81,11 @@ const TableRowComponent = (props: TableRowProps) => { [showModal, tag] ); - const getColorValue = useCallback((color: string) => (tagSettings && tagSettings.colors[color].default) || '1578D4', [ - tagSettings, - ]); + const getColorValue = useCallback( + (color: string) => + (settings && settings.colors && settings.colors[color] && settings.colors[color].default) || '1578D4', + [settings] + ); const isEditing = tagState.edit && tagState.id === tag.id; @@ -145,7 +147,7 @@ const TableRowComponent = (props: TableRowProps) => { const mapStateToProps = (state: RootState) => { return { - tagSettings: state.data.settings, + settings: state.data.settings, }; }; diff --git a/frontend/demo/src/pages/Tags/index.tsx b/frontend/demo/src/pages/Tags/index.tsx index 318101edf4..1e9fd344f5 100644 --- a/frontend/demo/src/pages/Tags/index.tsx +++ b/frontend/demo/src/pages/Tags/index.tsx @@ -6,7 +6,6 @@ import {SettingsModal, LinkButton, Button, SearchField, Input} from '@airyhq/com import plus from '../../assets/images/icons/plus.svg'; import {listTags, deleteTag, filterTags, errorTag} from '../../actions/tags'; -import {fakeSettingsAPICall} from '../../actions/settings'; import {filteredTags} from '../../selectors/tags'; import {Tag} from 'httpclient'; import {ModalType} from '../../types'; @@ -34,7 +33,6 @@ class Tags extends Component, typeof initialSta componentDidMount() { this.props.listTags(); - this.props.fakeSettingsAPICall(); this.props.filterTags(''); } @@ -217,7 +215,6 @@ const mapDispatchToProps = { deleteTag, errorTag, filterTags, - fakeSettingsAPICall, }; const connector = connect(mapStateToProps, mapDispatchToProps); diff --git a/frontend/demo/src/reducers/data/conversations/index.ts b/frontend/demo/src/reducers/data/conversations/index.ts index 0a262f7fe0..81a2406e33 100644 --- a/frontend/demo/src/reducers/data/conversations/index.ts +++ b/frontend/demo/src/reducers/data/conversations/index.ts @@ -1,6 +1,6 @@ import {ActionType, getType} from 'typesafe-actions'; import {combineReducers} from 'redux'; -import {cloneDeep} from 'lodash-es'; +import {cloneDeep, uniq} from 'lodash-es'; import {Conversation, Message} from 'httpclient'; import {ResponseMetadataPayload} from 'httpclient/payload/ResponseMetadataPayload'; @@ -44,9 +44,6 @@ function mergeConversations( newConversations: MergedConversation[] ): ConversationMap { newConversations.forEach((conversation: MergedConversation) => { - if (conversation.contact && !conversation.contact.displayName) { - conversation.contact.displayName = `${conversation.contact.firstName} ${conversation.contact.lastName}`; - } if (conversation.lastMessage) { conversation.lastMessage.sentAt = new Date(conversation.lastMessage.sentAt); } @@ -99,6 +96,45 @@ const initialState: AllConversationsState = { }, }; +const addTagToConversation = (state: AllConversationsState, conversationId, tagId) => { + const conversation: Conversation = state.items[conversationId]; + if (conversation) { + const tags: string[] = [...state.items[conversationId].tags]; + tags.push(tagId); + + return { + ...state, + items: { + ...state.items, + [conversation.id]: { + ...conversation, + tags: uniq(tags), + }, + }, + }; + } + + return state; +}; + +const removeTagFromConversation = (state: AllConversationsState, conversationId, tagId) => { + const conversation: Conversation = state.items[conversationId]; + if (conversation) { + return { + ...state, + items: { + ...state.items, + [conversation.id]: { + ...conversation, + tags: conversation.tags.filter(tag => tag !== tagId), + }, + }, + }; + } + + return state; +}; + function allReducer(state: AllConversationsState = initialState, action: Action): AllConversationsState { switch (action.type) { case getType(actions.mergeConversationsAction): @@ -122,6 +158,28 @@ function allReducer(state: AllConversationsState = initialState, action: Action) items: setLoadingOfConversation(state.items, action.payload, true), }; + case getType(actions.readConversationsAction): + return { + ...state, + items: { + ...state.items, + [action.payload.conversationId]: { + ...state.items[action.payload.conversationId], + unreadMessageCount: 0, + }, + }, + metadata: { + ...state.metadata, + loading: false, + }, + }; + + case getType(actions.addTagToConversationAction): + return addTagToConversation(state, action.payload.conversationId, action.payload.tagId); + + case getType(actions.removeTagFromConversationAction): + return removeTagFromConversation(state, action.payload.conversationId, action.payload.tagId); + default: return state; } diff --git a/frontend/demo/src/reducers/data/index.ts b/frontend/demo/src/reducers/data/index.ts index 1c593309f3..3fbe7a23db 100644 --- a/frontend/demo/src/reducers/data/index.ts +++ b/frontend/demo/src/reducers/data/index.ts @@ -8,6 +8,7 @@ import conversations, {ConversationsState} from './conversations'; import tags from './tags'; import settings from './settings'; import channels from './channels'; +import messages, {Messages} from './messages'; export * from './channels'; export * from './conversations'; @@ -18,6 +19,7 @@ export {initialState} from './user'; export type DataState = { user: User; conversations: ConversationsState; + messages: Messages; tags: Tags; settings: Settings; channels: Channel[]; @@ -26,6 +28,7 @@ export type DataState = { const reducers: Reducer = combineReducers({ user, conversations, + messages, tags, settings, channels, diff --git a/frontend/demo/src/reducers/data/messages/index.ts b/frontend/demo/src/reducers/data/messages/index.ts new file mode 100644 index 0000000000..66bed466c5 --- /dev/null +++ b/frontend/demo/src/reducers/data/messages/index.ts @@ -0,0 +1,43 @@ +import {ActionType, getType} from 'typesafe-actions'; + +import * as actions from '../../../actions/messages'; +import {Message} from 'httpclient'; +import {DataState} from '..'; +import _ from 'lodash-es'; + +type Action = ActionType; + +export type MessagesState = { + data: DataState; +}; + +export type MessageById = { + [messageId: string]: Message; +}; + +export type Messages = { + all: {[conversationId: string]: MessageById}; +}; + +const initialState = { + all: {}, +}; + +function organiseMessages(messages: Message[]): MessageById { + return _.keyBy(messages, 'id'); +} + +export default function messagesReducer(state = initialState, action: Action): any { + switch (action.type) { + case getType(actions.loadingMessagesAction): + return { + ...state, + all: { + ...state.all, + [action.payload.conversationId]: organiseMessages(action.payload.messages), + }, + }; + default: + return state; + } +} diff --git a/frontend/demo/src/reducers/data/settings/index.ts b/frontend/demo/src/reducers/data/settings/index.ts index ceb6d1dc26..b87264068d 100644 --- a/frontend/demo/src/reducers/data/settings/index.ts +++ b/frontend/demo/src/reducers/data/settings/index.ts @@ -8,12 +8,20 @@ export type SettingsState = { data: DataState; }; +export interface ColorSettings { + default: string; + background: string; + font: string; + position: number; + border: string; +} + export type Settings = { - colors: {}; + colors: {[id: string]: ColorSettings}; }; const defaultState = { - colors: [], + colors: {}, }; export default function tagsReducer(state = defaultState, action: Action): Settings { diff --git a/frontend/demo/src/reducers/data/user/index.ts b/frontend/demo/src/reducers/data/user/index.ts index 1db881fbab..8d21abd4e3 100644 --- a/frontend/demo/src/reducers/data/user/index.ts +++ b/frontend/demo/src/reducers/data/user/index.ts @@ -1,6 +1,7 @@ import {ActionType, getType} from 'typesafe-actions'; import * as actions from '../../../actions/user'; -import {getUserFromStore, storeUserData, User} from 'httpclient'; +import {User} from 'httpclient'; +import {getUserFromStore, storeUserData} from '../../../cookies'; type Action = ActionType; diff --git a/frontend/demo/src/reducers/index.ts b/frontend/demo/src/reducers/index.ts index 3a874c6e92..c86d3f9c02 100644 --- a/frontend/demo/src/reducers/index.ts +++ b/frontend/demo/src/reducers/index.ts @@ -3,7 +3,7 @@ import {ActionType, getType} from 'typesafe-actions'; import _, {CombinedState} from 'redux'; import * as authActions from '../actions/user'; -import {clearUserData} from 'httpclient'; +import {clearUserData} from '../cookies'; import data, {DataState} from './data'; diff --git a/frontend/demo/src/types/tag.ts b/frontend/demo/src/types/tag.ts index 6cb778d004..be668108bf 100644 --- a/frontend/demo/src/types/tag.ts +++ b/frontend/demo/src/types/tag.ts @@ -1,19 +1,3 @@ -import {Tag} from 'httpclient'; - -export interface ColorSettings { - default: string; - background: string; - font: string; - position: number; - border: string; -} - -export interface TagSettings { - colors: ColorSettings[]; - enabled: boolean; - channels: Tag[]; -} - export interface ErrorTag { status: string; data?: string; diff --git a/infrastructure/.gitignore b/infrastructure/.gitignore index 64b7b113e9..f0e21c63a5 100644 --- a/infrastructure/.gitignore +++ b/infrastructure/.gitignore @@ -2,4 +2,4 @@ .vagrant-out/ images/.vagrant/ images/.vagrant-out/ -airy.conf +airy.yaml diff --git a/infrastructure/Vagrantfile b/infrastructure/Vagrantfile index 91ea0dbf75..52f9e6cea1 100644 --- a/infrastructure/Vagrantfile +++ b/infrastructure/Vagrantfile @@ -20,9 +20,6 @@ Vagrant.configure("2") do |config| airy_core.vm.provision "core", type: "shell", env: {"AIRY_VERSION" => ENV['AIRY_VERSION']} do |c| c.inline = "/vagrant/scripts/provision/core.sh" end - airy_core.vm.provision "conf", type: "shell", env: {"AIRY_VERSION" => ENV['AIRY_VERSION']} do |u| - u.inline = "/vagrant/scripts/conf.sh" - end airy_core.trigger.before [:halt, :reload] do |stop| stop.name = "stop" stop.run_remote = {inline: "/vagrant/scripts/trigger/stop.sh"} diff --git a/infrastructure/airy.conf.all b/infrastructure/airy.tpl.yaml similarity index 62% rename from infrastructure/airy.conf.all rename to infrastructure/airy.tpl.yaml index a9db0e8404..b5efdf06cf 100644 --- a/infrastructure/airy.conf.all +++ b/infrastructure/airy.tpl.yaml @@ -1,11 +1,11 @@ # Global configuration global: - appImageTag: latest + appImageTag: develop containerRegistry: ghcr.io/airyhq namespace: default -# Configuration for the Kafka cluster core: apps: + # Configuration for the Kafka cluster kafka: brokers: "kafka-headless:9092" schemaRegistryUrl: "http://schema-registry:8081" @@ -18,8 +18,8 @@ core: postgresql: endpoint: "postgres:5432" dbName: "admin" - username: "postgresadmin" - password: "long-random-generated-password" + username: "postgresadmin" + password: "changeme" # Specific configurations for sources sources: facebook: @@ -28,11 +28,12 @@ core: webhookSecret: "changeme" google: partnerKey: "changeme" - saFile: > - '{"type":"service_account","project_id":"airy","private_key_id":"no","private_key":"nokey","client_email":"no","client_id":"no","auth_uri":"no","token_uri":"no","no":"no","client_x509_cert_url":"no"}' + saFile: '{"type":"service_account","project_id":"airy","private_key_id":"no","private_key":"nokey","client_email":"no","client_id":"no","auth_uri":"no","token_uri":"no","no":"no","client_x509_cert_url":"no"}' twilio: authToken: "changeme" accountSid: "changeme" + webhooks: + name: "Airy-web" # Specific configuration for the API apps api: mailFrom: "changeme" @@ -42,3 +43,14 @@ core: mailPassword: "changeme" jwtSecret: "long-random-generated-jwt-secret" allowedOrigins: "*" + storage: + s3: + key: "changeme" + secret: "changeme" + bucket: "changeme" + region: "changeme" + path: "path" +ingress: + apiHost: api.airy + uiHost: demo.airy + chatpluginHost: chatplugin.airy diff --git a/infrastructure/cli/BUILD b/infrastructure/cli/BUILD index ee37f6ecec..7d1cb16920 100644 --- a/infrastructure/cli/BUILD +++ b/infrastructure/cli/BUILD @@ -1,11 +1,9 @@ # gazelle:prefix cli -# gazelle:importmap_prefix infrastructure -load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library", "go_test") +load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library") go_library( name = "cli_lib", srcs = ["main.go"], - importmap = "infrastructure", importpath = "cli", visibility = ["//visibility:private"], deps = ["//infrastructure/cli/cmd"], @@ -18,13 +16,30 @@ go_binary( visibility = ["//visibility:public"], ) -go_test( - name = "cli_test", - srcs = ["main_test.go"], - data = [ - "airy", - "//infrastructure/cli/pkg/tests/golden:golden_files", - ], - embed = [":cli_lib"], - deps = ["//infrastructure/cli/pkg/tests"], -) +os_list = [ + "linux", + "darwin", + "windows", +] + +[ + go_binary( + name = "airy_" + os, + out = "airy_" + os, + embed = [":cli_lib"], + goarch = "amd64", + goos = os, + visibility = ["//visibility:public"], + ) + for os in os_list +] + +[ + genrule( + name = "airy_" + os + "_bin_rule", + srcs = [":airy_" + os], + outs = ["airy_" + os + "_bin"], + cmd = "cp $(SRCS) $@", + ) + for os in os_list +] diff --git a/infrastructure/cli/cmd/BUILD b/infrastructure/cli/cmd/BUILD index 77f0424c35..4b275c53a6 100644 --- a/infrastructure/cli/cmd/BUILD +++ b/infrastructure/cli/cmd/BUILD @@ -2,22 +2,20 @@ load("@io_bazel_rules_go//go:def.bzl", "go_library") go_library( name = "cmd", - srcs = [ - "root.go", - "version.go", - ], - importmap = "infrastructure/cmd", + srcs = ["root.go"], importpath = "cli/cmd", visibility = ["//visibility:public"], x_defs = { - "CLIVersion": "{STABLE_VERSION}", - "GitCommit": "{STABLE_GIT_COMMIT}", + "Version": "{STABLE_VERSION}", + "CommitSHA1": "{STABLE_GIT_COMMIT}", }, deps = [ - "//infrastructure/cli/cmd/auth", - "//infrastructure/cli/cmd/bootstrap", + "//infrastructure/cli/cmd/api", "//infrastructure/cli/cmd/config", - "//infrastructure/cli/cmd/demo", + "//infrastructure/cli/cmd/status", + "//infrastructure/cli/cmd/ui", + "@com_github_mitchellh_go_homedir//:go-homedir", "@com_github_spf13_cobra//:cobra", + "@com_github_spf13_viper//:viper", ], ) diff --git a/infrastructure/cli/cmd/auth/BUILD b/infrastructure/cli/cmd/api/BUILD similarity index 58% rename from infrastructure/cli/cmd/auth/BUILD rename to infrastructure/cli/cmd/api/BUILD index cc679d53f4..0802f7ccae 100644 --- a/infrastructure/cli/cmd/auth/BUILD +++ b/infrastructure/cli/cmd/api/BUILD @@ -1,14 +1,18 @@ load("@io_bazel_rules_go//go:def.bzl", "go_library") go_library( - name = "auth", - srcs = ["auth.go"], - importmap = "infrastructure/cmd/auth", - importpath = "cli/cmd/auth", + name = "api", + srcs = [ + "api.go", + "login.go", + "signup.go", + ], + importpath = "cli/cmd/api", visibility = ["//visibility:public"], deps = [ "//lib/go/httpclient", "//lib/go/httpclient/payloads", "@com_github_spf13_cobra//:cobra", + "@com_github_spf13_viper//:viper", ], ) diff --git a/infrastructure/cli/cmd/api/api.go b/infrastructure/cli/cmd/api/api.go new file mode 100644 index 0000000000..bc364ed762 --- /dev/null +++ b/infrastructure/cli/cmd/api/api.go @@ -0,0 +1,18 @@ +package api + +import ( + "github.com/spf13/cobra" +) + +// APICmd subcommand for Airy Core +var APICmd = &cobra.Command{ + Use: "api", + TraverseChildren: true, + Short: "Interacts with the Airy Core Platform HTTP API", + Long: ``, +} + +func init() { + APICmd.AddCommand(signupCmd) + APICmd.AddCommand(loginCmd) +} diff --git a/infrastructure/cli/cmd/api/login.go b/infrastructure/cli/cmd/api/login.go new file mode 100644 index 0000000000..4fd812a491 --- /dev/null +++ b/infrastructure/cli/cmd/api/login.go @@ -0,0 +1,42 @@ +package api + +import ( + "fmt" + "os" + + "github.com/airyhq/airy/lib/go/httpclient" + "github.com/airyhq/airy/lib/go/httpclient/payloads" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var loginCmd = &cobra.Command{ + Use: "login", + Short: "Logs you in in the Airy Core Platform", + Long: ``, + Run: login, +} + +func login(cmd *cobra.Command, args []string) { + email, _ := cmd.Flags().GetString("email") + password, _ := cmd.Flags().GetString("password") + c := httpclient.NewClient(viper.GetString("apihost")) + + loginRequestPayload := payloads.LoginRequestPayload{Email: email, Password: password} + res, err := c.Login(loginRequestPayload) + if err != nil { + fmt.Println("could not login:", err) + os.Exit(1) + } + fmt.Printf("logged in correctly: %s\n", res.Token) + + viper.Set("apiJWTToken", res.Token) + viper.WriteConfig() +} + +func init() { + var email, password string + loginCmd.Flags().StringVarP(&email, "email", "e", "grace@hopper.com", "Email") + loginCmd.Flags().StringVarP(&password, "password", "p", "the_answer_is_42", "Password") +} diff --git a/infrastructure/cli/cmd/api/signup.go b/infrastructure/cli/cmd/api/signup.go new file mode 100644 index 0000000000..6470d19878 --- /dev/null +++ b/infrastructure/cli/cmd/api/signup.go @@ -0,0 +1,43 @@ +package api + +import ( + "fmt" + "os" + + "github.com/airyhq/airy/lib/go/httpclient" + "github.com/airyhq/airy/lib/go/httpclient/payloads" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var signupCmd = &cobra.Command{ + Use: "signup", + Short: "Signs users up in the Airy Core Platform", + Long: ``, + Run: signup, +} + +func signup(cmd *cobra.Command, args []string) { + firstName, _ := cmd.Flags().GetString("firstName") + lastName, _ := cmd.Flags().GetString("lastName") + email, _ := cmd.Flags().GetString("email") + password, _ := cmd.Flags().GetString("password") + c := httpclient.NewClient(viper.GetString("apihost")) + + signupRequestPayload := payloads.SignupRequestPayload{FirstName: firstName, LastName: lastName, Email: email, Password: password} + res, err := c.Signup(signupRequestPayload) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + fmt.Printf("user created: %s\n", res.ID) +} + +func init() { + var firstName, lastName, email, password string + signupCmd.Flags().StringVarP(&firstName, "firstName", "f", "Grace", "First name") + signupCmd.Flags().StringVarP(&lastName, "lastName", "l", "Hopper", "Last name") + signupCmd.Flags().StringVarP(&email, "email", "e", "grace@hopper.com", "Email") + signupCmd.Flags().StringVarP(&password, "password", "p", "the_answer_is_42", "Password") +} diff --git a/infrastructure/cli/cmd/auth/auth.go b/infrastructure/cli/cmd/auth/auth.go deleted file mode 100644 index 629bad9d39..0000000000 --- a/infrastructure/cli/cmd/auth/auth.go +++ /dev/null @@ -1,49 +0,0 @@ -package auth - -import ( - "fmt" - "log" - - "github.com/airyhq/airy/lib/go/httpclient" - "github.com/airyhq/airy/lib/go/httpclient/payloads" - - "github.com/spf13/cobra" -) - -// AuthCmd subcommand for Airy Core -var AuthCmd = &cobra.Command{ - Use: "auth", - TraverseChildren: true, - Short: "Create a default user and return a JWT token", - Long: ``, - Run: auth, -} - -func auth(cmd *cobra.Command, args []string) { - url, _ := cmd.Flags().GetString("url") - email, _ := cmd.Flags().GetString("email") - password, _ := cmd.Flags().GetString("password") - c := httpclient.NewClient() - c.BaseURL = url - - loginRequestPayload := payloads.LoginRequestPayload{Email: email, Password: password} - - res, err := c.Login(loginRequestPayload) - if err != nil { - signupRequestPayload := payloads.SignupRequestPayload{FirstName: "Firstname", LastName: "Lastname", Email: email, Password: password} - res, err := c.Signup(signupRequestPayload) - if err != nil { - log.Fatal(err) - } - fmt.Println(res.Token) - return - } - fmt.Println(res.Token) -} - -func init() { - var url, email, password string - AuthCmd.Flags().StringVarP(&url, "url", "u", "http://api.airy", "The url of the Airy API") - AuthCmd.Flags().StringVarP(&email, "email", "e", "grace@hopper.com", "Email to use for the authentication") - AuthCmd.Flags().StringVarP(&password, "password", "p", "the_answer_is_42", "Password to use for the authentication") -} diff --git a/infrastructure/cli/cmd/bootstrap/bootstrap.go b/infrastructure/cli/cmd/bootstrap/bootstrap.go deleted file mode 100644 index 66301513d6..0000000000 --- a/infrastructure/cli/cmd/bootstrap/bootstrap.go +++ /dev/null @@ -1,31 +0,0 @@ -package bootstrap - -import ( - "log" - - "github.com/spf13/cobra" -) - -// ResponsePayload for receiving the request - -// BootstrapCmd subcommand for Airy Core -var BootstrapCmd = &cobra.Command{ - Use: "bootstrap", - TraverseChildren: true, - Short: "Bootstrap Airy Core Platform locally", - Long: `This will install the Airy Core Platform in the current directory unless you choose a different one. - It will also try to install Vagrant and VirtualBox.`, - Run: bootstrap, -} - -func bootstrap(cmd *cobra.Command, args []string) { - // Initialize the api request - - log.Println("BootstrapCmd called") - -} - -func init() { - var imageTag string - BootstrapCmd.Flags().StringVarP(&imageTag, "image-tag", "i", "", "The docker image tag that the Airy apps will use.") -} diff --git a/infrastructure/cli/cmd/config/BUILD b/infrastructure/cli/cmd/config/BUILD index 102a335cff..8c79bdd734 100644 --- a/infrastructure/cli/cmd/config/BUILD +++ b/infrastructure/cli/cmd/config/BUILD @@ -2,9 +2,20 @@ load("@io_bazel_rules_go//go:def.bzl", "go_library") go_library( name = "config", - srcs = ["config.go"], - importmap = "infrastructure/cmd/config", + srcs = [ + "config.go", + "configmaps.go", + "parser.go", + ], importpath = "cli/cmd/config", visibility = ["//visibility:public"], - deps = ["@com_github_spf13_cobra//:cobra"], + deps = [ + "@com_github_mitchellh_go_homedir//:go-homedir", + "@com_github_spf13_cobra//:cobra", + "@in_gopkg_yaml_v2//:yaml_v2", + "@io_k8s_api//core/v1:core", + "@io_k8s_apimachinery//pkg/apis/meta/v1:meta", + "@io_k8s_client_go//kubernetes", + "@io_k8s_client_go//tools/clientcmd", + ], ) diff --git a/infrastructure/cli/cmd/config/config.go b/infrastructure/cli/cmd/config/config.go index 68fa0c78f6..ebf92fc2bc 100644 --- a/infrastructure/cli/cmd/config/config.go +++ b/infrastructure/cli/cmd/config/config.go @@ -2,27 +2,64 @@ package config import ( "fmt" + "os" + "path" + "github.com/mitchellh/go-homedir" "github.com/spf13/cobra" ) -// ResponsePayload for receiving the request +var kubeConfigFile string +var configFile string // ConfigCmd subcommand for Airy Core var ConfigCmd = &cobra.Command{ Use: "config", TraverseChildren: true, - Short: "Reloads configuration based on airy.conf", - Long: ``, - Run: config, + Short: "Manages your Airy Core Platform instance via airy.yaml", } -func config(cmd *cobra.Command, args []string) { - // Initialize the api request +func applyConfig(cmd *cobra.Command, args []string) { + conf, err := parseConf(configFile) + if err != nil { + fmt.Println("error parsing configuration file: ", err) + os.Exit(1) + } - fmt.Println("ConfigCmd called") + if twilioApply(conf, kubeConfigFile) { + fmt.Println("Twilio configuration applied.") + } + if facebookApply(conf, kubeConfigFile) { + fmt.Println("Facebook configuration applied.") + } + + if googleApply(conf, kubeConfigFile) { + fmt.Println("Google configuration applied.") + } + + if webhooksApply(conf, kubeConfigFile) { + fmt.Println("Webhooks configuration applied.") + } +} + +var applyConfigCmd = &cobra.Command{ + Use: "apply", + TraverseChildren: true, + Short: "Applies configuration values from airy.yaml configuration to the Airy Core Platform", + Run: applyConfig, } func init() { + ConfigCmd.PersistentFlags().StringVar(&kubeConfigFile, "kube-config", "", "Kubernetes config file for the cluster where the Airy Core Platform is running (default \"~/.airy/kube.conf\")") + ConfigCmd.PersistentFlags().StringVar(&configFile, "config", "./airy.yaml", "Configuration file for the Airy Core Platform") + if kubeConfigFile == "" { + home, err := homedir.Dir() + if err != nil { + fmt.Println(err) + os.Exit(1) + } + kubeConfigFile = path.Join(home, ".airy/kube.conf") + } + ConfigCmd.AddCommand(applyConfigCmd) } diff --git a/infrastructure/cli/cmd/config/configmaps.go b/infrastructure/cli/cmd/config/configmaps.go new file mode 100644 index 0000000000..b5a091496e --- /dev/null +++ b/infrastructure/cli/cmd/config/configmaps.go @@ -0,0 +1,122 @@ +package config + +import ( + "context" + "fmt" + "os" + + corev1 "k8s.io/api/core/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/clientcmd" +) + +func applyConfigMap(configMapName string, newCmData map[string]string, kubeConfigFile string, namespace string) error { + config, kubeConfigErr := clientcmd.BuildConfigFromFlags("", kubeConfigFile) + if kubeConfigErr != nil { + return kubeConfigErr + } + + clientset, clientsetErr := kubernetes.NewForConfig(config) + if clientsetErr != nil { + return clientsetErr + } + + cm, _ := clientset.CoreV1().ConfigMaps(namespace).Get(context.TODO(), configMapName, v1.GetOptions{}) + + if cm.GetName() == "" { + _, err := clientset.CoreV1().ConfigMaps(namespace).Create(context.TODO(), + &corev1.ConfigMap{ + ObjectMeta: v1.ObjectMeta{ + Name: configMapName, + Namespace: namespace, + }, + Data: newCmData, + }, v1.CreateOptions{}) + return err + } else { + cm.Data = newCmData + _, err := clientset.CoreV1().ConfigMaps(namespace).Update(context.TODO(), cm, v1.UpdateOptions{}) + return err + } + +} + +func facebookApply(airyConf airyConf, kubeConfigFile string) bool { + facebookConfig := airyConf.Core.Apps.Sources.Facebook + if facebookConfig.AppID != "" || facebookConfig.AppSecret != "" || facebookConfig.WebhookSecret != "" { + configMapData := make(map[string]string, 0) + configMapData["FACEBOOK_APP_ID"] = facebookConfig.AppID + configMapData["FACEBOOK_APP_SECRET"] = facebookConfig.AppSecret + configMapData["FACEBOOK_WEBHOOK_SECRET"] = facebookConfig.WebhookSecret + err := applyConfigMap("sources-facebook", configMapData, kubeConfigFile, airyConf.Global.Namespace) + + if err != nil { + fmt.Println("unable to update configMap: ", err) + os.Exit(1) + } + + return true + } + + return false +} + +func googleApply(airyConf airyConf, kubeConfigFile string) bool { + googleConfig := airyConf.Core.Apps.Sources.Google + if googleConfig.PartnerKey != "" || googleConfig.SaFile != "" { + configMapData := make(map[string]string, 0) + configMapData["GOOGLE_PARTNER_KEY"] = googleConfig.PartnerKey + configMapData["GOOGLE_SA_FILE"] = googleConfig.SaFile + + err := applyConfigMap("sources-google", configMapData, kubeConfigFile, airyConf.Global.Namespace) + + if err != nil { + fmt.Println("unable to update configMap: ", err) + os.Exit(1) + } + + return true + } + + return false +} + +func twilioApply(airyConf airyConf, kubeConfigFile string) bool { + twilioConfig := airyConf.Core.Apps.Sources.Twilio + if twilioConfig.AccountSid != "" || twilioConfig.AuthToken != "" { + configMapData := make(map[string]string, 0) + configMapData["TWILIO_ACCOUNT_SID"] = twilioConfig.AccountSid + configMapData["TWILIO_AUTH_TOKEN"] = twilioConfig.AuthToken + + err := applyConfigMap("sources-twilio", configMapData, kubeConfigFile, airyConf.Global.Namespace) + + if err != nil { + fmt.Println("unable to update configMap: ", err) + os.Exit(1) + } + + return true + } + + return false +} + +func webhooksApply(airyConf airyConf, kubeConfigFile string) bool { + webhooksConfig := airyConf.Core.Apps.Webhooks + if webhooksConfig.Name != "" { + configMapData := make(map[string]string, 0) + configMapData["NAME"] = webhooksConfig.Name + + err := applyConfigMap("webhooks-config", configMapData, kubeConfigFile, airyConf.Global.Namespace) + + if err != nil { + fmt.Println("unable to update configMap: ", err) + os.Exit(1) + } + + return true + } + + return false +} diff --git a/infrastructure/cli/cmd/config/parser.go b/infrastructure/cli/cmd/config/parser.go new file mode 100644 index 0000000000..db95a16c8b --- /dev/null +++ b/infrastructure/cli/cmd/config/parser.go @@ -0,0 +1,65 @@ +package config + +import ( + "fmt" + "io/ioutil" + "os" + + "gopkg.in/yaml.v2" +) + +type globalConf struct { + AppImageTag string `yaml:"appImageTag"` + ContainerRegistry string `yaml:"containerRegistry"` + Namespace string `yaml:"namespace"` +} + +type coreConf struct { + Apps struct { + Sources struct { + Twilio struct { + AuthToken string `yaml:"authToken"` + AccountSid string `yaml:"accountSid"` + } + Facebook struct { + AppID string `yaml:"appId"` + AppSecret string `yaml:"appSecret"` + WebhookSecret string `yaml:"webhookSecret"` + } + Google struct { + PartnerKey string `yaml:"partnerKey"` + SaFile string `yaml:"saFile"` + } + } + Webhooks struct { + Name string `yaml:"name"` + } + } +} + +type airyConf struct { + Global globalConf + Core coreConf +} + +func parseConf(configFile string) (airyConf, error) { + data, err := ioutil.ReadFile(configFile) + if err != nil { + fmt.Println("error reading configuration file: ", err) + os.Exit(1) + } + + conf := airyConf{ + Global: globalConf{ + Namespace: "default", + }, + } + + err = yaml.Unmarshal(data, &conf) + if err != nil { + fmt.Println("error parsing configuration file: ", err) + os.Exit(1) + } + + return conf, nil +} diff --git a/infrastructure/cli/cmd/root.go b/infrastructure/cli/cmd/root.go index d7268ec9fe..777763d2bb 100644 --- a/infrastructure/cli/cmd/root.go +++ b/infrastructure/cli/cmd/root.go @@ -3,37 +3,123 @@ package cmd import ( "fmt" "os" + "path" - "cli/cmd/auth" - "cli/cmd/bootstrap" + "cli/cmd/api" "cli/cmd/config" - "cli/cmd/demo" + "cli/cmd/status" + "cli/cmd/ui" + homedir "github.com/mitchellh/go-homedir" "github.com/spf13/cobra" + "github.com/spf13/viper" ) -// rootCmd represents the base command when called without any subcommands -var RootCmd = &cobra.Command{ +const cliConfigFileName = "cli.yaml" +const cliConfigDirName = ".airy" + +var cliConfigFile string +var Version string +var CommitSHA1 string + +var rootCmd = &cobra.Command{ Use: "airy", - Short: "Airy CLI", + Short: "airy controls your Airy Core Platform instance", Long: ``, TraverseChildren: true, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + if cmd.Name() != "init" { + initConfig() + } + }, } -// Execute adds all child commands to the root command and sets flags appropriately. -// This is called by main.main(). It only needs to happen once to the rootCmd. -func Execute() { +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Prints version information", + Long: ``, + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("Version: %s, GitCommit: %s", Version, CommitSHA1) + }, +} + +var initCmd = &cobra.Command{ + Use: "init", + Short: "Inits your Airy CLI configuration", + Long: ``, + Run: func(cmd *cobra.Command, args []string) { + home, err := homedir.Dir() + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + configDirPath := path.Join(home, cliConfigDirName) + + if _, errConfigDir := os.Stat(configDirPath); os.IsNotExist(errConfigDir) { + errDir := os.MkdirAll(configDirPath, 0700) + if errDir != nil { + fmt.Println(errDir) + os.Exit(1) + } + } + + err = viper.WriteConfigAs(path.Join(home, cliConfigDirName, cliConfigFileName)) + if err != nil { + fmt.Println("cannot write config: ", err) + } + }, +} - if err := RootCmd.Execute(); err != nil { +func Execute() { + if err := rootCmd.Execute(); err != nil { fmt.Println(err) os.Exit(1) } +} + +func initConfig() { + if cliConfigFile != "" { + viper.SetConfigFile(cliConfigFile) + } else { + home, err := homedir.Dir() + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + viper.AddConfigPath(path.Join(home, cliConfigDirName)) + viper.SetConfigType("yaml") + viper.SetConfigName(cliConfigFileName) + } + if err := viper.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); ok { + fmt.Println(err) + fmt.Println("please run airy init") + } else { + fmt.Println("invalid configuration: ", err) + } + + os.Exit(1) + } } func init() { - RootCmd.AddCommand(bootstrap.BootstrapCmd) - RootCmd.AddCommand(auth.AuthCmd) - RootCmd.AddCommand(config.ConfigCmd) - RootCmd.AddCommand(demo.DemoCmd) + apiHost := "" + rootCmd.PersistentFlags().StringVar(&apiHost, "apihost", "http://api.airy", "Airy Core Platform HTTP API host") + viper.BindPFlag("apihost", rootCmd.PersistentFlags().Lookup("apihost")) + viper.SetDefault("apihost", "http://api.airy") + + apiJWTToken := "" + rootCmd.PersistentFlags().StringVarP(&apiJWTToken, "apiJWTToken", "", "", "apiJWTToken") + rootCmd.PersistentFlags().MarkHidden("apiJWTToken") + viper.BindPFlag("apiJWTToken", rootCmd.PersistentFlags().Lookup("apiJWTToken")) + rootCmd.PersistentFlags().StringVar(&cliConfigFile, "cli-config", "", "config file (default is $HOME/.airy/cli.yaml)") + rootCmd.AddCommand(api.APICmd) + rootCmd.AddCommand(config.ConfigCmd) + rootCmd.AddCommand(status.StatusCmd) + rootCmd.AddCommand(ui.UICmd) + rootCmd.AddCommand(versionCmd) + rootCmd.AddCommand(initCmd) } diff --git a/infrastructure/cli/cmd/bootstrap/BUILD b/infrastructure/cli/cmd/status/BUILD similarity index 52% rename from infrastructure/cli/cmd/bootstrap/BUILD rename to infrastructure/cli/cmd/status/BUILD index d016d3af30..6ff870c55d 100644 --- a/infrastructure/cli/cmd/bootstrap/BUILD +++ b/infrastructure/cli/cmd/status/BUILD @@ -1,12 +1,13 @@ load("@io_bazel_rules_go//go:def.bzl", "go_library") go_library( - name = "bootstrap", - srcs = ["bootstrap.go"], - importmap = "infrastructure/cmd/bootstrap", - importpath = "cli/cmd/bootstrap", + name = "status", + srcs = ["status.go"], + importpath = "cli/cmd/status", visibility = ["//visibility:public"], deps = [ + "//lib/go/httpclient", "@com_github_spf13_cobra//:cobra", + "@com_github_spf13_viper//:viper", ], ) diff --git a/infrastructure/cli/cmd/status/status.go b/infrastructure/cli/cmd/status/status.go new file mode 100644 index 0000000000..f39437ac89 --- /dev/null +++ b/infrastructure/cli/cmd/status/status.go @@ -0,0 +1,44 @@ +package status + +import ( + "fmt" + "os" + "text/tabwriter" + + "github.com/airyhq/airy/lib/go/httpclient" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// StatusCmd reports the status of your Airy Core Platform +var StatusCmd = &cobra.Command{ + Use: "status", + Short: "Reports the status of your Airy Core Platform", + Long: ``, + Run: status, +} + +func status(cmd *cobra.Command, args []string) { + c := httpclient.NewClient(viper.GetString("apihost")) + + c.JWTToken = viper.GetString("apiJWTToken") + + res, err := c.Config() + + if err != nil { + fmt.Println("could not read status: ", err) + os.Exit(1) + } + + w := tabwriter.NewWriter(os.Stdout, 1, 1, 1, ' ', 0) + + for k, v := range res.Components { + if v["enabled"].(bool) { + fmt.Fprintf(w, "%s\t\u2713\n", k) + } else { + fmt.Fprintf(w, "%s\t\u2717\n", k) + } + } + + w.Flush() +} diff --git a/infrastructure/cli/cmd/demo/BUILD b/infrastructure/cli/cmd/ui/BUILD similarity index 57% rename from infrastructure/cli/cmd/demo/BUILD rename to infrastructure/cli/cmd/ui/BUILD index 7bf9222d39..52a704a950 100644 --- a/infrastructure/cli/cmd/demo/BUILD +++ b/infrastructure/cli/cmd/ui/BUILD @@ -1,10 +1,9 @@ load("@io_bazel_rules_go//go:def.bzl", "go_library") go_library( - name = "demo", - srcs = ["demo.go"], - importmap = "infrastructure/cmd/demo", - importpath = "cli/cmd/demo", + name = "ui", + srcs = ["ui.go"], + importpath = "cli/cmd/ui", visibility = ["//visibility:public"], deps = ["@com_github_spf13_cobra//:cobra"], ) diff --git a/infrastructure/cli/cmd/demo/demo.go b/infrastructure/cli/cmd/ui/ui.go similarity index 68% rename from infrastructure/cli/cmd/demo/demo.go rename to infrastructure/cli/cmd/ui/ui.go index 7bb8e0132c..25c7e91f35 100644 --- a/infrastructure/cli/cmd/demo/demo.go +++ b/infrastructure/cli/cmd/ui/ui.go @@ -1,4 +1,4 @@ -package demo +package ui import ( "fmt" @@ -9,13 +9,11 @@ import ( "github.com/spf13/cobra" ) -// ResponsePayload for receiving the request - -// DemoCmd subcommand for Airy Core -var DemoCmd = &cobra.Command{ - Use: "demo", +// UICmd opens the Airy Core Platform UI +var UICmd = &cobra.Command{ + Use: "ui", TraverseChildren: true, - Short: "Opens the demo page in the browser", + Short: "Opens the Airy Core Platform UI in your local browser", Long: ``, Run: demo, } @@ -23,7 +21,7 @@ var DemoCmd = &cobra.Command{ func demo(cmd *cobra.Command, args []string) { // Initialize the api request - url := "http://chatplugin.airy/example.html" + url := "http://ui.airy/" var err error @@ -42,6 +40,3 @@ func demo(cmd *cobra.Command, args []string) { } } - -func init() { -} diff --git a/infrastructure/cli/cmd/version.go b/infrastructure/cli/cmd/version.go deleted file mode 100644 index 5a5eeefb49..0000000000 --- a/infrastructure/cli/cmd/version.go +++ /dev/null @@ -1,26 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/spf13/cobra" -) - -var CLIVersion string -var GitCommit string - -// StatusCmd cli kafka version -var versionCmd = &cobra.Command{ - Use: "version", - Short: "Return current version", - Long: ``, - Run: version, -} - -func version(cmd *cobra.Command, args []string) { - fmt.Printf("Version: %s, GitCommit: %s", CLIVersion, GitCommit) -} - -func init() { - RootCmd.AddCommand(versionCmd) -} diff --git a/infrastructure/cli/go.mod b/infrastructure/cli/go.mod index d113097457..3fb491520e 100644 --- a/infrastructure/cli/go.mod +++ b/infrastructure/cli/go.mod @@ -5,9 +5,18 @@ go 1.12 require ( github.com/airyhq/airy/lib/go/httpclient v0.0.0 github.com/kr/pretty v0.2.1 - github.com/spf13/cobra v0.0.3 - github.com/spf13/viper v1.3.1 + github.com/mitchellh/go-homedir v1.1.0 + github.com/spf13/cobra v1.1.1 + github.com/spf13/viper v1.7.1 + github.com/stretchr/testify v1.6.1 + github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8 // indirect + github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77 // indirect goji.io v2.0.2+incompatible + gopkg.in/yaml.v2 v2.4.0 + k8s.io/api v0.20.0 + k8s.io/apimachinery v0.20.0 + k8s.io/client-go v0.20.0 + ) replace github.com/airyhq/airy/lib/go/httpclient => ../../lib/go/httpclient diff --git a/infrastructure/cli/go.sum b/infrastructure/cli/go.sum index a53a9ff9a8..0a4a4f41b9 100644 --- a/infrastructure/cli/go.sum +++ b/infrastructure/cli/go.sum @@ -1,63 +1,572 @@ -github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= -github.com/aws/aws-sdk-go v1.28.3 h1:FnkDp+fz4JHWUW3Ust2Wh89RpdGif077Wjis/sMrGKM= -github.com/aws/aws-sdk-go v1.28.3/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= -github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= +github.com/Azure/go-autorest/autorest v0.11.1/go.mod h1:JFgpikqFJ/MleTTxwepExTKnFUKKszPS8UavbQYUMuw= +github.com/Azure/go-autorest/autorest/adal v0.9.0/go.mod h1:/c022QCutn2P7uY+/oQWWNcK9YU+MH96NgK+jErpbcg= +github.com/Azure/go-autorest/autorest/adal v0.9.5/go.mod h1:B7KF7jKIeC9Mct5spmyCB/A8CG/sEz1vwIRGv/bbw7A= +github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74= +github.com/Azure/go-autorest/autorest/mocks v0.4.0/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= +github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= +github.com/Azure/go-autorest/logger v0.2.0/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= +github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= +github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= -github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZgvJUkLughtfhJv5dyTYa91l1fOUCrgjqmcifM= +github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= +github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc= +github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= +github.com/go-logr/logr v0.2.0 h1:QvGt2nLcHH0WK9orKa+ppBPAxREcH364nPUedEpK0TY= +github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU= +github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc= +github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8= +github.com/go-openapi/spec v0.19.3/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo= +github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/gogo/protobuf v1.3.1 h1:DqDEcV5aeaTmdFBePNpYsp3FlcVH/2ISVVM9Qf8PSls= +github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3 h1:JjCZWpVbqXDqFVmTfYWEVTMIYrL/NPdPSCHPJ0T/raM= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.1.0 h1:Hsa8mG0dQ46ij8Sl2AYJDUv1oA9/d6Vk+3LG99Oe02g= +github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/googleapis/gnostic v0.4.1 h1:DLJCy1n/vrD4HPjOvYcT8aYQXpPIzoRZONaYwyycI+I= +github.com/googleapis/gnostic v0.4.1/go.mod h1:LRhVm6pbyptWbWbuZ38d1eyptfvIytN3ir6b65WBswg= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= +github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM= -github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= +github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= +github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/imdario/mergo v0.3.5 h1:JboBksRwiiAJWvIYJVo46AfV+IAIKZpfrSzVKj42R4Q= +github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.10 h1:Kz6Cvnvv2wGdaG/V8yMvfkmNiXq9Ya2KUv4rouJJr68= +github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/pty v1.1.5/go.mod h1:9r2w37qlBe7rQ6e1fg1S/9xpWHSnaqNdHD3WcMdbPDA= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY= -github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4= +github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= +github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= +github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.11.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc= +github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= github.com/spf13/cast v1.3.0 h1:oget//CVOEoFewqQxwr0Ej5yjygnqGkvggSE/gB35Q8= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cobra v0.0.3 h1:ZlrZ4XsMRm04Fr5pSFxBgfND2EBVa1nLpiy1stUsX/8= -github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= -github.com/spf13/cobra v0.0.5 h1:f0B+LkLX6DtmRH1isoNA9VTtNUK9K8xYd28JNNfOv/s= +github.com/spf13/cobra v1.1.1 h1:KfztREH0tPxJJ+geloSLaAkaPkr4ki2Er5quFV1TDo4= +github.com/spf13/cobra v1.1.1/go.mod h1:WnodtKOvamDL/PwE2M4iKs8aMDBZ5Q5klgD3qfVJQMI= github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= -github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= +github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/viper v1.3.1 h1:5+8j8FTpnFV4nEImW/ofkzEt8VoOiLXxdYIDsB73T38= -github.com/spf13/viper v1.3.1/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= +github.com/spf13/viper v1.7.1 h1:pM5oEahlgWv/WnHXpgbKz7iLIxRf65tye2Ci+XFK5sk= +github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/tcnksm/go-input v0.0.0-20180404061846-548a7d7a8ee8 h1:RB0v+/pc8oMzPsN97aZYEwNuJ6ouRJ2uhjxemJ9zvrY= -github.com/tcnksm/go-input v0.0.0-20180404061846-548a7d7a8ee8/go.mod h1:IlWNj9v/13q7xFbaK4mbyzMNwrZLaWSHx/aibKIZuIg= +github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= +github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= goji.io v2.0.2+incompatible h1:uIssv/elbKRLznFUy3Xj4+2Mz/qKhek/9aZQDUMae7c= goji.io v2.0.2+incompatible/go.mod h1:sbqFwrtqZACxLBTQcdgVjFh54yGVCvwq8+w49MVMMIk= -golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9 h1:mKdxBk7AujPs8kU4m80U72y/zjbZ3UcXC7dClwKbUI0= -golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a h1:1n5lsVfiQW3yfsRGu98756EH1YthsFqr/5mxHduZW2A= -golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0 h1:hb9wdF1z5waM+dSIICn1l0DkLVDT3hqhhQsDNUmHPRE= +golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b h1:uwuIcX0g4Yl1NC5XAz37xsr2lTtcqevgzYNVt49waME= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d h1:TzXSXBo42m9gQenoE3b9BGiEpg5IG2JkU5FkPIawgtw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190616124812-15dcb6c0061f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201112073958-5cba982894dd h1:5CtCZbICpIOFdgO940moixOPjc0178IU44m4EjOO5IY= +golang.org/x/sys v0.0.0-20201112073958-5cba982894dd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4 h1:0YWbFKbhXG/wIiuHDSKpS0Iy7FSA+u45VtBMfQcFTTc= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e h1:EHBhcS0mlXEAVwNyO2dLfjToGsyY4j24pTs2ScHnX7s= +golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno= +gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +k8s.io/api v0.20.0 h1:WwrYoZNM1W1aQEbyl8HNG+oWGzLpZQBlcerS9BQw9yI= +k8s.io/api v0.20.0/go.mod h1:HyLC5l5eoS/ygQYl1BXBgFzWNlkHiAuyNAbevIn+FKg= +k8s.io/apimachinery v0.20.0 h1:jjzbTJRXk0unNS71L7h3lxGDH/2HPxMPaQY+MjECKL8= +k8s.io/apimachinery v0.20.0/go.mod h1:WlLqWAHZGg07AeltaI0MV5uk1Omp8xaN0JGLY6gkRpU= +k8s.io/apimachinery v0.20.2 h1:hFx6Sbt1oG0n6DZ+g4bFt5f6BoMkOjKWsQFu077M3Vg= +k8s.io/client-go v0.20.0 h1:Xlax8PKbZsjX4gFvNtt4F5MoJ1V5prDvCuoq9B7iax0= +k8s.io/client-go v0.20.0/go.mod h1:4KWh/g+Ocd8KkCwKF8vUNnmqgv+EVnQDK4MBF4oB5tY= +k8s.io/client-go v1.5.1 h1:XaX/lo2/u3/pmFau8HN+sB5C/b4dc4Dmm2eXjBH4p1E= +k8s.io/client-go v11.0.0+incompatible h1:LBbX2+lOwY9flffWlJM7f1Ct8V2SRNiMRDFeiwnJo9o= +k8s.io/gengo v0.0.0-20200413195148-3a45101e95ac/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= +k8s.io/klog/v2 v2.0.0/go.mod h1:PBfzABfn139FHAV07az/IF9Wp1bkk3vpT2XSJ76fSDE= +k8s.io/klog/v2 v2.4.0 h1:7+X0fUguPyrKEC4WjH8iGDg3laWgMo5tMnRTIGTTxGQ= +k8s.io/klog/v2 v2.4.0/go.mod h1:Od+F08eJP+W3HUb4pSrPpgp9DGU4GzlpG/TmITuYh/Y= +k8s.io/kube-openapi v0.0.0-20201113171705-d219536bb9fd/go.mod h1:WOJ3KddDSol4tAGcJo0Tvi+dK12EcqSLqcWsryKMpfM= +k8s.io/utils v0.0.0-20201110183641-67b214c5f920 h1:CbnUZsM497iRC5QMVkHwyl8s2tB3g7yaSHkYPkpgelw= +k8s.io/utils v0.0.0-20201110183641-67b214c5f920/go.mod h1:jPW/WVKK9YHAvNhRxK0md/EJ228hCsBRufyofKtW8HA= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= +sigs.k8s.io/structured-merge-diff/v4 v4.0.2 h1:YHQV7Dajm86OuqnIR6zAelnDWBRjo+YhYV9PmGrh1s8= +sigs.k8s.io/structured-merge-diff/v4 v4.0.2/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK1F7G282QMXDPYydCw= +sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= +sigs.k8s.io/yaml v1.2.0 h1:kr/MCeFWJWTwyaHoR9c8EjH9OumOmoF9YGiZd7lFm/Q= +sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= diff --git a/infrastructure/cli/integration/BUILD b/infrastructure/cli/integration/BUILD new file mode 100644 index 0000000000..39ed65dabe --- /dev/null +++ b/infrastructure/cli/integration/BUILD @@ -0,0 +1,31 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "integration", + srcs = [ + "mockserver.go", + "runner.go", + "test_file.go", + ], + importpath = "cli/integration", + visibility = ["//visibility:public"], + deps = [ + "@com_github_kr_pretty//:pretty", + "@io_goji//:goji.io", + "@io_goji//pat", + ], +) + +go_test( + name = "integration_test", + srcs = [ + "api_login_test.go", + "noargs_test.go", + "version_test.go", + ], + data = [ + "//infrastructure/cli:airy", + "//infrastructure/cli/integration/golden:golden_files", + ], + embed = [":integration"], +) diff --git a/infrastructure/cli/integration/api_login_test.go b/infrastructure/cli/integration/api_login_test.go new file mode 100644 index 0000000000..31ea6c5a7e --- /dev/null +++ b/infrastructure/cli/integration/api_login_test.go @@ -0,0 +1,19 @@ +package integration + +import ( + "testing" +) + +func TestApiLogin(t *testing.T) { + ms := NewMockServer(t) + + go func() { + ms.Serve() + }() + + tests := []test{ + {"login", []string{"api", "login", "--apihost", ms.Host, "--cli-config", "golden/cli.yaml"}, "cli.login", false}, + } + + runner(t, tests) +} diff --git a/infrastructure/cli/integration/golden/BUILD b/infrastructure/cli/integration/golden/BUILD new file mode 100644 index 0000000000..b0e79c68ab --- /dev/null +++ b/infrastructure/cli/integration/golden/BUILD @@ -0,0 +1,9 @@ +package(default_visibility = ["//visibility:public"]) + +filegroup( + name = "golden_files", + srcs = glob([ + "*.golden", + "*.yaml", + ]), +) diff --git a/infrastructure/cli/integration/golden/cli.login.golden b/infrastructure/cli/integration/golden/cli.login.golden new file mode 100644 index 0000000000..44e53ecb9a --- /dev/null +++ b/infrastructure/cli/integration/golden/cli.login.golden @@ -0,0 +1 @@ +logged in correctly: eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiIxMDM1MmU3NC00MjEyLTQyODItYWM3NC0wZjU4ZjgzYzJkYjUiLCJzdWIiOiIxMDM1MmU3NC00MjEyLTQyODItYWM3NC0wZjU4ZjgzYzJkYjUiLCJpYXQiOjE2MDc2MTk0ODksInVzZXJfaWQiOiIxMDM1MmU3NC00MjEyLTQyODItYWM3NC0wZjU4ZjgzYzJkYjUiLCJleHAiOjE2MDc3MDU4ODl9.ZuIv_t0D358n04gamNwz3U_tkxr4IO36gXuZyU9X3e4 diff --git a/infrastructure/cli/integration/golden/cli.no-args.golden b/infrastructure/cli/integration/golden/cli.no-args.golden new file mode 100644 index 0000000000..340de9711e --- /dev/null +++ b/infrastructure/cli/integration/golden/cli.no-args.golden @@ -0,0 +1,20 @@ +airy controls your Airy Core Platform instance + +Usage: + airy [command] + +Available Commands: + api Interacts with the Airy Core Platform HTTP API + config Manages your Airy Core Platform instance via airy.yaml + help Help about any command + init Inits your Airy CLI configuration + status Reports the status of your Airy Core Platform + ui Opens the Airy Core Platform UI in your local browser + version Prints version information + +Flags: + --apihost string Airy Core Platform HTTP API host (default "http://api.airy") + --cli-config string config file (default is $HOME/.airy/cli.yaml) + -h, --help help for airy + +Use "airy [command] --help" for more information about a command. diff --git a/infrastructure/cli/pkg/tests/golden/cli.version.golden b/infrastructure/cli/integration/golden/cli.version.golden similarity index 100% rename from infrastructure/cli/pkg/tests/golden/cli.version.golden rename to infrastructure/cli/integration/golden/cli.version.golden diff --git a/infrastructure/cli/integration/golden/cli.yaml b/infrastructure/cli/integration/golden/cli.yaml new file mode 100644 index 0000000000..1fec8f3a3e --- /dev/null +++ b/infrastructure/cli/integration/golden/cli.yaml @@ -0,0 +1,2 @@ +apihost: http://localhost:50926 +apijwttoken: eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiIxMDM1MmU3NC00MjEyLTQyODItYWM3NC0wZjU4ZjgzYzJkYjUiLCJzdWIiOiIxMDM1MmU3NC00MjEyLTQyODItYWM3NC0wZjU4ZjgzYzJkYjUiLCJpYXQiOjE2MDc2MTk0ODksInVzZXJfaWQiOiIxMDM1MmU3NC00MjEyLTQyODItYWM3NC0wZjU4ZjgzYzJkYjUiLCJleHAiOjE2MDc3MDU4ODl9.ZuIv_t0D358n04gamNwz3U_tkxr4IO36gXuZyU9X3e4 diff --git a/infrastructure/cli/pkg/tests/golden/api.signup.golden b/infrastructure/cli/integration/golden/users.signup.golden similarity index 100% rename from infrastructure/cli/pkg/tests/golden/api.signup.golden rename to infrastructure/cli/integration/golden/users.signup.golden diff --git a/infrastructure/cli/integration/mockserver.go b/infrastructure/cli/integration/mockserver.go new file mode 100644 index 0000000000..109685e38d --- /dev/null +++ b/infrastructure/cli/integration/mockserver.go @@ -0,0 +1,54 @@ +package integration + +import ( + "fmt" + "io/ioutil" + "log" + "net" + "net/http" + "path" + "testing" + + "goji.io" + "goji.io/pat" +) + +type MockServer struct { + l net.Listener + Host string + mux *goji.Mux +} + +func NewMockServer(t *testing.T) *MockServer { + listener, err := net.Listen("tcp", ":0") + if err != nil { + t.Fatal("mock server error: ", err) + } + + mux := goji.NewMux() + mux.HandleFunc(pat.Post("/users.signup"), mockEndpoint("users.signup")) + mux.HandleFunc(pat.Post("/users.login"), mockEndpoint("users.signup")) + + return &MockServer{ + l: listener, + mux: mux, + Host: fmt.Sprintf("http://localhost:%d", listener.Addr().(*net.TCPAddr).Port), + } +} + +func (ms *MockServer) Serve() { + http.Serve(ms.l, ms.mux) +} + +func mockEndpoint(endpoint string) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + data, err := ioutil.ReadFile(path.Join("golden", endpoint+".golden")) + if err != nil { + fmt.Fprint(w, err) + } + _, err = w.Write(data) + if err != nil { + log.Println(err) + } + } +} diff --git a/infrastructure/cli/integration/noargs_test.go b/infrastructure/cli/integration/noargs_test.go new file mode 100644 index 0000000000..81d33b5f72 --- /dev/null +++ b/infrastructure/cli/integration/noargs_test.go @@ -0,0 +1,18 @@ +package integration + +import ( + "testing" +) + +func TestNoArgs(t *testing.T) { + tests := []test{ + {"no args", []string{}, "cli.no-args", false}, + } + + go func() { + ms := NewMockServer(t) + ms.Serve() + }() + + runner(t, tests) +} diff --git a/infrastructure/cli/integration/runner.go b/infrastructure/cli/integration/runner.go new file mode 100644 index 0000000000..442dfbe771 --- /dev/null +++ b/infrastructure/cli/integration/runner.go @@ -0,0 +1,37 @@ +package integration + +import ( + "os/exec" + "reflect" + "testing" +) + +type test struct { + name string + args []string + golden string + wantErr bool +} + +func runner(t *testing.T, tests []test) { + for _, tt := range tests { + t.Run(tt.name, func(testing *testing.T) { + cmd := exec.Command("../airy", tt.args...) + output, err := cmd.CombinedOutput() + actual := string(output) + if (err != nil) != tt.wantErr { + if tt.wantErr { + t.Fatalf("Test %s expected to fail but did not. Error message: %v Output: %s\n", tt.name, err, actual) + } else { + t.Fatalf("Test %s expected to pass but did not. Error message: %v Output: %s\n", tt.name, err, actual) + } + } + golden := NewGoldenFile(t, tt.golden) + expected := golden.Load() + + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("diff: %v", Diff(actual, expected)) + } + }) + } +} diff --git a/infrastructure/cli/pkg/tests/golden.go b/infrastructure/cli/integration/test_file.go similarity index 88% rename from infrastructure/cli/pkg/tests/golden.go rename to infrastructure/cli/integration/test_file.go index fee90e27c2..a4547b9f01 100644 --- a/infrastructure/cli/pkg/tests/golden.go +++ b/infrastructure/cli/integration/test_file.go @@ -1,4 +1,4 @@ -package tests +package integration import ( "io/ioutil" @@ -22,7 +22,7 @@ func Diff(expected, actual interface{}) []string { // NewGoldenFile function func NewGoldenFile(t *testing.T, name string) *TestFile { - return &TestFile{t: t, name: name, dir: "./pkg/tests/golden/"} + return &TestFile{t: t, name: name + ".golden", dir: "./golden/"} } func (tf *TestFile) path() string { diff --git a/infrastructure/cli/integration/version_test.go b/infrastructure/cli/integration/version_test.go new file mode 100644 index 0000000000..e2db0ae45e --- /dev/null +++ b/infrastructure/cli/integration/version_test.go @@ -0,0 +1,13 @@ +package integration + +import ( + "testing" +) + +func TestVersion(t *testing.T) { + tests := []test{ + {"version", []string{"version", "--cli-config", "golden/cli.yaml"}, "cli.version", false}, + } + + runner(t, tests) +} diff --git a/infrastructure/cli/main.go b/infrastructure/cli/main.go index abf40a3da2..6b3302a5ac 100644 --- a/infrastructure/cli/main.go +++ b/infrastructure/cli/main.go @@ -5,6 +5,5 @@ import ( ) func main() { - cmd.Execute() } diff --git a/infrastructure/cli/main_test.go b/infrastructure/cli/main_test.go deleted file mode 100644 index 7a82256b7a..0000000000 --- a/infrastructure/cli/main_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package main - -import ( - "fmt" - "os/exec" - "testing" - - airytests "cli/pkg/tests" - "reflect" -) - -const binaryName = "./airy" - -func TestCli(t *testing.T) { - tests := []struct { - name string - args []string - golden string - wantErr bool - }{ - {"no args", []string{}, "cli.no-args.golden", false}, - {"auth", []string{"auth", "--url", "http://localhost:3001"}, "cli.auth.golden", false}, - {"auth", []string{"auth", "--url", "http://localhost:3001", "--email", "example@email.com"}, "cli.auth.golden", false}, - {"auth", []string{"auth", "--url", "http://localhost:3001", "--email", "example@email.com", "--password", "examplepassword"}, "cli.auth.golden", false}, - // {"bootstrap", []string{"bootstrap"}, "cli.bootstrap.golden", false}, - // {"config", []string{"config"}, "cli.config.no-args.golden", true}, - {"version", []string{"version"}, "cli.version.golden", false}, - } - - go func() { - airytests.MockServer() - }() - - for _, tt := range tests { - t.Run(tt.name, func(testing *testing.T) { - cmd := exec.Command(binaryName, tt.args...) - output, err := cmd.CombinedOutput() - - if (err != nil) != tt.wantErr { - t.Fatalf("Test expected to fail: %t. Did the test pass: %t. Error message: %v\n", tt.wantErr, err == nil, err) - } - fmt.Println(output) - - actual := string(output) - golden := airytests.NewGoldenFile(t, tt.golden) - expected := golden.Load() - - if !reflect.DeepEqual(actual, expected) { - t.Fatalf("diff: %v", airytests.Diff(actual, expected)) - } - - }) - - } -} diff --git a/infrastructure/cli/pkg/tests/BUILD b/infrastructure/cli/pkg/tests/BUILD deleted file mode 100644 index 21f5fede37..0000000000 --- a/infrastructure/cli/pkg/tests/BUILD +++ /dev/null @@ -1,18 +0,0 @@ -load("@io_bazel_rules_go//go:def.bzl", "go_library") - -package(default_visibility = ["//visibility:public"]) - -go_library( - name = "tests", - srcs = [ - "golden.go", - "mockserver.go", - ], - importmap = "infrastructure/pkg/tests", - importpath = "cli/pkg/tests", - deps = [ - "@com_github_kr_pretty//:pretty", - "@io_goji//:goji.io", - "@io_goji//pat", - ], -) diff --git a/infrastructure/cli/pkg/tests/golden/BUILD b/infrastructure/cli/pkg/tests/golden/BUILD deleted file mode 100644 index 61fb98b9de..0000000000 --- a/infrastructure/cli/pkg/tests/golden/BUILD +++ /dev/null @@ -1,11 +0,0 @@ -package(default_visibility = ["//visibility:public"]) - -filegroup( - name = "golden_files", - srcs = [ - "api.signup.golden", - "cli.auth.golden", - "cli.no-args.golden", - "cli.version.golden", - ], -) diff --git a/infrastructure/cli/pkg/tests/golden/cli.auth.golden b/infrastructure/cli/pkg/tests/golden/cli.auth.golden deleted file mode 100644 index 7511a74d6d..0000000000 --- a/infrastructure/cli/pkg/tests/golden/cli.auth.golden +++ /dev/null @@ -1 +0,0 @@ -eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiIxMDM1MmU3NC00MjEyLTQyODItYWM3NC0wZjU4ZjgzYzJkYjUiLCJzdWIiOiIxMDM1MmU3NC00MjEyLTQyODItYWM3NC0wZjU4ZjgzYzJkYjUiLCJpYXQiOjE2MDc2MTk0ODksInVzZXJfaWQiOiIxMDM1MmU3NC00MjEyLTQyODItYWM3NC0wZjU4ZjgzYzJkYjUiLCJleHAiOjE2MDc3MDU4ODl9.ZuIv_t0D358n04gamNwz3U_tkxr4IO36gXuZyU9X3e4 diff --git a/infrastructure/cli/pkg/tests/golden/cli.no-args.golden b/infrastructure/cli/pkg/tests/golden/cli.no-args.golden deleted file mode 100644 index 52769b5c49..0000000000 --- a/infrastructure/cli/pkg/tests/golden/cli.no-args.golden +++ /dev/null @@ -1,17 +0,0 @@ -Airy CLI - -Usage: - airy [command] - -Available Commands: - auth Create a default user and return a JWT token - bootstrap Bootstrap Airy Core Platform locally - config Reloads configuration based on airy.conf - demo Opens the demo page in the browser - help Help about any command - version Return current version - -Flags: - -h, --help help for airy - -Use "airy [command] --help" for more information about a command. diff --git a/infrastructure/cli/pkg/tests/mockserver.go b/infrastructure/cli/pkg/tests/mockserver.go deleted file mode 100644 index 65c1783d88..0000000000 --- a/infrastructure/cli/pkg/tests/mockserver.go +++ /dev/null @@ -1,53 +0,0 @@ -package tests - -import ( - "fmt" - "io/ioutil" - "log" - "net/http" - "time" - - "goji.io" - "goji.io/pat" -) - -// MockServer starts the local server that returns the corresponding golden files for each endpoint -func MockServer() { - mux := goji.NewMux() - mux.HandleFunc(pat.Post("/users.signup"), mockUserSignupHandler) - mux.HandleFunc(pat.Post("/users.login"), mockUserLoginHandler) - - log.Println("starting mock server on port localhost:3001") - s := &http.Server{ - Addr: ":3001", - Handler: mux, - ReadTimeout: 10 * time.Second, - WriteTimeout: 10 * time.Second, - } - err := s.ListenAndServe() - if err != nil { - log.Println(err) - } -} - -func mockUserSignupHandler(w http.ResponseWriter, r *http.Request) { - data, err := ioutil.ReadFile("pkg/tests/golden/api.signup.golden") - if err != nil { - fmt.Fprint(w, err) - } - _, err = w.Write(data) - if err != nil { - log.Println(err) - } -} - -func mockUserLoginHandler(w http.ResponseWriter, r *http.Request) { - data, err := ioutil.ReadFile("pkg/tests/golden/api.signup.golden") - if err != nil { - fmt.Fprint(w, err) - } - _, err = w.Write(data) - if err != nil { - log.Println(err) - } -} diff --git a/infrastructure/controller/BUILD b/infrastructure/controller/BUILD index 8b68346131..dbddf8602b 100644 --- a/infrastructure/controller/BUILD +++ b/infrastructure/controller/BUILD @@ -1,5 +1,4 @@ -# gazelle:prefix controller -# gazelle:prefix k8s.io/kubernetes +# gazelle:prefix github.com/airyhq/airy/infrastructure/controller load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library") load("@io_bazel_rules_docker//go:image.bzl", "go_image") load("//tools/build:container_push.bzl", "container_push") @@ -11,8 +10,7 @@ go_library( visibility = ["//visibility:private"], deps = [ "//infrastructure/controller/pkg/configmap-controller", - "//infrastructure/lib/go/k8s/handler", - "//infrastructure/lib/go/k8s/util", + "@io_k8s_api//core/v1:core", "@io_k8s_client_go//kubernetes", "@io_k8s_client_go//tools/clientcmd", "@io_k8s_klog//:klog", diff --git a/infrastructure/controller/main.go b/infrastructure/controller/main.go index 5101b7925c..1c49b437cc 100644 --- a/infrastructure/controller/main.go +++ b/infrastructure/controller/main.go @@ -10,8 +10,9 @@ package main import ( "flag" - cm "github.com/airyhq/airy/infrastructure/controller/pkg/configmap-controller" + v1 "k8s.io/api/core/v1" + "os" "k8s.io/klog" @@ -20,28 +21,39 @@ import ( ) func main() { - var kubeconfig string + var kubeConfig string var master string // Check if kubernetes configuration is provided, otherwise use serviceAccount - flag.StringVar(&kubeconfig, "kubeconfig", "", "absolute path to the kubeconfig file") + flag.StringVar(&kubeConfig, "kubeconfig", "", "absolute path to the kubeConfig file") flag.StringVar(&master, "master", "", "master url") flag.Parse() // Create connection - config, err := clientcmd.BuildConfigFromFlags(master, kubeconfig) + config, err := clientcmd.BuildConfigFromFlags(master, kubeConfig) if err != nil { klog.Fatal(err) } - // Create clientset client - clientset, err := kubernetes.NewForConfig(config) + clientSet, err := kubernetes.NewForConfig(config) if err != nil { klog.Fatal(err) } + namespace, present := os.LookupEnv("NAMESPACE") + if present != true { + klog.Infof("Namespace not set. Defaulting to: %s", v1.NamespaceDefault) + namespace = v1.NamespaceDefault + } + + labelSelector := os.Getenv("LABEL_SELECTOR") + // Create configMap controller - configMapController := cm.ConfigMapController(clientset) + configMapController := cm.ConfigMapController(cm.Context{ + ClientSet: clientSet, + Namespace: namespace, + LabelSelector: labelSelector, + }) stop := make(chan struct{}) defer close(stop) go configMapController.Run(1, stop) diff --git a/infrastructure/controller/pkg/configmap-controller/BUILD b/infrastructure/controller/pkg/configmap-controller/BUILD index ef6399fb37..dc329e973e 100644 --- a/infrastructure/controller/pkg/configmap-controller/BUILD +++ b/infrastructure/controller/pkg/configmap-controller/BUILD @@ -1,10 +1,13 @@ -# gazelle:prefix configmap-controller -# gazelle:prefix k8s.io/kubernetes load("@io_bazel_rules_go//go:def.bzl", "go_library") go_library( name = "configmap-controller", - srcs = ["configmap-controller.go"], + srcs = [ + "controller.go", + "create.go", + "delete.go", + "update.go", + ], importpath = "github.com/airyhq/airy/infrastructure/controller/pkg/configmap-controller", visibility = ["//visibility:public"], deps = [ diff --git a/infrastructure/controller/pkg/configmap-controller/configmap-controller.go b/infrastructure/controller/pkg/configmap-controller/configmap-controller.go deleted file mode 100644 index edfdda7b99..0000000000 --- a/infrastructure/controller/pkg/configmap-controller/configmap-controller.go +++ /dev/null @@ -1,149 +0,0 @@ -package configmapController - -import ( - "fmt" - "time" - - "github.com/airyhq/airy/infrastructure/lib/go/k8s/handler" - "github.com/airyhq/airy/infrastructure/lib/go/k8s/util" - - v1 "k8s.io/api/core/v1" - - "k8s.io/apimachinery/pkg/fields" - "k8s.io/apimachinery/pkg/util/runtime" - "k8s.io/apimachinery/pkg/util/wait" - "k8s.io/klog" - - "k8s.io/client-go/kubernetes" - "k8s.io/client-go/tools/cache" - "k8s.io/client-go/util/workqueue" -) - -type Controller struct { - indexer cache.Indexer - queue workqueue.RateLimitingInterface - informer cache.Controller - clientset kubernetes.Interface -} - -func NewController(queue workqueue.RateLimitingInterface, indexer cache.Indexer, informer cache.Controller, clientset kubernetes.Interface) *Controller { - return &Controller{ - informer: informer, - indexer: indexer, - queue: queue, - clientset: clientset, - } -} - -func (c *Controller) processNextItem() bool { - // Wait until there is a new item in the working queue - key, quit := c.queue.Get() - if quit { - return false - } - defer c.queue.Done(key) - // Invoke the method containing the business logic - err := c.Handle(key.(string)) - // Handle the error if something went wrong during the execution of the business logic - c.handleErr(err, key) - return true -} - -// Handle is the business logic of the controller. -func (c *Controller) Handle(key string) error { - obj, exists, err := c.indexer.GetByKey(key) - if err != nil { - klog.Errorf("Fetching object with key %s from store failed with %v", key, err) - return err - } - - if !exists { - fmt.Printf("Object %s does not exist anymore\n", key) - } else { - configmap := handler.GetConfigmapConfig(obj.(*v1.ConfigMap)) - klog.Infof("Handling change in configmap %s\n", configmap.Name) - affectedDeployments, errGetDeployments := handler.GetAffectedDeploymentsConfigmap(c.clientset, configmap.Name, "default", "") - if errGetDeployments != nil { - klog.Errorf("Error retrieving affected deployments %v", errGetDeployments) - } - for _, affectedDeployment := range affectedDeployments { - klog.Infof("Scheduling reload for deployment: %s", affectedDeployment) - handler.ReloadDeployment(c.clientset, "default", affectedDeployment) - - } - } - return nil -} - -// handleErr checks if an error happened and makes sure we will retry later. -func (c *Controller) handleErr(err error, key interface{}) { - if err == nil { - c.queue.Forget(key) - return - } - - // This controller retries 5 times if something goes wrong. After that, it stops trying. - if c.queue.NumRequeues(key) < 5 { - klog.Infof("Error syncing %v: %v", key, err) - c.queue.AddRateLimited(key) - return - } - - c.queue.Forget(key) - // Report to an external entity that, even after several retries, we could not successfully process this key - runtime.HandleError(err) - klog.Infof("Dropping pod %q out of the queue: %v", key, err) -} - -func (c *Controller) Run(threadiness int, stopCh chan struct{}) { - defer runtime.HandleCrash() - - // Let the workers stop when we are done - defer c.queue.ShutDown() - klog.Info("Starting controller") - - go c.informer.Run(stopCh) - - // Wait for all involved caches to be synced, before processing items from the queue is started - if !cache.WaitForCacheSync(stopCh, c.informer.HasSynced) { - runtime.HandleError(fmt.Errorf("Timed out waiting for caches to sync")) - return - } - - for i := 0; i < threadiness; i++ { - go wait.Until(c.runWorker, time.Second, stopCh) - } - - <-stopCh - klog.Info("Stopping controller") -} - -func (c *Controller) runWorker() { - for c.processNextItem() { - } -} - -// ConfigMapController for monitoring the configmaps -func ConfigMapController(clientset kubernetes.Interface) *Controller { - - configMapListWatcher := cache.NewListWatchFromClient(clientset.CoreV1().RESTClient(), "configmaps", v1.NamespaceDefault, fields.Everything()) - queue := workqueue.NewRateLimitingQueue(workqueue.DefaultControllerRateLimiter()) - indexer, informer := cache.NewIndexerInformer(configMapListWatcher, &v1.ConfigMap{}, 0, cache.ResourceEventHandlerFuncs{ - AddFunc: func(obj interface{}) { - // Currently we do nothing when a new configMap is added - klog.Infof("Added configMap: %s , sha: %s", obj.(*v1.ConfigMap).GetName(), util.GetSHAfromConfigmap(obj.(*v1.ConfigMap))) - }, - UpdateFunc: func(old interface{}, new interface{}) { - key, err := cache.MetaNamespaceKeyFunc(new) - if err == nil { - queue.Add(key) - klog.Infof("Updated configMap %s from sha: %s to sha: %s", old.(*v1.ConfigMap).GetName(), util.GetSHAfromConfigmap(old.(*v1.ConfigMap)), util.GetSHAfromConfigmap(new.(*v1.ConfigMap))) - } - }, - DeleteFunc: func(obj interface{}) { - // Currently we do nothing when a new configMap is deleted - klog.Infof("Deleted configMap %s", obj.(*v1.ConfigMap).GetName()) - }, - }, cache.Indexers{}) - return NewController(queue, indexer, informer, clientset) -} diff --git a/infrastructure/controller/pkg/configmap-controller/controller.go b/infrastructure/controller/pkg/configmap-controller/controller.go new file mode 100644 index 0000000000..c30b5a7b06 --- /dev/null +++ b/infrastructure/controller/pkg/configmap-controller/controller.go @@ -0,0 +1,126 @@ +package cmcontroller + +import ( + "fmt" + "time" + + v1 "k8s.io/api/core/v1" + + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/klog" + + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/util/workqueue" +) + +type Controller struct { + indexer cache.Indexer + queue workqueue.RateLimitingInterface + informer cache.Controller + context Context +} + +type Context struct { + ClientSet kubernetes.Interface + Namespace string + LabelSelector string +} + +type ResourceHandler interface { + Handle(context Context) error +} + +func (c *Controller) processNextItem() bool { + // Wait until there is a new item in the working queue + handler, quit := c.queue.Get() + if quit { + return false + } + defer c.queue.Done(handler) + // Invoke the method containing the business logic + err := handler.(ResourceHandler).Handle(c.context) + // Handle the error if something went wrong during the execution of the business logic + c.handleErr(err, handler) + return true +} + +// handleErr checks if an error happened and makes sure we will retry later. +func (c *Controller) handleErr(err error, key interface{}) { + if err == nil { + c.queue.Forget(key) + return + } + + // This controller retries 5 times if something goes wrong. After that, it stops trying. + if c.queue.NumRequeues(key) < 5 { + klog.Infof("Error syncing %v: %v", key, err) + c.queue.AddRateLimited(key) + return + } + + c.queue.Forget(key) + // Report to an external entity that, even after several retries, we could not successfully process this key + runtime.HandleError(err) + klog.Infof("Dropping configmap %q out of the queue: %v", key, err) +} + +func (c *Controller) Run(threadiness int, stopCh chan struct{}) { + defer runtime.HandleCrash() + + // Let the workers stop when we are done + defer c.queue.ShutDown() + klog.Info("Starting controller") + + go c.informer.Run(stopCh) + + // Wait for all involved caches to be synced, before processing items from the queue is started + if !cache.WaitForCacheSync(stopCh, c.informer.HasSynced) { + runtime.HandleError(fmt.Errorf("Timed out waiting for caches to sync")) + return + } + + for i := 0; i < threadiness; i++ { + go wait.Until(c.runWorker, time.Second, stopCh) + } + + <-stopCh + klog.Info("Stopping controller") +} + +func (c *Controller) runWorker() { + for c.processNextItem() { + } +} + +func ConfigMapController(context Context) *Controller { + configMapListWatcher := cache.NewListWatchFromClient(context.ClientSet.CoreV1().RESTClient(), "configmaps", context.Namespace, fields.Everything()) + queue := workqueue.NewRateLimitingQueue(workqueue.DefaultControllerRateLimiter()) + indexer, informer := cache.NewIndexerInformer(configMapListWatcher, &v1.ConfigMap{}, 0, cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + queue.Add(&ResourceCreatedHandler{ + ConfigMap: obj.(*v1.ConfigMap), + }) + }, + UpdateFunc: func(old interface{}, new interface{}) { + queue.Add(&ResourceUpdatedHandler{ + ConfigMap: new.(*v1.ConfigMap), + OldConfigMap: old.(*v1.ConfigMap), + }) + }, + DeleteFunc: func(obj interface{}) { + queue.Add(&ResourceDeleteHandler{ + ConfigMap: obj.(*v1.ConfigMap), + }) + }, + }, cache.Indexers{}) + + return &Controller{ + informer: informer, + indexer: indexer, + queue: queue, + context: context, + } +} diff --git a/infrastructure/controller/pkg/configmap-controller/create.go b/infrastructure/controller/pkg/configmap-controller/create.go new file mode 100644 index 0000000000..9ea049ae3e --- /dev/null +++ b/infrastructure/controller/pkg/configmap-controller/create.go @@ -0,0 +1,42 @@ +package cmcontroller + +import ( + "github.com/airyhq/airy/infrastructure/lib/go/k8s/handler" + "github.com/airyhq/airy/infrastructure/lib/go/k8s/util" + v1 "k8s.io/api/core/v1" + "k8s.io/klog" +) + +type ResourceCreatedHandler struct { + ConfigMap *v1.ConfigMap +} + +func (r ResourceCreatedHandler) Handle(ctx Context) error { + klog.Infof("Added configMap: %s , sha: %s", r.ConfigMap.GetName(), util.GetSHAfromConfigmap(r.ConfigMap)) + deployments, errGetDeployments := handler.GetDeploymentsReferencingCm(ctx.ClientSet, + r.ConfigMap.Name, ctx.Namespace, ctx.LabelSelector) + if errGetDeployments != nil { + klog.Errorf("Error retrieving affected deployments %v", errGetDeployments) + return errGetDeployments + } + + for _, deployment := range deployments { + if !handler.CanBeStarted(deployment, ctx.ClientSet) { + klog.Infof("Skipping deployment %s because it is missing config maps", deployment.Name) + continue + } + + klog.Infof("Scheduling start for deployment: %s", deployment.Name) + if err := handler.ScaleDeployment(handler.ScaleCommand{ + ClientSet: ctx.ClientSet, + Namespace: ctx.Namespace, + DeploymentName: deployment.Name, + DesiredReplicas: 1, //TODO extract from annotation + }); err != nil { + klog.Errorf("Starting deployment failed: %v", err) + return err + } + klog.Infof("Started deployment: %s", deployment.Name) + } + return nil +} diff --git a/infrastructure/controller/pkg/configmap-controller/delete.go b/infrastructure/controller/pkg/configmap-controller/delete.go new file mode 100644 index 0000000000..0453d6428f --- /dev/null +++ b/infrastructure/controller/pkg/configmap-controller/delete.go @@ -0,0 +1,37 @@ +package cmcontroller + +import ( + "github.com/airyhq/airy/infrastructure/lib/go/k8s/handler" + "github.com/airyhq/airy/infrastructure/lib/go/k8s/util" + v1 "k8s.io/api/core/v1" + "k8s.io/klog" +) + +type ResourceDeleteHandler struct { + ConfigMap *v1.ConfigMap +} + +func (r ResourceDeleteHandler) Handle(ctx Context) error { + klog.Infof("Deleted configMap: %s , sha: %s", r.ConfigMap.GetName(), util.GetSHAfromConfigmap(r.ConfigMap)) + deployments, errGetDeployments := handler.GetDeploymentsReferencingCm(ctx.ClientSet, + r.ConfigMap.Name, ctx.Namespace, ctx.LabelSelector) + if errGetDeployments != nil { + klog.Errorf("Error retrieving affected deployments %v", errGetDeployments) + return errGetDeployments + } + + for _, deployment := range deployments { + klog.Infof("Scheduling stopping for deployment: %s", deployment.Name) + if err := handler.ScaleDeployment(handler.ScaleCommand{ + ClientSet: ctx.ClientSet, + Namespace: ctx.Namespace, + DeploymentName: deployment.Name, + DesiredReplicas: 0, + }); err != nil { + klog.Errorf("Stopping deployment failed: %v", err) + return err + } + klog.Infof("Stopped deployment: %s", deployment.Name) + } + return nil +} diff --git a/infrastructure/controller/pkg/configmap-controller/update.go b/infrastructure/controller/pkg/configmap-controller/update.go new file mode 100644 index 0000000000..7a0f07b726 --- /dev/null +++ b/infrastructure/controller/pkg/configmap-controller/update.go @@ -0,0 +1,33 @@ +package cmcontroller + +import ( + "github.com/airyhq/airy/infrastructure/lib/go/k8s/handler" + "github.com/airyhq/airy/infrastructure/lib/go/k8s/util" + v1 "k8s.io/api/core/v1" + "k8s.io/klog" +) + +type ResourceUpdatedHandler struct { + ConfigMap *v1.ConfigMap + OldConfigMap *v1.ConfigMap +} + +func (r ResourceUpdatedHandler) Handle(ctx Context) error { + klog.Infof("Updated configMap %s from sha: %s to sha: %s", + r.ConfigMap.GetName(), util.GetSHAfromConfigmap(r.OldConfigMap), util.GetSHAfromConfigmap(r.ConfigMap)) + deployments, errGetDeployments := handler.GetDeploymentsReferencingCm(ctx.ClientSet, + r.ConfigMap.Name, ctx.Namespace, ctx.LabelSelector) + if errGetDeployments != nil { + klog.Errorf("Error retrieving affected deployments %v", errGetDeployments) + } + + for _, deployment := range deployments { + klog.Infof("Scheduling reload for deployment: %s", deployment.Name) + if err := handler.ReloadDeployment(deployment, ctx.ClientSet); err != nil { + klog.Errorf("Reloading deployment failed: %v", err) + return err + } + klog.Infof("Reloaded deployment: %s", deployment.Name) + } + return nil +} diff --git a/infrastructure/helm-chart/charts/apps/charts/airy-config/templates/api.yaml b/infrastructure/helm-chart/charts/apps/charts/airy-config/templates/api.yaml index d6034a0064..72473ace16 100644 --- a/infrastructure/helm-chart/charts/apps/charts/airy-config/templates/api.yaml +++ b/infrastructure/helm-chart/charts/apps/charts/airy-config/templates/api.yaml @@ -11,4 +11,3 @@ data: MAIL_PASSWORD: {{ .Values.api.mailPassword }} JWT_SECRET: {{ randAlphaNum 128 | quote }} ALLOWED_ORIGINS: {{ .Values.api.allowedOrigins | quote }} - \ No newline at end of file diff --git a/infrastructure/helm-chart/charts/apps/charts/airy-config/templates/sources.yaml b/infrastructure/helm-chart/charts/apps/charts/airy-config/templates/sources.yaml index b59397d601..382f90b348 100644 --- a/infrastructure/helm-chart/charts/apps/charts/airy-config/templates/sources.yaml +++ b/infrastructure/helm-chart/charts/apps/charts/airy-config/templates/sources.yaml @@ -1,32 +1,3 @@ -{{ $coreID := randAlphaNum 10 | lower }} -apiVersion: v1 -kind: ConfigMap -metadata: - name: sources-facebook - namespace: {{ .Values.global.namespace }} -data: - FACEBOOK_APP_ID: {{ .Values.sources.facebook.appId | quote }} - FACEBOOK_APP_SECRET: {{ .Values.sources.facebook.appSecret | quote }} - FACEBOOK_WEBHOOK_SECRET: {{ .Values.sources.facebook.webhookSecret | quote }} ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: sources-google - namespace: {{ .Values.global.namespace }} -data: - GOOGLE_PARTNER_KEY: {{ .Values.sources.google.partnerKey }} - GOOGLE_SA_FILE: {{ .Values.sources.google.saFile }} ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: sources-twilio - namespace: {{ .Values.global.namespace }} -data: - TWILIO_AUTH_TOKEN: {{ .Values.sources.twilio.authToken }} - TWILIO_ACCOUNT_SID: {{ .Values.sources.twilio.accountSid }} ---- apiVersion: v1 kind: ConfigMap metadata: @@ -44,4 +15,5 @@ metadata: namespace: {{ .Values.global.namespace }} data: APP_IMAGE_TAG: {{ .Values.global.appImageTag }} - CORE_ID: {{ $coreID }} + CORE_ID: {{ randAlphaNum 10 | lower }} + CHATPLUGIN_JWT_SECRET: {{ randAlphaNum 128 | quote }} \ No newline at end of file diff --git a/infrastructure/helm-chart/charts/apps/charts/airy-config/templates/user-storage.yaml b/infrastructure/helm-chart/charts/apps/charts/airy-config/templates/user-storage.yaml new file mode 100644 index 0000000000..e1197a5d02 --- /dev/null +++ b/infrastructure/helm-chart/charts/apps/charts/airy-config/templates/user-storage.yaml @@ -0,0 +1,14 @@ +{{ if .Values.storage }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: user-storage +data: + {{- if .Values.storage.s3 -}} + STORAGE_S3_KEY: {{ .Values.storage.s3.key }} + STORAGE_S3_SECRET: "{{ .Values.storage.s3.secret }}" + STORAGE_S3_BUCKET: {{ .Values.storage.s3.bucket }} + STORAGE_S3_REGION: {{ .Values.storage.s3.region }} + STORAGE_S3_PATH: {{ .Values.storage.s3.path }} + {{- end -}} +{{ end }} diff --git a/infrastructure/helm-chart/charts/apps/charts/api-admin/templates/deployment.yaml b/infrastructure/helm-chart/charts/apps/charts/api-admin/templates/deployment.yaml index c85d710620..b1c26779d8 100644 --- a/infrastructure/helm-chart/charts/apps/charts/api-admin/templates/deployment.yaml +++ b/infrastructure/helm-chart/charts/apps/charts/api-admin/templates/deployment.yaml @@ -26,6 +26,10 @@ spec: image: "{{ .Values.global.containerRegistry}}/{{ .Values.image }}:{{ .Values.global.appImageTag }}" imagePullPolicy: Always env: + - name: KUBERNETES_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace - name: KAFKA_BROKERS valueFrom: configMapKeyRef: diff --git a/infrastructure/helm-chart/charts/apps/charts/frontend-chat-plugin/templates/deployment.yaml b/infrastructure/helm-chart/charts/apps/charts/frontend-chat-plugin/templates/deployment.yaml index 50a59433ff..2416ffe035 100644 --- a/infrastructure/helm-chart/charts/apps/charts/frontend-chat-plugin/templates/deployment.yaml +++ b/infrastructure/helm-chart/charts/apps/charts/frontend-chat-plugin/templates/deployment.yaml @@ -7,7 +7,7 @@ metadata: app: frontend-chat-plugin type: frontend spec: - replicas: 0 + replicas: 1 selector: matchLabels: app: frontend-chat-plugin @@ -25,6 +25,12 @@ spec: - name: app image: "{{ .Values.global.containerRegistry}}/{{ .Values.image }}:{{ .Values.global.appImageTag }}" imagePullPolicy: Always + env: + - name: API_HOST + valueFrom: + configMapKeyRef: + name: hostnames + key: CHATPLUGIN_HOST livenessProbe: httpGet: path: /health diff --git a/infrastructure/helm-chart/charts/apps/charts/frontend-demo/templates/deployment.yaml b/infrastructure/helm-chart/charts/apps/charts/frontend-demo/templates/deployment.yaml index bec1b7c43a..4ccf3f6022 100644 --- a/infrastructure/helm-chart/charts/apps/charts/frontend-demo/templates/deployment.yaml +++ b/infrastructure/helm-chart/charts/apps/charts/frontend-demo/templates/deployment.yaml @@ -7,7 +7,7 @@ metadata: app: frontend-demo type: frontend spec: - replicas: 0 + replicas: 1 selector: matchLabels: app: frontend-demo @@ -25,6 +25,12 @@ spec: - name: app image: "{{ .Values.global.containerRegistry}}/{{ .Values.image }}:{{ .Values.global.appImageTag }}" imagePullPolicy: Always + env: + - name: API_HOST + valueFrom: + configMapKeyRef: + name: hostnames + key: API_HOST livenessProbe: httpGet: path: /health diff --git a/infrastructure/helm-chart/charts/apps/charts/media-resolver/Chart.yaml b/infrastructure/helm-chart/charts/apps/charts/media-resolver/Chart.yaml new file mode 100644 index 0000000000..8e8e7a0e37 --- /dev/null +++ b/infrastructure/helm-chart/charts/apps/charts/media-resolver/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +appVersion: "1.0" +description: A Helm chart for the Media Resolver app +name: media-resolver +version: 0.1.0 diff --git a/infrastructure/helm-chart/charts/apps/charts/media-resolver/templates/deployment.yaml b/infrastructure/helm-chart/charts/apps/charts/media-resolver/templates/deployment.yaml new file mode 100644 index 0000000000..2379d5f75b --- /dev/null +++ b/infrastructure/helm-chart/charts/apps/charts/media-resolver/templates/deployment.yaml @@ -0,0 +1,58 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: media-resolver + namespace: default + labels: + app: media-resolver + type: media +spec: + replicas: 0 + selector: + matchLabels: + app: media-resolver + strategy: + rollingUpdate: + maxSurge: 1 + maxUnavailable: 1 + type: RollingUpdate + template: + metadata: + labels: + app: media-resolver + spec: + containers: + - name: app + image: "{{ .Values.global.containerRegistry}}/{{ .Values.image }}:{{ .Values.global.appImageTag }}" + imagePullPolicy: Always + envFrom: + - configMapRef: + name: user-storage + env: + - name: KAFKA_BROKERS + valueFrom: + configMapKeyRef: + name: kafka-config + key: KAFKA_BROKERS + - name: KAFKA_SCHEMA_REGISTRY_URL + valueFrom: + configMapKeyRef: + name: kafka-config + key: KAFKA_SCHEMA_REGISTRY_URL + - name: KAFKA_COMMIT_INTERVAL_MS + valueFrom: + configMapKeyRef: + name: kafka-config + key: KAFKA_COMMIT_INTERVAL_MS + - name: SERVICE_NAME + value: media-resolver + livenessProbe: + httpGet: + path: /actuator/health + port: 8080 + httpHeaders: + - name: Health-Check + value: health-check + initialDelaySeconds: 60 + periodSeconds: 10 + failureThreshold: 3 diff --git a/infrastructure/helm-chart/charts/apps/charts/media-resolver/templates/service.yaml b/infrastructure/helm-chart/charts/apps/charts/media-resolver/templates/service.yaml new file mode 100644 index 0000000000..8cd982a016 --- /dev/null +++ b/infrastructure/helm-chart/charts/apps/charts/media-resolver/templates/service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: media-resolver + namespace: default +spec: + ports: + - port: 80 + targetPort: 8080 + protocol: TCP + type: NodePort + selector: + app: media-resolver diff --git a/infrastructure/helm-chart/charts/apps/charts/media-resolver/values.yaml b/infrastructure/helm-chart/charts/apps/charts/media-resolver/values.yaml new file mode 100644 index 0000000000..6a2b9ac149 --- /dev/null +++ b/infrastructure/helm-chart/charts/apps/charts/media-resolver/values.yaml @@ -0,0 +1 @@ +image: media/resolver diff --git a/infrastructure/helm-chart/charts/apps/charts/sources-chatplugin/templates/deployment.yaml b/infrastructure/helm-chart/charts/apps/charts/sources-chatplugin/templates/deployment.yaml index e0aab7b08e..7364879de5 100644 --- a/infrastructure/helm-chart/charts/apps/charts/sources-chatplugin/templates/deployment.yaml +++ b/infrastructure/helm-chart/charts/apps/charts/sources-chatplugin/templates/deployment.yaml @@ -6,8 +6,9 @@ metadata: labels: app: sources-chatplugin type: sources-chatplugin + core.airy.co/managed: "true" spec: - replicas: 0 + replicas: 1 selector: matchLabels: app: sources-chatplugin @@ -34,8 +35,8 @@ spec: - name: JWT_SECRET valueFrom: configMapKeyRef: - name: api-config - key: JWT_SECRET + name: core-config + key: CHATPLUGIN_JWT_SECRET - name: KAFKA_BROKERS valueFrom: configMapKeyRef: diff --git a/infrastructure/helm-chart/charts/apps/charts/sources-facebook-connector/templates/deployment.yaml b/infrastructure/helm-chart/charts/apps/charts/sources-facebook-connector/templates/deployment.yaml index 57755fd259..7d551079b0 100644 --- a/infrastructure/helm-chart/charts/apps/charts/sources-facebook-connector/templates/deployment.yaml +++ b/infrastructure/helm-chart/charts/apps/charts/sources-facebook-connector/templates/deployment.yaml @@ -6,6 +6,7 @@ metadata: labels: app: sources-facebook-connector type: sources-facebook + core.airy.co/managed: "true" spec: replicas: 0 selector: @@ -64,11 +65,13 @@ spec: name: api-config key: JWT_SECRET livenessProbe: - tcpSocket: - port: 6000 + httpGet: + path: /actuator/health + port: 8080 + httpHeaders: + - name: Health-Check + value: health-check initialDelaySeconds: 60 - periodSeconds: 10 - failureThreshold: 3 - name: ngrok command: - /bin/bash diff --git a/infrastructure/helm-chart/charts/apps/charts/sources-facebook-events-router/templates/deployment.yaml b/infrastructure/helm-chart/charts/apps/charts/sources-facebook-events-router/templates/deployment.yaml index 4e7356cdef..4430f199ed 100644 --- a/infrastructure/helm-chart/charts/apps/charts/sources-facebook-events-router/templates/deployment.yaml +++ b/infrastructure/helm-chart/charts/apps/charts/sources-facebook-events-router/templates/deployment.yaml @@ -6,6 +6,7 @@ metadata: labels: app: sources-facebook-events-router type: sources-facebook + core.airy.co/managed: "true" spec: replicas: 0 selector: diff --git a/infrastructure/helm-chart/charts/apps/charts/sources-google-connector/templates/deployment.yaml b/infrastructure/helm-chart/charts/apps/charts/sources-google-connector/templates/deployment.yaml index 9681372125..c92caaee2a 100644 --- a/infrastructure/helm-chart/charts/apps/charts/sources-google-connector/templates/deployment.yaml +++ b/infrastructure/helm-chart/charts/apps/charts/sources-google-connector/templates/deployment.yaml @@ -6,6 +6,7 @@ metadata: labels: app: sources-google-connector type: sources-google + core.airy.co/managed: "true" spec: replicas: 0 selector: @@ -57,11 +58,13 @@ spec: name: api-config key: JWT_SECRET livenessProbe: - tcpSocket: - port: 6000 + httpGet: + path: /actuator/health + port: 8080 + httpHeaders: + - name: Health-Check + value: health-check initialDelaySeconds: 60 - periodSeconds: 10 - failureThreshold: 3 - name: ngrok command: - /bin/bash diff --git a/infrastructure/helm-chart/charts/apps/charts/sources-google-events-router/templates/deployment.yaml b/infrastructure/helm-chart/charts/apps/charts/sources-google-events-router/templates/deployment.yaml index bf366ca3c3..94360c1d7d 100644 --- a/infrastructure/helm-chart/charts/apps/charts/sources-google-events-router/templates/deployment.yaml +++ b/infrastructure/helm-chart/charts/apps/charts/sources-google-events-router/templates/deployment.yaml @@ -6,6 +6,7 @@ metadata: labels: app: sources-google-events-router type: sources-google + core.airy.co/managed: "true" spec: replicas: 0 selector: @@ -41,6 +42,16 @@ spec: configMapKeyRef: name: kafka-config key: KAFKA_COMMIT_INTERVAL_MS + - name: GOOGLE_SA_FILE + valueFrom: + configMapKeyRef: + name: sources-google + key: GOOGLE_SA_FILE + - name: GOOGLE_PARTNER_KEY + valueFrom: + configMapKeyRef: + name: sources-google + key: GOOGLE_PARTNER_KEY livenessProbe: tcpSocket: port: 6000 diff --git a/infrastructure/helm-chart/charts/apps/charts/sources-twilio-connector/templates/deployment.yaml b/infrastructure/helm-chart/charts/apps/charts/sources-twilio-connector/templates/deployment.yaml index 807a4fa113..dfddbfa752 100644 --- a/infrastructure/helm-chart/charts/apps/charts/sources-twilio-connector/templates/deployment.yaml +++ b/infrastructure/helm-chart/charts/apps/charts/sources-twilio-connector/templates/deployment.yaml @@ -6,6 +6,7 @@ metadata: labels: app: sources-twilio-connector type: sources-twilio + core.airy.co/managed: "true" spec: replicas: 0 selector: @@ -57,11 +58,13 @@ spec: name: api-config key: JWT_SECRET livenessProbe: - tcpSocket: - port: 6000 + httpGet: + path: /actuator/health + port: 8080 + httpHeaders: + - name: Health-Check + value: health-check initialDelaySeconds: 60 - periodSeconds: 10 - failureThreshold: 3 - name: ngrok command: - /bin/bash diff --git a/infrastructure/helm-chart/charts/apps/charts/sources-twilio-events-router/templates/deployment.yaml b/infrastructure/helm-chart/charts/apps/charts/sources-twilio-events-router/templates/deployment.yaml index ad099f29c4..0e4f3be848 100644 --- a/infrastructure/helm-chart/charts/apps/charts/sources-twilio-events-router/templates/deployment.yaml +++ b/infrastructure/helm-chart/charts/apps/charts/sources-twilio-events-router/templates/deployment.yaml @@ -6,6 +6,7 @@ metadata: labels: app: sources-twilio-events-router type: sources-twilio + core.airy.co/managed: "true" spec: replicas: 0 selector: @@ -41,6 +42,16 @@ spec: configMapKeyRef: name: kafka-config key: KAFKA_COMMIT_INTERVAL_MS + - name: TWILIO_AUTH_TOKEN + valueFrom: + configMapKeyRef: + name: sources-twilio + key: TWILIO_AUTH_TOKEN + - name: TWILIO_ACCOUNT_SID + valueFrom: + configMapKeyRef: + name: sources-twilio + key: TWILIO_ACCOUNT_SID livenessProbe: tcpSocket: port: 6000 diff --git a/infrastructure/helm-chart/charts/apps/charts/webhook-consumer/templates/deployment.yaml b/infrastructure/helm-chart/charts/apps/charts/webhook-consumer/templates/deployment.yaml index 53a468057b..ccc6ddc098 100644 --- a/infrastructure/helm-chart/charts/apps/charts/webhook-consumer/templates/deployment.yaml +++ b/infrastructure/helm-chart/charts/apps/charts/webhook-consumer/templates/deployment.yaml @@ -6,6 +6,7 @@ metadata: labels: app: webhook-consumer type: webhook + core.airy.co/managed: "true" spec: replicas: 0 selector: @@ -53,6 +54,11 @@ spec: configMapKeyRef: name: redis-config key: REDIS_PORT + - name: WEBHOOK_NAME + valueFrom: + configMapKeyRef: + name: webhooks-config + key: NAME livenessProbe: httpGet: path: /health diff --git a/infrastructure/helm-chart/charts/apps/charts/webhook-publisher/templates/deployment.yaml b/infrastructure/helm-chart/charts/apps/charts/webhook-publisher/templates/deployment.yaml index d19f5e3069..f32870f51e 100644 --- a/infrastructure/helm-chart/charts/apps/charts/webhook-publisher/templates/deployment.yaml +++ b/infrastructure/helm-chart/charts/apps/charts/webhook-publisher/templates/deployment.yaml @@ -6,6 +6,7 @@ metadata: labels: app: webhook-publisher type: webhook + core.airy.co/managed: "true" spec: replicas: 0 selector: @@ -53,6 +54,11 @@ spec: configMapKeyRef: name: redis-config key: REDIS_PORT + - name: WEBHOOK_NAME + valueFrom: + configMapKeyRef: + name: webhooks-config + key: NAME livenessProbe: tcpSocket: port: 6000 diff --git a/infrastructure/helm-chart/charts/controller/templates/deployment.yaml b/infrastructure/helm-chart/charts/controller/templates/deployment.yaml index cbceda4924..1b7d39f419 100644 --- a/infrastructure/helm-chart/charts/controller/templates/deployment.yaml +++ b/infrastructure/helm-chart/charts/controller/templates/deployment.yaml @@ -26,3 +26,6 @@ spec: - name: app image: "{{ .Values.global.containerRegistry}}/{{ .Values.image }}:{{ .Values.global.appImageTag }}" imagePullPolicy: Always + env: + - name: LABEL_SELECTOR + value: "core.airy.co/managed=true" diff --git a/infrastructure/helm-chart/charts/ingress/Chart.yaml b/infrastructure/helm-chart/charts/ingress/Chart.yaml new file mode 100644 index 0000000000..01e96eb5fc --- /dev/null +++ b/infrastructure/helm-chart/charts/ingress/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +appVersion: "1.0" +description: A Helm chart for the Traefik ingress controller +name: ingress +version: 0.1.0 diff --git a/infrastructure/helm-chart/charts/ingress/templates/configmap.yaml b/infrastructure/helm-chart/charts/ingress/templates/configmap.yaml new file mode 100644 index 0000000000..297ad1c5b9 --- /dev/null +++ b/infrastructure/helm-chart/charts/ingress/templates/configmap.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: hostnames + namespace: {{ .Values.global.namespace }} +data: + API_HOST: {{ .Values.apiHost }} + UI_HOST: {{ .Values.uiHost }} + CHATPLUGIN_HOST: {{ .Values.chatpluginHost }} diff --git a/infrastructure/network/ingress.yaml b/infrastructure/helm-chart/charts/ingress/templates/ingress.yaml similarity index 95% rename from infrastructure/network/ingress.yaml rename to infrastructure/helm-chart/charts/ingress/templates/ingress.yaml index bce0ce57c2..d3b25ac926 100644 --- a/infrastructure/network/ingress.yaml +++ b/infrastructure/helm-chart/charts/ingress/templates/ingress.yaml @@ -2,9 +2,10 @@ kind: Ingress apiVersion: networking.k8s.io/v1 metadata: name: 'airy-core' + namespace: {{ .Values.global.namespace }} spec: rules: - - host: 'api.airy' + - host: {{ .Values.apiHost }} http: paths: - path: /users.login @@ -77,6 +78,13 @@ spec: name: api-communication port: number: 80 + - path: /client.config + pathType: Prefix + backend: + service: + name: api-admin + port: + number: 80 - path: /channels.list pathType: Prefix backend: @@ -210,7 +218,7 @@ spec: name: sources-twilio-connector port: number: 80 - - host: 'demo.airy' + - host: {{ .Values.uiHost }} http: paths: - path: / @@ -220,7 +228,7 @@ spec: name: frontend-demo port: number: 80 - - host: 'chatplugin.airy' + - host: {{ .Values.chatpluginHost }} http: paths: - path: /ws.chatplugin diff --git a/infrastructure/helm-chart/charts/ingress/values.yaml b/infrastructure/helm-chart/charts/ingress/values.yaml new file mode 100644 index 0000000000..7dce025b09 --- /dev/null +++ b/infrastructure/helm-chart/charts/ingress/values.yaml @@ -0,0 +1,4 @@ + +apiHost: "api.airy" +uiHost: "demo.airy" +chatpluginHost: "chatplugin.airy" diff --git a/infrastructure/helm-chart/charts/kafka/charts/kafka/values.yaml b/infrastructure/helm-chart/charts/kafka/charts/kafka/values.yaml index 9cd848c8af..5a0fc937ab 100644 --- a/infrastructure/helm-chart/charts/kafka/charts/kafka/values.yaml +++ b/infrastructure/helm-chart/charts/kafka/charts/kafka/values.yaml @@ -1,6 +1,6 @@ brokers: 1 image: ghcr.io/airyhq/infrastructure/kafka -imageTag: release +imageTag: 2.5.1 imagePullPolicy: IfNotPresent imagePullSecrets: podManagementPolicy: OrderedReady diff --git a/infrastructure/helm-chart/charts/kafka/charts/schema-registry/templates/deployment.yaml b/infrastructure/helm-chart/charts/kafka/charts/schema-registry/templates/deployment.yaml index c4763f40a5..a8dd878e4f 100644 --- a/infrastructure/helm-chart/charts/kafka/charts/schema-registry/templates/deployment.yaml +++ b/infrastructure/helm-chart/charts/kafka/charts/schema-registry/templates/deployment.yaml @@ -24,7 +24,7 @@ spec: spec: containers: - name: schema-registry-server - image: "{{ .Values.global.containerRegistry}}/{{ .Values.image }}:{{ .Values.global.appImageTag }}" + image: "{{ .Values.global.containerRegistry}}/{{ .Values.image }}:{{ .Values.imageTag }}" imagePullPolicy: "{{ .Values.imagePullPolicy }}" ports: - name: schema-registry diff --git a/infrastructure/helm-chart/charts/kafka/charts/schema-registry/values.yaml b/infrastructure/helm-chart/charts/kafka/charts/schema-registry/values.yaml index 1ed01b5c09..b76d2b791e 100644 --- a/infrastructure/helm-chart/charts/kafka/charts/schema-registry/values.yaml +++ b/infrastructure/helm-chart/charts/kafka/charts/schema-registry/values.yaml @@ -1,5 +1,6 @@ replicaCount: 0 image: infrastructure/schema-registry +imageTag: 2.0.1 imagePullPolicy: Always servicePort: 8081 kafka: diff --git a/infrastructure/helm-chart/charts/kafka/charts/zookeeper/values.yaml b/infrastructure/helm-chart/charts/kafka/charts/zookeeper/values.yaml index 97a2ff75fc..e93c0527a1 100644 --- a/infrastructure/helm-chart/charts/kafka/charts/zookeeper/values.yaml +++ b/infrastructure/helm-chart/charts/kafka/charts/zookeeper/values.yaml @@ -1,6 +1,6 @@ servers: 1 image: ghcr.io/airyhq/infrastructure/kafka -imageTag: release +imageTag: 2.5.1 imagePullPolicy: IfNotPresent imagePullSecrets: podManagementPolicy: OrderedReady diff --git a/infrastructure/images/kafka/Makefile b/infrastructure/images/kafka/Makefile index 716c33ca55..10d19433a8 100644 --- a/infrastructure/images/kafka/Makefile +++ b/infrastructure/images/kafka/Makefile @@ -1,10 +1,6 @@ build: docker build -t airy-kafka . -release-beta: build - docker tag airy-kafka ghcr.io/airyhq/infrastructure/kafka:beta - docker push ghcr.io/airyhq/infrastructure/kafka:beta - release: build - docker tag airy-kafka ghcr.io/airyhq/infrastructure/kafka:release - docker push ghcr.io/airyhq/infrastructure/kafka:release + docker tag airy-kafka ghcr.io/airyhq/infrastructure/kafka:2.5.1 + docker push ghcr.io/airyhq/infrastructure/kafka:2.5.1 diff --git a/infrastructure/images/nginx/Dockerfile b/infrastructure/images/nginx/Dockerfile new file mode 100644 index 0000000000..c3870d3e11 --- /dev/null +++ b/infrastructure/images/nginx/Dockerfile @@ -0,0 +1,5 @@ +FROM fabiocicerchia/nginx-lua:1.19.6-alpine3.13.0 + +RUN apk add --no-cache lua5.1-dev luarocks5.1 +RUN luarocks-5.1 install lua-resty-template +RUN apk del lua5.1-dev luarocks5.1 diff --git a/infrastructure/images/nginx/Makefile b/infrastructure/images/nginx/Makefile new file mode 100644 index 0000000000..91419b5941 --- /dev/null +++ b/infrastructure/images/nginx/Makefile @@ -0,0 +1,6 @@ +build: + docker build -t nginx-lua . + +release: build + docker tag nginx-lua ghcr.io/airyhq/frontend/nginx-lua:1.0.0 + docker push ghcr.io/airyhq/frontend/nginx-lua:1.0.0 diff --git a/infrastructure/images/schema-registry/Makefile b/infrastructure/images/schema-registry/Makefile index d1aa464411..529ac7e112 100644 --- a/infrastructure/images/schema-registry/Makefile +++ b/infrastructure/images/schema-registry/Makefile @@ -1,10 +1,7 @@ build: docker build -t schema-registry . --build-arg SCHEMA_REGISTRY_VERSION=${SCHEMA_REGISTRY_VERSION} -release-beta: build - docker tag schema-registry ghcr.io/airyhq/infrastructure/schema-registry:beta - docker push ghcr.io/airyhq/infrastructure/schema-registry:beta - release: build - docker tag schema-registry ghcr.io/airyhq/infrastructure/schema-registry:release - docker push ghcr.io/airyhq/infrastructure/schema-registry:release + docker tag schema-registry ghcr.io/airyhq/infrastructure/schema-registry:2.0.1 + docker push ghcr.io/airyhq/infrastructure/schema-registry:2.0.1 + diff --git a/infrastructure/images/tasks/pull-images.sh b/infrastructure/images/tasks/pull-images.sh deleted file mode 100755 index a0bb68b5ab..0000000000 --- a/infrastructure/images/tasks/pull-images.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash -set -euo pipefail -IFS=$'\n\t' - -docker pull confluentinc/cp-kafka:5.5.0 -docker pull confluentinc/cp-zookeeper:5.5.0 -docker pull postgres:12.4-alpine -docker pull redis:5.0.1-alpine diff --git a/infrastructure/images/vagrant.json b/infrastructure/images/vagrant.json index da2cdf66b2..17f54e1fb4 100644 --- a/infrastructure/images/vagrant.json +++ b/infrastructure/images/vagrant.json @@ -16,12 +16,6 @@ } ], "provisioners": [ - { - "scripts": [ - "tasks/pull-images.sh" - ], - "type": "shell" - }, { "scripts": [ "../scripts/provision.sh" @@ -30,7 +24,7 @@ }, { "scripts": [ - "../scripts/airy-conf.sh" + "../scripts/airy.yaml.sh" ], "type": "shell" } diff --git a/infrastructure/lib/go/k8s/handler/BUILD b/infrastructure/lib/go/k8s/handler/BUILD index 14f813ff08..ba971cf2d2 100644 --- a/infrastructure/lib/go/k8s/handler/BUILD +++ b/infrastructure/lib/go/k8s/handler/BUILD @@ -1,5 +1,4 @@ -# gazelle:prefix handler -# gazelle:prefix k8s.io/kubernetes +# gazelle:prefix github.com/airyhq/airy/infrastructure/lib/go/k8s/handler load("@io_bazel_rules_go//go:def.bzl", "go_library") diff --git a/infrastructure/lib/go/k8s/handler/configmaps.go b/infrastructure/lib/go/k8s/handler/configmaps.go index a58f7d0273..4e47c519d3 100644 --- a/infrastructure/lib/go/k8s/handler/configmaps.go +++ b/infrastructure/lib/go/k8s/handler/configmaps.go @@ -1,17 +1,17 @@ package handler import ( - "github.com/airyhq/airy/infrastructure/lib/go/k8s/util" - + "context" v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" ) -// GetConfigmapConfig provides utility config for configmap -func GetConfigmapConfig(configmap *v1.ConfigMap) Config { - return Config{ - Namespace: configmap.Namespace, - Name: configmap.Name, - SHAValue: util.GetSHAfromConfigmap(configmap), - Type: "CONFIGMAP", - } +func ConfigMapExists(name string, clientSet kubernetes.Interface, namespace string) bool { + configMap, err := GetConfigMap(name, clientSet, namespace) + return configMap != nil && err == nil +} + +func GetConfigMap(name string, clientSet kubernetes.Interface, namespace string) (*v1.ConfigMap, error) { + return clientSet.CoreV1().ConfigMaps(namespace).Get(context.TODO(), name, metav1.GetOptions{}) } diff --git a/infrastructure/lib/go/k8s/handler/containers.go b/infrastructure/lib/go/k8s/handler/containers.go index 6e22022ff7..662a0656b9 100644 --- a/infrastructure/lib/go/k8s/handler/containers.go +++ b/infrastructure/lib/go/k8s/handler/containers.go @@ -74,3 +74,26 @@ func getContainerWithEnvReference(containers []v1.Container, resourceName string } return nil } + +// Does not return config maps whose only use is marked "optional" +func GetReferencedConfigMaps(container v1.Container) []string { + envs := container.Env + var configMaps []string + for j := range envs { + envVarSource := envs[j].ValueFrom + if envVarSource != nil && envVarSource.ConfigMapKeyRef != nil && + (envVarSource.ConfigMapKeyRef.Optional == nil || *envVarSource.ConfigMapKeyRef.Optional != true) { + configMaps = append(configMaps, envVarSource.ConfigMapKeyRef.LocalObjectReference.Name) + } + } + + envsFrom := container.EnvFrom + for j := range envsFrom { + if envsFrom[j].ConfigMapRef != nil && + (envsFrom[j].ConfigMapRef.Optional == nil || *envsFrom[j].ConfigMapRef.Optional != true) { + configMaps = append(configMaps, envsFrom[j].ConfigMapRef.LocalObjectReference.Name) + } + } + + return configMaps +} diff --git a/infrastructure/lib/go/k8s/handler/deployments.go b/infrastructure/lib/go/k8s/handler/deployments.go index ad86ebab9b..2790e29a52 100644 --- a/infrastructure/lib/go/k8s/handler/deployments.go +++ b/infrastructure/lib/go/k8s/handler/deployments.go @@ -2,38 +2,22 @@ package handler import ( "context" + apps_v1 "k8s.io/api/apps/v1" "github.com/airyhq/airy/infrastructure/lib/go/k8s/util" v1 "k8s.io/api/core/v1" - meta_v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" "k8s.io/client-go/util/retry" "k8s.io/klog" ) -func GetDeployments(clientset kubernetes.Interface, namespace string, labelSelector string) ([]string, error) { - var deployments []string - deploymentsClient := clientset.AppsV1().Deployments(namespace) +func GetDeploymentsReferencingCm(clientSet kubernetes.Interface, configMapName string, namespace string, labelSelector string) ([]apps_v1.Deployment, error) { + deploymentsClient := clientSet.AppsV1().Deployments(namespace) + var deployments []apps_v1.Deployment retryErr := retry.RetryOnConflict(retry.DefaultRetry, func() error { - result, getErr := deploymentsClient.List(context.TODO(), meta_v1.ListOptions{LabelSelector: labelSelector}) - if getErr != nil { - klog.Errorf("Failed to get latest version of the Deployments: %v", getErr) - return getErr - } - for _, deploymentItem := range (*result).Items { - deployments = append(deployments, deploymentItem.Name) - } - return nil - }) - return deployments, retryErr -} - -func GetAffectedDeploymentsConfigmap(clientset kubernetes.Interface, configmapName string, namespace string, labelSelector string) ([]string, error) { - deploymentsClient := clientset.AppsV1().Deployments(namespace) - var affectedDeployments []string - retryErr := retry.RetryOnConflict(retry.DefaultRetry, func() error { - result, getErr := deploymentsClient.List(context.TODO(), meta_v1.ListOptions{LabelSelector: labelSelector}) + result, getErr := deploymentsClient.List(context.TODO(), metav1.ListOptions{LabelSelector: labelSelector}) if getErr != nil { klog.Errorf("Failed to get latest version of the Deployments: %v", getErr) return getErr @@ -44,64 +28,107 @@ func GetAffectedDeploymentsConfigmap(clientset kubernetes.Interface, configmapNa initContainers := GetDeploymentInitContainers(deploymentItem) // Check the containers which have an EnvReference - container = getContainerWithEnvReference(containers, configmapName, "CONFIGMAP") + container = getContainerWithEnvReference(containers, configMapName, ConfigmapEnvVarPostfix) if container != nil { klog.Infof("Found affected container in deployment: %s", deploymentItem.Name) - affectedDeployments = append(affectedDeployments, deploymentItem.Name) + deployments = append(deployments, deploymentItem) } else { - container = getContainerWithEnvReference(initContainers, configmapName, "CONFIGMAP") + container = getContainerWithEnvReference(initContainers, configMapName, ConfigmapEnvVarPostfix) if container != nil { klog.Infof("Found affected initContainer in deployment: %s", deploymentItem.Name) - affectedDeployments = append(affectedDeployments, deploymentItem.Name) + deployments = append(deployments, deploymentItem) } } // Check the containers which have a VolumeMount volumes := GetDeploymentVolumes(deploymentItem) - volumeMountName := getVolumeMountName(volumes, "CONFIGMAP", configmapName) + volumeMountName := getVolumeMountName(volumes, ConfigmapEnvVarPostfix, configMapName) if volumeMountName != "" { container = getContainerWithVolumeMount(containers, volumeMountName) if container == nil && len(initContainers) > 0 { container = getContainerWithVolumeMount(initContainers, volumeMountName) if container != nil { // if configmap/secret is being used in init container then return the first Pod container to save reloader env - affectedDeployments = append(affectedDeployments, deploymentItem.Name) + deployments = append(deployments, deploymentItem) } } else if container != nil { - affectedDeployments = append(affectedDeployments, deploymentItem.Name) + deployments = append(deployments, deploymentItem) } } } return nil }) - return affectedDeployments, retryErr + return deployments, retryErr } -func ReloadDeployment(clientset kubernetes.Interface, namespace string, deploymentName string) error { - deploymentsClient := clientset.AppsV1().Deployments(namespace) - deployment, getErr := deploymentsClient.Get(context.TODO(), deploymentName, meta_v1.GetOptions{}) +// Won't do anything for replicas that are scaled down +func ReloadDeployment(deployment apps_v1.Deployment, clientSet kubernetes.Interface) error { + deploymentsClient := clientSet.AppsV1().Deployments(deployment.Namespace) + currentReplicas := deployment.Spec.Replicas - // If currentReplicas is 0 - don't do anything - if *currentReplicas != 0 { - retryErr := retry.RetryOnConflict(retry.DefaultRetry, func() error { - deployment, getErr = deploymentsClient.Get(context.TODO(), deploymentName, meta_v1.GetOptions{}) - if getErr != nil { - klog.Errorf("Failed to get latest version of Deployment: %v", getErr) - return getErr + if *currentReplicas == 0 { + return nil + } + + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + deployment, err := deploymentsClient.Get(context.TODO(), deployment.Name, metav1.GetOptions{}) + if err != nil { + klog.Errorf("Failed to get latest version of Deployment: %v", err) + return err + } + deployment.Spec.Replicas = util.Int32Ptr(0) // reduce replica count + _, updateErr := deploymentsClient.Update(context.TODO(), deployment, metav1.UpdateOptions{}) + deployment.Spec.Replicas = currentReplicas // increase replica count + _, updateErr = deploymentsClient.Update(context.TODO(), deployment, metav1.UpdateOptions{}) + return updateErr + }) +} + +type ScaleCommand struct { + ClientSet kubernetes.Interface + Namespace string + DeploymentName string + DesiredReplicas int32 +} + +func ScaleDeployment(command ScaleCommand) error { + deploymentsClient := command.ClientSet.AppsV1().Deployments(command.Namespace) + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + deployment, err := deploymentsClient.Get(context.TODO(), command.DeploymentName, metav1.GetOptions{}) + if err != nil { + klog.Errorf("failed to get latest version of Deployment: %v", err) + return err + } + + if *deployment.Spec.Replicas == command.DesiredReplicas { + return nil + } + + deployment.Spec.Replicas = util.Int32Ptr(command.DesiredReplicas) + _, updateErr := deploymentsClient.Update(context.TODO(), deployment, metav1.UpdateOptions{}) + return updateErr + }) +} + +func CanBeStarted(deployment apps_v1.Deployment, clientSet kubernetes.Interface) bool { + containers := GetDeploymentContainers(deployment) + + // Check that all referenced configMaps are present + checkedConfigMaps := make(map[string]bool) + for _, container := range containers { + configMaps := GetReferencedConfigMaps(container) + + for _, configMapName := range configMaps { + if !checkedConfigMaps[configMapName] { + if !ConfigMapExists(configMapName, clientSet, deployment.Namespace) { + return false + } + checkedConfigMaps[configMapName] = true } - deployment.Spec.Replicas = util.Int32Ptr(0) // reduce replica count - _, updateErr := deploymentsClient.Update(context.TODO(), deployment, meta_v1.UpdateOptions{}) - deployment.Spec.Replicas = currentReplicas // increase replica count - _, updateErr = deploymentsClient.Update(context.TODO(), deployment, meta_v1.UpdateOptions{}) - return updateErr - }) - if retryErr != nil { - klog.Errorf("Update failed: %v", retryErr) - return retryErr } - klog.Infof("Reloaded deployment %s", deploymentName) - return nil } - return getErr + + return true } + diff --git a/infrastructure/lib/go/k8s/util/BUILD b/infrastructure/lib/go/k8s/util/BUILD index c6387d371e..f7106742fc 100644 --- a/infrastructure/lib/go/k8s/util/BUILD +++ b/infrastructure/lib/go/k8s/util/BUILD @@ -1,5 +1,4 @@ -# gazelle:prefix util -# gazelle:prefix k8s.io/kubernetes +# gazelle:prefix github.com/airyhq/airy/infrastructure/lib/go/k8s/util load("@io_bazel_rules_go//go:def.bzl", "go_library") diff --git a/infrastructure/scripts/conf.sh b/infrastructure/scripts/conf.sh deleted file mode 100755 index 59b00b0f68..0000000000 --- a/infrastructure/scripts/conf.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/bin/bash -set -eo pipefail -IFS=$'\n\t' - -SCRIPT_PATH=$(cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P) -INFRASTRUCTURE_PATH=$(cd ${SCRIPT_PATH}/../; pwd -P) - -if [[ ! -f ${INFRASTRUCTURE_PATH}/airy.conf ]]; then - echo "No airy.conf config file found" - exit 0 -fi - -source ${INFRASTRUCTURE_PATH}/scripts/lib/k8s.sh - -DEPLOYED_AIRY_VERSION=`kubectl get configmap core-config -o jsonpath='{.data.APP_IMAGE_TAG}'` -if $(grep -q " appImageTag" ${INFRASTRUCTURE_PATH}/airy.conf); then - CONFIGURED_AIRY_VERSION=$(grep " appImageTag" ${INFRASTRUCTURE_PATH}/airy.conf | head -n 1 | awk '{ print $2}') -else - CONFIGURED_AIRY_VERSION="" -fi - -if [ -z ${CONFIGURED_AIRY_VERSION} ]; then - AIRY_VERSION=${DEPLOYED_AIRY_VERSION} -else - AIRY_VERSION=${CONFIGURED_AIRY_VERSION} -fi - -kubectl delete pod startup-helper --force 2>/dev/null || true -kubectl run startup-helper --image busybox --command -- /bin/sh -c "tail -f /dev/null" - -helm upgrade core ${INFRASTRUCTURE_PATH}/helm-chart/ --values ${INFRASTRUCTURE_PATH}/airy.conf --set global.appImageTag=${AIRY_VERSION} --timeout 1000s > /dev/null 2>&1 - -kubectl scale deployment schema-registry --replicas=1 - -wait-for-running-pod startup-helper -wait-for-service startup-helper schema-registry 8081 15 "Schema registry" - -kubectl scale deployment -l type=api --replicas=1 -kubectl scale deployment -l type=sources-chatplugin --replicas=1 -kubectl scale deployment -l type=frontend --replicas=1 - -wait-for-service startup-helper api-auth 80 10 api-auth - -kubectl scale deployment -l type=sources-twilio --replicas=1 -kubectl scale deployment -l type=sources-google --replicas=1 -kubectl scale deployment -l type=sources-facebook --replicas=1 -kubectl scale deployment -l type=webhook --replicas=1 - -kubectl delete pod startup-helper --force 2>/dev/null diff --git a/infrastructure/scripts/provision/core.sh b/infrastructure/scripts/provision/core.sh index 3e99c62ee6..6a64859f18 100755 --- a/infrastructure/scripts/provision/core.sh +++ b/infrastructure/scripts/provision/core.sh @@ -2,18 +2,31 @@ set -euo pipefail IFS=$'\n\t' +AIRY_VERSION=${AIRY_VERSION} SCRIPT_PATH=$(cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P) INFRASTRUCTURE_PATH=$(cd ${SCRIPT_PATH}/../../; pwd -P) source ${INFRASTRUCTURE_PATH}/scripts/lib/k8s.sh -APP_IMAGE_TAG="${AIRY_VERSION:-latest}" -echo "Deploying the Airy Core Platform with the ${APP_IMAGE_TAG} image tag" cd ${INFRASTRUCTURE_PATH}/scripts/ wait-for-service-account -helm install core ${INFRASTRUCTURE_PATH}/helm-chart/ --set global.appImageTag=${APP_IMAGE_TAG} --version 0.5.0 --timeout 1000s > /dev/null 2>&1 + +echo "Deploying the Airy Core Platform with the ${AIRY_VERSION} image tag" + +if [[ -f ${INFRASTRUCTURE_PATH}/airy.yaml ]]; then + yq eval '.global.appImageTag="'${AIRY_VERSION}'"' -i ${INFRASTRUCTURE_PATH}/airy.yaml + helm install core ${INFRASTRUCTURE_PATH}/helm-chart/ --values ${INFRASTRUCTURE_PATH}/airy.yaml --timeout 1000s > /dev/null 2>&1 + wget -qnv https://airy-core-binaries.s3.amazonaws.com/alpine/airy.gz + gunzip airy.gz + chmod +x airy + mv airy /usr/local/bin/ + airy init + airy config apply --kube-config /etc/rancher/k3s/k3s.yaml --config ${INFRASTRUCTURE_PATH}/airy.yaml +else + helm install core ${INFRASTRUCTURE_PATH}/helm-chart/ --set global.appImageTag=${AIRY_VERSION} --timeout 1000s > /dev/null 2>&1 +fi kubectl run startup-helper --image busybox --command -- /bin/sh -c "tail -f /dev/null" @@ -29,6 +42,3 @@ wait-for-service startup-helper postgres 5432 10 Postgres kubectl scale statefulset redis-cluster --replicas=1 wait-for-service startup-helper redis-cluster 6379 10 Redis kubectl delete pod startup-helper --force 2>/dev/null - -echo "Deploying ingress controller" -kubectl apply -f ../network/ingress.yaml diff --git a/infrastructure/scripts/provision/prerequisites.sh b/infrastructure/scripts/provision/prerequisites.sh index 16d16d04d2..544b31dc17 100755 --- a/infrastructure/scripts/provision/prerequisites.sh +++ b/infrastructure/scripts/provision/prerequisites.sh @@ -2,7 +2,9 @@ set -euo pipefail IFS=$'\n\t' -apk add --no-cache wget unzip jq bash-completion +echo "http://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories +apk update +apk add --no-cache wget unzip jq yq bash-completion curl -sfL https://get.k3s.io | sh - diff --git a/infrastructure/scripts/status.sh b/infrastructure/scripts/status.sh index 062259b724..7b743dd9a9 100755 --- a/infrastructure/scripts/status.sh +++ b/infrastructure/scripts/status.sh @@ -14,7 +14,8 @@ wait-for-ingress-service wait-for-running-pod startup-helper wait-for-service startup-helper api-auth 80 10 api-auth -CORE_ID=`kubectl get configmap core-config -o jsonpath='{.data.CORE_ID}'` +CORE_ID=$(kubectl get configmap core-config -o jsonpath='{.data.CORE_ID}') +API_HOSTNAME=$(kubectl get ingress airy-core -o jsonpath='{.spec.rules[0].host}') FACEBOOK_WEBHOOK_PUBLIC_URL="https://fb-${CORE_ID}.tunnel.airy.co" GOOGLE_WEBHOOK_PUBLIC_URL="https://gl-${CORE_ID}.tunnel.airy.co" TWILIO_WEBHOOK_PUBLIC_URL="https://tw-${CORE_ID}.tunnel.airy.co" @@ -30,9 +31,9 @@ echo "Your public url for the Twilio Webhook is:" echo ${TWILIO_WEBHOOK_PUBLIC_URL}/twilio echo echo "You can access the API of the Airy Core Platform at:" -echo "http://api.airy/" +echo "http://${API_HOSTNAME}" echo echo "Example:" -echo "curl -X POST -H 'Content-Type: application/json' -d '{\"first_name\": \"Grace\",\"last_name\": \"Hopper\",\"password\": \"the_answer_is_42\",\"email\": \"grace@example.com\"}' http://api.airy/users.signup" +echo "curl -X POST -H 'Content-Type: application/json' -d '{\"first_name\": \"Grace\",\"last_name\": \"Hopper\",\"password\": \"the_answer_is_42\",\"email\": \"grace@example.com\"}' http://${API_HOSTNAME}/users.signup" kubectl delete pod startup-helper --force 2>/dev/null diff --git a/infrastructure/scripts/trigger/start.sh b/infrastructure/scripts/trigger/start.sh index 7956bad5f0..fa715e4447 100755 --- a/infrastructure/scripts/trigger/start.sh +++ b/infrastructure/scripts/trigger/start.sh @@ -24,15 +24,10 @@ wait-for-service startup-helper schema-registry 8081 15 "Schema registry" echo "Starting up Airy Core Platform appplications" kubectl scale deployment -l type=api --replicas=1 -kubectl scale deployment -l type=sources-chatplugin --replicas=1 -kubectl scale deployment -l type=frontend --replicas=1 wait-for-service startup-helper api-auth 80 10 api-auth -kubectl scale deployment -l type=sources-twilio --replicas=1 -kubectl scale deployment -l type=sources-google --replicas=1 -kubectl scale deployment -l type=sources-facebook --replicas=1 -kubectl scale deployment -l type=webhook --replicas=1 +kubectl scale deployment -l app=airy-controller --replicas=1 kubectl delete pod startup-helper --force 2>/dev/null chmod o+r /etc/rancher/k3s/k3s.yaml diff --git a/infrastructure/scripts/trigger/stop.sh b/infrastructure/scripts/trigger/stop.sh index cbe03d2f9c..587f8bd514 100755 --- a/infrastructure/scripts/trigger/stop.sh +++ b/infrastructure/scripts/trigger/stop.sh @@ -3,12 +3,14 @@ set -euo pipefail IFS=$'\n\t' echo "Scaling down Airy Core platform applications" +kubectl scale deployment -l app=airy-controller --replicas=0 kubectl scale deployment -l type=frontend --replicas=0 kubectl scale deployment -l type=sources-twilio --replicas=0 kubectl scale deployment -l type=sources-google --replicas=0 kubectl scale deployment -l type=sources-facebook --replicas=0 kubectl scale deployment -l type=sources-chatplugin --replicas=0 kubectl scale deployment -l type=webhook --replicas=0 +kubectl scale deployment -l type=media --replicas=0 kubectl scale deployment -l type=sources-chatplugin --replicas=0 kubectl scale deployment -l type=api --replicas=0 kubectl scale deployment schema-registry --replicas=0 diff --git a/lib/go/httpclient/BUILD b/lib/go/httpclient/BUILD index 01b9934a9b..778fda8778 100644 --- a/lib/go/httpclient/BUILD +++ b/lib/go/httpclient/BUILD @@ -1,10 +1,11 @@ -# gazelle:prefix httpclient -# gazelle:importmap_prefix lib/go -load("@io_bazel_rules_go//go:def.bzl", "go_library") +load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") + +# gazelle:prefix github.com/airyhq/airy/lib/go/httpclient go_library( name = "httpclient", srcs = [ + "client.go", "httpclient.go", "users.go", ], @@ -12,3 +13,13 @@ go_library( visibility = ["//visibility:public"], deps = ["//lib/go/httpclient/payloads"], ) + +go_test( + name = "httpclient_test", + srcs = ["users_test.go"], + embed = [":httpclient"], + deps = [ + "//lib/go/httpclient/payloads", + "@com_github_stretchr_testify//assert", + ], +) diff --git a/lib/go/httpclient/client.go b/lib/go/httpclient/client.go new file mode 100644 index 0000000000..681cd4dc11 --- /dev/null +++ b/lib/go/httpclient/client.go @@ -0,0 +1,23 @@ +package httpclient + +import ( + "encoding/json" + + "github.com/airyhq/airy/lib/go/httpclient/payloads" +) + +func (c *Client) Config() (*payloads.ClientConfigResponsePayload, error) { + payload, err := json.Marshal(payloads.ClientConfigRequestPayload{}) + if err != nil { + return nil, err + } + + res := payloads.ClientConfigResponsePayload{} + + e := c.post("client.config", payload, &res) + if e != nil { + return nil, e + } + + return &res, nil +} diff --git a/lib/go/httpclient/http-client-test/BUILD b/lib/go/httpclient/http-client-test/BUILD deleted file mode 100644 index 3a4a8ebe15..0000000000 --- a/lib/go/httpclient/http-client-test/BUILD +++ /dev/null @@ -1,11 +0,0 @@ -load("@io_bazel_rules_go//go:def.bzl", "go_test") - -go_test( - name = "http-client-test_test", - srcs = ["users_test.go"], - deps = [ - "//lib/go/httpclient", - "//lib/go/httpclient/payloads", - "@com_github_stretchr_testify//assert", - ], -) diff --git a/lib/go/httpclient/httpclient.go b/lib/go/httpclient/httpclient.go index f557d556a9..8d214ca43c 100644 --- a/lib/go/httpclient/httpclient.go +++ b/lib/go/httpclient/httpclient.go @@ -3,63 +3,48 @@ package httpclient import ( "bytes" "encoding/json" - "errors" "fmt" "net/http" "time" ) -const ( - BaseURL = "http://api.airy" -) - type Client struct { - BaseURL string - HTTPClient *http.Client + BaseURL string + JWTToken string + c *http.Client } -func NewClient() *Client { +func NewClient(baseURL string) *Client { return &Client{ - BaseURL: BaseURL, - HTTPClient: &http.Client{ + BaseURL: baseURL, + c: &http.Client{ Timeout: time.Minute, }, } } -type errorResponse struct { - Code int `json:"code"` - Message string `json:"message"` -} - -func (c *Client) sendRequest(requestDataJSON []byte, endpoint string, v interface{}) error { - - req, err := http.NewRequest("POST", fmt.Sprintf("%s/%s", c.BaseURL, endpoint), bytes.NewBuffer(requestDataJSON)) +func (c *Client) post(endpoint string, payload []byte, res interface{}) error { + req, err := http.NewRequest("POST", fmt.Sprintf("%s/%s", c.BaseURL, endpoint), bytes.NewBuffer(payload)) if err != nil { return err } + req.Header.Set("Content-Type", "application/json; charset=utf-8") req.Header.Set("Accept", "application/json; charset=utf-8") + if c.JWTToken != "" { + req.Header.Set("Authorization", c.JWTToken) + } - res, err := c.HTTPClient.Do(req) + r, err := c.c.Do(req) if err != nil { return err } - defer res.Body.Close() - - if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusBadRequest { - var errRes errorResponse - if err = json.NewDecoder(res.Body).Decode(&errRes); err == nil { - return errors.New(errRes.Message) - } + defer r.Body.Close() - return fmt.Errorf("unknown error, status code: %d", res.StatusCode) - } - - if err = json.NewDecoder(res.Body).Decode(v); err != nil { - return err + if r.StatusCode < http.StatusOK || r.StatusCode >= http.StatusBadRequest { + return fmt.Errorf("request was unsuccessful. Status code: %d", r.StatusCode) } - return nil + return json.NewDecoder(r.Body).Decode(&res) } diff --git a/lib/go/httpclient/payloads/BUILD b/lib/go/httpclient/payloads/BUILD index e313270567..33775dcaf9 100644 --- a/lib/go/httpclient/payloads/BUILD +++ b/lib/go/httpclient/payloads/BUILD @@ -1,12 +1,15 @@ load("@io_bazel_rules_go//go:def.bzl", "go_library") +# gazelle:prefix github.com/airyhq/airy/lib/go/httpclient/payloads go_library( name = "payloads", srcs = [ - "login_request.go", - "login_response.go", - "signup_request.go", - "signup_response.go", + "client_config_request_payload.go", + "client_config_response_payload.go", + "login_request_payload.go", + "login_response_payload.go", + "signup_request_payload.go", + "signup_response_payload.go", ], importpath = "github.com/airyhq/airy/lib/go/httpclient/payloads", visibility = ["//visibility:public"], diff --git a/lib/go/httpclient/payloads/client_config_request_payload.go b/lib/go/httpclient/payloads/client_config_request_payload.go new file mode 100644 index 0000000000..f132cbb455 --- /dev/null +++ b/lib/go/httpclient/payloads/client_config_request_payload.go @@ -0,0 +1,4 @@ +package payloads + +type ClientConfigRequestPayload struct { +} diff --git a/lib/go/httpclient/payloads/client_config_response_payload.go b/lib/go/httpclient/payloads/client_config_response_payload.go new file mode 100644 index 0000000000..23ab1d77a6 --- /dev/null +++ b/lib/go/httpclient/payloads/client_config_response_payload.go @@ -0,0 +1,6 @@ +package payloads + +type ClientConfigResponsePayload struct { + Components map[string]map[string]interface{} `json:"components"` + Features map[string]string `json:"features"` +} diff --git a/lib/go/httpclient/payloads/login_request.go b/lib/go/httpclient/payloads/login_request_payload.go similarity index 100% rename from lib/go/httpclient/payloads/login_request.go rename to lib/go/httpclient/payloads/login_request_payload.go diff --git a/lib/go/httpclient/payloads/login_response.go b/lib/go/httpclient/payloads/login_response_payload.go similarity index 100% rename from lib/go/httpclient/payloads/login_response.go rename to lib/go/httpclient/payloads/login_response_payload.go diff --git a/lib/go/httpclient/payloads/signup_request.go b/lib/go/httpclient/payloads/signup_request_payload.go similarity index 100% rename from lib/go/httpclient/payloads/signup_request.go rename to lib/go/httpclient/payloads/signup_request_payload.go diff --git a/lib/go/httpclient/payloads/signup_response.go b/lib/go/httpclient/payloads/signup_response_payload.go similarity index 100% rename from lib/go/httpclient/payloads/signup_response.go rename to lib/go/httpclient/payloads/signup_response_payload.go diff --git a/lib/go/httpclient/users.go b/lib/go/httpclient/users.go index 9c858be202..8a5af50f6f 100644 --- a/lib/go/httpclient/users.go +++ b/lib/go/httpclient/users.go @@ -7,31 +7,30 @@ import ( ) func (c *Client) Signup(signupRequestPayload payloads.SignupRequestPayload) (*payloads.SignupResponsePayload, error) { - requestDataJSON, err := json.Marshal(signupRequestPayload) + payload, err := json.Marshal(signupRequestPayload) if err != nil { return nil, err } + res := payloads.SignupResponsePayload{} - if err := c.sendRequest(requestDataJSON, "users.signup", &res); err != nil { - return nil, err + e := c.post("users.signup", payload, &res) + if e != nil { + return nil, e } return &res, nil - } func (c *Client) Login(loginRequestPayload payloads.LoginRequestPayload) (*payloads.LoginResponsePayload, error) { - requestDataJSON, err := json.Marshal(loginRequestPayload) + payload, err := json.Marshal(loginRequestPayload) if err != nil { return nil, err } res := payloads.LoginResponsePayload{} - - if err := c.sendRequest(requestDataJSON, "users.login", &res); err != nil { - return nil, err + e := c.post("users.login", payload, &res) + if e != nil { + return nil, e } - return &res, nil - } diff --git a/lib/go/httpclient/http-client-test/users_test.go b/lib/go/httpclient/users_test.go similarity index 55% rename from lib/go/httpclient/http-client-test/users_test.go rename to lib/go/httpclient/users_test.go index e2cb5ea19f..8124dab466 100644 --- a/lib/go/httpclient/http-client-test/users_test.go +++ b/lib/go/httpclient/users_test.go @@ -1,4 +1,4 @@ -package tests +package httpclient import ( "fmt" @@ -6,19 +6,20 @@ import ( "net/http/httptest" "testing" - "github.com/airyhq/airy/lib/go/httpclient" "github.com/airyhq/airy/lib/go/httpclient/payloads" - "github.com/stretchr/testify/assert" ) -func TestSignup(t *testing.T) { - c := httpclient.NewClient() - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +func mockedUserResponseServer() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "{\"id\":\"a6c413a7-8d42-4c2b-8736-d033134eec59\",\"first_name\":\"Grace\",\"last_name\":\"Hopper\",\"token\":\"eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiJhNmM0MTNhNy04ZDQyLTRjMmItODczNi1kMDMzMTM0ZWVjNTkiLCJzdWIiOiJhNmM0MTNhNy04ZDQyLTRjMmItODczNi1kMDMzMTM0ZWVjNTkiLCJpYXQiOjE2MDczMzY2NjMsInVzZXJfaWQiOiJhNmM0MTNhNy04ZDQyLTRjMmItODczNi1kMDMzMTM0ZWVjNTkiLCJleHAiOjE2MDc0MjMwNjN9.I4sf2j36RQCPRrirzSYyRhJ4U3bG2sUmHfxX4yBJvQA\"}") })) - c.BaseURL = ts.URL +} + +func TestSignup(t *testing.T) { + ts := mockedUserResponseServer() + defer ts.Close() + c := NewClient(ts.URL) signupRequestPayload := payloads.SignupRequestPayload{FirstName: "Grace", LastName: "Hopper", Password: "the_answer_is_42", Email: "grace@example.com"} @@ -26,26 +27,18 @@ func TestSignup(t *testing.T) { assert.Nil(t, err, "expecting nil error") assert.NotNil(t, res, "expecting non-nil result") - - assert.NotEmpty(t, res.Token, "expecting non-empty token") - + assert.Equal(t, res.Token, "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiJhNmM0MTNhNy04ZDQyLTRjMmItODczNi1kMDMzMTM0ZWVjNTkiLCJzdWIiOiJhNmM0MTNhNy04ZDQyLTRjMmItODczNi1kMDMzMTM0ZWVjNTkiLCJpYXQiOjE2MDczMzY2NjMsInVzZXJfaWQiOiJhNmM0MTNhNy04ZDQyLTRjMmItODczNi1kMDMzMTM0ZWVjNTkiLCJleHAiOjE2MDc0MjMwNjN9.I4sf2j36RQCPRrirzSYyRhJ4U3bG2sUmHfxX4yBJvQA") } func TestLogin(t *testing.T) { - c := httpclient.NewClient() - - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "{\"id\":\"a6c413a7-8d42-4c2b-8736-d033134eec59\",\"first_name\":\"Grace\",\"last_name\":\"Hopper\",\"token\":\"eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiJhNmM0MTNhNy04ZDQyLTRjMmItODczNi1kMDMzMTM0ZWVjNTkiLCJzdWIiOiJhNmM0MTNhNy04ZDQyLTRjMmItODczNi1kMDMzMTM0ZWVjNTkiLCJpYXQiOjE2MDczMzY2NjMsInVzZXJfaWQiOiJhNmM0MTNhNy04ZDQyLTRjMmItODczNi1kMDMzMTM0ZWVjNTkiLCJleHAiOjE2MDc0MjMwNjN9.I4sf2j36RQCPRrirzSYyRhJ4U3bG2sUmHfxX4yBJvQA\"}") - })) - c.BaseURL = ts.URL + ts := mockedUserResponseServer() + defer ts.Close() + c := NewClient(ts.URL) loginRequestPayload := payloads.LoginRequestPayload{Password: "the_answer_is_42", Email: "grace@example.com"} res, err := c.Login(loginRequestPayload) - assert.Nil(t, err, "expecting nil error") assert.NotNil(t, res, "expecting non-nil result") - - assert.NotEmpty(t, res.Token, "expecting non-empty token") - + assert.Equal(t, res.Token, "eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiJhNmM0MTNhNy04ZDQyLTRjMmItODczNi1kMDMzMTM0ZWVjNTkiLCJzdWIiOiJhNmM0MTNhNy04ZDQyLTRjMmItODczNi1kMDMzMTM0ZWVjNTkiLCJpYXQiOjE2MDczMzY2NjMsInVzZXJfaWQiOiJhNmM0MTNhNy04ZDQyLTRjMmItODczNi1kMDMzMTM0ZWVjNTkiLCJleHAiOjE2MDc0MjMwNjN9.I4sf2j36RQCPRrirzSYyRhJ4U3bG2sUmHfxX4yBJvQA") } diff --git a/lib/java/mapping/src/main/java/co/airy/mapping/OutboundMapper.java b/lib/java/mapping/src/main/java/co/airy/mapping/OutboundMapper.java index ac3eb07efd..a0b16dcf21 100644 --- a/lib/java/mapping/src/main/java/co/airy/mapping/OutboundMapper.java +++ b/lib/java/mapping/src/main/java/co/airy/mapping/OutboundMapper.java @@ -18,7 +18,7 @@ public OutboundMapper() { } public List render(String payload) throws Exception { - final JsonNode jsonNode = objectMapper.readTree(payload); - return List.of(new Text(jsonNode.get("text").textValue())); + final Content content = objectMapper.readValue(payload, Content.class); + return List.of(content); } } diff --git a/lib/java/mapping/src/main/java/co/airy/mapping/model/Content.java b/lib/java/mapping/src/main/java/co/airy/mapping/model/Content.java index ffb4681b99..bdcd8534f2 100644 --- a/lib/java/mapping/src/main/java/co/airy/mapping/model/Content.java +++ b/lib/java/mapping/src/main/java/co/airy/mapping/model/Content.java @@ -9,7 +9,8 @@ @JsonSubTypes.Type(value = Audio.class, name = "audio"), @JsonSubTypes.Type(value = File.class, name = "file"), @JsonSubTypes.Type(value = Image.class, name = "image"), - @JsonSubTypes.Type(value = Video.class, name = "video") + @JsonSubTypes.Type(value = Video.class, name = "video"), + @JsonSubTypes.Type(value = SourceTemplate.class, name = "source.template") }) public abstract class Content { } diff --git a/lib/java/mapping/src/main/java/co/airy/mapping/model/SourceTemplate.java b/lib/java/mapping/src/main/java/co/airy/mapping/model/SourceTemplate.java new file mode 100644 index 0000000000..84456cdc02 --- /dev/null +++ b/lib/java/mapping/src/main/java/co/airy/mapping/model/SourceTemplate.java @@ -0,0 +1,21 @@ +package co.airy.mapping.model; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.NotNull; +import java.io.Serializable; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = false) +public class SourceTemplate extends Content implements Serializable { + @NotNull + private JsonNode payload; +} diff --git a/lib/java/mapping/src/main/java/co/airy/mapping/sources/facebook/FacebookMapper.java b/lib/java/mapping/src/main/java/co/airy/mapping/sources/facebook/FacebookMapper.java index 0c8c10fb0b..85705ae58b 100644 --- a/lib/java/mapping/src/main/java/co/airy/mapping/sources/facebook/FacebookMapper.java +++ b/lib/java/mapping/src/main/java/co/airy/mapping/sources/facebook/FacebookMapper.java @@ -5,6 +5,7 @@ import co.airy.mapping.model.Content; import co.airy.mapping.model.File; import co.airy.mapping.model.Image; +import co.airy.mapping.model.SourceTemplate; import co.airy.mapping.model.Text; import co.airy.mapping.model.Video; import com.fasterxml.jackson.databind.JsonNode; @@ -31,6 +32,7 @@ public FacebookMapper() { "audio", Audio::new, "file", File::new ); + @Override public List getIdentifiers() { return List.of("facebook"); @@ -52,6 +54,12 @@ public List render(String payload) throws Exception { .elements() .forEachRemaining(attachmentNode -> { final String attachmentType = attachmentNode.get("type").textValue(); + + if (attachmentType.equals("template")) { + contents.add(new SourceTemplate(attachmentNode.get("payload"))); + return; + } + final String url = attachmentNode.get("payload").get("url").textValue(); final Content mediaContent = mediaContentFactory.get(attachmentType).apply(url); diff --git a/lib/java/mapping/src/main/java/co/airy/mapping/sources/google/GoogleMapper.java b/lib/java/mapping/src/main/java/co/airy/mapping/sources/google/GoogleMapper.java index 0b252c8233..6ce335b784 100644 --- a/lib/java/mapping/src/main/java/co/airy/mapping/sources/google/GoogleMapper.java +++ b/lib/java/mapping/src/main/java/co/airy/mapping/sources/google/GoogleMapper.java @@ -32,7 +32,18 @@ public List getIdentifiers() { public List render(String payload) throws Exception { final JsonNode jsonNode = objectMapper.readTree(payload); final JsonNode messageNode = jsonNode.get("message"); + if (messageNode != null) { + return renderMessage(messageNode); + } + final JsonNode suggestionResponseNode = jsonNode.get("suggestionResponse"); + if (suggestionResponseNode != null) { + return renderSuggestionResponse(suggestionResponseNode); + } + + throw new Exception("google mapper only supports `message` and `suggestionResponse`"); + } + private List renderMessage(JsonNode messageNode) { final String messageNodeValue = messageNode.get("text").textValue(); if (isGoogleStorageUrl(messageNodeValue)) { return List.of(new Image(messageNodeValue)); @@ -41,6 +52,11 @@ public List render(String payload) throws Exception { } } + private List renderSuggestionResponse(JsonNode suggestionResponseNode) { + final String textContent = suggestionResponseNode.get("text").textValue(); + return List.of(new Text(textContent)); + } + private boolean isGoogleStorageUrl(final String url) { URI uri; try { diff --git a/lib/java/mapping/src/test/java/co/airy/mapping/ContentMapperTest.java b/lib/java/mapping/src/test/java/co/airy/mapping/ContentMapperTest.java index c2bc10c2d6..57cb265b93 100644 --- a/lib/java/mapping/src/test/java/co/airy/mapping/ContentMapperTest.java +++ b/lib/java/mapping/src/test/java/co/airy/mapping/ContentMapperTest.java @@ -35,7 +35,9 @@ public class ContentMapperTest { @Test void rendersOutbound() throws Exception { - final String text = "Hello World"; + final String textContent = "Hello World"; + final Text text = new Text(textContent); + final Message message = Message.newBuilder() .setId("other-message-id") .setSource("facebook") @@ -45,12 +47,12 @@ void rendersOutbound() throws Exception { .setDeliveryState(DeliveryState.DELIVERED) .setConversationId("conversationId") .setChannelId("channelId") - .setContent("{\"text\":\"" + text + "\"}") + .setContent((new ObjectMapper()).writeValueAsString(text)) .build(); final Text textMessage = (Text) mapper.render(message).get(0); - assertThat(textMessage.getText(), equalTo(text)); + assertThat(textMessage.getText(), equalTo(textContent)); Mockito.verify(outboundMapper).render(Mockito.anyString()); } @@ -102,7 +104,6 @@ public List render(String payload) { final String persistentUrl = "http://storage.org/path/data"; final Map messageMetadata = Map.of("data_" + originalUrl, persistentUrl); - // No replacement without metadata audioMessage = (Audio) mapper.render(message, messageMetadata).get(0); assertThat(audioMessage.getUrl(), equalTo(persistentUrl)); } diff --git a/lib/java/mapping/src/test/java/co/airy/mapping/FacebookTest.java b/lib/java/mapping/src/test/java/co/airy/mapping/FacebookTest.java index 5f20f56751..8a3fcb9335 100644 --- a/lib/java/mapping/src/test/java/co/airy/mapping/FacebookTest.java +++ b/lib/java/mapping/src/test/java/co/airy/mapping/FacebookTest.java @@ -3,13 +3,16 @@ import co.airy.mapping.model.Audio; import co.airy.mapping.model.Content; import co.airy.mapping.model.File; +import co.airy.mapping.model.SourceTemplate; import co.airy.mapping.model.Video; import co.airy.mapping.sources.facebook.FacebookMapper; import org.junit.jupiter.api.Test; import org.springframework.util.StreamUtils; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.stream.Collectors; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; @@ -74,4 +77,16 @@ void canRenderFile() throws Exception { assertThat(contents, everyItem(isA(File.class))); assertThat(contents, everyItem(hasProperty("url", equalTo(fileUrl)))); } + + @Test + void canRenderTemplates() throws Exception { + final List templateTypes = List.of("generic"); + + for (String templateType : templateTypes) { + final String content = StreamUtils.copyToString(getClass().getClassLoader().getResourceAsStream(String.format("facebook/template_%s.json", templateType)), StandardCharsets.UTF_8); + final List contents = mapper.render(content); + assertThat(contents, hasSize(1)); + assertThat(contents, everyItem(isA(SourceTemplate.class))); + } + } } diff --git a/lib/java/mapping/src/test/java/co/airy/mapping/GoogleTest.java b/lib/java/mapping/src/test/java/co/airy/mapping/GoogleTest.java index 2a11d8e72c..f33159bbaa 100644 --- a/lib/java/mapping/src/test/java/co/airy/mapping/GoogleTest.java +++ b/lib/java/mapping/src/test/java/co/airy/mapping/GoogleTest.java @@ -4,6 +4,9 @@ import co.airy.mapping.model.Text; import co.airy.mapping.sources.google.GoogleMapper; import org.junit.jupiter.api.Test; +import org.springframework.util.StreamUtils; + +import java.nio.charset.StandardCharsets; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; @@ -13,48 +16,31 @@ public class GoogleTest { @Test void canRenderText() throws Exception { - final String content = "{\n" + - " \"message\": {\n" + - " \"name\": \"conversations/9cec28cc-8dbe-40d0-ad68-edd0f440c743/messages/3A25E132-20D6-4A5D-8602-7DF4979F181B\",\n" + - " \"text\": \"Yes confirmed\",\n" + - " \"createTime\": \"2020-05-14T12:45:54.531828Z\",\n" + - " \"messageId\": \"3A25E132-20D6-4A5D-8602-7DF4979F181B\"\n" + - " },\n" + - " \"context\": {},\n" + - " \"sendTime\": \"2020-05-14T12:45:55.302Z\",\n" + - " \"conversationId\": \"9cec28cc-8dbe-40d0-ad68-edd0f440c743\",\n" + - " \"customAgentId\": \"5b43b04d-aa75-4b7b-bdca-28e90a344db1\",\n" + - " \"requestId\": \"3A25E132-20D6-4A5D-8602-7DF4979F181B\",\n" + - " \"agent\": \"brands/af0ef816-cef8-479e-b4b6-650d5e8b90b1/agents/31a8d3e0-490f-4ecc-887b-42df4dd1952e\"\n" + - "}"; - - final Text message = (Text) mapper.render(content).get(0); - assertThat(message.getText(), equalTo("Yes confirmed")); + final String textContent = "Hello World"; + final String sourceContent = String.format(StreamUtils.copyToString(getClass().getClassLoader() + .getResourceAsStream("google/text.json"), StandardCharsets.UTF_8), textContent); + + final Text message = (Text) mapper.render(sourceContent).get(0); + assertThat(message.getText(), equalTo(textContent)); } @Test void canRenderImage() throws Exception { final String signedImageUrl = "https://storage.googleapis.com/business-messages-us/936640919331/jzsu6cdguNGsBhmGJGuLs1DS?x-goog-algorithm\u003dGOOG4-RSA-SHA256\u0026x-goog-credential\u003duranium%40rcs-uranium.iam.gserviceaccount.com%2F20190826%2Fauto%2Fstorage%2Fgoog4_request\u0026x-goog-date\u003d20190826T201038Z\u0026x-goog-expires\u003d604800\u0026x-goog-signedheaders\u003dhost\u0026x-goog-signature\u003d89dbf7a74d21ab42ad25be071b37840a544a43d68e67270382054e1442d375b0b53d15496dbba12896b9d88a6501cac03b5cfca45d789da3e0cae75b050a89d8f54c1ffb27e467bd6ba1d146b7d42e30504c295c5c372a46e44728f554ba74b7b99bd9c6d3ed45f18588ed1b04522af1a47330cff73a711a6a8c65bb15e3289f480486f6695127e1014727cac949e284a7f74afd8220840159c589d48dddef1cc97b248dfc34802570448242eac4d7190b1b10a008404a330b4ff6f9656fa84e87f9a18ab59dc9b91e54ad11ffdc0ad1dc9d1ccc7855c0d263d93fce6f999971ec79879f922b582cf3bb196a1fedc3eefa226bb412e49af7dfd91cc072608e98"; - final String content = "{\n" + - " \"agent\": \"brands/BRAND_ID/agents/AGENT_ID\",\n" + - " \"conversationId\": \"CONVERSATION_ID\",\n" + - " \"customAgentId\": \"CUSTOM_AGENT_ID\",\n" + - " \"requestId\": \"REQUEST_ID\",\n" + - " \"message\": {\n" + - " \"messageId\": \"MESSAGE_ID\",\n" + - " \"name\": \"conversations/CONVERSATION_ID/messages/MESSAGE_ID\",\n" + - " \"text\": \"" + signedImageUrl + "\",\n" + - " \"createTime\": \"MESSAGE_CREATE_TIME\"\n" + - " },\n" + - " \"context\": {},\n" + - " \"sendTime\": \"2020-05-14T12:45:55.302Z\",\n" + - " \"conversationId\": \"9cec28cc-8dbe-40d0-ad68-edd0f440c743\",\n" + - " \"customAgentId\": \"5b43b04d-aa75-4b7b-bdca-28e90a344db1\",\n" + - " \"requestId\": \"3A25E132-20D6-4A5D-8602-7DF4979F181B\",\n" + - " \"agent\": \"brands/af0ef816-cef8-479e-b4b6-650d5e8b90b1/agents/31a8d3e0-490f-4ecc-887b-42df4dd1952e\"\n" + - "}\n"; - - final Image message = (Image) mapper.render(content).get(0); + final String sourceContent = String.format(StreamUtils.copyToString(getClass().getClassLoader() + .getResourceAsStream("google/text.json"), StandardCharsets.UTF_8), signedImageUrl); + + final Image message = (Image) mapper.render(sourceContent).get(0); assertThat(message.getUrl(), equalTo(signedImageUrl)); } + + @Test + void canRenderSuggestionResponses() throws Exception { + final String textContent = "Hello World"; + final String sourceContent = String.format(StreamUtils.copyToString(getClass().getClassLoader() + .getResourceAsStream("google/suggestionResponse.json"), StandardCharsets.UTF_8), textContent); + + final Text message = (Text) mapper.render(sourceContent).get(0); + assertThat(message.getText(), equalTo(textContent)); + } } diff --git a/lib/java/mapping/src/test/resources/facebook/template_generic.json b/lib/java/mapping/src/test/resources/facebook/template_generic.json new file mode 100644 index 0000000000..9012127a66 --- /dev/null +++ b/lib/java/mapping/src/test/resources/facebook/template_generic.json @@ -0,0 +1,33 @@ +{ + "sender": { + "id": "4616529495039079" + }, + "recipient": { + "id": "778234505682382" + }, + "timestamp": 1550050473934, + "message": { + "is_echo": true, + "app_id": 123, + "mid": "l9sIeXHGFbkAL5m62DbqF2nKw8PPGcoZ0ruggAoYBrnyu8w-rcnEazyvqHqp3VeTu8k3NK-N1fCnAdzcs9kcUw", + "seq": 85083, + "attachments": [ + { + "title": "test", + "url": null, + "type": "template", + "payload": { + "template_type": "generic", + "sharable": true, + "elements": [ + { + "title": "awdadw", + "image_url": "https://airy-layer-production.s3.amazonaws.com/templates/8787c530-2f72-11e9-867e-f7de52fd949f.jpeg", + "subtitle": "adww" + } + ] + } + } + ] + } +} diff --git a/lib/java/mapping/src/test/resources/google/suggestionResponse.json b/lib/java/mapping/src/test/resources/google/suggestionResponse.json new file mode 100644 index 0000000000..5b6f1110a1 --- /dev/null +++ b/lib/java/mapping/src/test/resources/google/suggestionResponse.json @@ -0,0 +1,15 @@ +{ + "suggestionResponse": { + "message": "conversations/11ab7bf3-0410-46a6-bcce-1877ca6957b0/messages/11ab7bf3-0410-46a6-bcce-1877ca6957b0", + "postbackData": "postback-data", + "createTime": "2020-07-13T12:54:50.479632Z", + "text": "%s", + "type": "REPLY" + }, + "context": {}, + "sendTime": "2020-05-14T12:45:55.302Z", + "conversationId": "11ab7bf3-0410-46a6-bcce-1877ca6957b0", + "customAgentId": "11ab7bf3-0410-46a6-bcce-1877ca6957b0", + "requestId": "11ab7bf3-0410-46a6-bcce-1877ca6957b0", + "agent": "brands/11ab7bf3-0410-46a6-bcce-1877ca6957b0/agents/11ab7bf3-0410-46a6-bcce-1877ca6957b0" +} diff --git a/lib/java/mapping/src/test/resources/google/text.json b/lib/java/mapping/src/test/resources/google/text.json new file mode 100644 index 0000000000..fa2156c57f --- /dev/null +++ b/lib/java/mapping/src/test/resources/google/text.json @@ -0,0 +1,14 @@ +{ + "message": { + "name": "conversations/11ab7bf3-0410-46a6-bcce-1877ca6957b0/messages/11ab7bf3-0410-46a6-bcce-1877ca6957b0", + "text": "%s", + "createTime": "2020-05-14T12:45:54.531828Z", + "messageId": "11ab7bf3-0410-46a6-bcce-1877ca6957b0" + }, + "context": {}, + "sendTime": "2020-05-14T12:45:55.302Z", + "conversationId": "11ab7bf3-0410-46a6-bcce-1877ca6957b0", + "customAgentId": "11ab7bf3-0410-46a6-bcce-1877ca6957b0", + "requestId": "11ab7bf3-0410-46a6-bcce-1877ca6957b0", + "agent": "brands/11ab7bf3-0410-46a6-bcce-1877ca6957b0/agents/11ab7bf3-0410-46a6-bcce-1877ca6957b0" +} diff --git a/lib/java/mapping/src/ts-generator/java/co/airy/ts_generator/Main.java b/lib/java/mapping/src/ts-generator/java/co/airy/ts_generator/Main.java index a56e9d5f0b..e4b7c8ed51 100644 --- a/lib/java/mapping/src/ts-generator/java/co/airy/ts_generator/Main.java +++ b/lib/java/mapping/src/ts-generator/java/co/airy/ts_generator/Main.java @@ -27,7 +27,7 @@ public static void main(String[] args) { parameters.debug = false; parameters.classNamePatterns = List.of("co.airy.mapping.model.**"); - final File output = new File(System.getenv().get("BUILD_WORKSPACE_DIRECTORY") + "/frontend/types/content.ts"); + final File output = new File(System.getenv().get("BUILD_WORKSPACE_DIRECTORY") + "/lib/typescript/types/content.ts"); settings.validateFileName(output); generator.generateTypeScript(Input.from(parameters), Output.to(output)); diff --git a/lib/java/url/BUILD b/lib/java/url/BUILD new file mode 100644 index 0000000000..e16ada072e --- /dev/null +++ b/lib/java/url/BUILD @@ -0,0 +1,7 @@ +load("//tools/build:java_library.bzl", "custom_java_library") + +custom_java_library( + name = "url", + srcs = glob(["src/main/java/co/airy/url/**/*.java"]), + visibility = ["//visibility:public"], +) diff --git a/lib/java/url/src/main/java/co/airy/url/UrlUtil.java b/lib/java/url/src/main/java/co/airy/url/UrlUtil.java new file mode 100644 index 0000000000..cfdfaadacf --- /dev/null +++ b/lib/java/url/src/main/java/co/airy/url/UrlUtil.java @@ -0,0 +1,29 @@ +package co.airy.url; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static java.util.stream.Collectors.toMap; + +public class UrlUtil { + public static Map parseUrlEncoded(String payload) { + List kvPairs = Arrays.asList(payload.split("&")); + + return kvPairs.stream() + .map((kvPair) -> { + String[] fields = kvPair.split("="); + + String name = URLDecoder.decode(fields[0], StandardCharsets.UTF_8); + String value = ""; + if (fields.length > 1) { + value = URLDecoder.decode(fields[1], StandardCharsets.UTF_8); + } + + return List.of(name, value); + }) + .collect(toMap((tuple) -> tuple.get(0), (tuple) -> tuple.get(1))); + } +} diff --git a/lib/typescript/httpclient/README.md b/lib/typescript/httpclient/README.md index b90afc4eb7..44d4f4c88a 100644 --- a/lib/typescript/httpclient/README.md +++ b/lib/typescript/httpclient/README.md @@ -1,23 +1,22 @@ ### HttpClient Library -The HttpClient Library includes helper functions for using Airy's endpoints on the frontend. - -Each function performs an http request and returns a promise. - -To use the library's functions, import the library and call the module's methods. +The HttpClient Library includes a HTTP client for making requests to Airy's API. +The library exports a HttpClient class. To use the library, you need to instantiate the class with the authentification token and your api url. Both the authentification token and api url are optional (the default api url is "http://api.airy"), but communication with the endpoints always requires a token, except for /users.login and /users.signup endpoints. For example: ``` import { HttpClient} from 'httpclient'; -HttpClient.listChannels() +const myInstance = new HttpClient(authtoken, apiUrl); + +myInstance.listChannels() ``` -Here is a list of the functions it includes: +Here is a list of the public methods the library's class includes: CHANNELS - listChannels @@ -27,6 +26,10 @@ CHANNELS CONVERSATIONS - listConversations +- readConversations + +MESSAGES +- listMessages TAGS - listTags diff --git a/lib/typescript/httpclient/api/airyConfig.ts b/lib/typescript/httpclient/api/airyConfig.ts deleted file mode 100644 index d3b76d00c6..0000000000 --- a/lib/typescript/httpclient/api/airyConfig.ts +++ /dev/null @@ -1,55 +0,0 @@ -import {getAuthToken} from './webStore'; - -export class AiryConfig { - static API_URL = 'http://api.airy'; - static NODE_ENV = process.env.NODE_ENV; - static FACEBOOK_APP_ID = 'CHANGE_ME'; -} - -const headers = { - Accept: 'application/json', -}; - -export const doFetchFromBackend = async (url: string, body?: Object): Promise => { - const token = getAuthToken(); - if (token) { - headers['Authorization'] = token; - } - - if (!(body instanceof FormData)) { - if (!isString(body)) { - body = JSON.stringify(body); - } - headers['Content-Type'] = 'application/json'; - } - - const response: Response = await fetch(`${AiryConfig.API_URL}/${url}`, { - method: 'POST', - headers: headers, - body: body as BodyInit, - }); - - return parseBody(response); -}; - -async function parseBody(response: Response): Promise { - if (response.ok) { - return response.json(); - } - - let body = await response.text(); - if (body.length > 0) { - body = JSON.parse(body); - } - - const errorResponse = { - status: response.status, - body: body, - }; - - throw errorResponse; -} - -function isString(object: any) { - return typeof object === 'string' || object instanceof String; -} diff --git a/lib/typescript/httpclient/endpoints/connectChannel.ts b/lib/typescript/httpclient/endpoints/connectChannel.ts deleted file mode 100644 index 768f182985..0000000000 --- a/lib/typescript/httpclient/endpoints/connectChannel.ts +++ /dev/null @@ -1,35 +0,0 @@ -import {doFetchFromBackend} from '../api'; -import {ConnectChannelRequestPayload, ConnectChannelRequestApiPayload} from '../payload'; -import {Channel} from '../model'; -import {ChannelApiPayload} from '../payload/ChannelApiPayload'; - -const connectChannelApiMapper = (payload: ConnectChannelRequestPayload): ConnectChannelRequestApiPayload => { - return { - source: payload.source, - source_channel_id: payload.sourceChannelId, - token: payload.token, - name: payload.name, - image_url: payload.imageUrl, - }; -}; - -const channelMapper = (payload: ChannelApiPayload): Channel => { - return { - name: payload.name, - source: payload.source, - sourceChannelId: payload.source_channel_id, - imageUrl: payload.image_url, - connected: true, - }; -}; - -export function connectChannel(requestPayload: ConnectChannelRequestPayload) { - return doFetchFromBackend('channels.connect', connectChannelApiMapper(requestPayload)) - .then((response: ChannelApiPayload) => { - const channel = channelMapper(response); - return channel; - }) - .catch((error: Error) => { - return error; - }); -} diff --git a/lib/typescript/httpclient/endpoints/createTag.ts b/lib/typescript/httpclient/endpoints/createTag.ts deleted file mode 100644 index 2313504601..0000000000 --- a/lib/typescript/httpclient/endpoints/createTag.ts +++ /dev/null @@ -1,19 +0,0 @@ -import {doFetchFromBackend} from '../api'; -import {Tag, TagColor} from '../model'; -import {CreateTagRequestPayload} from '../payload'; -import {TagPayload} from '../payload/TagPayload'; - -export function createTag(requestPayload: CreateTagRequestPayload) { - return doFetchFromBackend('tags.create', requestPayload) - .then((response: TagPayload) => { - const tag: Tag = { - id: response.id, - name: requestPayload.name, - color: requestPayload.color as TagColor, - }; - return tag; - }) - .catch((error: Error) => { - return error; - }); -} diff --git a/lib/typescript/httpclient/endpoints/deleteTag.ts b/lib/typescript/httpclient/endpoints/deleteTag.ts deleted file mode 100644 index 72abdf31d9..0000000000 --- a/lib/typescript/httpclient/endpoints/deleteTag.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {doFetchFromBackend} from '../api'; - -export function deleteTag(id: string) { - return doFetchFromBackend('tags.delete', { - id, - }) - .then(() => Promise.resolve(true)) - .catch((error: Error) => Promise.reject(error)); -} diff --git a/lib/typescript/httpclient/endpoints/disconnectChannel.ts b/lib/typescript/httpclient/endpoints/disconnectChannel.ts deleted file mode 100644 index c63d5ece66..0000000000 --- a/lib/typescript/httpclient/endpoints/disconnectChannel.ts +++ /dev/null @@ -1,32 +0,0 @@ -import {doFetchFromBackend} from '../api'; -import {DisconnectChannelRequestPayload, DisconnectChannelRequestApiPayload} from '../payload'; -import {Channel} from '../model'; -import {ChannelsPayload} from '../payload/ChannelsPayload'; - -const channelsMapper = (payload: ChannelsPayload, source?: string): Channel[] => { - return payload.data.map( - (entry: Channel): Channel => { - return { - source, - ...entry, - }; - } - ); -}; - -const disconnectChannelApiMapper = (payload: DisconnectChannelRequestPayload): DisconnectChannelRequestApiPayload => { - return { - channel_id: payload.channelId, - }; -}; - -export function disconnectChannel(requestPayload: DisconnectChannelRequestPayload) { - return doFetchFromBackend('channels.disconnect', disconnectChannelApiMapper(requestPayload)) - .then((response: ChannelsPayload) => { - const channels = channelsMapper(response); - return channels; - }) - .catch((error: Error) => { - return error; - }); -} diff --git a/lib/typescript/httpclient/endpoints/exploreChannels.ts b/lib/typescript/httpclient/endpoints/exploreChannels.ts deleted file mode 100644 index 6de5235e61..0000000000 --- a/lib/typescript/httpclient/endpoints/exploreChannels.ts +++ /dev/null @@ -1,26 +0,0 @@ -import {doFetchFromBackend} from '../api'; -import {ExploreChannelRequestPayload} from '../payload'; -import {Channel} from '../model'; -import {ChannelsPayload} from '../payload/ChannelsPayload'; - -const channelsMapper = (payload: ChannelsPayload, source?: string): Channel[] => { - return payload.data.map( - (entry: Channel): Channel => { - return { - source, - ...entry, - }; - } - ); -}; - -export function exploreChannels(requestPayload: ExploreChannelRequestPayload) { - return doFetchFromBackend('channels.explore', requestPayload) - .then((response: ChannelsPayload) => { - const channels = channelsMapper(response, requestPayload.source); - return channels; - }) - .catch((error: Error) => { - return error; - }); -} diff --git a/lib/typescript/httpclient/endpoints/index.ts b/lib/typescript/httpclient/endpoints/index.ts deleted file mode 100644 index cb394896b7..0000000000 --- a/lib/typescript/httpclient/endpoints/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export * from './listChannels'; -export * from './exploreChannels'; -export * from './connectChannel'; -export * from './disconnectChannel'; -export * from './listConversations'; -export * from './listTags'; -export * from './createTag'; -export * from './updateTag'; -export * from './deleteTag'; -export * from './loginViaEmail'; diff --git a/lib/typescript/httpclient/endpoints/listChannels.ts b/lib/typescript/httpclient/endpoints/listChannels.ts deleted file mode 100644 index bc1e47642e..0000000000 --- a/lib/typescript/httpclient/endpoints/listChannels.ts +++ /dev/null @@ -1,25 +0,0 @@ -import {doFetchFromBackend} from '../api'; -import {Channel} from '../model'; -import {ChannelsPayload} from '../payload/ChannelsPayload'; - -const channelsMapper = (payload: ChannelsPayload, source?: string): Channel[] => { - return payload.data.map( - (entry: Channel): Channel => { - return { - source, - ...entry, - }; - } - ); -}; - -export function listChannels() { - return doFetchFromBackend('channels.list') - .then((response: ChannelsPayload) => { - const channels = channelsMapper(response); - return channels; - }) - .catch((error: Error) => { - return error; - }); -} diff --git a/lib/typescript/httpclient/endpoints/listConversations.ts b/lib/typescript/httpclient/endpoints/listConversations.ts deleted file mode 100644 index 1234aa1023..0000000000 --- a/lib/typescript/httpclient/endpoints/listConversations.ts +++ /dev/null @@ -1,54 +0,0 @@ -import {doFetchFromBackend} from '../api'; -import {ListConversationsRequestPayload} from '../payload'; -import {Conversation, Message} from '../model'; -import {ConversationPayload} from '../payload/ConversationPayload'; -import {MessagePayload} from '../payload/MessagePayload'; -import {PaginatedPayload} from '../payload/PaginatedPayload'; - -const messageMapper = (payload: MessagePayload): Message => { - const message: Message = { - id: payload.id, - content: payload.content, - state: payload.state, - alignment: payload.alignment, - sentAt: payload.sent_at, - }; - return message; -}; - -const conversationMapper = (payload: ConversationPayload): Conversation => { - const conversation: Conversation = { - id: payload.id, - channel: payload.channel, - createdAt: payload.created_at, - contact: { - avatarUrl: payload.contact.avatar_url, - firstName: payload.contact.first_name, - lastName: payload.contact.last_name, - displayName: payload.contact.first_name + ' ' + payload.contact.last_name, - id: payload.contact.id, - }, - tags: payload.tags, - lastMessage: messageMapper(payload.last_message), - unreadMessageCount: payload.unread_message_count, - }; - return conversation; -}; - -const conversationsMapper = (payloadArray: ConversationPayload[]): Conversation[] => { - return (payloadArray || []).map(conversation => conversationMapper(conversation)); -}; - -export function listConversations(conversationListRequest: ListConversationsRequestPayload) { - conversationListRequest.page_size = conversationListRequest.page_size ?? 10; - conversationListRequest.cursor = conversationListRequest.cursor ?? null; - - return doFetchFromBackend('conversations.list', conversationListRequest) - .then((response: PaginatedPayload) => { - const {responseMetadata} = response; - return {data: conversationsMapper(response.data), metadata: responseMetadata}; - }) - .catch((error: Error) => { - return error; - }); -} diff --git a/lib/typescript/httpclient/endpoints/listTags.ts b/lib/typescript/httpclient/endpoints/listTags.ts deleted file mode 100644 index 27fe74381b..0000000000 --- a/lib/typescript/httpclient/endpoints/listTags.ts +++ /dev/null @@ -1,24 +0,0 @@ -import {doFetchFromBackend} from '../api'; -import {Tag} from '../model'; -import {ListTagsResponsePayload} from '../payload'; - -const tagMapper = { - BLUE: 'tag-blue', - RED: 'tag-red', - GREEN: 'tag-green', - PURPLE: 'tag-purple', -}; - -const tagsMapper = (serverTags: Tag[]): Tag[] => { - return serverTags.map(t => ({id: t.id, name: t.name, color: tagMapper[t.color] || 'tag-blue'})); -}; - -export function listTags() { - return doFetchFromBackend('tags.list') - .then((response: ListTagsResponsePayload) => { - return tagsMapper(response.data); - }) - .catch((error: Error) => { - return error; - }); -} diff --git a/lib/typescript/httpclient/endpoints/loginViaEmail.ts b/lib/typescript/httpclient/endpoints/loginViaEmail.ts deleted file mode 100644 index e64db2f026..0000000000 --- a/lib/typescript/httpclient/endpoints/loginViaEmail.ts +++ /dev/null @@ -1,24 +0,0 @@ -import {doFetchFromBackend} from '../api'; -import {User} from '../model'; -import {LoginViaEmailRequestPayload} from '../payload'; -import {UserPayload} from '../payload/UserPayload'; - -const userMapper = (payload: UserPayload): User => { - return { - id: payload.id, - firstName: payload.first_name, - lastName: payload.last_name, - displayName: payload.first_name + ' ' + payload.last_name, - token: payload.token, - }; -}; - -export function loginViaEmail(requestPayload: LoginViaEmailRequestPayload) { - return doFetchFromBackend('users.login', requestPayload) - .then((response: UserPayload) => { - return userMapper(response); - }) - .catch((error: Error) => { - return error; - }); -} diff --git a/lib/typescript/httpclient/endpoints/updateTag.ts b/lib/typescript/httpclient/endpoints/updateTag.ts deleted file mode 100644 index 033a2e6e23..0000000000 --- a/lib/typescript/httpclient/endpoints/updateTag.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {doFetchFromBackend} from '../api'; -import {Tag} from '../model'; - -export function updateTag(tag: Tag) { - return doFetchFromBackend('tags.update', {...tag}) - .then(() => Promise.resolve(true)) - .catch((error: Error) => Promise.reject(error)); -} diff --git a/lib/typescript/httpclient/index.ts b/lib/typescript/httpclient/index.ts index ebe9609742..6f1994d915 100644 --- a/lib/typescript/httpclient/index.ts +++ b/lib/typescript/httpclient/index.ts @@ -1,32 +1,243 @@ +import {ChannelsPayload} from './payload/ChannelsPayload'; +import {channelsMapper} from './mappers/channelsMapper'; import { - listChannels, - exploreChannels, - connectChannel, - disconnectChannel, - listConversations, - listTags, - createTag, - updateTag, - deleteTag, - loginViaEmail, -} from './endpoints'; - -export const HttpClient = (function() { - return { - listChannels: listChannels, - exploreChannels: exploreChannels, - connectChannel: connectChannel, - disconnectChannel: disconnectChannel, - listConversations: listConversations, - listTags: listTags, - createTag: createTag, - updateTag: updateTag, - deleteTag: deleteTag, - loginViaEmail: loginViaEmail, + ExploreChannelRequestPayload, + ConnectChannelRequestPayload, + DisconnectChannelRequestPayload, + ListConversationsRequestPayload, + ListTagsResponsePayload, + CreateTagRequestPayload, + LoginViaEmailRequestPayload, +} from './payload'; +import {ChannelApiPayload} from './payload/ChannelApiPayload'; +import {connectChannelApiMapper} from './mappers/connectChannelApiMapper'; +import {channelMapper} from './mappers/channelMapper'; +import {disconnectChannelApiMapper} from './mappers/disconnectChannelApiMapper'; +import {ConversationPayload} from './payload/ConversationPayload'; +import {PaginatedPayload} from './payload/PaginatedPayload'; +import {conversationsMapper} from './mappers/conversationsMapper'; +import {ListMessagesRequestPayload} from './payload/ListMessagesRequestPayload'; +import {TagConversationRequestPayload} from './payload/TagConversationRequestPayload'; +import {UntagConversationRequestPayload} from './payload/UntagConversationRequestPayload'; +import {MessagePayload} from './payload/MessagePayload'; +import {messageMapperData} from './mappers/messageMapperData'; +import {tagsMapper} from './mappers/tagsMapper'; +import {TagColor, Tag} from './model'; +import {TagPayload} from './payload/TagPayload'; +import {userMapper} from './mappers/userMapper'; + +const headers = { + Accept: 'application/json', +}; + +export async function parseBody(response: Response): Promise { + if (response.ok) { + try { + return await response.json(); + } catch { + // NOP + } + } + + let body = await response.text(); + + if (body.length > 0) { + body = JSON.parse(body); + } + + const errorResponse = { + status: response.status, + body: body, }; -})(); -export * from './api'; + throw errorResponse; +} + +export function isString(object: any) { + return typeof object === 'string' || object instanceof String; +} + +export class HttpClient { + public readonly token?: string; + public readonly apiUrlConfig?: string; + + constructor(token?: string, apiUrlConfig?: string) { + this.token = token; + this.apiUrlConfig = apiUrlConfig || 'http://api.airy'; + } + + private async doFetchFromBackend(url: string, body?: Object): Promise { + if (this.token) { + headers['Authorization'] = this.token; + } + if (!(body instanceof FormData)) { + if (!isString(body)) { + body = JSON.stringify(body); + } + headers['Content-Type'] = 'application/json'; + } + + const response: Response = await fetch(`${this.apiUrlConfig}/${url}`, { + method: 'POST', + headers: headers, + body: body as BodyInit, + }); + + return parseBody(response); + } + + public async listChannels() { + try { + const response: ChannelsPayload = await this.doFetchFromBackend('channels.list'); + return channelsMapper(response); + } catch (error) { + return error; + } + } + + public async exploreChannels(requestPayload: ExploreChannelRequestPayload) { + try { + const response: ChannelsPayload = await this.doFetchFromBackend('channels.explore', requestPayload); + return channelsMapper(response, requestPayload.source); + } catch (error) { + return error; + } + } + + public async connectChannel(requestPayload: ConnectChannelRequestPayload) { + try { + const response: ChannelApiPayload = await this.doFetchFromBackend( + 'channels.connect', + connectChannelApiMapper(requestPayload) + ); + return channelMapper(response); + } catch (error) { + return error; + } + } + + public async disconnectChannel(requestPayload: DisconnectChannelRequestPayload) { + try { + const response: ChannelsPayload = await this.doFetchFromBackend( + 'channels.disconnect', + disconnectChannelApiMapper(requestPayload) + ); + return channelsMapper(response); + } catch (error) { + return error; + } + } + + public async listConversations(conversationListRequest: ListConversationsRequestPayload) { + conversationListRequest.page_size = conversationListRequest.page_size ?? 10; + conversationListRequest.cursor = conversationListRequest.cursor ?? null; + try { + const response: PaginatedPayload = await this.doFetchFromBackend( + 'conversations.list', + conversationListRequest + ); + const {response_metadata} = response; + return {data: conversationsMapper(response.data), metadata: response_metadata}; + } catch (error) { + return error; + } + } + + public async readConversations(conversationId: string) { + await this.doFetchFromBackend('conversations.read', {conversation_id: conversationId}); + return Promise.resolve(true); + } + + public async listMessages(conversationListRequest: ListMessagesRequestPayload) { + conversationListRequest.pageSize = conversationListRequest.pageSize ?? 10; + conversationListRequest.cursor = conversationListRequest.cursor ?? null; + + try { + const response: PaginatedPayload = await this.doFetchFromBackend('messages.list', { + conversation_id: conversationListRequest.conversationId, + cursor: conversationListRequest.cursor, + page_size: conversationListRequest.pageSize, + }); + const {response_metadata} = response; + return {data: messageMapperData(response), metadata: response_metadata}; + } catch (error) { + return error; + } + } + + public async listTags() { + try { + const response: ListTagsResponsePayload = await this.doFetchFromBackend('tags.list'); + return tagsMapper(response.data); + } catch (error) { + return error; + } + } + + public async createTag(requestPayload: CreateTagRequestPayload) { + try { + const response: TagPayload = await this.doFetchFromBackend('tags.create', requestPayload); + return { + id: response.id, + name: requestPayload.name, + color: requestPayload.color as TagColor, + }; + } catch (error) { + return error; + } + } + + public async updateTag(tag: Tag) { + try { + await this.doFetchFromBackend('tags.update', {...tag}); + return Promise.resolve(true); + } catch (error) { + return error; + } + } + + public async deleteTag(id: string) { + try { + await this.doFetchFromBackend('tags.delete', {id}); + return Promise.resolve(true); + } catch (error) { + return error; + } + } + + public async loginViaEmail(requestPayload: LoginViaEmailRequestPayload) { + try { + const response = await this.doFetchFromBackend('users.login', requestPayload); + return userMapper(response); + } catch (error) { + return error; + } + } + + public async tagConversation(requestPayload: TagConversationRequestPayload) { + try { + await this.doFetchFromBackend('conversations.tag', { + conversation_id: requestPayload.conversationId, + tag_id: requestPayload.tagId, + }); + return Promise.resolve(true); + } catch (error) { + return error; + } + } + + public async untagConversation(requestPayload: UntagConversationRequestPayload) { + try { + await this.doFetchFromBackend('conversations.untag', { + conversation_id: requestPayload.conversationId, + tag_id: requestPayload.tagId, + }); + return Promise.resolve(true); + } catch (error) { + return error; + } + } +} + export * from './model'; -export * from './endpoints'; export * from './payload'; diff --git a/lib/typescript/httpclient/mappers/channelMapper.ts b/lib/typescript/httpclient/mappers/channelMapper.ts new file mode 100644 index 0000000000..b8bf0b19f9 --- /dev/null +++ b/lib/typescript/httpclient/mappers/channelMapper.ts @@ -0,0 +1,10 @@ +import {Channel} from '../model'; +import {ChannelApiPayload} from '../payload/ChannelApiPayload'; + +export const channelMapper = (payload: ChannelApiPayload): Channel => ({ + name: payload.name, + source: payload.source, + sourceChannelId: payload.source_channel_id, + imageUrl: payload.image_url, + connected: true, +}); diff --git a/lib/typescript/httpclient/mappers/channelsMapper.ts b/lib/typescript/httpclient/mappers/channelsMapper.ts new file mode 100644 index 0000000000..2980892bb3 --- /dev/null +++ b/lib/typescript/httpclient/mappers/channelsMapper.ts @@ -0,0 +1,11 @@ +import {ChannelsPayload} from '../payload/ChannelsPayload'; +import {Channel} from '../model'; + +export const channelsMapper = (payload: ChannelsPayload, source?: string): Channel[] => { + return payload.data.map( + (entry: Channel): Channel => ({ + source, + ...entry, + }) + ); +}; diff --git a/lib/typescript/httpclient/mappers/connectChannelApiMapper.ts b/lib/typescript/httpclient/mappers/connectChannelApiMapper.ts new file mode 100644 index 0000000000..65e3f20b12 --- /dev/null +++ b/lib/typescript/httpclient/mappers/connectChannelApiMapper.ts @@ -0,0 +1,9 @@ +import {ConnectChannelRequestPayload, ConnectChannelRequestApiPayload} from '../payload'; + +export const connectChannelApiMapper = (payload: ConnectChannelRequestPayload): ConnectChannelRequestApiPayload => ({ + source: payload.source, + source_channel_id: payload.sourceChannelId, + token: payload.token, + name: payload.name, + image_url: payload.imageUrl, +}); diff --git a/lib/typescript/httpclient/mappers/conversationMapper.ts b/lib/typescript/httpclient/mappers/conversationMapper.ts new file mode 100644 index 0000000000..066e4417dc --- /dev/null +++ b/lib/typescript/httpclient/mappers/conversationMapper.ts @@ -0,0 +1,17 @@ +import {Conversation} from '../model'; +import {ConversationPayload} from '../payload/ConversationPayload'; +import {messageMapper} from './messageMapper'; + +export const conversationMapper = (payload: ConversationPayload): Conversation => ({ + id: payload.id, + channel: payload.channel, + createdAt: payload.created_at, + contact: { + avatarUrl: payload.contact.avatar_url, + displayName: payload.contact.display_name, + id: payload.contact.id, + }, + tags: payload.tags, + lastMessage: messageMapper(payload.last_message), + unreadMessageCount: payload.unread_message_count, +}); diff --git a/lib/typescript/httpclient/mappers/conversationsMapper.ts b/lib/typescript/httpclient/mappers/conversationsMapper.ts new file mode 100644 index 0000000000..b17a56d3ec --- /dev/null +++ b/lib/typescript/httpclient/mappers/conversationsMapper.ts @@ -0,0 +1,7 @@ +import {Conversation} from '../model'; +import {ConversationPayload} from '../payload/ConversationPayload'; +import {conversationMapper} from './conversationMapper'; + +export const conversationsMapper = (payloadArray: ConversationPayload[]): Conversation[] => { + return (payloadArray || []).map(conversation => conversationMapper(conversation)); +}; diff --git a/lib/typescript/httpclient/mappers/disconnectChannelApiMapper.ts b/lib/typescript/httpclient/mappers/disconnectChannelApiMapper.ts new file mode 100644 index 0000000000..0916c16c6b --- /dev/null +++ b/lib/typescript/httpclient/mappers/disconnectChannelApiMapper.ts @@ -0,0 +1,7 @@ +import {DisconnectChannelRequestPayload, DisconnectChannelRequestApiPayload} from '../payload'; + +export const disconnectChannelApiMapper = ( + payload: DisconnectChannelRequestPayload +): DisconnectChannelRequestApiPayload => ({ + channel_id: payload.channelId, +}); diff --git a/lib/typescript/httpclient/mappers/messageMapper.ts b/lib/typescript/httpclient/mappers/messageMapper.ts new file mode 100644 index 0000000000..59fba866f5 --- /dev/null +++ b/lib/typescript/httpclient/mappers/messageMapper.ts @@ -0,0 +1,10 @@ +import {MessagePayload} from '../payload/MessagePayload'; +import {Message} from '../model'; + +export const messageMapper = (payload: MessagePayload): Message => ({ + id: payload.id, + content: payload.content, + deliveryState: payload.delivery_state, + senderType: payload.sender_type, + sentAt: new Date(payload.sent_at), +}); diff --git a/lib/typescript/httpclient/mappers/messageMapperData.ts b/lib/typescript/httpclient/mappers/messageMapperData.ts new file mode 100644 index 0000000000..fc480dda91 --- /dev/null +++ b/lib/typescript/httpclient/mappers/messageMapperData.ts @@ -0,0 +1,7 @@ +import {MessagePayload} from '../payload/MessagePayload'; +import {Message, MessagePayloadData} from '../model'; +import {messageMapper} from './messageMapper'; + +export const messageMapperData = (payload: MessagePayloadData): Message[] => { + return payload.data.map((messagePayload: MessagePayload) => messageMapper(messagePayload)); +}; diff --git a/lib/typescript/httpclient/mappers/tagsMapper.ts b/lib/typescript/httpclient/mappers/tagsMapper.ts new file mode 100644 index 0000000000..02d6dec3f3 --- /dev/null +++ b/lib/typescript/httpclient/mappers/tagsMapper.ts @@ -0,0 +1,12 @@ +import {Tag} from '../model'; + +const tagMapper = { + BLUE: 'tag-blue', + RED: 'tag-red', + GREEN: 'tag-green', + PURPLE: 'tag-purple', +}; + +export const tagsMapper = (serverTags: Tag[]): Tag[] => { + return serverTags.map(t => ({id: t.id, name: t.name, color: tagMapper[t.color] || 'tag-blue'})); +}; diff --git a/lib/typescript/httpclient/mappers/userMapper.ts b/lib/typescript/httpclient/mappers/userMapper.ts new file mode 100644 index 0000000000..a79115b0bb --- /dev/null +++ b/lib/typescript/httpclient/mappers/userMapper.ts @@ -0,0 +1,10 @@ +import {UserPayload} from '../payload/UserPayload'; +import {User} from '../model'; + +export const userMapper = (payload: UserPayload): User => ({ + id: payload.id, + firstName: payload.first_name, + lastName: payload.last_name, + displayName: payload.first_name + ' ' + payload.last_name, + token: payload.token, +}); diff --git a/lib/typescript/httpclient/model/Contact.ts b/lib/typescript/httpclient/model/Contact.ts index 8e0e4b327b..827cac6189 100644 --- a/lib/typescript/httpclient/model/Contact.ts +++ b/lib/typescript/httpclient/model/Contact.ts @@ -2,10 +2,7 @@ import {Tag} from './Tag'; export interface Contact { id: string; - info: Dictionary; - first_name: string; - last_name: string; - display_name: string; - avatar_url: string; - tags: Tag[]; + displayName: string; + avatarUrl: string; + tags?: Tag[]; } diff --git a/lib/typescript/httpclient/model/Conversation.ts b/lib/typescript/httpclient/model/Conversation.ts index df10890cfb..927a15e4c1 100644 --- a/lib/typescript/httpclient/model/Conversation.ts +++ b/lib/typescript/httpclient/model/Conversation.ts @@ -1,17 +1,12 @@ import {Channel} from './Channel'; +import {Contact} from './Contact'; import {Message} from './Message'; export interface Conversation { id: string; channel: Channel; createdAt: string; - contact: { - avatarUrl: string; - firstName: string; - lastName: string; - displayName: string; - id: string; - }; + contact: Contact; tags: string[]; lastMessage: Message; unreadMessageCount?: number; diff --git a/lib/typescript/httpclient/model/Message.ts b/lib/typescript/httpclient/model/Message.ts index 106d1a6d0c..1b32c14848 100644 --- a/lib/typescript/httpclient/model/Message.ts +++ b/lib/typescript/httpclient/model/Message.ts @@ -1,3 +1,5 @@ +import {MessagePayload} from '../payload/MessagePayload'; + export interface Attachement { type: string; payload: { @@ -28,13 +30,21 @@ export enum MessageState { delivered = 'DELIVERED', } +export enum SenderType { + sourceContact = 'source_contact', + sourceUser = 'source_user', + appUser = 'app_user', +} export interface Message { id: string; content: { text: string; type: MessageType; }; - state: MessageState; - alignment: MessageAlignment; - sentAt: string | Date; + deliveryState: MessageState; + senderType: SenderType; + sentAt: Date; +} +export interface MessagePayloadData { + data: MessagePayload[]; } diff --git a/lib/typescript/httpclient/payload/ConversationPayload.ts b/lib/typescript/httpclient/payload/ConversationPayload.ts index d7bd809c40..7f28245b6c 100644 --- a/lib/typescript/httpclient/payload/ConversationPayload.ts +++ b/lib/typescript/httpclient/payload/ConversationPayload.ts @@ -7,8 +7,7 @@ export interface ConversationPayload { created_at: string; contact: { avatar_url: string; - first_name: string; - last_name: string; + display_name: string; id: string; }; tags: string[]; diff --git a/lib/typescript/httpclient/payload/ListMessagesRequestPayload.ts b/lib/typescript/httpclient/payload/ListMessagesRequestPayload.ts new file mode 100644 index 0000000000..7c4839721d --- /dev/null +++ b/lib/typescript/httpclient/payload/ListMessagesRequestPayload.ts @@ -0,0 +1,5 @@ +export interface ListMessagesRequestPayload { + conversationId: string; + cursor?: string | null; + pageSize?: number; +} diff --git a/lib/typescript/httpclient/payload/MessagePayload.ts b/lib/typescript/httpclient/payload/MessagePayload.ts index 587e3b7ead..5b75c905d0 100644 --- a/lib/typescript/httpclient/payload/MessagePayload.ts +++ b/lib/typescript/httpclient/payload/MessagePayload.ts @@ -1,4 +1,4 @@ -import {MessageType, MessageState, MessageAlignment} from '../model'; +import {MessageType, MessageState, SenderType} from '../model'; export interface MessagePayload { id: string; @@ -6,7 +6,7 @@ export interface MessagePayload { text: string; type: MessageType; }; - state: MessageState; - alignment: MessageAlignment; - sent_at: string | Date; + delivery_state: MessageState; + sender_type: SenderType; + sent_at: Date; } diff --git a/lib/typescript/httpclient/payload/PaginatedPayload.ts b/lib/typescript/httpclient/payload/PaginatedPayload.ts index 327378568f..4afb6d42a5 100644 --- a/lib/typescript/httpclient/payload/PaginatedPayload.ts +++ b/lib/typescript/httpclient/payload/PaginatedPayload.ts @@ -1,4 +1,4 @@ export interface PaginatedPayload { data: T[]; - responseMetadata: {previousCursor: string; nextCursor: string; total: number}; + response_metadata: {previous_cursor: string; next_cursor: string; total: number}; } diff --git a/lib/typescript/httpclient/payload/TagConversationRequestPayload.ts b/lib/typescript/httpclient/payload/TagConversationRequestPayload.ts new file mode 100644 index 0000000000..264411731d --- /dev/null +++ b/lib/typescript/httpclient/payload/TagConversationRequestPayload.ts @@ -0,0 +1,4 @@ +export interface TagConversationRequestPayload { + conversationId: string; + tagId: string; +} diff --git a/lib/typescript/httpclient/payload/UntagConversationRequestPayload.ts b/lib/typescript/httpclient/payload/UntagConversationRequestPayload.ts new file mode 100644 index 0000000000..60b6ee60c6 --- /dev/null +++ b/lib/typescript/httpclient/payload/UntagConversationRequestPayload.ts @@ -0,0 +1,4 @@ +export interface UntagConversationRequestPayload { + conversationId: string; + tagId: string; +} diff --git a/lib/typescript/httpclient/payload/index.ts b/lib/typescript/httpclient/payload/index.ts index ee0ddcfcd6..dbe7695df8 100644 --- a/lib/typescript/httpclient/payload/index.ts +++ b/lib/typescript/httpclient/payload/index.ts @@ -1,9 +1,12 @@ export * from './ConnectChannelRequestApiPayload'; export * from './ConnectChannelRequestPayload'; +export * from './CreateTagRequestPayload'; export * from './DisconnectChannelRequestApiPayload'; export * from './DisconnectChannelRequestPayload'; export * from './ExploreChannelRequestPayload'; -export * from './LoginViaEmailRequestPayload'; -export * from './ListTagsResponsePayload'; -export * from './CreateTagRequestPayload'; export * from './ListConversationsRequestPayload'; +export * from './ListTagsResponsePayload'; +export * from './LoginViaEmailRequestPayload'; +export * from './ResponseMetadataPayload'; +export * from './TagConversationRequestPayload'; +export * from './UntagConversationRequestPayload'; diff --git a/lib/typescript/types/BUILD b/lib/typescript/types/BUILD index 7fed076ef6..67865228e8 100644 --- a/lib/typescript/types/BUILD +++ b/lib/typescript/types/BUILD @@ -5,6 +5,10 @@ package(default_visibility = ["//visibility:public"]) ts_library( name = "types", srcs = glob([ - "**/*.d.ts", + "**/*.ts", ]), + deps = [ + "@npm//@types/react", + "@npm//@types/react-dom", + ], ) diff --git a/lib/typescript/types/content.ts b/lib/typescript/types/content.ts index d3d788072c..1ad4c12925 100644 --- a/lib/typescript/types/content.ts +++ b/lib/typescript/types/content.ts @@ -1,9 +1,31 @@ /* tslint:disable */ /* eslint-disable */ -// Generated using typescript-generator version 2.26.723 on 2020-12-02 10:41:15. + +// Generated using typescript-generator version 2.26.723 on 2021-01-19 11:27:56. + +export interface Audio extends Content, DataUrl { + type: 'audio'; +} export interface Content { - type: 'text'; + type: 'audio' | 'file' | 'image' | 'source.template' | 'text' | 'video'; +} + +export interface DataUrl { + url: string; +} + +export interface File extends Content, DataUrl { + type: 'file'; +} + +export interface Image extends Content, DataUrl { + type: 'image'; +} + +export interface SourceTemplate extends Content { + type: 'source.template'; + payload: any; } export interface Text extends Content { @@ -11,4 +33,8 @@ export interface Text extends Content { text: string; } -export type ContentUnion = Text; +export interface Video extends Content, DataUrl { + type: 'video'; +} + +export type ContentUnion = Text | Audio | File | Image | Video | SourceTemplate; diff --git a/lib/typescript/types/global.d.ts b/lib/typescript/types/global.d.ts index dfacfac5de..b99178d4e2 100644 --- a/lib/typescript/types/global.d.ts +++ b/lib/typescript/types/global.d.ts @@ -1,13 +1,6 @@ /// /// -interface CustomNodeModule extends NodeModule { - hot: any; -} - -// Hot Module Replacement -declare let module: CustomNodeModule; - declare module '*.gif' { const src: string; export default src; diff --git a/lib/typescript/types/index.ts b/lib/typescript/types/index.ts new file mode 100644 index 0000000000..7b367d1463 --- /dev/null +++ b/lib/typescript/types/index.ts @@ -0,0 +1 @@ +export * from './content'; diff --git a/maven_install.json b/maven_install.json index edd7cea957..6d9d1796fa 100644 --- a/maven_install.json +++ b/maven_install.json @@ -1,6 +1,6 @@ { "dependency_tree": { - "__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": -1613073853, + "__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": 1000798703, "conflict_resolution": { "com.fasterxml.jackson.core:jackson-annotations:2.10.0": "com.fasterxml.jackson.core:jackson-annotations:2.11.2", "com.fasterxml.jackson.core:jackson-core:2.10.0": "com.fasterxml.jackson.core:jackson-core:2.11.2", @@ -68,6 +68,146 @@ "sha256": "72e05e5031508115cafa6092cd53af306c5584957a34012511a20aac5e6c45e5", "url": "https://repo1.maven.org/maven2/com/101tec/zkclient/0.11/zkclient-0.11.jar" }, + { + "coord": "com.amazonaws:aws-java-sdk-core:1.11.933", + "dependencies": [ + "com.fasterxml.jackson.core:jackson-core:2.11.2", + "commons-logging:commons-logging:1.2", + "software.amazon.ion:ion-java:1.0.2", + "commons-codec:commons-codec:1.11", + "org.apache.httpcomponents:httpcore:4.4.13", + "joda-time:joda-time:2.10.2", + "com.fasterxml.jackson.core:jackson-annotations:2.11.2", + "com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:jar:2.6.7", + "org.apache.httpcomponents:httpclient:4.5.13", + "com.fasterxml.jackson.core:jackson-databind:jar:2.11.2" + ], + "directDependencies": [ + "commons-logging:commons-logging:1.2", + "software.amazon.ion:ion-java:1.0.2", + "joda-time:joda-time:2.10.2", + "com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:jar:2.6.7", + "org.apache.httpcomponents:httpclient:4.5.13", + "com.fasterxml.jackson.core:jackson-databind:jar:2.11.2" + ], + "exclusions": [ + "ch.qos.logback:logback-classic", + "org.springframework.boot:spring-boot-starter-tomcat", + "org.springframework.boot:spring-boot-starter-logging", + "org.slf4j:slf4j-log4j12" + ], + "file": "v1/https/repo1.maven.org/maven2/com/amazonaws/aws-java-sdk-core/1.11.933/aws-java-sdk-core-1.11.933.jar", + "mirror_urls": [ + "https://packages.confluent.io/maven/com/amazonaws/aws-java-sdk-core/1.11.933/aws-java-sdk-core-1.11.933.jar", + "https://oss.sonatype.org/content/repositories/snapshots/com/amazonaws/aws-java-sdk-core/1.11.933/aws-java-sdk-core-1.11.933.jar", + "https://repo1.maven.org/maven2/com/amazonaws/aws-java-sdk-core/1.11.933/aws-java-sdk-core-1.11.933.jar", + "https://jitpack.io/com/amazonaws/aws-java-sdk-core/1.11.933/aws-java-sdk-core-1.11.933.jar" + ], + "sha256": "3ec5d0fc6a6a605f74f5ac736bd3a96d189c113ee7fd84117b8b1281223c224c", + "url": "https://repo1.maven.org/maven2/com/amazonaws/aws-java-sdk-core/1.11.933/aws-java-sdk-core-1.11.933.jar" + }, + { + "coord": "com.amazonaws:aws-java-sdk-kms:1.11.933", + "dependencies": [ + "com.fasterxml.jackson.core:jackson-core:2.11.2", + "com.amazonaws:jmespath-java:1.11.933", + "com.amazonaws:aws-java-sdk-core:1.11.933", + "commons-logging:commons-logging:1.2", + "software.amazon.ion:ion-java:1.0.2", + "commons-codec:commons-codec:1.11", + "org.apache.httpcomponents:httpcore:4.4.13", + "joda-time:joda-time:2.10.2", + "com.fasterxml.jackson.core:jackson-annotations:2.11.2", + "com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:jar:2.6.7", + "org.apache.httpcomponents:httpclient:4.5.13", + "com.fasterxml.jackson.core:jackson-databind:2.11.2", + "com.fasterxml.jackson.core:jackson-databind:jar:2.11.2" + ], + "directDependencies": [ + "com.amazonaws:aws-java-sdk-core:1.11.933", + "com.amazonaws:jmespath-java:1.11.933" + ], + "exclusions": [ + "ch.qos.logback:logback-classic", + "org.springframework.boot:spring-boot-starter-tomcat", + "org.springframework.boot:spring-boot-starter-logging", + "org.slf4j:slf4j-log4j12" + ], + "file": "v1/https/repo1.maven.org/maven2/com/amazonaws/aws-java-sdk-kms/1.11.933/aws-java-sdk-kms-1.11.933.jar", + "mirror_urls": [ + "https://packages.confluent.io/maven/com/amazonaws/aws-java-sdk-kms/1.11.933/aws-java-sdk-kms-1.11.933.jar", + "https://oss.sonatype.org/content/repositories/snapshots/com/amazonaws/aws-java-sdk-kms/1.11.933/aws-java-sdk-kms-1.11.933.jar", + "https://repo1.maven.org/maven2/com/amazonaws/aws-java-sdk-kms/1.11.933/aws-java-sdk-kms-1.11.933.jar", + "https://jitpack.io/com/amazonaws/aws-java-sdk-kms/1.11.933/aws-java-sdk-kms-1.11.933.jar" + ], + "sha256": "12311f2824c5fd1a8d8f6aef7fbbf450192c406507003c3aa22aa1e660612035", + "url": "https://repo1.maven.org/maven2/com/amazonaws/aws-java-sdk-kms/1.11.933/aws-java-sdk-kms-1.11.933.jar" + }, + { + "coord": "com.amazonaws:aws-java-sdk-s3:1.11.933", + "dependencies": [ + "com.fasterxml.jackson.core:jackson-core:2.11.2", + "com.amazonaws:jmespath-java:1.11.933", + "com.amazonaws:aws-java-sdk-core:1.11.933", + "commons-logging:commons-logging:1.2", + "com.amazonaws:aws-java-sdk-kms:1.11.933", + "software.amazon.ion:ion-java:1.0.2", + "commons-codec:commons-codec:1.11", + "org.apache.httpcomponents:httpcore:4.4.13", + "joda-time:joda-time:2.10.2", + "com.fasterxml.jackson.core:jackson-annotations:2.11.2", + "com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:jar:2.6.7", + "org.apache.httpcomponents:httpclient:4.5.13", + "com.fasterxml.jackson.core:jackson-databind:2.11.2", + "com.fasterxml.jackson.core:jackson-databind:jar:2.11.2" + ], + "directDependencies": [ + "com.amazonaws:aws-java-sdk-core:1.11.933", + "com.amazonaws:aws-java-sdk-kms:1.11.933", + "com.amazonaws:jmespath-java:1.11.933" + ], + "exclusions": [ + "ch.qos.logback:logback-classic", + "org.springframework.boot:spring-boot-starter-tomcat", + "org.springframework.boot:spring-boot-starter-logging", + "org.slf4j:slf4j-log4j12" + ], + "file": "v1/https/repo1.maven.org/maven2/com/amazonaws/aws-java-sdk-s3/1.11.933/aws-java-sdk-s3-1.11.933.jar", + "mirror_urls": [ + "https://packages.confluent.io/maven/com/amazonaws/aws-java-sdk-s3/1.11.933/aws-java-sdk-s3-1.11.933.jar", + "https://oss.sonatype.org/content/repositories/snapshots/com/amazonaws/aws-java-sdk-s3/1.11.933/aws-java-sdk-s3-1.11.933.jar", + "https://repo1.maven.org/maven2/com/amazonaws/aws-java-sdk-s3/1.11.933/aws-java-sdk-s3-1.11.933.jar", + "https://jitpack.io/com/amazonaws/aws-java-sdk-s3/1.11.933/aws-java-sdk-s3-1.11.933.jar" + ], + "sha256": "8201d0e4db03e80050bf6e57009e3a71d47c31f4fe9e5bba3faa19ae5099a035", + "url": "https://repo1.maven.org/maven2/com/amazonaws/aws-java-sdk-s3/1.11.933/aws-java-sdk-s3-1.11.933.jar" + }, + { + "coord": "com.amazonaws:jmespath-java:1.11.933", + "dependencies": [ + "com.fasterxml.jackson.core:jackson-databind:2.11.2", + "com.fasterxml.jackson.core:jackson-core:2.11.2", + "com.fasterxml.jackson.core:jackson-annotations:2.11.2" + ], + "directDependencies": [ + "com.fasterxml.jackson.core:jackson-databind:2.11.2" + ], + "exclusions": [ + "ch.qos.logback:logback-classic", + "org.springframework.boot:spring-boot-starter-tomcat", + "org.springframework.boot:spring-boot-starter-logging", + "org.slf4j:slf4j-log4j12" + ], + "file": "v1/https/repo1.maven.org/maven2/com/amazonaws/jmespath-java/1.11.933/jmespath-java-1.11.933.jar", + "mirror_urls": [ + "https://packages.confluent.io/maven/com/amazonaws/jmespath-java/1.11.933/jmespath-java-1.11.933.jar", + "https://oss.sonatype.org/content/repositories/snapshots/com/amazonaws/jmespath-java/1.11.933/jmespath-java-1.11.933.jar", + "https://repo1.maven.org/maven2/com/amazonaws/jmespath-java/1.11.933/jmespath-java-1.11.933.jar", + "https://jitpack.io/com/amazonaws/jmespath-java/1.11.933/jmespath-java-1.11.933.jar" + ], + "sha256": "e8752d6d6f857f86c886957bbc20160b2fd22750d10f28f2bbac783cc8351ff7", + "url": "https://repo1.maven.org/maven2/com/amazonaws/jmespath-java/1.11.933/jmespath-java-1.11.933.jar" + }, { "coord": "com.cedarsoftware:java-util:1.34.0", "dependencies": [], @@ -179,6 +319,30 @@ "sha256": "cb890b4aad8ed21a7b57e3c8f7924dbdca1aeff9ddd27cb0ff37243037ae1342", "url": "https://repo1.maven.org/maven2/com/fasterxml/jackson/core/jackson-databind/2.11.2/jackson-databind-2.11.2.jar" }, + { + "coord": "com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:jar:2.6.7", + "dependencies": [ + "com.fasterxml.jackson.core:jackson-core:2.11.2" + ], + "directDependencies": [ + "com.fasterxml.jackson.core:jackson-core:2.11.2" + ], + "exclusions": [ + "ch.qos.logback:logback-classic", + "org.springframework.boot:spring-boot-starter-tomcat", + "org.springframework.boot:spring-boot-starter-logging", + "org.slf4j:slf4j-log4j12" + ], + "file": "v1/https/repo1.maven.org/maven2/com/fasterxml/jackson/dataformat/jackson-dataformat-cbor/2.6.7/jackson-dataformat-cbor-2.6.7.jar", + "mirror_urls": [ + "https://packages.confluent.io/maven/com/fasterxml/jackson/dataformat/jackson-dataformat-cbor/2.6.7/jackson-dataformat-cbor-2.6.7.jar", + "https://oss.sonatype.org/content/repositories/snapshots/com/fasterxml/jackson/dataformat/jackson-dataformat-cbor/2.6.7/jackson-dataformat-cbor-2.6.7.jar", + "https://repo1.maven.org/maven2/com/fasterxml/jackson/dataformat/jackson-dataformat-cbor/2.6.7/jackson-dataformat-cbor-2.6.7.jar", + "https://jitpack.io/com/fasterxml/jackson/dataformat/jackson-dataformat-cbor/2.6.7/jackson-dataformat-cbor-2.6.7.jar" + ], + "sha256": "956a0fb9186a796b8a6548909da1ee55004279647e261c7f540e5d49d4f199bf", + "url": "https://repo1.maven.org/maven2/com/fasterxml/jackson/dataformat/jackson-dataformat-cbor/2.6.7/jackson-dataformat-cbor-2.6.7.jar" + }, { "coord": "com.fasterxml.jackson.dataformat:jackson-dataformat-csv:2.10.2", "dependencies": [ @@ -702,7 +866,6 @@ "dependencies": [ "com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava", "com.fasterxml.jackson.core:jackson-core:2.11.2", - "org.apache.httpcomponents:httpclient:4.5.10", "com.google.j2objc:j2objc-annotations:1.3", "commons-logging:commons-logging:1.2", "io.opencensus:opencensus-contrib-http-util:0.24.0", @@ -712,12 +875,13 @@ "commons-codec:commons-codec:1.11", "io.opencensus:opencensus-api:0.24.0", "io.grpc:grpc-context:1.22.1", + "org.apache.httpcomponents:httpcore:4.4.13", "com.google.http-client:google-http-client-jackson2:1.34.0", "com.google.errorprone:error_prone_annotations:2.3.4", "com.google.http-client:google-http-client:1.34.0", "com.google.guava:failureaccess:1.0.1", "com.google.guava:guava:29.0-jre", - "org.apache.httpcomponents:httpcore:4.4.12", + "org.apache.httpcomponents:httpclient:4.5.13", "org.checkerframework:checker-qual:2.11.1" ], "directDependencies": [ @@ -903,7 +1067,6 @@ "dependencies": [ "com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava", "com.fasterxml.jackson.core:jackson-core:2.11.2", - "org.apache.httpcomponents:httpclient:4.5.10", "com.google.j2objc:j2objc-annotations:1.3", "commons-logging:commons-logging:1.2", "io.opencensus:opencensus-contrib-http-util:0.24.0", @@ -911,11 +1074,12 @@ "commons-codec:commons-codec:1.11", "io.opencensus:opencensus-api:0.24.0", "io.grpc:grpc-context:1.22.1", + "org.apache.httpcomponents:httpcore:4.4.13", "com.google.errorprone:error_prone_annotations:2.3.4", "com.google.http-client:google-http-client:1.34.0", "com.google.guava:failureaccess:1.0.1", "com.google.guava:guava:29.0-jre", - "org.apache.httpcomponents:httpcore:4.4.12", + "org.apache.httpcomponents:httpclient:4.5.13", "org.checkerframework:checker-qual:2.11.1" ], "directDependencies": [ @@ -942,7 +1106,6 @@ "coord": "com.google.http-client:google-http-client:1.34.0", "dependencies": [ "com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava", - "org.apache.httpcomponents:httpclient:4.5.10", "com.google.j2objc:j2objc-annotations:1.3", "commons-logging:commons-logging:1.2", "io.opencensus:opencensus-contrib-http-util:0.24.0", @@ -950,20 +1113,21 @@ "commons-codec:commons-codec:1.11", "io.opencensus:opencensus-api:0.24.0", "io.grpc:grpc-context:1.22.1", + "org.apache.httpcomponents:httpcore:4.4.13", "com.google.errorprone:error_prone_annotations:2.3.4", "com.google.guava:failureaccess:1.0.1", "com.google.guava:guava:29.0-jre", - "org.apache.httpcomponents:httpcore:4.4.12", + "org.apache.httpcomponents:httpclient:4.5.13", "org.checkerframework:checker-qual:2.11.1" ], "directDependencies": [ - "org.apache.httpcomponents:httpclient:4.5.10", "com.google.j2objc:j2objc-annotations:1.3", "io.opencensus:opencensus-contrib-http-util:0.24.0", "com.google.code.findbugs:jsr305:3.0.2", "io.opencensus:opencensus-api:0.24.0", + "org.apache.httpcomponents:httpcore:4.4.13", "com.google.guava:guava:29.0-jre", - "org.apache.httpcomponents:httpcore:4.4.12" + "org.apache.httpcomponents:httpclient:4.5.13" ], "exclusions": [ "ch.qos.logback:logback-classic", @@ -1447,7 +1611,6 @@ "dependencies": [ "com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava", "com.fasterxml.jackson.core:jackson-core:2.11.2", - "org.apache.httpcomponents:httpclient:4.5.10", "javax.xml.bind:jaxb-api:2.3.1", "com.google.j2objc:j2objc-annotations:1.3", "commons-logging:commons-logging:1.2", @@ -1455,27 +1618,28 @@ "com.google.code.findbugs:jsr305:3.0.2", "javax.activation:javax.activation-api:1.2.0", "io.jsonwebtoken:jjwt-jackson:0.10.7", + "org.apache.httpcomponents:httpcore:4.4.13", "com.google.errorprone:error_prone_annotations:2.3.4", "io.jsonwebtoken:jjwt-api:0.10.7", "joda-time:joda-time:2.10.2", "com.fasterxml.jackson.core:jackson-annotations:2.11.2", "com.google.guava:failureaccess:1.0.1", "com.google.guava:guava:29.0-jre", - "org.apache.httpcomponents:httpcore:4.4.12", + "org.apache.httpcomponents:httpclient:4.5.13", "com.fasterxml.jackson.core:jackson-databind:2.11.2", "org.checkerframework:checker-qual:2.11.1" ], "directDependencies": [ "com.fasterxml.jackson.core:jackson-core:2.11.2", - "org.apache.httpcomponents:httpclient:4.5.10", "javax.xml.bind:jaxb-api:2.3.1", "io.jsonwebtoken:jjwt-impl:0.10.7", "io.jsonwebtoken:jjwt-jackson:0.10.7", + "org.apache.httpcomponents:httpcore:4.4.13", "io.jsonwebtoken:jjwt-api:0.10.7", "joda-time:joda-time:2.10.2", "com.fasterxml.jackson.core:jackson-annotations:2.11.2", "com.google.guava:guava:29.0-jre", - "org.apache.httpcomponents:httpcore:4.4.12", + "org.apache.httpcomponents:httpclient:4.5.13", "com.fasterxml.jackson.core:jackson-databind:2.11.2" ], "exclusions": [ @@ -4560,34 +4724,35 @@ "url": "https://repo1.maven.org/maven2/org/apache/curator/curator-test/4.2.0/curator-test-4.2.0.jar" }, { - "coord": "org.apache.httpcomponents:httpclient:4.5.10", + "coord": "org.apache.httpcomponents:httpclient:4.5.13", "dependencies": [ + "org.apache.httpcomponents:httpcore:4.4.13", "commons-logging:commons-logging:1.2", - "org.apache.httpcomponents:httpcore:4.4.12" + "commons-codec:commons-codec:1.11" ], "directDependencies": [ + "commons-codec:commons-codec:1.11", "commons-logging:commons-logging:1.2", - "org.apache.httpcomponents:httpcore:4.4.12" + "org.apache.httpcomponents:httpcore:4.4.13" ], "exclusions": [ - "org.slf4j:slf4j-log4j12", - "commons-codec:commons-codec", - "org.springframework.boot:spring-boot-starter-tomcat", "ch.qos.logback:logback-classic", - "org.springframework.boot:spring-boot-starter-logging" + "org.springframework.boot:spring-boot-starter-tomcat", + "org.springframework.boot:spring-boot-starter-logging", + "org.slf4j:slf4j-log4j12" ], - "file": "v1/https/repo1.maven.org/maven2/org/apache/httpcomponents/httpclient/4.5.10/httpclient-4.5.10.jar", + "file": "v1/https/repo1.maven.org/maven2/org/apache/httpcomponents/httpclient/4.5.13/httpclient-4.5.13.jar", "mirror_urls": [ - "https://packages.confluent.io/maven/org/apache/httpcomponents/httpclient/4.5.10/httpclient-4.5.10.jar", - "https://oss.sonatype.org/content/repositories/snapshots/org/apache/httpcomponents/httpclient/4.5.10/httpclient-4.5.10.jar", - "https://repo1.maven.org/maven2/org/apache/httpcomponents/httpclient/4.5.10/httpclient-4.5.10.jar", - "https://jitpack.io/org/apache/httpcomponents/httpclient/4.5.10/httpclient-4.5.10.jar" + "https://packages.confluent.io/maven/org/apache/httpcomponents/httpclient/4.5.13/httpclient-4.5.13.jar", + "https://oss.sonatype.org/content/repositories/snapshots/org/apache/httpcomponents/httpclient/4.5.13/httpclient-4.5.13.jar", + "https://repo1.maven.org/maven2/org/apache/httpcomponents/httpclient/4.5.13/httpclient-4.5.13.jar", + "https://jitpack.io/org/apache/httpcomponents/httpclient/4.5.13/httpclient-4.5.13.jar" ], - "sha256": "38b9f16f504928e4db736a433b9cd10968d9ec8d6f5d0e61a64889a689172134", - "url": "https://repo1.maven.org/maven2/org/apache/httpcomponents/httpclient/4.5.10/httpclient-4.5.10.jar" + "sha256": "6fe9026a566c6a5001608cf3fc32196641f6c1e5e1986d1037ccdbd5f31ef743", + "url": "https://repo1.maven.org/maven2/org/apache/httpcomponents/httpclient/4.5.13/httpclient-4.5.13.jar" }, { - "coord": "org.apache.httpcomponents:httpcore:4.4.12", + "coord": "org.apache.httpcomponents:httpcore:4.4.13", "dependencies": [], "directDependencies": [], "exclusions": [ @@ -4596,15 +4761,15 @@ "org.springframework.boot:spring-boot-starter-logging", "org.slf4j:slf4j-log4j12" ], - "file": "v1/https/repo1.maven.org/maven2/org/apache/httpcomponents/httpcore/4.4.12/httpcore-4.4.12.jar", + "file": "v1/https/repo1.maven.org/maven2/org/apache/httpcomponents/httpcore/4.4.13/httpcore-4.4.13.jar", "mirror_urls": [ - "https://packages.confluent.io/maven/org/apache/httpcomponents/httpcore/4.4.12/httpcore-4.4.12.jar", - "https://oss.sonatype.org/content/repositories/snapshots/org/apache/httpcomponents/httpcore/4.4.12/httpcore-4.4.12.jar", - "https://repo1.maven.org/maven2/org/apache/httpcomponents/httpcore/4.4.12/httpcore-4.4.12.jar", - "https://jitpack.io/org/apache/httpcomponents/httpcore/4.4.12/httpcore-4.4.12.jar" + "https://packages.confluent.io/maven/org/apache/httpcomponents/httpcore/4.4.13/httpcore-4.4.13.jar", + "https://oss.sonatype.org/content/repositories/snapshots/org/apache/httpcomponents/httpcore/4.4.13/httpcore-4.4.13.jar", + "https://repo1.maven.org/maven2/org/apache/httpcomponents/httpcore/4.4.13/httpcore-4.4.13.jar", + "https://jitpack.io/org/apache/httpcomponents/httpcore/4.4.13/httpcore-4.4.13.jar" ], - "sha256": "ab765334beabf0ea024484a5e90a7c40e8160b145f22d199e11e27f68d57da08", - "url": "https://repo1.maven.org/maven2/org/apache/httpcomponents/httpcore/4.4.12/httpcore-4.4.12.jar" + "sha256": "e06e89d40943245fcfa39ec537cdbfce3762aecde8f9c597780d2b00c2b43424", + "url": "https://repo1.maven.org/maven2/org/apache/httpcomponents/httpcore/4.4.13/httpcore-4.4.13.jar" }, { "coord": "org.apache.kafka:connect-api:2.5.1", @@ -5207,6 +5372,26 @@ "sha256": "a9aae9ff8ae3e17a2a18f79175e82b16267c246fbbd3ca9dfbbb290b08dcfdd4", "url": "https://repo1.maven.org/maven2/org/apiguardian/apiguardian-api/1.1.0/apiguardian-api-1.1.0.jar" }, + { + "coord": "org.aspectj:aspectjweaver:1.8.10", + "dependencies": [], + "directDependencies": [], + "exclusions": [ + "ch.qos.logback:logback-classic", + "org.springframework.boot:spring-boot-starter-tomcat", + "org.springframework.boot:spring-boot-starter-logging", + "org.slf4j:slf4j-log4j12" + ], + "file": "v1/https/repo1.maven.org/maven2/org/aspectj/aspectjweaver/1.8.10/aspectjweaver-1.8.10.jar", + "mirror_urls": [ + "https://packages.confluent.io/maven/org/aspectj/aspectjweaver/1.8.10/aspectjweaver-1.8.10.jar", + "https://oss.sonatype.org/content/repositories/snapshots/org/aspectj/aspectjweaver/1.8.10/aspectjweaver-1.8.10.jar", + "https://repo1.maven.org/maven2/org/aspectj/aspectjweaver/1.8.10/aspectjweaver-1.8.10.jar", + "https://jitpack.io/org/aspectj/aspectjweaver/1.8.10/aspectjweaver-1.8.10.jar" + ], + "sha256": "9687a76555ae2fc334ed6434343c62b17e04e1be86ca473149b6c9469405ecf7", + "url": "https://repo1.maven.org/maven2/org/aspectj/aspectjweaver/1.8.10/aspectjweaver-1.8.10.jar" + }, { "coord": "org.assertj:assertj-core:3.16.1", "dependencies": [], @@ -9100,6 +9285,31 @@ "sha256": "6c02c06ee4c9f989d48d30d3b17cf90fe07b19ed39596e2ede69b1c674acaa97", "url": "https://repo1.maven.org/maven2/org/springframework/data/spring-data-relational/2.0.1.RELEASE/spring-data-relational-2.0.1.RELEASE.jar" }, + { + "coord": "org.springframework.retry:spring-retry:1.2.5.RELEASE", + "dependencies": [ + "org.springframework:spring-jcl:5.2.8.RELEASE", + "org.springframework:spring-core:5.2.8.RELEASE" + ], + "directDependencies": [ + "org.springframework:spring-core:5.2.8.RELEASE" + ], + "exclusions": [ + "ch.qos.logback:logback-classic", + "org.springframework.boot:spring-boot-starter-tomcat", + "org.springframework.boot:spring-boot-starter-logging", + "org.slf4j:slf4j-log4j12" + ], + "file": "v1/https/repo1.maven.org/maven2/org/springframework/retry/spring-retry/1.2.5.RELEASE/spring-retry-1.2.5.RELEASE.jar", + "mirror_urls": [ + "https://packages.confluent.io/maven/org/springframework/retry/spring-retry/1.2.5.RELEASE/spring-retry-1.2.5.RELEASE.jar", + "https://oss.sonatype.org/content/repositories/snapshots/org/springframework/retry/spring-retry/1.2.5.RELEASE/spring-retry-1.2.5.RELEASE.jar", + "https://repo1.maven.org/maven2/org/springframework/retry/spring-retry/1.2.5.RELEASE/spring-retry-1.2.5.RELEASE.jar", + "https://jitpack.io/org/springframework/retry/spring-retry/1.2.5.RELEASE/spring-retry-1.2.5.RELEASE.jar" + ], + "sha256": "71e7cb0d33e3f595011d3e98b14f41ca165a435760ecd4d68cb935e8afa8a3d2", + "url": "https://repo1.maven.org/maven2/org/springframework/retry/spring-retry/1.2.5.RELEASE/spring-retry-1.2.5.RELEASE.jar" + }, { "coord": "org.springframework.security:spring-security-config:5.3.3.RELEASE", "dependencies": [ @@ -9716,6 +9926,26 @@ ], "sha256": "d87d607e500885356c03c1cae61e8c2e05d697df8787d5aba13484c2eb76a844", "url": "https://repo1.maven.org/maven2/org/yaml/snakeyaml/1.26/snakeyaml-1.26.jar" + }, + { + "coord": "software.amazon.ion:ion-java:1.0.2", + "dependencies": [], + "directDependencies": [], + "exclusions": [ + "ch.qos.logback:logback-classic", + "org.springframework.boot:spring-boot-starter-tomcat", + "org.springframework.boot:spring-boot-starter-logging", + "org.slf4j:slf4j-log4j12" + ], + "file": "v1/https/repo1.maven.org/maven2/software/amazon/ion/ion-java/1.0.2/ion-java-1.0.2.jar", + "mirror_urls": [ + "https://packages.confluent.io/maven/software/amazon/ion/ion-java/1.0.2/ion-java-1.0.2.jar", + "https://oss.sonatype.org/content/repositories/snapshots/software/amazon/ion/ion-java/1.0.2/ion-java-1.0.2.jar", + "https://repo1.maven.org/maven2/software/amazon/ion/ion-java/1.0.2/ion-java-1.0.2.jar", + "https://jitpack.io/software/amazon/ion/ion-java/1.0.2/ion-java-1.0.2.jar" + ], + "sha256": "0d127b205a1fce0abc2a3757a041748651bc66c15cf4c059bac5833b27d471a5", + "url": "https://repo1.maven.org/maven2/software/amazon/ion/ion-java/1.0.2/ion-java-1.0.2.jar" } ], "version": "0.1.0" diff --git a/package.json b/package.json index 9bf313abec..204a88155a 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,9 @@ "lodash-es": "^4.17.15", "react-window": "1.8.5", "react-window-infinite-loader": "1.0.5", + "reselect": "4.0.0" + }, "devDependencies": { "@babel/core": "7.8.4", diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 9cbff712af..2f0b1625b8 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -59,7 +59,7 @@ read -p "Do you want to add the vagrant box to the host file so you can access i echo if [[ $REPLY =~ ^[Yy]$ ]]; then - vagrant plugin install vagrant-hostsupdater --plugin-clean-sources --plugin-source https://gems.ruby-china.com + vagrant plugin install vagrant-hostsupdater || vagrant plugin install vagrant-hostsupdater --plugin-clean-sources --plugin-source https://gems.ruby-china.com fi if ! command -v VBoxManage &> /dev/null @@ -130,16 +130,17 @@ if [ -z ${AIRY_VERSION+x} ]; then branch_name=${branch_name##refs/heads/} case "$branch_name" in - develop ) - AIRY_VERSION=beta - ;; - release* ) - AIRY_VERSION=release + main|release* ) + AIRY_VERSION=(`cat ../VERSION`) ;; * ) - AIRY_VERSION=latest + AIRY_VERSION=develop ;; esac fi AIRY_VERSION=${AIRY_VERSION} vagrant up + +mkdir -p ~/.airy +cd $infra_path +vagrant ssh -c "cat /etc/rancher/k3s/k3s.yaml" 2>/dev/null | sed "s/127.0.0.1/192.168.50.5/g" > ~/.airy/kube.conf diff --git a/scripts/push-images.sh b/scripts/push-images.sh index 878c725e40..d170851184 100755 --- a/scripts/push-images.sh +++ b/scripts/push-images.sh @@ -9,14 +9,10 @@ echo "Branch target: ${BRANCH_TARGET}" case ${BRANCH_TARGET} in develop) - tag="beta" + tag="develop" ;; - main) - tag="latest" - ;; - - release) + main|release) tag="release" ;; esac diff --git a/scripts/release.sh b/scripts/release.sh index 922423c4ce..2f87070569 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -8,6 +8,8 @@ start() { echo -e "Starting release ${release_number}\n" create_issue create_release_branch + update_release_version + commit_version } create_issue() { @@ -31,14 +33,13 @@ create_release_branch() { finish() { release_number=$1 echo -e "Finishing release ${release_number}\n" - increase_version - commit_version merge_main merge_develop echo -e "Release ${release_number} is finished\n" + create_alpha_version } -increase_version() { +update_release_version() { issue_number=$(curl -s\ -H "Accept: application/vnd.github.v3+json" \ "https://api.github.com/repos/airyhq/airy/issues?labels=release" | jq '.[0].number') @@ -53,6 +54,21 @@ commit_version() { echo -e "Updated VERSION file\n" } +create_alpha_version() { + regex="([0-9]+).([0-9]+).([0-9]+)" + if [[ $release_number =~ $regex ]]; then + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + patch="${BASH_REMATCH[3]}" + fi + alpha_version=$(printf "%s.%s.%s.alpha\n" $major $((minor+1)) $patch) + command echo ${alpha_version}> VERSION + command git add VERSION + command git commit -m "Bump version to ${alpha_version}" + command git push origin develop + echo -e "Updated VERSION file to ${alpha_version}\n" +} + merge_main() { command git checkout main command git pull origin main @@ -71,18 +87,16 @@ merge_develop() { echo -e "Successfully merged into develop branch\n" } -if [[ -b $1 ]] && [[ -b $2 ]]; -then - case $1 in - "start") - start $2 - ;; - "finish") - finish $2 - esac -else +if [[ -z ${1+x} || -z ${2+x} ]]; then echo -ne "Error executing script\n" echo -ne "Expected syntax: release.sh \n" exit 1 fi +case $1 in + "start") + start $2 + ;; + "finish") + finish $2 +esac diff --git a/tools/build/status.sh b/tools/build/bazel_status.sh similarity index 100% rename from tools/build/status.sh rename to tools/build/bazel_status.sh diff --git a/tools/build/container_push.bzl b/tools/build/container_push.bzl index 460cc9426f..a8c90a42a3 100644 --- a/tools/build/container_push.bzl +++ b/tools/build/container_push.bzl @@ -1,19 +1,14 @@ load("@io_bazel_rules_docker//container:container.bzl", lib_push = "container_push") -tags_to_push = ["release", "latest", "beta"] - def container_push(registry, repository): - [ - lib_push( - name = tag, - format = "Docker", - image = ":image", - registry = registry, - repository = repository, - tag = tag, - ) - for tag in tags_to_push - ] + lib_push( + name = "develop", + format = "Docker", + image = ":image", + registry = registry, + repository = repository, + tag = "develop", + ) lib_push( name = "local", @@ -23,3 +18,12 @@ def container_push(registry, repository): repository = repository, tag = "{BUILD_USER}", ) + + lib_push( + name = "release", + format = "Docker", + image = ":image", + registry = registry, + repository = repository, + tag = "{STABLE_VERSION}", + ) diff --git a/tsconfig.json b/tsconfig.json index f9c645c902..77d800bb15 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,6 +22,18 @@ "components/*": [ "./frontend/components/src/*" ], + "types": [ + "./lib/typescript/types" + ], + "types/*": [ + "./lib/typescript/types/*" + ], + "httpclient": [ + "./lib/typescript/httpclient" + ], + "httpclient/*": [ + "./lib/typescript/httpclient/*" + ], "*": [ "./*" ] diff --git a/yarn.lock b/yarn.lock index f4d6c9dfa6..5bc1b62f4a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3,9 +3,9 @@ "@airyhq/components@latest": - version "0.4.8" - resolved "https://registry.yarnpkg.com/@airyhq/components/-/components-0.4.8.tgz#5fca05ebbcd1195d70075b35c1f48cc32b3b0056" - integrity sha512-qxdNLgMukxHq5cFacdOTPRC/Km8zHrJf0y2UoE0KcCfPMc7dzh1ziM5EFoHtxsIQJkhPlzcYyNaZxA6IpTdBLQ== + version "0.4.11" + resolved "https://registry.yarnpkg.com/@airyhq/components/-/components-0.4.11.tgz#68e803bb502ae201f64199c025878fd360f51577" + integrity sha512-aZkv/ncpbMeCHaIstuPDPliVZxKjOBDyVeFFUfu+/Y1dA6TOTc3JRGxKQ0wQqMtYrycV+FIuktnszCcIgswzLA== dependencies: "@crello/react-lottie" "^0.0.9" emoji-mart "^3.0.0" @@ -7193,12 +7193,12 @@ react-hot-loader@^4.12.20: shallowequal "^1.1.0" source-map "^0.7.3" -react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1: +react-is@^16.6.0, react-is@^16.7.0: version "16.12.0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.12.0.tgz#2cc0fe0fba742d97fd527c42a13bec4eeb06241c" integrity sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q== -react-is@^16.9.0: +react-is@^16.8.1, react-is@^16.9.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== @@ -7209,9 +7209,9 @@ react-lifecycles-compat@^3.0.0, react-lifecycles-compat@^3.0.4: integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== react-modal@^3.11.2: - version "3.11.2" - resolved "https://registry.yarnpkg.com/react-modal/-/react-modal-3.11.2.tgz#bad911976d4add31aa30dba8a41d11e21c4ac8a4" - integrity sha512-o8gvvCOFaG1T7W6JUvsYjRjMVToLZgLIsi5kdhFIQCtHxDkA47LznX62j+l6YQkpXDbvQegsDyxe/+JJsFQN7w== + version "3.12.1" + resolved "https://registry.yarnpkg.com/react-modal/-/react-modal-3.12.1.tgz#38c33f70d81c33d02ff1ed115530443a3dc2afd3" + integrity sha512-WGuXn7Fq31PbFJwtWmOk+jFtGC7E9tJVbFX0lts8ZoS5EPi9+WWylUJWLKKVm3H4GlQ7ZxY7R6tLlbSIBQ5oZA== dependencies: exenv "^1.2.0" prop-types "^15.5.10" @@ -7423,16 +7423,11 @@ regenerator-runtime@^0.13.2: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz#7cf6a77d8f5c6f60eb73c5fc1955b2ceb01e6bf5" integrity sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw== -regenerator-runtime@^0.13.4: +regenerator-runtime@^0.13.4, regenerator-runtime@^0.13.5: version "0.13.7" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55" integrity sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew== -regenerator-runtime@^0.13.5: - version "0.13.5" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.5.tgz#d878a1d094b4306d10b9096484b33ebd55e26697" - integrity sha512-ZS5w8CpKFinUzOwW3c83oPeVXoNsrLsaCoLtJvAClH135j/R77RuymhiSErhm2lKcwSCIpmvIWSbDkIfAqKQlA== - regenerator-transform@^0.14.0: version "0.14.1" resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.14.1.tgz#3b2fce4e1ab7732c08f665dfdb314749c7ddd2fb"