From 1b59edaa68991373c3702b9d1244bd6ea8191341 Mon Sep 17 00:00:00 2001 From: Nicholas Molnar <65710+neekolas@users.noreply.github.com> Date: Fri, 1 Mar 2024 14:40:29 -0700 Subject: [PATCH] Add integation test suite --- .dockerignore | 5 + .env.docker | 4 + .github/workflows/test.yml | 8 +- .gitignore | 3 +- cmd/server/main.go | 4 + dev/integration | 4 + dev/up | 2 +- docker-compose.yml | 34 ++ integration/.dockerignore | 1 + integration/.gitignore | 175 +++++++++++ integration/Dockerfile | 19 ++ integration/README.md | 29 ++ integration/bun.lockb | Bin 0 -> 19042 bytes integration/package.json | 17 + integration/src/config.ts | 13 + .../gen/notifications/v1/service_connect.d.ts | 62 ++++ .../gen/notifications/v1/service_connect.js | 62 ++++ .../src/gen/notifications/v1/service_pb.d.ts | 292 ++++++++++++++++++ .../src/gen/notifications/v1/service_pb.js | 123 ++++++++ integration/src/index.test.ts | 71 +++++ integration/src/index.ts | 30 ++ integration/src/types.ts | 50 +++ integration/tsconfig.json | 27 ++ pkg/api/api.go | 36 ++- pkg/db/models.go | 2 +- pkg/delivery/http.go | 68 ++++ pkg/interfaces/interfaces.go | 42 +-- pkg/options/options.go | 15 +- proto/buf.gen.yaml | 4 + 29 files changed, 1170 insertions(+), 32 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.docker create mode 100755 dev/integration create mode 100644 integration/.dockerignore create mode 100644 integration/.gitignore create mode 100644 integration/Dockerfile create mode 100644 integration/README.md create mode 100755 integration/bun.lockb create mode 100644 integration/package.json create mode 100644 integration/src/config.ts create mode 100644 integration/src/gen/notifications/v1/service_connect.d.ts create mode 100644 integration/src/gen/notifications/v1/service_connect.js create mode 100644 integration/src/gen/notifications/v1/service_pb.d.ts create mode 100644 integration/src/gen/notifications/v1/service_pb.js create mode 100644 integration/src/index.test.ts create mode 100644 integration/src/index.ts create mode 100644 integration/src/types.ts create mode 100644 integration/tsconfig.json create mode 100644 pkg/delivery/http.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..68fa2f6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +/integration +docker-compose.yml +proto +docs +dist \ No newline at end of file diff --git a/.env.docker b/.env.docker new file mode 100644 index 0000000..20a6c6a --- /dev/null +++ b/.env.docker @@ -0,0 +1,4 @@ +DB_CONNECTION_STRING="postgres://postgres:xmtp@db:5432/postgres?sslmode=disable" +XMTP_GRPC_ADDRESS="node:5556" +LOG_ENCODING=console +API_PORT="8080" \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fdb1364..2956240 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,6 +10,12 @@ jobs: - uses: actions/setup-go@v3 with: go-version-file: go.mod - - run: docker-compose up -d + - run: ./dev/up - name: Run Tests run: go test -p 1 ./... + integration: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - run: docker-compose up integration diff --git a/.gitignore b/.gitignore index a504d00..2fa01af 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,5 @@ # Dependency directories (remove the comment below to include it) # vendor/ -/dist \ No newline at end of file +/dist +node_modules diff --git a/cmd/server/main.go b/cmd/server/main.go index 0b62b19..194b466 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -89,6 +89,10 @@ func main() { deliveryServices = append(deliveryServices, fcm) } + if opts.HttpDelivery.Enabled { + deliveryServices = append(deliveryServices, delivery.NewHttpDelivery(logger, opts.HttpDelivery)) + } + listener, err = xmtp.NewListener(ctx, logger, opts.Xmtp, installationsService, subscriptionsService, deliveryServices, clientVersion, appVersion) if err != nil { logger.Fatal("failed to initialize listener", zap.Error(err)) diff --git a/dev/integration b/dev/integration new file mode 100755 index 0000000..ed71453 --- /dev/null +++ b/dev/integration @@ -0,0 +1,4 @@ +#!/bin/bash +set -eou pipefail + +docker-compose up integration \ No newline at end of file diff --git a/dev/up b/dev/up index f1f8a96..4ad2582 100755 --- a/dev/up +++ b/dev/up @@ -1,4 +1,4 @@ #!/bin/bash set -eou pipefail -docker compose up -d +docker compose up -d node diff --git a/docker-compose.yml b/docker-compose.yml index 4e69f7b..2d1fed2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,9 +13,12 @@ services: - --api.enable-mls - --wait-for-db=30s ports: + - 25555:5555 - 25556:5556 depends_on: - db + - mlsdb + - validation validation: image: ghcr.io/xmtp/mls-validation-service:main @@ -32,3 +35,34 @@ services: image: postgres:13 environment: POSTGRES_PASSWORD: xmtp + + notification_server: + build: + context: . + env_file: .env.docker + depends_on: + - node + command: + - --xmtp-listener + - --api + - --http-delivery + - --http-delivery-address=http://integration:7777/post + - --api-port=8080 + + integration: + build: + context: ./integration + dockerfile: Dockerfile + expose: + - 7777 + volumes: + - ./integration/src:/usr/app/src:ro + depends_on: + - node + - notification_server + environment: + - XMTP_NODE_URL=http://node:5555 + - NOTIFICATION_SERVER_URL=http://notification_server:8080 + command: + - bun + - test diff --git a/integration/.dockerignore b/integration/.dockerignore new file mode 100644 index 0000000..b512c09 --- /dev/null +++ b/integration/.dockerignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/integration/.gitignore b/integration/.gitignore new file mode 100644 index 0000000..9b1ee42 --- /dev/null +++ b/integration/.gitignore @@ -0,0 +1,175 @@ +# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore + +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Caches + +.cache + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# dotenv environment variable files + +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) + +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/integration/Dockerfile b/integration/Dockerfile new file mode 100644 index 0000000..0b7b427 --- /dev/null +++ b/integration/Dockerfile @@ -0,0 +1,19 @@ +FROM oven/bun:1 as base +WORKDIR /usr/app + +# install dependencies into temp directory +# this will cache them and speed up future builds +FROM base AS install +RUN mkdir -p /temp/dev +COPY package.json bun.lockb /temp/dev/ +RUN cd /temp/dev && bun install --frozen-lockfile + +# The real release artifact +FROM base +COPY --from=install /temp/dev/node_modules node_modules +COPY . . + +# run the app +USER bun +EXPOSE 7777/tcp +ENTRYPOINT [ "bun", "test" ] \ No newline at end of file diff --git a/integration/README.md b/integration/README.md new file mode 100644 index 0000000..a5f62db --- /dev/null +++ b/integration/README.md @@ -0,0 +1,29 @@ +# integration + +This package is designed to run as an integration test suite for the notification server. It may also serve as a useful reference for how to interact with the server. + +These tests rely on the `HttpDelivery` Delivery Service on the node to send notifications directly back to the server to verify the notification content. Normally notifications would be sent by APNS or FCM. + +It is meant to be run inside Docker. + +## Usage + +In the root of the repo + +```bash +./dev/integration +``` + +## Development setup + +To install dependencies: + +```bash +bun install +``` + +To run: + +```bash +bun run src/index.ts +``` diff --git a/integration/bun.lockb b/integration/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..4505f83027fa6472e758bd62a0cfc376e67eba5c GIT binary patch literal 19042 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 O&LI!=TKB*3&wl~-vPc2| literal 0 HcmV?d00001 diff --git a/integration/package.json b/integration/package.json new file mode 100644 index 0000000..c717222 --- /dev/null +++ b/integration/package.json @@ -0,0 +1,17 @@ +{ + "name": "integration", + "module": "src/index.ts", + "type": "module", + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "dependencies": { + "@connectrpc/connect": "^1.4.0", + "@connectrpc/connect-web": "^1.4.0", + "@xmtp/xmtp-js": "^11.4.1", + "viem": "^2.7.16" + } +} diff --git a/integration/src/config.ts b/integration/src/config.ts new file mode 100644 index 0000000..7d88f96 --- /dev/null +++ b/integration/src/config.ts @@ -0,0 +1,13 @@ +function assertEnvVar(key: string): string { + const value = process.env[key]; + if (!value) { + throw new Error(`Missing environment variable ${key}`); + } + + return value; +} + +export const config = { + nodeUrl: assertEnvVar("XMTP_NODE_URL"), + notificationServerUrl: assertEnvVar("NOTIFICATION_SERVER_URL"), +} as const; diff --git a/integration/src/gen/notifications/v1/service_connect.d.ts b/integration/src/gen/notifications/v1/service_connect.d.ts new file mode 100644 index 0000000..7aa26aa --- /dev/null +++ b/integration/src/gen/notifications/v1/service_connect.d.ts @@ -0,0 +1,62 @@ +// @generated by protoc-gen-connect-es v1.4.0 +// @generated from file notifications/v1/service.proto (package notifications.v1, syntax proto3) +/* eslint-disable */ +// @ts-nocheck + +import { DeleteInstallationRequest, RegisterInstallationRequest, RegisterInstallationResponse, SubscribeRequest, SubscribeWithMetadataRequest, UnsubscribeRequest } from "./service_pb.js"; +import { Empty, MethodKind } from "@bufbuild/protobuf"; + +/** + * @generated from service notifications.v1.Notifications + */ +export declare const Notifications: { + readonly typeName: "notifications.v1.Notifications", + readonly methods: { + /** + * @generated from rpc notifications.v1.Notifications.RegisterInstallation + */ + readonly registerInstallation: { + readonly name: "RegisterInstallation", + readonly I: typeof RegisterInstallationRequest, + readonly O: typeof RegisterInstallationResponse, + readonly kind: MethodKind.Unary, + }, + /** + * @generated from rpc notifications.v1.Notifications.DeleteInstallation + */ + readonly deleteInstallation: { + readonly name: "DeleteInstallation", + readonly I: typeof DeleteInstallationRequest, + readonly O: typeof Empty, + readonly kind: MethodKind.Unary, + }, + /** + * @generated from rpc notifications.v1.Notifications.Subscribe + */ + readonly subscribe: { + readonly name: "Subscribe", + readonly I: typeof SubscribeRequest, + readonly O: typeof Empty, + readonly kind: MethodKind.Unary, + }, + /** + * @generated from rpc notifications.v1.Notifications.SubscribeWithMetadata + */ + readonly subscribeWithMetadata: { + readonly name: "SubscribeWithMetadata", + readonly I: typeof SubscribeWithMetadataRequest, + readonly O: typeof Empty, + readonly kind: MethodKind.Unary, + }, + /** + * @generated from rpc notifications.v1.Notifications.Unsubscribe + */ + readonly unsubscribe: { + readonly name: "Unsubscribe", + readonly I: typeof UnsubscribeRequest, + readonly O: typeof Empty, + readonly kind: MethodKind.Unary, + }, + } +}; + diff --git a/integration/src/gen/notifications/v1/service_connect.js b/integration/src/gen/notifications/v1/service_connect.js new file mode 100644 index 0000000..d970d89 --- /dev/null +++ b/integration/src/gen/notifications/v1/service_connect.js @@ -0,0 +1,62 @@ +// @generated by protoc-gen-connect-es v1.4.0 +// @generated from file notifications/v1/service.proto (package notifications.v1, syntax proto3) +/* eslint-disable */ +// @ts-nocheck + +import { DeleteInstallationRequest, RegisterInstallationRequest, RegisterInstallationResponse, SubscribeRequest, SubscribeWithMetadataRequest, UnsubscribeRequest } from "./service_pb.js"; +import { Empty, MethodKind } from "@bufbuild/protobuf"; + +/** + * @generated from service notifications.v1.Notifications + */ +export const Notifications = { + typeName: "notifications.v1.Notifications", + methods: { + /** + * @generated from rpc notifications.v1.Notifications.RegisterInstallation + */ + registerInstallation: { + name: "RegisterInstallation", + I: RegisterInstallationRequest, + O: RegisterInstallationResponse, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc notifications.v1.Notifications.DeleteInstallation + */ + deleteInstallation: { + name: "DeleteInstallation", + I: DeleteInstallationRequest, + O: Empty, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc notifications.v1.Notifications.Subscribe + */ + subscribe: { + name: "Subscribe", + I: SubscribeRequest, + O: Empty, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc notifications.v1.Notifications.SubscribeWithMetadata + */ + subscribeWithMetadata: { + name: "SubscribeWithMetadata", + I: SubscribeWithMetadataRequest, + O: Empty, + kind: MethodKind.Unary, + }, + /** + * @generated from rpc notifications.v1.Notifications.Unsubscribe + */ + unsubscribe: { + name: "Unsubscribe", + I: UnsubscribeRequest, + O: Empty, + kind: MethodKind.Unary, + }, + } +}; + diff --git a/integration/src/gen/notifications/v1/service_pb.d.ts b/integration/src/gen/notifications/v1/service_pb.d.ts new file mode 100644 index 0000000..02c294d --- /dev/null +++ b/integration/src/gen/notifications/v1/service_pb.d.ts @@ -0,0 +1,292 @@ +// @generated by protoc-gen-es v1.7.2 +// @generated from file notifications/v1/service.proto (package notifications.v1, syntax proto3) +/* eslint-disable */ +// @ts-nocheck + +import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf"; +import { Message, proto3 } from "@bufbuild/protobuf"; + +/** + * An union of possible delibery mechanisms + * + * @generated from message notifications.v1.DeliveryMechanism + */ +export declare class DeliveryMechanism extends Message { + /** + * @generated from oneof notifications.v1.DeliveryMechanism.delivery_mechanism_type + */ + deliveryMechanismType: { + /** + * @generated from field: string apns_device_token = 1; + */ + value: string; + case: "apnsDeviceToken"; + } | { + /** + * @generated from field: string firebase_device_token = 2; + */ + value: string; + case: "firebaseDeviceToken"; + } | { case: undefined; value?: undefined }; + + constructor(data?: PartialMessage); + + static readonly runtime: typeof proto3; + static readonly typeName = "notifications.v1.DeliveryMechanism"; + static readonly fields: FieldList; + + static fromBinary(bytes: Uint8Array, options?: Partial): DeliveryMechanism; + + static fromJson(jsonValue: JsonValue, options?: Partial): DeliveryMechanism; + + static fromJsonString(jsonString: string, options?: Partial): DeliveryMechanism; + + static equals(a: DeliveryMechanism | PlainMessage | undefined, b: DeliveryMechanism | PlainMessage | undefined): boolean; +} + +/** + * A request to register an installation with the service + * + * @generated from message notifications.v1.RegisterInstallationRequest + */ +export declare class RegisterInstallationRequest extends Message { + /** + * @generated from field: string installation_id = 1; + */ + installationId: string; + + /** + * @generated from field: notifications.v1.DeliveryMechanism delivery_mechanism = 2; + */ + deliveryMechanism?: DeliveryMechanism; + + constructor(data?: PartialMessage); + + static readonly runtime: typeof proto3; + static readonly typeName = "notifications.v1.RegisterInstallationRequest"; + static readonly fields: FieldList; + + static fromBinary(bytes: Uint8Array, options?: Partial): RegisterInstallationRequest; + + static fromJson(jsonValue: JsonValue, options?: Partial): RegisterInstallationRequest; + + static fromJsonString(jsonString: string, options?: Partial): RegisterInstallationRequest; + + static equals(a: RegisterInstallationRequest | PlainMessage | undefined, b: RegisterInstallationRequest | PlainMessage | undefined): boolean; +} + +/** + * Response to RegisterInstallationRequest + * + * @generated from message notifications.v1.RegisterInstallationResponse + */ +export declare class RegisterInstallationResponse extends Message { + /** + * @generated from field: string installation_id = 1; + */ + installationId: string; + + /** + * @generated from field: uint64 valid_until = 2; + */ + validUntil: bigint; + + constructor(data?: PartialMessage); + + static readonly runtime: typeof proto3; + static readonly typeName = "notifications.v1.RegisterInstallationResponse"; + static readonly fields: FieldList; + + static fromBinary(bytes: Uint8Array, options?: Partial): RegisterInstallationResponse; + + static fromJson(jsonValue: JsonValue, options?: Partial): RegisterInstallationResponse; + + static fromJsonString(jsonString: string, options?: Partial): RegisterInstallationResponse; + + static equals(a: RegisterInstallationResponse | PlainMessage | undefined, b: RegisterInstallationResponse | PlainMessage | undefined): boolean; +} + +/** + * Delete an installation from the service + * + * @generated from message notifications.v1.DeleteInstallationRequest + */ +export declare class DeleteInstallationRequest extends Message { + /** + * @generated from field: string installation_id = 1; + */ + installationId: string; + + constructor(data?: PartialMessage); + + static readonly runtime: typeof proto3; + static readonly typeName = "notifications.v1.DeleteInstallationRequest"; + static readonly fields: FieldList; + + static fromBinary(bytes: Uint8Array, options?: Partial): DeleteInstallationRequest; + + static fromJson(jsonValue: JsonValue, options?: Partial): DeleteInstallationRequest; + + static fromJsonString(jsonString: string, options?: Partial): DeleteInstallationRequest; + + static equals(a: DeleteInstallationRequest | PlainMessage | undefined, b: DeleteInstallationRequest | PlainMessage | undefined): boolean; +} + +/** + * A subscription with associated metadata + * + * @generated from message notifications.v1.Subscription + */ +export declare class Subscription extends Message { + /** + * @generated from field: string topic = 1; + */ + topic: string; + + /** + * @generated from field: repeated notifications.v1.Subscription.HmacKey hmac_keys = 2; + */ + hmacKeys: Subscription_HmacKey[]; + + /** + * @generated from field: bool is_silent = 3; + */ + isSilent: boolean; + + constructor(data?: PartialMessage); + + static readonly runtime: typeof proto3; + static readonly typeName = "notifications.v1.Subscription"; + static readonly fields: FieldList; + + static fromBinary(bytes: Uint8Array, options?: Partial): Subscription; + + static fromJson(jsonValue: JsonValue, options?: Partial): Subscription; + + static fromJsonString(jsonString: string, options?: Partial): Subscription; + + static equals(a: Subscription | PlainMessage | undefined, b: Subscription | PlainMessage | undefined): boolean; +} + +/** + * @generated from message notifications.v1.Subscription.HmacKey + */ +export declare class Subscription_HmacKey extends Message { + /** + * @generated from field: uint32 thirty_day_periods_since_epoch = 1; + */ + thirtyDayPeriodsSinceEpoch: number; + + /** + * @generated from field: bytes key = 2; + */ + key: Uint8Array; + + constructor(data?: PartialMessage); + + static readonly runtime: typeof proto3; + static readonly typeName = "notifications.v1.Subscription.HmacKey"; + static readonly fields: FieldList; + + static fromBinary(bytes: Uint8Array, options?: Partial): Subscription_HmacKey; + + static fromJson(jsonValue: JsonValue, options?: Partial): Subscription_HmacKey; + + static fromJsonString(jsonString: string, options?: Partial): Subscription_HmacKey; + + static equals(a: Subscription_HmacKey | PlainMessage | undefined, b: Subscription_HmacKey | PlainMessage | undefined): boolean; +} + +/** + * A request to subscribe to a list of topics and update the associated metadata + * + * @generated from message notifications.v1.SubscribeWithMetadataRequest + */ +export declare class SubscribeWithMetadataRequest extends Message { + /** + * @generated from field: string installation_id = 1; + */ + installationId: string; + + /** + * @generated from field: repeated notifications.v1.Subscription subscriptions = 2; + */ + subscriptions: Subscription[]; + + constructor(data?: PartialMessage); + + static readonly runtime: typeof proto3; + static readonly typeName = "notifications.v1.SubscribeWithMetadataRequest"; + static readonly fields: FieldList; + + static fromBinary(bytes: Uint8Array, options?: Partial): SubscribeWithMetadataRequest; + + static fromJson(jsonValue: JsonValue, options?: Partial): SubscribeWithMetadataRequest; + + static fromJsonString(jsonString: string, options?: Partial): SubscribeWithMetadataRequest; + + static equals(a: SubscribeWithMetadataRequest | PlainMessage | undefined, b: SubscribeWithMetadataRequest | PlainMessage | undefined): boolean; +} + +/** + * Subscribe to a list of topics + * + * @generated from message notifications.v1.SubscribeRequest + */ +export declare class SubscribeRequest extends Message { + /** + * @generated from field: string installation_id = 1; + */ + installationId: string; + + /** + * @generated from field: repeated string topics = 2; + */ + topics: string[]; + + constructor(data?: PartialMessage); + + static readonly runtime: typeof proto3; + static readonly typeName = "notifications.v1.SubscribeRequest"; + static readonly fields: FieldList; + + static fromBinary(bytes: Uint8Array, options?: Partial): SubscribeRequest; + + static fromJson(jsonValue: JsonValue, options?: Partial): SubscribeRequest; + + static fromJsonString(jsonString: string, options?: Partial): SubscribeRequest; + + static equals(a: SubscribeRequest | PlainMessage | undefined, b: SubscribeRequest | PlainMessage | undefined): boolean; +} + +/** + * Unsubscribe from a list of topics + * + * @generated from message notifications.v1.UnsubscribeRequest + */ +export declare class UnsubscribeRequest extends Message { + /** + * @generated from field: string installation_id = 1; + */ + installationId: string; + + /** + * @generated from field: repeated string topics = 2; + */ + topics: string[]; + + constructor(data?: PartialMessage); + + static readonly runtime: typeof proto3; + static readonly typeName = "notifications.v1.UnsubscribeRequest"; + static readonly fields: FieldList; + + static fromBinary(bytes: Uint8Array, options?: Partial): UnsubscribeRequest; + + static fromJson(jsonValue: JsonValue, options?: Partial): UnsubscribeRequest; + + static fromJsonString(jsonString: string, options?: Partial): UnsubscribeRequest; + + static equals(a: UnsubscribeRequest | PlainMessage | undefined, b: UnsubscribeRequest | PlainMessage | undefined): boolean; +} + diff --git a/integration/src/gen/notifications/v1/service_pb.js b/integration/src/gen/notifications/v1/service_pb.js new file mode 100644 index 0000000..7ec7048 --- /dev/null +++ b/integration/src/gen/notifications/v1/service_pb.js @@ -0,0 +1,123 @@ +// @generated by protoc-gen-es v1.7.2 +// @generated from file notifications/v1/service.proto (package notifications.v1, syntax proto3) +/* eslint-disable */ +// @ts-nocheck + +import { proto3 } from "@bufbuild/protobuf"; + +/** + * An union of possible delibery mechanisms + * + * @generated from message notifications.v1.DeliveryMechanism + */ +export const DeliveryMechanism = proto3.makeMessageType( + "notifications.v1.DeliveryMechanism", + () => [ + { no: 1, name: "apns_device_token", kind: "scalar", T: 9 /* ScalarType.STRING */, oneof: "delivery_mechanism_type" }, + { no: 2, name: "firebase_device_token", kind: "scalar", T: 9 /* ScalarType.STRING */, oneof: "delivery_mechanism_type" }, + ], +); + +/** + * A request to register an installation with the service + * + * @generated from message notifications.v1.RegisterInstallationRequest + */ +export const RegisterInstallationRequest = proto3.makeMessageType( + "notifications.v1.RegisterInstallationRequest", + () => [ + { no: 1, name: "installation_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "delivery_mechanism", kind: "message", T: DeliveryMechanism }, + ], +); + +/** + * Response to RegisterInstallationRequest + * + * @generated from message notifications.v1.RegisterInstallationResponse + */ +export const RegisterInstallationResponse = proto3.makeMessageType( + "notifications.v1.RegisterInstallationResponse", + () => [ + { no: 1, name: "installation_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "valid_until", kind: "scalar", T: 4 /* ScalarType.UINT64 */ }, + ], +); + +/** + * Delete an installation from the service + * + * @generated from message notifications.v1.DeleteInstallationRequest + */ +export const DeleteInstallationRequest = proto3.makeMessageType( + "notifications.v1.DeleteInstallationRequest", + () => [ + { no: 1, name: "installation_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ], +); + +/** + * A subscription with associated metadata + * + * @generated from message notifications.v1.Subscription + */ +export const Subscription = proto3.makeMessageType( + "notifications.v1.Subscription", + () => [ + { no: 1, name: "topic", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "hmac_keys", kind: "message", T: Subscription_HmacKey, repeated: true }, + { no: 3, name: "is_silent", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + ], +); + +/** + * @generated from message notifications.v1.Subscription.HmacKey + */ +export const Subscription_HmacKey = proto3.makeMessageType( + "notifications.v1.Subscription.HmacKey", + () => [ + { no: 1, name: "thirty_day_periods_since_epoch", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, + { no: 2, name: "key", kind: "scalar", T: 12 /* ScalarType.BYTES */ }, + ], + {localName: "Subscription_HmacKey"}, +); + +/** + * A request to subscribe to a list of topics and update the associated metadata + * + * @generated from message notifications.v1.SubscribeWithMetadataRequest + */ +export const SubscribeWithMetadataRequest = proto3.makeMessageType( + "notifications.v1.SubscribeWithMetadataRequest", + () => [ + { no: 1, name: "installation_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "subscriptions", kind: "message", T: Subscription, repeated: true }, + ], +); + +/** + * Subscribe to a list of topics + * + * @generated from message notifications.v1.SubscribeRequest + */ +export const SubscribeRequest = proto3.makeMessageType( + "notifications.v1.SubscribeRequest", + () => [ + { no: 1, name: "installation_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "topics", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, + ], +); + +/** + * Unsubscribe from a list of topics + * + * @generated from message notifications.v1.UnsubscribeRequest + */ +export const UnsubscribeRequest = proto3.makeMessageType( + "notifications.v1.UnsubscribeRequest", + () => [ + { no: 1, name: "installation_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "topics", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, + ], +); + diff --git a/integration/src/index.test.ts b/integration/src/index.test.ts new file mode 100644 index 0000000..62ebab1 --- /dev/null +++ b/integration/src/index.test.ts @@ -0,0 +1,71 @@ +import { serve } from "bun"; +import { expect, test, beforeEach, afterAll, describe } from "bun:test"; +import { createNotificationClient, randomClient } from "."; +import { buildUserInviteTopic } from "@xmtp/xmtp-js"; +import type { NotificationResponse } from "./types"; + +const PORT = 7777; + +describe("notifications", () => { + let onRequest = (req: NotificationResponse) => + console.log("No request handler set for", req); + // Set up a server to receive messages from the HttpDelivery service + const server = serve({ + port: PORT, + async fetch(req: Request) { + const body = (await req.json()) as NotificationResponse; + onRequest(body); + return new Response("", { status: 200 }); + }, + // biome-ignore lint/suspicious/noExplicitAny: + } as any); + + afterAll(() => { + server.stop(); + }); + + const waitForNextRequest = ( + timeoutMs: number + ): Promise => + new Promise((resolve, reject) => { + onRequest = (body) => resolve(body); + setTimeout(reject, timeoutMs); + }); + + test("conversation invites", async () => { + const alix = await randomClient(); + const bo = await randomClient(); + const alixNotificationClient = createNotificationClient(); + await alixNotificationClient.registerInstallation({ + installationId: alix.address, + deliveryMechanism: { + deliveryMechanismType: { + value: "token", + case: "apnsDeviceToken", + }, + }, + }); + console.log("Installation registered"); + const alixInviteTopic = buildUserInviteTopic(alix.address); + await alixNotificationClient.subscribeWithMetadata({ + installationId: alix.address, + subscriptions: [ + { + topic: alixInviteTopic, + isSilent: true, + }, + ], + }); + + const notificationPromise = waitForNextRequest(10000); + await alix.conversations.newConversation(bo.address); + const notification = await notificationPromise; + + expect(notification.idempotency_key).toBeString(); + expect(notification.message.content_topic).toEqual(alixInviteTopic); + expect(notification.message.message).toBeString(); + expect(notification.subscription.is_silent).toBeTrue(); + expect(notification.installation.delivery_mechanism.token).toEqual("token"); + expect(notification.message_context.message_type).toEqual("v2-invite"); + }); +}); diff --git a/integration/src/index.ts b/integration/src/index.ts new file mode 100644 index 0000000..a537465 --- /dev/null +++ b/integration/src/index.ts @@ -0,0 +1,30 @@ +import { Client } from "@xmtp/xmtp-js"; +import { createWalletClient, http } from "viem"; +import { mainnet } from "viem/chains"; +import { privateKeyToAccount, generatePrivateKey } from "viem/accounts"; +import { createPromiseClient } from "@connectrpc/connect"; +import { Notifications } from "./gen/notifications/v1/service_connect"; +import { createConnectTransport } from "@connectrpc/connect-web"; +import { config } from "./config"; + +export function randomWallet() { + const account = privateKeyToAccount(generatePrivateKey()); + return createWalletClient({ + account, + chain: mainnet, + transport: http(), + }); +} + +export function randomClient() { + const wallet = randomWallet(); + return Client.create(wallet, { env: "local", apiUrl: config.nodeUrl }); +} + +export function createNotificationClient() { + const transport = createConnectTransport({ + baseUrl: config.notificationServerUrl, + }); + + return createPromiseClient(Notifications, transport); +} diff --git a/integration/src/types.ts b/integration/src/types.ts new file mode 100644 index 0000000..23225fe --- /dev/null +++ b/integration/src/types.ts @@ -0,0 +1,50 @@ +/** + * integration-1 | { +integration-1 | idempotency_key: "fab614b6bc3da2b15577f2aa84702046bde1cd56", +integration-1 | message: { +integration-1 | content_topic: "/xmtp/0/invite-0x997eF1DD0c5FCa10f843D59b2bFDAff763A9F7B9/proto", +integration-1 | timestamp_ns: 1709334943958000000, +integration-1 | message: "CrQGCvIECrACCpYBCkwI2LC14t8xGkMKQQRlOHK1nvZfzKP5eWVolCac+PBuvBOQY//YtZzee2kQiomUbkAAlB7Wll9YkSYHoXapN3WzMrzNp3kDG9F1+UieEkYSRApATUhOf7AAWweRVvZsvTpp60XI8/xTZvKYzgjzcksWJG1R2Bmvlk+89YuU7iZx7VK3lstnAyn87FlqdtX6wv/g0RABEpQBCkwI8rC14t8xGkMKQQR0BURuRcZ4bwnnFmiMw67SENNaEAtfAp9y/NYfEzD3YuYOaNvnipjTrvy3DpTVphsbjYq3SVBcX+RVb8DkTQd8EkQKQgpAEKtNTtg+WqGFqgusCvaw/Uo2lE2Its4qcnVwCCovv4Rm846ZiS2WGve+MgQ+sfeEreh8YhI02cbI4VuUH+9GoRKyAgqWAQpMCJuxteLfMRpDCkEEi+i8wBy2caUvj/tOtTzkU6jsCsymummq310Ps0R7k3ehjmW4dKN5hAPc/3vQeWKIgGCkDyPG2Qq8NDlRvvvp4hJGEkQKQDjQ38HxHLF/P+EPlfO5oExr0VZbPWn0uCtDZrCVMWcTTgdXxbzlpyec+9TgnFGL4Tus0J4IC3MESghJNdRrJe8QARKWAQpMCJ2xteLfMRpDCkEElgqdBKi1eHOMZkH7fSFY3ZBssi/3WHTNv7UbeYACvTmdU4Z7zgj4yqlw75+Wm7rXtSAPbWpduQyDGq5h/WFExBJGCkQKQKx5vZ7qOrHfamhtaIC96ws1VJPfd7jMyZWB2HCTAmCYAZqAB6JJuIVg114E1AalcqZi3KV9q9uUa8+W1JDqNYQQARiAw53Gs+Kx3BcSvAEKuQEKIHg4mdFG1+RQCy6V9ZWbRB9onHk25onCYVHHPaYF22NDEgwmIVHRUD/ydxkFFhEahgHYu5gXrfrhU/E4FIBxDKQnI6SjCff47lMpidvoDu3yjB539dO/xeJn8Kv9qp6kGATApEDHJbMV3cfsC5E+X0W6xQxFf4L0tZEePTzzykNSrcbkddprTGwD0kMoPgSqDFRf8jLKJyHZ6fCmsfRIp1vVBwtWw6edxLjrUHKRxZGFycduuSqTcQ==", +integration-1 | }, +integration-1 | message_context: { +integration-1 | message_type: "v2-invite", +integration-1 | }, +integration-1 | installation: { +integration-1 | id: "0x997eF1DD0c5FCa10f843D59b2bFDAff763A9F7B9", +integration-1 | delivery_mechanism: { +integration-1 | kind: "apns", +integration-1 | token: "token", +integration-1 | }, +integration-1 | }, +integration-1 | subscription: { +integration-1 | created_at: "0001-01-01T00:00:00Z", +integration-1 | topic: "/xmtp/0/invite-0x997eF1DD0c5FCa10f843D59b2bFDAff763A9F7B9/proto", +integration-1 | is_silent: true, +integration-1 | }, +integration-1 | } + */ + +export type NotificationResponse = { + idempotency_key: string; + message: { + content_topic: string; + timestamp_ns: string; + message: string; + }; + message_context: { + message_type: string; + should_push?: boolean; + }; + installation: { + id: string; + delivery_mechanism: { + kind: string; + token: string; + }; + }; + subscription: { + created_at: string; + topic: string; + is_silent: boolean; + }; +}; diff --git a/integration/tsconfig.json b/integration/tsconfig.json new file mode 100644 index 0000000..0fef23a --- /dev/null +++ b/integration/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + // Enable latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/pkg/api/api.go b/pkg/api/api.go index 2b5ac19..b553734 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -76,6 +76,7 @@ func (s *ApiServer) RegisterInstallation( if mechanism == nil { return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("missing delivery mechanism")) } + s.logger.Info("got mechanism", zap.Any("mechanism", mechanism)) result, err := s.installations.Register( ctx, interfaces.Installation{ @@ -83,12 +84,11 @@ func (s *ApiServer) RegisterInstallation( DeliveryMechanism: *mechanism, }, ) - if err != nil { s.logger.Error("error registering installation", zap.Error(err)) return nil, connect.NewError(connect.CodeInternal, err) } - + s.logger.Info("sending response", zap.Any("result", result)) return connect.NewResponse(&proto.RegisterInstallationResponse{ InstallationId: req.Msg.InstallationId, ValidUntil: uint64(result.ValidUntil.UnixMilli()), @@ -141,7 +141,37 @@ func (s *ApiServer) Unsubscribe( } func (s *ApiServer) SubscribeWithMetadata(ctx context.Context, req *connect.Request[proto.SubscribeWithMetadataRequest]) (*connect.Response[emptypb.Empty], error) { - return nil, nil + log := s.logger.With(zap.String("method", "subscribeWithMetadata")) + log.Info("starting") + err := s.subscriptions.SubscribeWithMetadata(ctx, req.Msg.InstallationId, buildSubscriptionInputs(req.Msg.Subscriptions)) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + return connect.NewResponse(&emptypb.Empty{}), nil +} + +func buildSubscriptionInputs(subs []*proto.Subscription) []interfaces.SubscriptionInput { + out := make([]interfaces.SubscriptionInput, len(subs)) + for idx, sub := range subs { + out[idx] = interfaces.SubscriptionInput{ + Topic: sub.Topic, + IsSilent: sub.IsSilent, + HmacKeys: buildHmacKeys(sub.HmacKeys), + } + } + return out +} + +func buildHmacKeys(protoKeys []*proto.Subscription_HmacKey) []interfaces.HmacKey { + out := make([]interfaces.HmacKey, len(protoKeys)) + for idx, key := range protoKeys { + out[idx] = interfaces.HmacKey{ + ThirtyDayPeriodsSinceEpoch: int(key.ThirtyDayPeriodsSinceEpoch), + Key: key.Key, + } + } + return out } func convertDeliveryMechanism(mechanism *proto.DeliveryMechanism) *interfaces.DeliveryMechanism { diff --git a/pkg/db/models.go b/pkg/db/models.go index 4c1f44a..691483e 100644 --- a/pkg/db/models.go +++ b/pkg/db/models.go @@ -32,7 +32,7 @@ type Subscription struct { bun.BaseModel `bun:"table:subscriptions"` Id int64 `bun:",pk,autoincrement"` - CreatedAt time.Time `bun:"created_at,notnull"` + CreatedAt time.Time `bun:"created_at,notnull,default:current_timestamp"` InstallationId string `bun:"installation_id,notnull"` Topic string `bun:"topic,notnull"` IsActive bool `bun:"is_active,notnull"` diff --git a/pkg/delivery/http.go b/pkg/delivery/http.go new file mode 100644 index 0000000..b76f94e --- /dev/null +++ b/pkg/delivery/http.go @@ -0,0 +1,68 @@ +package delivery + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + + "github.com/xmtp/example-notification-server-go/pkg/interfaces" + "github.com/xmtp/example-notification-server-go/pkg/options" + "go.uber.org/zap" +) + +type HttpDelivery struct { + address string + authHeader string + logger *zap.Logger +} + +func NewHttpDelivery(logger *zap.Logger, opts options.HttpDeliveryOptions) *HttpDelivery { + return &HttpDelivery{ + logger: logger, + address: opts.Address, + authHeader: opts.AuthHeader, + } +} + +func (h HttpDelivery) CanDeliver(req interfaces.SendRequest) bool { + return true +} + +func (h HttpDelivery) Send(ctx context.Context, req interfaces.SendRequest) error { + // Convert the request data to JSON + jsonData, err := json.Marshal(req) + if err != nil { + return err + } + + // Create a new HTTP request with context + httpRequest, err := http.NewRequestWithContext(ctx, "POST", h.address, bytes.NewBuffer(jsonData)) + if err != nil { + return err + } + + // Set the content type and authorization headers + httpRequest.Header.Set("Content-Type", "application/json") + if h.authHeader != "" { + httpRequest.Header.Set("Authorization", h.authHeader) + } + + // Send the request using the http.DefaultClient + response, err := http.DefaultClient.Do(httpRequest) + if err != nil { + return err + } + defer response.Body.Close() + + // Check the response status code + if response.StatusCode != http.StatusOK { + h.logger.Error("HTTP request failed", + zap.Int("status_code", response.StatusCode), + ) + return errors.New("HTTP request failed") + } + + return nil +} diff --git a/pkg/interfaces/interfaces.go b/pkg/interfaces/interfaces.go index bfca9a0..8907732 100644 --- a/pkg/interfaces/interfaces.go +++ b/pkg/interfaces/interfaces.go @@ -18,9 +18,9 @@ const ( ) type DeliveryMechanism struct { - Kind DeliveryMechanismKind - Token string - UpdatedAt time.Time + Kind DeliveryMechanismKind `json:"kind"` + Token string `json:"token"` + UpdatedAt time.Time `json:"-"` } type RegisterResponse struct { @@ -34,33 +34,33 @@ An installation represents an app installed on a device. If the app is reinstall a new device it is expected to generate a fresh installation_id. */ type Installation struct { - Id string - DeliveryMechanism DeliveryMechanism + Id string `json:"id"` + DeliveryMechanism DeliveryMechanism `json:"delivery_mechanism"` } type Subscription struct { - Id int64 - CreatedAt time.Time - InstallationId string - Topic string - IsActive bool - IsSilent bool - HmacKey *HmacKey + Id int64 `json:"-"` + CreatedAt time.Time `json:"created_at"` + InstallationId string `json:"-"` + Topic string `json:"topic"` + IsActive bool `json:"-"` + IsSilent bool `json:"is_silent"` + HmacKey *HmacKey `json:"-"` } type SendRequest struct { - IdempotencyKey string - Message *v1.Envelope - MessageContext MessageContext - Installation Installation - Subscription Subscription + IdempotencyKey string `json:"idempotency_key"` + Message *v1.Envelope `json:"message"` + MessageContext MessageContext `json:"message_context"` + Installation Installation `json:"installation"` + Subscription Subscription `json:"subscription"` } type MessageContext struct { - MessageType topics.MessageType - ShouldPush *bool - HmacInputs *[]byte - SenderHmac *[]byte + MessageType topics.MessageType `json:"message_type"` + ShouldPush *bool `json:"should_push,omitempty"` + HmacInputs *[]byte `json:"-"` + SenderHmac *[]byte `json:"-"` } func (m MessageContext) IsSender(hmacKey []byte) bool { diff --git a/pkg/options/options.go b/pkg/options/options.go index ebb350a..604f0cc 100644 --- a/pkg/options/options.go +++ b/pkg/options/options.go @@ -28,11 +28,18 @@ type XmtpOptions struct { NumWorkers int `long:"num-workers" description:"Number of workers used to process messages" default:"50"` } +type HttpDeliveryOptions struct { + Enabled bool `long:"http-delivery"` + Address string `long:"http-delivery-address"` + AuthHeader string `long:"http-auth-header"` +} + type Options struct { - Api ApiOptions `group:"API Options"` - Xmtp XmtpOptions `group:"Worker Options"` - Apns ApnsOptions `group:"APNS Options"` - Fcm FcmOptions `group:"FCM Options"` + Api ApiOptions `group:"API Options"` + Xmtp XmtpOptions `group:"Worker Options"` + Apns ApnsOptions `group:"APNS Options"` + Fcm FcmOptions `group:"FCM Options"` + HttpDelivery HttpDeliveryOptions `group:"HTTP Delivery Options"` DbConnectionString string `short:"d" long:"db-connection-string" env:"DB_CONNECTION_STRING" description:"Address to database"` LogEncoding string `long:"log-encoding" env:"LOG_ENCODING" description:"Log encoding" choice:"console" choice:"json" default:"console"` diff --git a/proto/buf.gen.yaml b/proto/buf.gen.yaml index 34b72ce..34c4238 100644 --- a/proto/buf.gen.yaml +++ b/proto/buf.gen.yaml @@ -10,6 +10,10 @@ managed: - buf.build/googleapis/googleapis - buf.build/grpc-ecosystem/grpc-gateway plugins: + - plugin: buf.build/connectrpc/es:v1.4.0 + out: integration/gen + - plugin: buf.build/bufbuild/es + out: integration/gen - plugin: buf.build/protocolbuffers/go:v1.32.0 out: pkg/proto opt: