From fe09487a6ab56866c02763617b8b4560ced28fde Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 25 May 2022 20:46:19 +0800 Subject: [PATCH 01/15] Add webhooks model --- packages/db/migrations/0083.do.webhooks.sql | 21 +++++++++++++++++++ packages/db/migrations/0083.undo.webhooks.sql | 9 ++++++++ 2 files changed, 30 insertions(+) create mode 100755 packages/db/migrations/0083.do.webhooks.sql create mode 100755 packages/db/migrations/0083.undo.webhooks.sql diff --git a/packages/db/migrations/0083.do.webhooks.sql b/packages/db/migrations/0083.do.webhooks.sql new file mode 100755 index 000000000..aa044413b --- /dev/null +++ b/packages/db/migrations/0083.do.webhooks.sql @@ -0,0 +1,21 @@ +-- Type: DO +-- Name: webhooks +-- Description: webhooks model + +BEGIN; + +CREATE TABLE omnivore.webhooks ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v1mc(), + url text NOT NULL, + method text NOT NULL DEFAULT 'POST', + content_type text NOT NULL DEFAULT 'application/json', + active boolean NOT NULL DEFAULT true, + event_types text[] NOT NULL, + created_at timestamptz NOT NULL DEFAULT current_timestamp, + updated_at timestamptz NOT NULL DEFAULT current_timestamp +); + +CREATE TRIGGER update_webhook_modtime BEFORE UPDATE ON omnivore.webhooks + FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column(); + +COMMIT; diff --git a/packages/db/migrations/0083.undo.webhooks.sql b/packages/db/migrations/0083.undo.webhooks.sql new file mode 100755 index 000000000..b700ba0ee --- /dev/null +++ b/packages/db/migrations/0083.undo.webhooks.sql @@ -0,0 +1,9 @@ +-- Type: UNDO +-- Name: webhooks +-- Description: webhooks model + +BEGIN; + +DROP TABLE IF EXISTS omnivore.webhooks; + +COMMIT; From aaab02c9d84b202d39a5fec9636f44b8210bc6e8 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 25 May 2022 20:56:18 +0800 Subject: [PATCH 02/15] Add createWebhook gql schema --- packages/api/src/generated/graphql.ts | 97 +++++++++++++++++++++++ packages/api/src/generated/schema.graphql | 46 +++++++++++ packages/api/src/schema.ts | 46 +++++++++++ 3 files changed, 189 insertions(+) diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 201e76621..d5626cd88 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -394,6 +394,30 @@ export type CreateReminderSuccess = { reminder: Reminder; }; +export type CreateWebhookError = { + __typename?: 'CreateWebhookError'; + errorCodes: Array; +}; + +export enum CreateWebhookErrorCode { + BadRequest = 'BAD_REQUEST', + Unauthorized = 'UNAUTHORIZED' +} + +export type CreateWebhookInput = { + contentType?: InputMaybe; + eventTypes: Array>; + method?: InputMaybe; + url: Scalars['String']; +}; + +export type CreateWebhookResult = CreateWebhookError | CreateWebhookSuccess; + +export type CreateWebhookSuccess = { + __typename?: 'CreateWebhookSuccess'; + webhook: Webhook; +}; + export type DeleteHighlightError = { __typename?: 'DeleteHighlightError'; errorCodes: Array; @@ -811,6 +835,7 @@ export type Mutation = { createNewsletterEmail: CreateNewsletterEmailResult; createReaction: CreateReactionResult; createReminder: CreateReminderResult; + createWebhook: CreateWebhookResult; deleteHighlight: DeleteHighlightResult; deleteHighlightReply: DeleteHighlightReplyResult; deleteLabel: DeleteLabelResult; @@ -892,6 +917,11 @@ export type MutationCreateReminderArgs = { }; +export type MutationCreateWebhookArgs = { + input: CreateWebhookInput; +}; + + export type MutationDeleteHighlightArgs = { highlightId: Scalars['ID']; }; @@ -2073,6 +2103,30 @@ export type UserSuccess = { user: User; }; +export type Webhook = { + __typename?: 'Webhook'; + active: Scalars['Boolean']; + contentType?: Maybe; + createdAt: Scalars['Date']; + eventTypes: Array; + id: Scalars['ID']; + method?: Maybe; + updatedAt: Scalars['Date']; + url: Scalars['String']; +}; + +export enum WebhookEvent { + HighlightCreated = 'HIGHLIGHT_CREATED', + HighlightDeleted = 'HIGHLIGHT_DELETED', + HighlightUpdated = 'HIGHLIGHT_UPDATED', + LabelCreated = 'LABEL_CREATED', + LabelDeleted = 'LABEL_DELETED', + LabelUpdated = 'LABEL_UPDATED', + PageCreated = 'PAGE_CREATED', + PageDeleted = 'PAGE_DELETED', + PageUpdated = 'PAGE_UPDATED' +} + export type ResolverTypeWrapper = Promise | T; @@ -2209,6 +2263,11 @@ export type ResolversTypes = { CreateReminderInput: CreateReminderInput; CreateReminderResult: ResolversTypes['CreateReminderError'] | ResolversTypes['CreateReminderSuccess']; CreateReminderSuccess: ResolverTypeWrapper; + CreateWebhookError: ResolverTypeWrapper; + CreateWebhookErrorCode: CreateWebhookErrorCode; + CreateWebhookInput: CreateWebhookInput; + CreateWebhookResult: ResolversTypes['CreateWebhookError'] | ResolversTypes['CreateWebhookSuccess']; + CreateWebhookSuccess: ResolverTypeWrapper; Date: ResolverTypeWrapper; DeleteHighlightError: ResolverTypeWrapper; DeleteHighlightErrorCode: DeleteHighlightErrorCode; @@ -2456,6 +2515,8 @@ export type ResolversTypes = { UsersResult: ResolversTypes['UsersError'] | ResolversTypes['UsersSuccess']; UsersSuccess: ResolverTypeWrapper; UserSuccess: ResolverTypeWrapper; + Webhook: ResolverTypeWrapper; + WebhookEvent: WebhookEvent; }; /** Mapping between all available schema types and the resolvers parents */ @@ -2512,6 +2573,10 @@ export type ResolversParentTypes = { CreateReminderInput: CreateReminderInput; CreateReminderResult: ResolversParentTypes['CreateReminderError'] | ResolversParentTypes['CreateReminderSuccess']; CreateReminderSuccess: CreateReminderSuccess; + CreateWebhookError: CreateWebhookError; + CreateWebhookInput: CreateWebhookInput; + CreateWebhookResult: ResolversParentTypes['CreateWebhookError'] | ResolversParentTypes['CreateWebhookSuccess']; + CreateWebhookSuccess: CreateWebhookSuccess; Date: Scalars['Date']; DeleteHighlightError: DeleteHighlightError; DeleteHighlightReplyError: DeleteHighlightReplyError; @@ -2708,6 +2773,7 @@ export type ResolversParentTypes = { UsersResult: ResolversParentTypes['UsersError'] | ResolversParentTypes['UsersSuccess']; UsersSuccess: UsersSuccess; UserSuccess: UserSuccess; + Webhook: Webhook; }; export type SanitizeDirectiveArgs = { @@ -2963,6 +3029,20 @@ export type CreateReminderSuccessResolvers; }; +export type CreateWebhookErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type CreateWebhookResultResolvers = { + __resolveType: TypeResolveFn<'CreateWebhookError' | 'CreateWebhookSuccess', ParentType, ContextType>; +}; + +export type CreateWebhookSuccessResolvers = { + webhook?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export interface DateScalarConfig extends GraphQLScalarTypeConfig { name: 'Date'; } @@ -3295,6 +3375,7 @@ export type MutationResolvers; createReaction?: Resolver>; createReminder?: Resolver>; + createWebhook?: Resolver>; deleteHighlight?: Resolver>; deleteHighlightReply?: Resolver>; deleteLabel?: Resolver>; @@ -3928,6 +4009,18 @@ export type UserSuccessResolvers; }; +export type WebhookResolvers = { + active?: Resolver; + contentType?: Resolver, ParentType, ContextType>; + createdAt?: Resolver; + eventTypes?: Resolver, ParentType, ContextType>; + id?: Resolver; + method?: Resolver, ParentType, ContextType>; + updatedAt?: Resolver; + url?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type Resolvers = { AddPopularReadError?: AddPopularReadErrorResolvers; AddPopularReadResult?: AddPopularReadResultResolvers; @@ -3971,6 +4064,9 @@ export type Resolvers = { CreateReminderError?: CreateReminderErrorResolvers; CreateReminderResult?: CreateReminderResultResolvers; CreateReminderSuccess?: CreateReminderSuccessResolvers; + CreateWebhookError?: CreateWebhookErrorResolvers; + CreateWebhookResult?: CreateWebhookResultResolvers; + CreateWebhookSuccess?: CreateWebhookSuccessResolvers; Date?: GraphQLScalarType; DeleteHighlightError?: DeleteHighlightErrorResolvers; DeleteHighlightReplyError?: DeleteHighlightReplyErrorResolvers; @@ -4132,6 +4228,7 @@ export type Resolvers = { UsersResult?: UsersResultResolvers; UsersSuccess?: UsersSuccessResolvers; UserSuccess?: UserSuccessResolvers; + Webhook?: WebhookResolvers; }; export type DirectiveResolvers = { diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index d6c81bf3e..79525bf81 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -342,6 +342,28 @@ type CreateReminderSuccess { reminder: Reminder! } +type CreateWebhookError { + errorCodes: [CreateWebhookErrorCode!]! +} + +enum CreateWebhookErrorCode { + BAD_REQUEST + UNAUTHORIZED +} + +input CreateWebhookInput { + contentType: String + eventTypes: [WebhookEvent]! + method: String + url: String! +} + +union CreateWebhookResult = CreateWebhookError | CreateWebhookSuccess + +type CreateWebhookSuccess { + webhook: Webhook! +} + scalar Date type DeleteHighlightError { @@ -719,6 +741,7 @@ type Mutation { createNewsletterEmail: CreateNewsletterEmailResult! createReaction(input: CreateReactionInput!): CreateReactionResult! createReminder(input: CreateReminderInput!): CreateReminderResult! + createWebhook(input: CreateWebhookInput!): CreateWebhookResult! deleteHighlight(highlightId: ID!): DeleteHighlightResult! deleteHighlightReply(highlightReplyId: ID!): DeleteHighlightReplyResult! deleteLabel(id: ID!): DeleteLabelResult! @@ -1607,3 +1630,26 @@ type UsersSuccess { type UserSuccess { user: User! } + +type Webhook { + active: Boolean! + contentType: String + createdAt: Date! + eventTypes: [WebhookEvent!]! + id: ID! + method: String + updatedAt: Date! + url: String! +} + +enum WebhookEvent { + HIGHLIGHT_CREATED + HIGHLIGHT_DELETED + HIGHLIGHT_UPDATED + LABEL_CREATED + LABEL_DELETED + LABEL_UPDATED + PAGE_CREATED + PAGE_DELETED + PAGE_UPDATED +} diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 2a0e8959a..c198b7f13 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -1571,6 +1571,51 @@ const schema = gql` NOT_FOUND } + input CreateWebhookInput { + url: String! + eventTypes: [WebhookEvent]! + contentType: String + method: String + } + + enum WebhookEvent { + PAGE_CREATED + PAGE_UPDATED + PAGE_DELETED + HIGHLIGHT_CREATED + HIGHLIGHT_UPDATED + HIGHLIGHT_DELETED + LABEL_CREATED + LABEL_UPDATED + LABEL_DELETED + } + + union CreateWebhookResult = CreateWebhookSuccess | CreateWebhookError + + type CreateWebhookSuccess { + webhook: Webhook! + } + + type Webhook { + id: ID! + url: String! + eventTypes: [WebhookEvent!]! + contentType: String + method: String + active: Boolean! + createdAt: Date! + updatedAt: Date! + } + + type CreateWebhookError { + errorCodes: [CreateWebhookErrorCode!]! + } + + enum CreateWebhookErrorCode { + UNAUTHORIZED + BAD_REQUEST + } + # Mutations type Mutation { googleLogin(input: GoogleLoginInput!): LoginResult! @@ -1636,6 +1681,7 @@ const schema = gql` unsubscribe(name: String!): UnsubscribeResult! subscribe(name: String!): SubscribeResult! addPopularRead(name: String!): AddPopularReadResult! + createWebhook(input: CreateWebhookInput!): CreateWebhookResult! } # FIXME: remove sort from feedArticles after all cached tabs are closed From a65527575499ab729b1ba5c6ff7b29ac51bcfd83 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 25 May 2022 21:12:19 +0800 Subject: [PATCH 03/15] Add unique constraint on user_id and event_types --- packages/api/src/schema.ts | 1 + packages/db/migrations/0083.do.webhooks.sql | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index c198b7f13..254d1a076 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -1614,6 +1614,7 @@ const schema = gql` enum CreateWebhookErrorCode { UNAUTHORIZED BAD_REQUEST + ALREADY_EXISTS } # Mutations diff --git a/packages/db/migrations/0083.do.webhooks.sql b/packages/db/migrations/0083.do.webhooks.sql index aa044413b..018762b74 100755 --- a/packages/db/migrations/0083.do.webhooks.sql +++ b/packages/db/migrations/0083.do.webhooks.sql @@ -6,13 +6,15 @@ BEGIN; CREATE TABLE omnivore.webhooks ( id uuid PRIMARY KEY DEFAULT uuid_generate_v1mc(), + user_id uuid NOT NULL REFERENCES omnivore.user ON DELETE CASCADE, url text NOT NULL, method text NOT NULL DEFAULT 'POST', content_type text NOT NULL DEFAULT 'application/json', active boolean NOT NULL DEFAULT true, event_types text[] NOT NULL, created_at timestamptz NOT NULL DEFAULT current_timestamp, - updated_at timestamptz NOT NULL DEFAULT current_timestamp + updated_at timestamptz NOT NULL DEFAULT current_timestamp, + UNIQUE (user_id, event_types) ); CREATE TRIGGER update_webhook_modtime BEFORE UPDATE ON omnivore.webhooks From f61f61312c621961ab67369d3f2742bdeeca066c Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 25 May 2022 21:21:17 +0800 Subject: [PATCH 04/15] Add webhook entity --- packages/api/src/entity/webhook.ts | 41 +++++++++++++++++++++ packages/db/migrations/0083.do.webhooks.sql | 2 +- 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 packages/api/src/entity/webhook.ts diff --git a/packages/api/src/entity/webhook.ts b/packages/api/src/entity/webhook.ts new file mode 100644 index 000000000..34a07cbc5 --- /dev/null +++ b/packages/api/src/entity/webhook.ts @@ -0,0 +1,41 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm' +import { User } from './user' + +@Entity({ name: 'webhooks' }) +export class Webhook { + @PrimaryGeneratedColumn('uuid') + id!: string + + @ManyToOne(() => User) + @JoinColumn({ name: 'user_id' }) + user!: User + + @Column('text') + url!: string + + @Column('text[]') + eventTypes!: string[] + + @Column('text') + method?: string + + @Column('text') + contentType?: string + + @Column('boolean') + enabled?: boolean + + @CreateDateColumn() + createdAt!: Date + + @UpdateDateColumn() + updatedAt!: Date +} diff --git a/packages/db/migrations/0083.do.webhooks.sql b/packages/db/migrations/0083.do.webhooks.sql index 018762b74..f03a548f7 100755 --- a/packages/db/migrations/0083.do.webhooks.sql +++ b/packages/db/migrations/0083.do.webhooks.sql @@ -10,7 +10,7 @@ CREATE TABLE omnivore.webhooks ( url text NOT NULL, method text NOT NULL DEFAULT 'POST', content_type text NOT NULL DEFAULT 'application/json', - active boolean NOT NULL DEFAULT true, + enabled boolean NOT NULL DEFAULT true, event_types text[] NOT NULL, created_at timestamptz NOT NULL DEFAULT current_timestamp, updated_at timestamptz NOT NULL DEFAULT current_timestamp, From 5528d1d3492f761d56a328698296895784f7b3e4 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 25 May 2022 21:34:03 +0800 Subject: [PATCH 05/15] Replace createWebhook with setWebhook --- packages/api/src/entity/webhook.ts | 14 +-- packages/api/src/generated/graphql.ts | 128 +++++++++++----------- packages/api/src/generated/schema.graphql | 54 ++++----- packages/api/src/schema.ts | 23 ++-- 4 files changed, 112 insertions(+), 107 deletions(-) diff --git a/packages/api/src/entity/webhook.ts b/packages/api/src/entity/webhook.ts index 34a07cbc5..98d6efbf2 100644 --- a/packages/api/src/entity/webhook.ts +++ b/packages/api/src/entity/webhook.ts @@ -24,18 +24,18 @@ export class Webhook { @Column('text[]') eventTypes!: string[] - @Column('text') + @Column('text', { default: 'POST' }) method?: string - @Column('text') + @Column('text', { default: 'application/json' }) contentType?: string - @Column('boolean') + @Column('boolean', { default: true }) enabled?: boolean - @CreateDateColumn() - createdAt!: Date + @CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' }) + createdAt?: Date - @UpdateDateColumn() - updatedAt!: Date + @UpdateDateColumn({ default: () => 'CURRENT_TIMESTAMP' }) + updatedAt?: Date } diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index d5626cd88..615ae0e64 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -394,30 +394,6 @@ export type CreateReminderSuccess = { reminder: Reminder; }; -export type CreateWebhookError = { - __typename?: 'CreateWebhookError'; - errorCodes: Array; -}; - -export enum CreateWebhookErrorCode { - BadRequest = 'BAD_REQUEST', - Unauthorized = 'UNAUTHORIZED' -} - -export type CreateWebhookInput = { - contentType?: InputMaybe; - eventTypes: Array>; - method?: InputMaybe; - url: Scalars['String']; -}; - -export type CreateWebhookResult = CreateWebhookError | CreateWebhookSuccess; - -export type CreateWebhookSuccess = { - __typename?: 'CreateWebhookSuccess'; - webhook: Webhook; -}; - export type DeleteHighlightError = { __typename?: 'DeleteHighlightError'; errorCodes: Array; @@ -835,7 +811,6 @@ export type Mutation = { createNewsletterEmail: CreateNewsletterEmailResult; createReaction: CreateReactionResult; createReminder: CreateReminderResult; - createWebhook: CreateWebhookResult; deleteHighlight: DeleteHighlightResult; deleteHighlightReply: DeleteHighlightReplyResult; deleteLabel: DeleteLabelResult; @@ -861,6 +836,7 @@ export type Mutation = { setShareArticle: SetShareArticleResult; setShareHighlight: SetShareHighlightResult; setUserPersonalization: SetUserPersonalizationResult; + setWebhook: SetWebhookResult; signup: SignupResult; subscribe: SubscribeResult; unsubscribe: UnsubscribeResult; @@ -917,11 +893,6 @@ export type MutationCreateReminderArgs = { }; -export type MutationCreateWebhookArgs = { - input: CreateWebhookInput; -}; - - export type MutationDeleteHighlightArgs = { highlightId: Scalars['ID']; }; @@ -1042,6 +1013,11 @@ export type MutationSetUserPersonalizationArgs = { }; +export type MutationSetWebhookArgs = { + input: SetWebhookInput; +}; + + export type MutationSignupArgs = { input: SignupInput; }; @@ -1641,6 +1617,32 @@ export type SetUserPersonalizationSuccess = { updatedUserPersonalization: UserPersonalization; }; +export type SetWebhookError = { + __typename?: 'SetWebhookError'; + errorCodes: Array; +}; + +export enum SetWebhookErrorCode { + AlreadyExists = 'ALREADY_EXISTS', + BadRequest = 'BAD_REQUEST', + Unauthorized = 'UNAUTHORIZED' +} + +export type SetWebhookInput = { + contentType?: InputMaybe; + enabled?: InputMaybe; + eventTypes: Array; + method?: InputMaybe; + url: Scalars['String']; +}; + +export type SetWebhookResult = SetWebhookError | SetWebhookSuccess; + +export type SetWebhookSuccess = { + __typename?: 'SetWebhookSuccess'; + webhook: Webhook; +}; + export type SharedArticleError = { __typename?: 'SharedArticleError'; errorCodes: Array; @@ -2105,12 +2107,12 @@ export type UserSuccess = { export type Webhook = { __typename?: 'Webhook'; - active: Scalars['Boolean']; - contentType?: Maybe; + contentType: Scalars['String']; createdAt: Scalars['Date']; + enabled: Scalars['Boolean']; eventTypes: Array; id: Scalars['ID']; - method?: Maybe; + method: Scalars['String']; updatedAt: Scalars['Date']; url: Scalars['String']; }; @@ -2263,11 +2265,6 @@ export type ResolversTypes = { CreateReminderInput: CreateReminderInput; CreateReminderResult: ResolversTypes['CreateReminderError'] | ResolversTypes['CreateReminderSuccess']; CreateReminderSuccess: ResolverTypeWrapper; - CreateWebhookError: ResolverTypeWrapper; - CreateWebhookErrorCode: CreateWebhookErrorCode; - CreateWebhookInput: CreateWebhookInput; - CreateWebhookResult: ResolversTypes['CreateWebhookError'] | ResolversTypes['CreateWebhookSuccess']; - CreateWebhookSuccess: ResolverTypeWrapper; Date: ResolverTypeWrapper; DeleteHighlightError: ResolverTypeWrapper; DeleteHighlightErrorCode: DeleteHighlightErrorCode; @@ -2426,6 +2423,11 @@ export type ResolversTypes = { SetUserPersonalizationInput: SetUserPersonalizationInput; SetUserPersonalizationResult: ResolversTypes['SetUserPersonalizationError'] | ResolversTypes['SetUserPersonalizationSuccess']; SetUserPersonalizationSuccess: ResolverTypeWrapper; + SetWebhookError: ResolverTypeWrapper; + SetWebhookErrorCode: SetWebhookErrorCode; + SetWebhookInput: SetWebhookInput; + SetWebhookResult: ResolversTypes['SetWebhookError'] | ResolversTypes['SetWebhookSuccess']; + SetWebhookSuccess: ResolverTypeWrapper; SharedArticleError: ResolverTypeWrapper; SharedArticleErrorCode: SharedArticleErrorCode; SharedArticleResult: ResolversTypes['SharedArticleError'] | ResolversTypes['SharedArticleSuccess']; @@ -2573,10 +2575,6 @@ export type ResolversParentTypes = { CreateReminderInput: CreateReminderInput; CreateReminderResult: ResolversParentTypes['CreateReminderError'] | ResolversParentTypes['CreateReminderSuccess']; CreateReminderSuccess: CreateReminderSuccess; - CreateWebhookError: CreateWebhookError; - CreateWebhookInput: CreateWebhookInput; - CreateWebhookResult: ResolversParentTypes['CreateWebhookError'] | ResolversParentTypes['CreateWebhookSuccess']; - CreateWebhookSuccess: CreateWebhookSuccess; Date: Scalars['Date']; DeleteHighlightError: DeleteHighlightError; DeleteHighlightReplyError: DeleteHighlightReplyError; @@ -2705,6 +2703,10 @@ export type ResolversParentTypes = { SetUserPersonalizationInput: SetUserPersonalizationInput; SetUserPersonalizationResult: ResolversParentTypes['SetUserPersonalizationError'] | ResolversParentTypes['SetUserPersonalizationSuccess']; SetUserPersonalizationSuccess: SetUserPersonalizationSuccess; + SetWebhookError: SetWebhookError; + SetWebhookInput: SetWebhookInput; + SetWebhookResult: ResolversParentTypes['SetWebhookError'] | ResolversParentTypes['SetWebhookSuccess']; + SetWebhookSuccess: SetWebhookSuccess; SharedArticleError: SharedArticleError; SharedArticleResult: ResolversParentTypes['SharedArticleError'] | ResolversParentTypes['SharedArticleSuccess']; SharedArticleSuccess: SharedArticleSuccess; @@ -3029,20 +3031,6 @@ export type CreateReminderSuccessResolvers; }; -export type CreateWebhookErrorResolvers = { - errorCodes?: Resolver, ParentType, ContextType>; - __isTypeOf?: IsTypeOfResolverFn; -}; - -export type CreateWebhookResultResolvers = { - __resolveType: TypeResolveFn<'CreateWebhookError' | 'CreateWebhookSuccess', ParentType, ContextType>; -}; - -export type CreateWebhookSuccessResolvers = { - webhook?: Resolver; - __isTypeOf?: IsTypeOfResolverFn; -}; - export interface DateScalarConfig extends GraphQLScalarTypeConfig { name: 'Date'; } @@ -3375,7 +3363,6 @@ export type MutationResolvers; createReaction?: Resolver>; createReminder?: Resolver>; - createWebhook?: Resolver>; deleteHighlight?: Resolver>; deleteHighlightReply?: Resolver>; deleteLabel?: Resolver>; @@ -3401,6 +3388,7 @@ export type MutationResolvers>; setShareHighlight?: Resolver>; setUserPersonalization?: Resolver>; + setWebhook?: Resolver>; signup?: Resolver>; subscribe?: Resolver>; unsubscribe?: Resolver>; @@ -3720,6 +3708,20 @@ export type SetUserPersonalizationSuccessResolvers; }; +export type SetWebhookErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type SetWebhookResultResolvers = { + __resolveType: TypeResolveFn<'SetWebhookError' | 'SetWebhookSuccess', ParentType, ContextType>; +}; + +export type SetWebhookSuccessResolvers = { + webhook?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type SharedArticleErrorResolvers = { errorCodes?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -4010,12 +4012,12 @@ export type UserSuccessResolvers = { - active?: Resolver; - contentType?: Resolver, ParentType, ContextType>; + contentType?: Resolver; createdAt?: Resolver; + enabled?: Resolver; eventTypes?: Resolver, ParentType, ContextType>; id?: Resolver; - method?: Resolver, ParentType, ContextType>; + method?: Resolver; updatedAt?: Resolver; url?: Resolver; __isTypeOf?: IsTypeOfResolverFn; @@ -4064,9 +4066,6 @@ export type Resolvers = { CreateReminderError?: CreateReminderErrorResolvers; CreateReminderResult?: CreateReminderResultResolvers; CreateReminderSuccess?: CreateReminderSuccessResolvers; - CreateWebhookError?: CreateWebhookErrorResolvers; - CreateWebhookResult?: CreateWebhookResultResolvers; - CreateWebhookSuccess?: CreateWebhookSuccessResolvers; Date?: GraphQLScalarType; DeleteHighlightError?: DeleteHighlightErrorResolvers; DeleteHighlightReplyError?: DeleteHighlightReplyErrorResolvers; @@ -4173,6 +4172,9 @@ export type Resolvers = { SetUserPersonalizationError?: SetUserPersonalizationErrorResolvers; SetUserPersonalizationResult?: SetUserPersonalizationResultResolvers; SetUserPersonalizationSuccess?: SetUserPersonalizationSuccessResolvers; + SetWebhookError?: SetWebhookErrorResolvers; + SetWebhookResult?: SetWebhookResultResolvers; + SetWebhookSuccess?: SetWebhookSuccessResolvers; SharedArticleError?: SharedArticleErrorResolvers; SharedArticleResult?: SharedArticleResultResolvers; SharedArticleSuccess?: SharedArticleSuccessResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 79525bf81..de6019e8b 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -342,28 +342,6 @@ type CreateReminderSuccess { reminder: Reminder! } -type CreateWebhookError { - errorCodes: [CreateWebhookErrorCode!]! -} - -enum CreateWebhookErrorCode { - BAD_REQUEST - UNAUTHORIZED -} - -input CreateWebhookInput { - contentType: String - eventTypes: [WebhookEvent]! - method: String - url: String! -} - -union CreateWebhookResult = CreateWebhookError | CreateWebhookSuccess - -type CreateWebhookSuccess { - webhook: Webhook! -} - scalar Date type DeleteHighlightError { @@ -741,7 +719,6 @@ type Mutation { createNewsletterEmail: CreateNewsletterEmailResult! createReaction(input: CreateReactionInput!): CreateReactionResult! createReminder(input: CreateReminderInput!): CreateReminderResult! - createWebhook(input: CreateWebhookInput!): CreateWebhookResult! deleteHighlight(highlightId: ID!): DeleteHighlightResult! deleteHighlightReply(highlightReplyId: ID!): DeleteHighlightReplyResult! deleteLabel(id: ID!): DeleteLabelResult! @@ -767,6 +744,7 @@ type Mutation { setShareArticle(input: SetShareArticleInput!): SetShareArticleResult! setShareHighlight(input: SetShareHighlightInput!): SetShareHighlightResult! setUserPersonalization(input: SetUserPersonalizationInput!): SetUserPersonalizationResult! + setWebhook(input: SetWebhookInput!): SetWebhookResult! signup(input: SignupInput!): SignupResult! subscribe(name: String!): SubscribeResult! unsubscribe(name: String!): UnsubscribeResult! @@ -1208,6 +1186,30 @@ type SetUserPersonalizationSuccess { updatedUserPersonalization: UserPersonalization! } +type SetWebhookError { + errorCodes: [SetWebhookErrorCode!]! +} + +enum SetWebhookErrorCode { + ALREADY_EXISTS + BAD_REQUEST + UNAUTHORIZED +} + +input SetWebhookInput { + contentType: String + enabled: Boolean + eventTypes: [WebhookEvent!]! + method: String + url: String! +} + +union SetWebhookResult = SetWebhookError | SetWebhookSuccess + +type SetWebhookSuccess { + webhook: Webhook! +} + type SharedArticleError { errorCodes: [SharedArticleErrorCode!]! } @@ -1632,12 +1634,12 @@ type UserSuccess { } type Webhook { - active: Boolean! - contentType: String + contentType: String! createdAt: Date! + enabled: Boolean! eventTypes: [WebhookEvent!]! id: ID! - method: String + method: String! updatedAt: Date! url: String! } diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index 254d1a076..ece4cc8ad 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -1571,11 +1571,12 @@ const schema = gql` NOT_FOUND } - input CreateWebhookInput { + input SetWebhookInput { url: String! - eventTypes: [WebhookEvent]! + eventTypes: [WebhookEvent!]! contentType: String method: String + enabled: Boolean } enum WebhookEvent { @@ -1590,9 +1591,9 @@ const schema = gql` LABEL_DELETED } - union CreateWebhookResult = CreateWebhookSuccess | CreateWebhookError + union SetWebhookResult = SetWebhookSuccess | SetWebhookError - type CreateWebhookSuccess { + type SetWebhookSuccess { webhook: Webhook! } @@ -1600,18 +1601,18 @@ const schema = gql` id: ID! url: String! eventTypes: [WebhookEvent!]! - contentType: String - method: String - active: Boolean! + contentType: String! + method: String! + enabled: Boolean! createdAt: Date! updatedAt: Date! } - type CreateWebhookError { - errorCodes: [CreateWebhookErrorCode!]! + type SetWebhookError { + errorCodes: [SetWebhookErrorCode!]! } - enum CreateWebhookErrorCode { + enum SetWebhookErrorCode { UNAUTHORIZED BAD_REQUEST ALREADY_EXISTS @@ -1682,7 +1683,7 @@ const schema = gql` unsubscribe(name: String!): UnsubscribeResult! subscribe(name: String!): SubscribeResult! addPopularRead(name: String!): AddPopularReadResult! - createWebhook(input: CreateWebhookInput!): CreateWebhookResult! + setWebhook(input: SetWebhookInput!): SetWebhookResult! } # FIXME: remove sort from feedArticles after all cached tabs are closed From 6d0ce30a445bcf548ceadceebf71f78286af3df8 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 25 May 2022 22:20:13 +0800 Subject: [PATCH 06/15] Add setWebhook resolver --- packages/api/src/entity/webhook.ts | 12 +-- packages/api/src/generated/graphql.ts | 2 + packages/api/src/generated/schema.graphql | 2 + .../api/src/resolvers/function_resolvers.ts | 4 + packages/api/src/resolvers/webhooks/index.ts | 79 +++++++++++++++++++ packages/api/src/schema.ts | 2 + 6 files changed, 95 insertions(+), 6 deletions(-) create mode 100644 packages/api/src/resolvers/webhooks/index.ts diff --git a/packages/api/src/entity/webhook.ts b/packages/api/src/entity/webhook.ts index 98d6efbf2..0af802e60 100644 --- a/packages/api/src/entity/webhook.ts +++ b/packages/api/src/entity/webhook.ts @@ -21,21 +21,21 @@ export class Webhook { @Column('text') url!: string - @Column('text[]') + @Column('text', { array: true }) eventTypes!: string[] @Column('text', { default: 'POST' }) - method?: string + method!: string @Column('text', { default: 'application/json' }) - contentType?: string + contentType!: string @Column('boolean', { default: true }) - enabled?: boolean + enabled!: boolean @CreateDateColumn({ default: () => 'CURRENT_TIMESTAMP' }) - createdAt?: Date + createdAt!: Date @UpdateDateColumn({ default: () => 'CURRENT_TIMESTAMP' }) - updatedAt?: Date + updatedAt!: Date } diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 615ae0e64..592927875 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -1625,6 +1625,7 @@ export type SetWebhookError = { export enum SetWebhookErrorCode { AlreadyExists = 'ALREADY_EXISTS', BadRequest = 'BAD_REQUEST', + NotFound = 'NOT_FOUND', Unauthorized = 'UNAUTHORIZED' } @@ -1632,6 +1633,7 @@ export type SetWebhookInput = { contentType?: InputMaybe; enabled?: InputMaybe; eventTypes: Array; + id?: InputMaybe; method?: InputMaybe; url: Scalars['String']; }; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index de6019e8b..b0d25e7e8 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -1193,6 +1193,7 @@ type SetWebhookError { enum SetWebhookErrorCode { ALREADY_EXISTS BAD_REQUEST + NOT_FOUND UNAUTHORIZED } @@ -1200,6 +1201,7 @@ input SetWebhookInput { contentType: String enabled: Boolean eventTypes: [WebhookEvent!]! + id: ID method: String url: String! } diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index 67baa03f9..7c50351c5 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -71,6 +71,7 @@ import { updateHighlightResolver, updateLabelResolver, updateLinkShareInfoResolver, + updatePageResolver, updateReminderResolver, updateSharedCommentResolver, updateUserProfileResolver, @@ -87,6 +88,7 @@ import { } from '../utils/uploads' import { getPageByParam } from '../elastic/pages' import { generateApiKeyResolver } from './api_key' +import { setWebhookResolver } from './webhooks' /* eslint-disable @typescript-eslint/naming-convention */ type ResultResolveType = { @@ -151,6 +153,7 @@ export const functionResolvers = { updatePage: updatePageResolver, subscribe: subscribeResolver, addPopularRead: addPopularReadResolver, + setWebhook: setWebhookResolver, }, Query: { me: getMeUserResolver, @@ -563,4 +566,5 @@ export const functionResolvers = { ...resultResolveTypeResolver('UpdatePage'), ...resultResolveTypeResolver('Subscribe'), ...resultResolveTypeResolver('AddPopularRead'), + ...resultResolveTypeResolver('SetWebhook'), } diff --git a/packages/api/src/resolvers/webhooks/index.ts b/packages/api/src/resolvers/webhooks/index.ts new file mode 100644 index 000000000..00eb1802d --- /dev/null +++ b/packages/api/src/resolvers/webhooks/index.ts @@ -0,0 +1,79 @@ +import { authorized } from '../../utils/helpers' +import { + MutationSetWebhookArgs, + SetWebhookError, + SetWebhookErrorCode, + SetWebhookSuccess, + WebhookEvent, +} from '../../generated/graphql' +import { getRepository } from '../../entity/utils' +import { User } from '../../entity/user' +import { Webhook } from '../../entity/webhook' + +export const setWebhookResolver = authorized< + SetWebhookSuccess, + SetWebhookError, + MutationSetWebhookArgs +>(async (_, { input }, { claims: { uid }, log }) => { + log.info('setWebhookResolver') + + try { + const user = await getRepository(User).findOneBy({ id: uid }) + if (!user) { + return { + errorCodes: [SetWebhookErrorCode.Unauthorized], + } + } + + const webhookToAdd = { + url: input.url, + eventTypes: input.eventTypes as string[], + method: input.method || 'POST', + contentType: input.contentType || 'application/json', + enabled: input.enabled === null ? true : input.enabled, + } + + let webhook: Webhook | null + + if (input.id) { + // Update + webhook = await getRepository(Webhook).findOneBy({ id: input.id }) + if (!webhook) { + return { + errorCodes: [SetWebhookErrorCode.NotFound], + } + } + if (webhook.user.id !== uid) { + return { + errorCodes: [SetWebhookErrorCode.Unauthorized], + } + } + await getRepository(Webhook).update(webhook.id, webhookToAdd) + } else { + // Create + webhook = await getRepository(Webhook).save({ + user, + ...webhookToAdd, + }) + + if (!webhook) { + return { + errorCodes: [SetWebhookErrorCode.AlreadyExists], + } + } + } + + return { + webhook: { + ...webhook, + eventTypes: webhook.eventTypes as WebhookEvent[], + }, + } + } catch (error) { + log.error(error) + + return { + errorCodes: [SetWebhookErrorCode.BadRequest], + } + } +}) diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index ece4cc8ad..ab21dc2fe 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -1572,6 +1572,7 @@ const schema = gql` } input SetWebhookInput { + id: ID url: String! eventTypes: [WebhookEvent!]! contentType: String @@ -1616,6 +1617,7 @@ const schema = gql` UNAUTHORIZED BAD_REQUEST ALREADY_EXISTS + NOT_FOUND } # Mutations From 7157f387383206039c9b099ec5401a7f43a72305 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 25 May 2022 23:53:42 +0800 Subject: [PATCH 07/15] Add test --- packages/api/src/entity/webhook.ts | 2 +- packages/api/src/resolvers/webhooks/index.ts | 40 +++++-- packages/api/test/resolvers/webhooks.test.ts | 111 +++++++++++++++++++ 3 files changed, 141 insertions(+), 12 deletions(-) create mode 100644 packages/api/test/resolvers/webhooks.test.ts diff --git a/packages/api/src/entity/webhook.ts b/packages/api/src/entity/webhook.ts index 0af802e60..253fa1b5a 100644 --- a/packages/api/src/entity/webhook.ts +++ b/packages/api/src/entity/webhook.ts @@ -14,7 +14,7 @@ export class Webhook { @PrimaryGeneratedColumn('uuid') id!: string - @ManyToOne(() => User) + @ManyToOne(() => User, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'user_id' }) user!: User diff --git a/packages/api/src/resolvers/webhooks/index.ts b/packages/api/src/resolvers/webhooks/index.ts index 00eb1802d..d3ca69a6e 100644 --- a/packages/api/src/resolvers/webhooks/index.ts +++ b/packages/api/src/resolvers/webhooks/index.ts @@ -9,6 +9,8 @@ import { import { getRepository } from '../../entity/utils' import { User } from '../../entity/user' import { Webhook } from '../../entity/webhook' +import { analytics } from '../../utils/analytics' +import { env } from '../../env' export const setWebhookResolver = authorized< SetWebhookSuccess, @@ -25,7 +27,7 @@ export const setWebhookResolver = authorized< } } - const webhookToAdd = { + const webhookToSave: Partial = { url: input.url, eventTypes: input.eventTypes as string[], method: input.method || 'POST', @@ -33,36 +35,52 @@ export const setWebhookResolver = authorized< enabled: input.enabled === null ? true : input.enabled, } - let webhook: Webhook | null - if (input.id) { // Update - webhook = await getRepository(Webhook).findOneBy({ id: input.id }) - if (!webhook) { + const existingWebhook = await getRepository(Webhook).findOne({ + where: { id: input.id }, + relations: ['user'], + }) + if (!existingWebhook) { return { errorCodes: [SetWebhookErrorCode.NotFound], } } - if (webhook.user.id !== uid) { + if (existingWebhook.user.id !== uid) { return { errorCodes: [SetWebhookErrorCode.Unauthorized], } } - await getRepository(Webhook).update(webhook.id, webhookToAdd) + + webhookToSave.id = input.id } else { // Create - webhook = await getRepository(Webhook).save({ - user, - ...webhookToAdd, + const existingWebhook = await getRepository(Webhook).findOneBy({ + user: { id: uid }, + eventTypes: `{${input.eventTypes.join(',')}}`, }) - if (!webhook) { + if (existingWebhook) { return { errorCodes: [SetWebhookErrorCode.AlreadyExists], } } } + const webhook = await getRepository(Webhook).save({ + user, + ...webhookToSave, + }) + + analytics.track({ + userId: uid, + event: 'webhook_set', + properties: { + webhookId: webhook.id, + env: env.server.apiEnv, + }, + }) + return { webhook: { ...webhook, diff --git a/packages/api/test/resolvers/webhooks.test.ts b/packages/api/test/resolvers/webhooks.test.ts new file mode 100644 index 000000000..5c1d9745d --- /dev/null +++ b/packages/api/test/resolvers/webhooks.test.ts @@ -0,0 +1,111 @@ +import { createTestUser, deleteTestUser } from '../db' +import { graphqlRequest, request } from '../util' +import { expect } from 'chai' +import 'mocha' +import { User } from '../../src/entity/user' +import { WebhookEvent } from '../../src/generated/graphql' +import { Webhook } from '../../src/entity/webhook' +import { getRepository } from '../../src/entity/utils' + +describe('Webhooks API', () => { + const username = 'fakeUser' + + let user: User + let authToken: string + let webhook: Webhook + + before(async () => { + // create test user and login + user = await createTestUser(username) + const res = await request + .post('/local/debug/fake-user-login') + .send({ fakeEmail: user.email }) + + authToken = res.body.authToken + + // create test webhooks + webhook = await getRepository(Webhook).save({ + url: 'http://localhost:3000/webhooks/test', + user: { id: user.id }, + eventTypes: [WebhookEvent.PageCreated], + }) + }) + + after(async () => { + // clean up + await deleteTestUser(username) + }) + + describe('Set webhook', () => { + let eventTypes: WebhookEvent[] + let query: string + let webhookUrl: string + let webhookId: string + let enabled: boolean + + beforeEach(async () => { + query = ` + mutation { + setWebhook( + input: { + id: "${webhookId}", + url: "${webhookUrl}", + eventTypes: [${eventTypes}], + enabled: ${enabled} + } + ) { + ... on SetWebhookSuccess { + webhook { + id + url + eventTypes + enabled + } + } + ... on SetWebhookError { + errorCodes + } + } + } + ` + }) + + context('when id is not set', () => { + before(() => { + webhookId = '' + webhookUrl = 'https://example.com/webhook' + eventTypes = [WebhookEvent.HighlightCreated] + enabled = true + }) + + it('should create a webhook', async () => { + const res = await graphqlRequest(query, authToken) + + expect(res.body.data.setWebhook.webhook).to.be.an('object') + expect(res.body.data.setWebhook.webhook.url).to.eql(webhookUrl) + expect(res.body.data.setWebhook.webhook.eventTypes).to.eql(eventTypes) + expect(res.body.data.setWebhook.webhook.enabled).to.be.true + }) + }) + + context('when id is there', () => { + before(() => { + webhookId = webhook.id + webhookUrl = 'https://example.com/webhook_2' + eventTypes = [WebhookEvent.PageCreated] + enabled = false + }) + + it('should update a webhook', async () => { + const res = await graphqlRequest(query, authToken) + + console.log(res.body.data.setWebhook) + + expect(res.body.data.setWebhook.webhook).to.be.an('object') + expect(res.body.data.setWebhook.webhook.url).to.eql(webhookUrl) + expect(res.body.data.setWebhook.webhook.eventTypes).to.eql(eventTypes) + expect(res.body.data.setWebhook.webhook.enabled).to.be.false + }) + }) + }) +}) From c84668e6b0d93928185c991d0d1fabd05a687564 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Wed, 25 May 2022 23:54:22 +0800 Subject: [PATCH 08/15] Remove debug log --- packages/api/test/resolvers/webhooks.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/api/test/resolvers/webhooks.test.ts b/packages/api/test/resolvers/webhooks.test.ts index 5c1d9745d..7bc48ef31 100644 --- a/packages/api/test/resolvers/webhooks.test.ts +++ b/packages/api/test/resolvers/webhooks.test.ts @@ -99,8 +99,6 @@ describe('Webhooks API', () => { it('should update a webhook', async () => { const res = await graphqlRequest(query, authToken) - console.log(res.body.data.setWebhook) - expect(res.body.data.setWebhook.webhook).to.be.an('object') expect(res.body.data.setWebhook.webhook.url).to.eql(webhookUrl) expect(res.body.data.setWebhook.webhook.eventTypes).to.eql(eventTypes) From 7b0ebb9dbf799a8d4bf2abe89b39b9b032efb5ea Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Thu, 26 May 2022 10:35:43 +0800 Subject: [PATCH 09/15] Add pubsub subscription to trigger webhook --- packages/api/src/routers/svc/webhooks.ts | 86 ++++++++++++++++++++++++ packages/api/src/server.ts | 2 + 2 files changed, 88 insertions(+) create mode 100644 packages/api/src/routers/svc/webhooks.ts diff --git a/packages/api/src/routers/svc/webhooks.ts b/packages/api/src/routers/svc/webhooks.ts new file mode 100644 index 000000000..c9b0697e0 --- /dev/null +++ b/packages/api/src/routers/svc/webhooks.ts @@ -0,0 +1,86 @@ +/* eslint-disable @typescript-eslint/no-misused-promises */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ +import express from 'express' +import { readPushSubscription } from '../../datalayer/pubsub' +import { getRepository } from '../../entity/utils' +import { Webhook } from '../../entity/webhook' +import axios, { Method } from 'axios' + +export function webhooksServiceRouter() { + const router = express.Router() + + router.post('/:action', async (req, res) => { + console.log('trigger webhook of action', req.params.action) + const { message: msgStr, expired } = readPushSubscription(req) + + if (!msgStr) { + res.status(400).send('Bad Request') + return + } + + if (expired) { + console.log('discarding expired message') + res.status(200).send('Expired') + return + } + + try { + const data = JSON.parse(msgStr) + const { userId, type } = data + if (!userId || !type) { + console.log('No userId or type found in message') + res.status(400).send('Bad Request') + return + } + + // example: PAGE_CREATED + const eventType = `${type as string}_${req.params.action}`.toUpperCase() + const webhooks = await getRepository(Webhook) + .createQueryBuilder() + .where('userId = :userId', { userId }) + .andWhere(':eventType = ANY(eventTypes)', { eventType }) + .andWhere('enabled = true') + .getMany() + + if (webhooks.length <= 0) { + console.log( + 'No active webhook found for user', + userId, + 'and eventType', + eventType + ) + res.status(200).send('No webhook found') + return + } + + // trigger webhooks + for (const webhook of webhooks) { + const url = webhook.url + const method = webhook.method as Method + const body = JSON.stringify({ + action: req.params.action, + userId, + [type]: data, + }) + + console.log('triggering webhook', url, method, body) + await axios.request({ + url, + method, + headers: { + 'Content-Type': webhook.contentType, + }, + data: body, + }) + } + + res.status(200).send('OK') + } catch (err) { + console.log('trigger webhook failed', err) + res.status(500).send(err) + } + }) + + return router +} diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts index 87e642d4e..d65bbcdd8 100755 --- a/packages/api/src/server.ts +++ b/packages/api/src/server.ts @@ -42,6 +42,7 @@ import { corsConfig } from './utils/corsConfig' import { initElasticsearch } from './elastic' import { uploadServiceRouter } from './routers/svc/upload' import rateLimit from 'express-rate-limit' +import { webhooksServiceRouter } from './routers/svc/webhooks' const PORT = process.env.PORT || 4000 @@ -111,6 +112,7 @@ export const createApp = (): { app.use('/svc/pubsub/newsletters', newsletterServiceRouter()) app.use('/svc/pubsub/emails', emailsServiceRouter()) app.use('/svc/pubsub/upload', uploadServiceRouter()) + app.use('/svc/pubsub/webhooks', webhooksServiceRouter()) app.use('/svc/reminders', remindersServiceRouter()) app.use('/svc/pdf-attachments', pdfAttachmentsRouter()) From c9c6f83e6566b04a440cb5418a0e23145a3f4b37 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Thu, 26 May 2022 10:51:30 +0800 Subject: [PATCH 10/15] Add tests for trigger endpoint --- packages/api/src/routers/svc/webhooks.ts | 6 +-- packages/api/test/routers/webhooks.test.ts | 51 ++++++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 packages/api/test/routers/webhooks.test.ts diff --git a/packages/api/src/routers/svc/webhooks.ts b/packages/api/src/routers/svc/webhooks.ts index c9b0697e0..eaa351be9 100644 --- a/packages/api/src/routers/svc/webhooks.ts +++ b/packages/api/src/routers/svc/webhooks.ts @@ -10,7 +10,7 @@ import axios, { Method } from 'axios' export function webhooksServiceRouter() { const router = express.Router() - router.post('/:action', async (req, res) => { + router.post('/trigger/:action', async (req, res) => { console.log('trigger webhook of action', req.params.action) const { message: msgStr, expired } = readPushSubscription(req) @@ -38,8 +38,8 @@ export function webhooksServiceRouter() { const eventType = `${type as string}_${req.params.action}`.toUpperCase() const webhooks = await getRepository(Webhook) .createQueryBuilder() - .where('userId = :userId', { userId }) - .andWhere(':eventType = ANY(eventTypes)', { eventType }) + .where('user_id = :userId', { userId }) + .andWhere(':eventType = ANY(event_types)', { eventType }) .andWhere('enabled = true') .getMany() diff --git a/packages/api/test/routers/webhooks.test.ts b/packages/api/test/routers/webhooks.test.ts new file mode 100644 index 000000000..b44beb201 --- /dev/null +++ b/packages/api/test/routers/webhooks.test.ts @@ -0,0 +1,51 @@ +import { createTestUser, deleteTestUser } from '../db' +import { request } from '../util' +import { User } from '../../src/entity/user' +import 'mocha' +import { getRepository } from '../../src/entity/utils' +import { Webhook } from '../../src/entity/webhook' + +describe('Webhooks Router', () => { + const username = 'fakeUser' + const token = process.env.PUBSUB_VERIFICATION_TOKEN || '' + + let user: User + let webhook: Webhook + + before(async () => { + // create test user and login + user = await createTestUser(username) + await request + .post('/local/debug/fake-user-login') + .send({ fakeEmail: user.email }) + + webhook = await getRepository(Webhook).save({ + url: 'https://example.com', + user: { id: user.id }, + eventTypes: ['PAGE_CREATED'], + }) + }) + + after(async () => { + // clean up + await deleteTestUser(username) + }) + + describe('trigger webhooks', () => { + it('should trigger webhooks', async () => { + const data = { + message: { + data: Buffer.from( + JSON.stringify({ userId: user.id, type: 'page' }) + ).toString('base64'), + publishTime: new Date().toISOString(), + }, + } + + await request + .post('/svc/pubsub/webhooks/trigger/created?token=' + token) + .send(data) + .expect(200) + }) + }) +}) From cc07c00a834364636f99635551d125c316580c69 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Thu, 26 May 2022 11:05:12 +0800 Subject: [PATCH 11/15] Add get/list/delete webhook gql schema --- packages/api/src/generated/graphql.ts | 141 +++++++++++++++++++++ packages/api/src/generated/schema.graphql | 50 ++++++++ packages/api/src/schema.ts | 50 ++++++++ packages/api/test/routers/webhooks.test.ts | 4 +- 4 files changed, 244 insertions(+), 1 deletion(-) diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 592927875..f6b93caa6 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -502,6 +502,24 @@ export type DeleteReminderSuccess = { reminder: Reminder; }; +export type DeleteWebhookError = { + __typename?: 'DeleteWebhookError'; + errorCodes: Array; +}; + +export enum DeleteWebhookErrorCode { + BadRequest = 'BAD_REQUEST', + NotFound = 'NOT_FOUND', + Unauthorized = 'UNAUTHORIZED' +} + +export type DeleteWebhookResult = DeleteWebhookError | DeleteWebhookSuccess; + +export type DeleteWebhookSuccess = { + __typename?: 'DeleteWebhookSuccess'; + webhook: Webhook; +}; + export type DeviceToken = { __typename?: 'DeviceToken'; createdAt: Scalars['Date']; @@ -817,6 +835,7 @@ export type Mutation = { deleteNewsletterEmail: DeleteNewsletterEmailResult; deleteReaction: DeleteReactionResult; deleteReminder: DeleteReminderResult; + deleteWebhook: DeleteWebhookResult; generateApiKey: GenerateApiKeyResult; googleLogin: LoginResult; googleSignup: GoogleSignupResult; @@ -923,6 +942,11 @@ export type MutationDeleteReminderArgs = { }; +export type MutationDeleteWebhookArgs = { + id: Scalars['ID']; +}; + + export type MutationGenerateApiKeyArgs = { input: GenerateApiKeyInput; }; @@ -1186,6 +1210,8 @@ export type Query = { user: UserResult; users: UsersResult; validateUsername: Scalars['Boolean']; + webhook: WebhookResult; + webhooks: WebhooksResult; }; @@ -1262,6 +1288,11 @@ export type QueryValidateUsernameArgs = { username: Scalars['String']; }; + +export type QueryWebhookArgs = { + id: Scalars['ID']; +}; + export type Reaction = { __typename?: 'Reaction'; code: ReactionType; @@ -2119,6 +2150,17 @@ export type Webhook = { url: Scalars['String']; }; +export type WebhookError = { + __typename?: 'WebhookError'; + errorCodes: Array; +}; + +export enum WebhookErrorCode { + BadRequest = 'BAD_REQUEST', + NotFound = 'NOT_FOUND', + Unauthorized = 'UNAUTHORIZED' +} + export enum WebhookEvent { HighlightCreated = 'HIGHLIGHT_CREATED', HighlightDeleted = 'HIGHLIGHT_DELETED', @@ -2131,6 +2173,30 @@ export enum WebhookEvent { PageUpdated = 'PAGE_UPDATED' } +export type WebhookResult = WebhookError | WebhookSuccess; + +export type WebhooksError = { + __typename?: 'WebhooksError'; + errorCodes: Array; +}; + +export enum WebhooksErrorCode { + BadRequest = 'BAD_REQUEST', + Unauthorized = 'UNAUTHORIZED' +} + +export type WebhooksResult = WebhooksError | WebhooksSuccess; + +export type WebhooksSuccess = { + __typename?: 'WebhooksSuccess'; + webhooks: Array; +}; + +export type WebhookSuccess = { + __typename?: 'WebhookSuccess'; + webhook: Webhook; +}; + export type ResolverTypeWrapper = Promise | T; @@ -2292,6 +2358,10 @@ export type ResolversTypes = { DeleteReminderErrorCode: DeleteReminderErrorCode; DeleteReminderResult: ResolversTypes['DeleteReminderError'] | ResolversTypes['DeleteReminderSuccess']; DeleteReminderSuccess: ResolverTypeWrapper; + DeleteWebhookError: ResolverTypeWrapper; + DeleteWebhookErrorCode: DeleteWebhookErrorCode; + DeleteWebhookResult: ResolversTypes['DeleteWebhookError'] | ResolversTypes['DeleteWebhookSuccess']; + DeleteWebhookSuccess: ResolverTypeWrapper; DeviceToken: ResolverTypeWrapper; FeedArticle: ResolverTypeWrapper; FeedArticleEdge: ResolverTypeWrapper; @@ -2520,7 +2590,15 @@ export type ResolversTypes = { UsersSuccess: ResolverTypeWrapper; UserSuccess: ResolverTypeWrapper; Webhook: ResolverTypeWrapper; + WebhookError: ResolverTypeWrapper; + WebhookErrorCode: WebhookErrorCode; WebhookEvent: WebhookEvent; + WebhookResult: ResolversTypes['WebhookError'] | ResolversTypes['WebhookSuccess']; + WebhooksError: ResolverTypeWrapper; + WebhooksErrorCode: WebhooksErrorCode; + WebhooksResult: ResolversTypes['WebhooksError'] | ResolversTypes['WebhooksSuccess']; + WebhooksSuccess: ResolverTypeWrapper; + WebhookSuccess: ResolverTypeWrapper; }; /** Mapping between all available schema types and the resolvers parents */ @@ -2596,6 +2674,9 @@ export type ResolversParentTypes = { DeleteReminderError: DeleteReminderError; DeleteReminderResult: ResolversParentTypes['DeleteReminderError'] | ResolversParentTypes['DeleteReminderSuccess']; DeleteReminderSuccess: DeleteReminderSuccess; + DeleteWebhookError: DeleteWebhookError; + DeleteWebhookResult: ResolversParentTypes['DeleteWebhookError'] | ResolversParentTypes['DeleteWebhookSuccess']; + DeleteWebhookSuccess: DeleteWebhookSuccess; DeviceToken: DeviceToken; FeedArticle: FeedArticle; FeedArticleEdge: FeedArticleEdge; @@ -2778,6 +2859,12 @@ export type ResolversParentTypes = { UsersSuccess: UsersSuccess; UserSuccess: UserSuccess; Webhook: Webhook; + WebhookError: WebhookError; + WebhookResult: ResolversParentTypes['WebhookError'] | ResolversParentTypes['WebhookSuccess']; + WebhooksError: WebhooksError; + WebhooksResult: ResolversParentTypes['WebhooksError'] | ResolversParentTypes['WebhooksSuccess']; + WebhooksSuccess: WebhooksSuccess; + WebhookSuccess: WebhookSuccess; }; export type SanitizeDirectiveArgs = { @@ -3121,6 +3208,20 @@ export type DeleteReminderSuccessResolvers; }; +export type DeleteWebhookErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type DeleteWebhookResultResolvers = { + __resolveType: TypeResolveFn<'DeleteWebhookError' | 'DeleteWebhookSuccess', ParentType, ContextType>; +}; + +export type DeleteWebhookSuccessResolvers = { + webhook?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type DeviceTokenResolvers = { createdAt?: Resolver; id?: Resolver; @@ -3371,6 +3472,7 @@ export type MutationResolvers>; deleteReaction?: Resolver>; deleteReminder?: Resolver>; + deleteWebhook?: Resolver>; generateApiKey?: Resolver>; googleLogin?: Resolver>; googleSignup?: Resolver>; @@ -3481,6 +3583,8 @@ export type QueryResolvers>; users?: Resolver; validateUsername?: Resolver>; + webhook?: Resolver>; + webhooks?: Resolver; }; export type ReactionResolvers = { @@ -4025,6 +4129,34 @@ export type WebhookResolvers; }; +export type WebhookErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type WebhookResultResolvers = { + __resolveType: TypeResolveFn<'WebhookError' | 'WebhookSuccess', ParentType, ContextType>; +}; + +export type WebhooksErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type WebhooksResultResolvers = { + __resolveType: TypeResolveFn<'WebhooksError' | 'WebhooksSuccess', ParentType, ContextType>; +}; + +export type WebhooksSuccessResolvers = { + webhooks?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type WebhookSuccessResolvers = { + webhook?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type Resolvers = { AddPopularReadError?: AddPopularReadErrorResolvers; AddPopularReadResult?: AddPopularReadResultResolvers; @@ -4087,6 +4219,9 @@ export type Resolvers = { DeleteReminderError?: DeleteReminderErrorResolvers; DeleteReminderResult?: DeleteReminderResultResolvers; DeleteReminderSuccess?: DeleteReminderSuccessResolvers; + DeleteWebhookError?: DeleteWebhookErrorResolvers; + DeleteWebhookResult?: DeleteWebhookResultResolvers; + DeleteWebhookSuccess?: DeleteWebhookSuccessResolvers; DeviceToken?: DeviceTokenResolvers; FeedArticle?: FeedArticleResolvers; FeedArticleEdge?: FeedArticleEdgeResolvers; @@ -4233,6 +4368,12 @@ export type Resolvers = { UsersSuccess?: UsersSuccessResolvers; UserSuccess?: UserSuccessResolvers; Webhook?: WebhookResolvers; + WebhookError?: WebhookErrorResolvers; + WebhookResult?: WebhookResultResolvers; + WebhooksError?: WebhooksErrorResolvers; + WebhooksResult?: WebhooksResultResolvers; + WebhooksSuccess?: WebhooksSuccessResolvers; + WebhookSuccess?: WebhookSuccessResolvers; }; export type DirectiveResolvers = { diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index b0d25e7e8..3e228c912 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -440,6 +440,22 @@ type DeleteReminderSuccess { reminder: Reminder! } +type DeleteWebhookError { + errorCodes: [DeleteWebhookErrorCode!]! +} + +enum DeleteWebhookErrorCode { + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED +} + +union DeleteWebhookResult = DeleteWebhookError | DeleteWebhookSuccess + +type DeleteWebhookSuccess { + webhook: Webhook! +} + type DeviceToken { createdAt: Date! id: ID! @@ -725,6 +741,7 @@ type Mutation { deleteNewsletterEmail(newsletterEmailId: ID!): DeleteNewsletterEmailResult! deleteReaction(id: ID!): DeleteReactionResult! deleteReminder(id: ID!): DeleteReminderResult! + deleteWebhook(id: ID!): DeleteWebhookResult! generateApiKey(input: GenerateApiKeyInput!): GenerateApiKeyResult! googleLogin(input: GoogleLoginInput!): LoginResult! googleSignup(input: GoogleSignupInput!): GoogleSignupResult! @@ -857,6 +874,8 @@ type Query { user(userId: ID, username: String): UserResult! users: UsersResult! validateUsername(username: String!): Boolean! + webhook(id: ID!): WebhookResult! + webhooks: WebhooksResult! } type Reaction { @@ -1646,6 +1665,16 @@ type Webhook { url: String! } +type WebhookError { + errorCodes: [WebhookErrorCode!]! +} + +enum WebhookErrorCode { + BAD_REQUEST + NOT_FOUND + UNAUTHORIZED +} + enum WebhookEvent { HIGHLIGHT_CREATED HIGHLIGHT_DELETED @@ -1657,3 +1686,24 @@ enum WebhookEvent { PAGE_DELETED PAGE_UPDATED } + +union WebhookResult = WebhookError | WebhookSuccess + +type WebhooksError { + errorCodes: [WebhooksErrorCode!]! +} + +enum WebhooksErrorCode { + BAD_REQUEST + UNAUTHORIZED +} + +union WebhooksResult = WebhooksError | WebhooksSuccess + +type WebhooksSuccess { + webhooks: [Webhook!]! +} + +type WebhookSuccess { + webhook: Webhook! +} diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index ab21dc2fe..5b634277e 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -1620,6 +1620,53 @@ const schema = gql` NOT_FOUND } + union DeleteWebhookResult = DeleteWebhookSuccess | DeleteWebhookError + + type DeleteWebhookSuccess { + webhook: Webhook! + } + + type DeleteWebhookError { + errorCodes: [DeleteWebhookErrorCode!]! + } + + enum DeleteWebhookErrorCode { + UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + } + + union WebhookResult = WebhookSuccess | WebhookError + + type WebhookSuccess { + webhook: Webhook! + } + + type WebhookError { + errorCodes: [WebhookErrorCode!]! + } + + enum WebhookErrorCode { + UNAUTHORIZED + BAD_REQUEST + NOT_FOUND + } + + union WebhooksResult = WebhooksSuccess | WebhooksError + + type WebhooksSuccess { + webhooks: [Webhook!]! + } + + type WebhooksError { + errorCodes: [WebhooksErrorCode!]! + } + + enum WebhooksErrorCode { + UNAUTHORIZED + BAD_REQUEST + } + # Mutations type Mutation { googleLogin(input: GoogleLoginInput!): LoginResult! @@ -1686,6 +1733,7 @@ const schema = gql` subscribe(name: String!): SubscribeResult! addPopularRead(name: String!): AddPopularReadResult! setWebhook(input: SetWebhookInput!): SetWebhookResult! + deleteWebhook(id: ID!): DeleteWebhookResult! } # FIXME: remove sort from feedArticles after all cached tabs are closed @@ -1725,6 +1773,8 @@ const schema = gql` labels: LabelsResult! search(after: String, first: Int, query: String): SearchResult! subscriptions(sort: SortParams): SubscriptionsResult! + webhooks: WebhooksResult! + webhook(id: ID!): WebhookResult! } ` diff --git a/packages/api/test/routers/webhooks.test.ts b/packages/api/test/routers/webhooks.test.ts index b44beb201..a2e942e4b 100644 --- a/packages/api/test/routers/webhooks.test.ts +++ b/packages/api/test/routers/webhooks.test.ts @@ -4,6 +4,7 @@ import { User } from '../../src/entity/user' import 'mocha' import { getRepository } from '../../src/entity/utils' import { Webhook } from '../../src/entity/webhook' +import { expect } from 'chai' describe('Webhooks Router', () => { const username = 'fakeUser' @@ -42,10 +43,11 @@ describe('Webhooks Router', () => { }, } - await request + const res = await request .post('/svc/pubsub/webhooks/trigger/created?token=' + token) .send(data) .expect(200) + expect(res.body.data).to.eql('OK') }) }) }) From a75ce3c44d88204891dc1e724e840d679545d249 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Thu, 26 May 2022 13:33:45 +0800 Subject: [PATCH 12/15] Add get/list/delete webhook resolver --- .../api/src/resolvers/function_resolvers.ts | 12 +- packages/api/src/resolvers/index.ts | 1 + packages/api/src/resolvers/webhooks/index.ts | 150 +++++++++++++++++- packages/api/test/routers/webhooks.test.ts | 2 +- 4 files changed, 158 insertions(+), 7 deletions(-) diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index 7c50351c5..de13e589d 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -32,6 +32,7 @@ import { deleteLabelResolver, deleteNewsletterEmailResolver, deleteReminderResolver, + deleteWebhookResolver, getAllUsersResolver, getArticleResolver, getArticlesResolver, @@ -64,6 +65,7 @@ import { setShareArticleResolver, setShareHighlightResolver, setUserPersonalizationResolver, + setWebhookResolver, signupResolver, subscribeResolver, subscriptionsResolver, @@ -78,8 +80,9 @@ import { updateUserResolver, uploadFileRequestResolver, validateUsernameResolver, - updatePageResolver, addPopularReadResolver, + webhookResolver, + webhooksResolver, } from './index' import { getShareInfoForArticle } from '../datalayer/links/share_info' import { @@ -88,7 +91,6 @@ import { } from '../utils/uploads' import { getPageByParam } from '../elastic/pages' import { generateApiKeyResolver } from './api_key' -import { setWebhookResolver } from './webhooks' /* eslint-disable @typescript-eslint/naming-convention */ type ResultResolveType = { @@ -154,6 +156,7 @@ export const functionResolvers = { subscribe: subscribeResolver, addPopularRead: addPopularReadResolver, setWebhook: setWebhookResolver, + deleteWebhook: deleteWebhookResolver, }, Query: { me: getMeUserResolver, @@ -173,6 +176,8 @@ export const functionResolvers = { labels: labelsResolver, search: searchResolver, subscriptions: subscriptionsResolver, + webhooks: webhooksResolver, + webhook: webhookResolver, }, User: { async sharedArticles( @@ -567,4 +572,7 @@ export const functionResolvers = { ...resultResolveTypeResolver('Subscribe'), ...resultResolveTypeResolver('AddPopularRead'), ...resultResolveTypeResolver('SetWebhook'), + ...resultResolveTypeResolver('Webhooks'), + ...resultResolveTypeResolver('DeleteWebhook'), + ...resultResolveTypeResolver('Webhook'), } diff --git a/packages/api/src/resolvers/index.ts b/packages/api/src/resolvers/index.ts index fc0da5241..58434a7e4 100644 --- a/packages/api/src/resolvers/index.ts +++ b/packages/api/src/resolvers/index.ts @@ -17,3 +17,4 @@ export * from './labels' export * from './subscriptions' export * from './update' export * from './popular_reads' +export * from './webhooks' diff --git a/packages/api/src/resolvers/webhooks/index.ts b/packages/api/src/resolvers/webhooks/index.ts index d3ca69a6e..91817c69c 100644 --- a/packages/api/src/resolvers/webhooks/index.ts +++ b/packages/api/src/resolvers/webhooks/index.ts @@ -1,10 +1,22 @@ import { authorized } from '../../utils/helpers' import { + DeleteWebhookError, + DeleteWebhookErrorCode, + DeleteWebhookSuccess, + MutationDeleteWebhookArgs, MutationSetWebhookArgs, + QueryWebhookArgs, SetWebhookError, SetWebhookErrorCode, SetWebhookSuccess, + Webhook as WebhookResponse, + WebhookError, + WebhookErrorCode, WebhookEvent, + WebhooksError, + WebhooksErrorCode, + WebhooksSuccess, + WebhookSuccess, } from '../../generated/graphql' import { getRepository } from '../../entity/utils' import { User } from '../../entity/user' @@ -12,6 +24,134 @@ import { Webhook } from '../../entity/webhook' import { analytics } from '../../utils/analytics' import { env } from '../../env' +export const webhooksResolver = authorized( + async (_obj, _params, { claims: { uid }, log }) => { + log.info('webhooksResolver') + + try { + const user = await getRepository(User).findOneBy({ id: uid }) + if (!user) { + return { + errorCodes: [WebhooksErrorCode.Unauthorized], + } + } + + const webhooks = await getRepository(Webhook).findBy({ + user: { id: uid }, + }) + + return { + webhooks: webhooks.map((webhook) => webhookDataToResponse(webhook)), + } + } catch (error) { + log.error(error) + + return { + errorCodes: [WebhooksErrorCode.BadRequest], + } + } + } +) + +export const webhookResolver = authorized< + WebhookSuccess, + WebhookError, + QueryWebhookArgs +>(async (_, { id }, { claims: { uid }, log }) => { + log.info('webhookResolver') + + try { + const user = await getRepository(User).findOneBy({ id: uid }) + if (!user) { + return { + errorCodes: [WebhookErrorCode.Unauthorized], + } + } + + const webhook = await getRepository(Webhook).findOne({ + where: { id }, + relations: ['user'], + }) + + if (!webhook) { + return { + errorCodes: [WebhookErrorCode.NotFound], + } + } + + if (webhook.user.id !== uid) { + return { + errorCodes: [WebhookErrorCode.Unauthorized], + } + } + + return { + webhook: webhookDataToResponse(webhook), + } + } catch (error) { + log.error(error) + + return { + errorCodes: [WebhookErrorCode.BadRequest], + } + } +}) + +export const deleteWebhookResolver = authorized< + DeleteWebhookSuccess, + DeleteWebhookError, + MutationDeleteWebhookArgs +>(async (_, { id }, { claims: { uid }, log }) => { + log.info('deleteWebhookResolver') + + try { + const user = await getRepository(User).findOneBy({ id: uid }) + if (!user) { + return { + errorCodes: [DeleteWebhookErrorCode.Unauthorized], + } + } + + const webhook = await getRepository(Webhook).findOne({ + where: { id }, + relations: ['user'], + }) + + if (!webhook) { + return { + errorCodes: [DeleteWebhookErrorCode.NotFound], + } + } + + if (webhook.user.id !== uid) { + return { + errorCodes: [DeleteWebhookErrorCode.Unauthorized], + } + } + + const deletedWebhook = await getRepository(Webhook).remove(webhook) + + analytics.track({ + userId: uid, + event: 'webhook_delete', + properties: { + webhookId: webhook.id, + env: env.server.apiEnv, + }, + }) + + return { + webhook: webhookDataToResponse(webhook), + } + } catch (error) { + log.error(error) + + return { + errorCodes: [DeleteWebhookErrorCode.BadRequest], + } + } +}) + export const setWebhookResolver = authorized< SetWebhookSuccess, SetWebhookError, @@ -82,10 +222,7 @@ export const setWebhookResolver = authorized< }) return { - webhook: { - ...webhook, - eventTypes: webhook.eventTypes as WebhookEvent[], - }, + webhook: webhookDataToResponse(webhook), } } catch (error) { log.error(error) @@ -95,3 +232,8 @@ export const setWebhookResolver = authorized< } } }) + +const webhookDataToResponse = (webhook: Webhook): WebhookResponse => ({ + ...webhook, + eventTypes: webhook.eventTypes as WebhookEvent[], +}) diff --git a/packages/api/test/routers/webhooks.test.ts b/packages/api/test/routers/webhooks.test.ts index a2e942e4b..1509a9420 100644 --- a/packages/api/test/routers/webhooks.test.ts +++ b/packages/api/test/routers/webhooks.test.ts @@ -47,7 +47,7 @@ describe('Webhooks Router', () => { .post('/svc/pubsub/webhooks/trigger/created?token=' + token) .send(data) .expect(200) - expect(res.body.data).to.eql('OK') + expect(res.text).to.eql('OK') }) }) }) From 26681e5ecdb33997cdca66f2c5a70daaaa264705 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Thu, 26 May 2022 14:45:21 +0800 Subject: [PATCH 13/15] Add get/list/delete webhook resolver tests --- packages/api/src/resolvers/webhooks/index.ts | 3 +- packages/api/test/resolvers/webhooks.test.ts | 150 +++++++++++++++++-- 2 files changed, 142 insertions(+), 11 deletions(-) diff --git a/packages/api/src/resolvers/webhooks/index.ts b/packages/api/src/resolvers/webhooks/index.ts index 91817c69c..718c7f6fc 100644 --- a/packages/api/src/resolvers/webhooks/index.ts +++ b/packages/api/src/resolvers/webhooks/index.ts @@ -130,6 +130,7 @@ export const deleteWebhookResolver = authorized< } const deletedWebhook = await getRepository(Webhook).remove(webhook) + deletedWebhook.id = id analytics.track({ userId: uid, @@ -141,7 +142,7 @@ export const deleteWebhookResolver = authorized< }) return { - webhook: webhookDataToResponse(webhook), + webhook: webhookDataToResponse(deletedWebhook), } } catch (error) { log.error(error) diff --git a/packages/api/test/resolvers/webhooks.test.ts b/packages/api/test/resolvers/webhooks.test.ts index 7bc48ef31..4e318276d 100644 --- a/packages/api/test/resolvers/webhooks.test.ts +++ b/packages/api/test/resolvers/webhooks.test.ts @@ -12,7 +12,6 @@ describe('Webhooks API', () => { let user: User let authToken: string - let webhook: Webhook before(async () => { // create test user and login @@ -24,11 +23,18 @@ describe('Webhooks API', () => { authToken = res.body.authToken // create test webhooks - webhook = await getRepository(Webhook).save({ - url: 'http://localhost:3000/webhooks/test', - user: { id: user.id }, - eventTypes: [WebhookEvent.PageCreated], - }) + await getRepository(Webhook).save([ + { + url: 'http://localhost:3000/webhooks/test', + user: { id: user.id }, + eventTypes: [WebhookEvent.PageCreated], + }, + { + url: 'http://localhost:3000/webhooks/test', + user: { id: user.id }, + eventTypes: [WebhookEvent.PageUpdated], + }, + ]) }) after(async () => { @@ -36,6 +42,78 @@ describe('Webhooks API', () => { await deleteTestUser(username) }) + describe('Get webhook', () => { + let webhook: Webhook + + before(async () => { + // create test webhooks + webhook = await getRepository(Webhook).save({ + url: 'http://localhost:3000/webhooks/test', + user: { id: user.id }, + eventTypes: [WebhookEvent.PageDeleted], + }) + }) + + it('should return a webhook', async () => { + const query = ` + query { + webhook(id: "${webhook.id}") { + ... on WebhookSuccess { + webhook { + id + url + eventTypes + enabled + } + } + } + } + ` + + const res = await graphqlRequest(query, authToken) + + expect(res.body.data.webhook.webhook.id).to.eql(webhook.id) + expect(res.body.data.webhook.webhook.url).to.eql(webhook.url) + expect(res.body.data.webhook.webhook.eventTypes).to.eql( + webhook.eventTypes + ) + expect(res.body.data.webhook.webhook.enabled).to.eql(webhook.enabled) + }) + }) + + describe('List webhooks', () => { + it('should return a list of webhooks', async () => { + const query = ` + query { + webhooks { + ... on WebhooksSuccess { + webhooks { + id + url + eventTypes + enabled + } + } + } + } + ` + + const res = await graphqlRequest(query, authToken) + const webhooks = await getRepository(Webhook).findBy({ + user: { id: user.id }, + }) + + expect(res.body.data.webhooks.webhooks).to.eql( + webhooks.map((w) => ({ + id: w.id, + url: w.url, + eventTypes: w.eventTypes, + enabled: w.enabled, + })) + ) + }) + }) + describe('Set webhook', () => { let eventTypes: WebhookEvent[] let query: string @@ -73,7 +151,7 @@ describe('Webhooks API', () => { context('when id is not set', () => { before(() => { webhookId = '' - webhookUrl = 'https://example.com/webhook' + webhookUrl = 'http://localhost:3000/webhooks/test' eventTypes = [WebhookEvent.HighlightCreated] enabled = true }) @@ -89,10 +167,18 @@ describe('Webhooks API', () => { }) context('when id is there', () => { - before(() => { + before(async () => { + const webhook = await getRepository(Webhook).save({ + url: 'http://localhost:3000/webhooks/test', + user: { id: user.id }, + eventTypes: [WebhookEvent.HighlightUpdated], + }) webhookId = webhook.id - webhookUrl = 'https://example.com/webhook_2' - eventTypes = [WebhookEvent.PageCreated] + webhookUrl = 'http://localhost:3000/webhooks/test_2' + eventTypes = [ + WebhookEvent.HighlightUpdated, + WebhookEvent.HighlightCreated, + ] enabled = false }) @@ -106,4 +192,48 @@ describe('Webhooks API', () => { }) }) }) + + describe('Delete webhook', () => { + let query: string + let webhookId: string + + beforeEach(async () => { + query = ` + mutation { + deleteWebhook(id: "${webhookId}") { + ... on DeleteWebhookSuccess { + webhook { + id + } + } + ... on DeleteWebhookError { + errorCodes + } + } + } + ` + }) + + context('when webhook exists', () => { + before(async () => { + const webhook = await getRepository(Webhook).save({ + url: 'http://localhost:3000/webhooks/test', + user: { id: user.id }, + eventTypes: [WebhookEvent.LabelCreated], + }) + webhookId = webhook.id + }) + + it('should delete a webhook', async () => { + const res = await graphqlRequest(query, authToken) + const webhook = await getRepository(Webhook).findOneBy({ + id: webhookId, + }) + + expect(res.body.data.deleteWebhook.webhook).to.be.an('object') + expect(res.body.data.deleteWebhook.webhook.id).to.eql(webhookId) + expect(webhook).to.be.undefined + }) + }) + }) }) From 49de17bea57efe1134fb1b0dce564caba4f4aae6 Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Thu, 26 May 2022 14:48:39 +0800 Subject: [PATCH 14/15] Fix tests --- packages/api/test/resolvers/webhooks.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/test/resolvers/webhooks.test.ts b/packages/api/test/resolvers/webhooks.test.ts index 4e318276d..e0e91b027 100644 --- a/packages/api/test/resolvers/webhooks.test.ts +++ b/packages/api/test/resolvers/webhooks.test.ts @@ -232,7 +232,7 @@ describe('Webhooks API', () => { expect(res.body.data.deleteWebhook.webhook).to.be.an('object') expect(res.body.data.deleteWebhook.webhook.id).to.eql(webhookId) - expect(webhook).to.be.undefined + expect(webhook).to.be.null }) }) }) From 9981e506e414fca85c0641925f82e609149c3d4a Mon Sep 17 00:00:00 2001 From: Hongbo Wu Date: Thu, 26 May 2022 15:12:11 +0800 Subject: [PATCH 15/15] mock webhook request --- packages/api/test/routers/webhooks.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/api/test/routers/webhooks.test.ts b/packages/api/test/routers/webhooks.test.ts index 1509a9420..60e0c2bd0 100644 --- a/packages/api/test/routers/webhooks.test.ts +++ b/packages/api/test/routers/webhooks.test.ts @@ -5,10 +5,13 @@ import 'mocha' import { getRepository } from '../../src/entity/utils' import { Webhook } from '../../src/entity/webhook' import { expect } from 'chai' +import nock from 'nock' describe('Webhooks Router', () => { const username = 'fakeUser' const token = process.env.PUBSUB_VERIFICATION_TOKEN || '' + const webhookBaseUrl = 'https://localhost:3000' + const webhookPath = `/webhooks` let user: User let webhook: Webhook @@ -21,7 +24,7 @@ describe('Webhooks Router', () => { .send({ fakeEmail: user.email }) webhook = await getRepository(Webhook).save({ - url: 'https://example.com', + url: webhookBaseUrl + webhookPath, user: { id: user.id }, eventTypes: ['PAGE_CREATED'], }) @@ -43,6 +46,8 @@ describe('Webhooks Router', () => { }, } + nock(webhookBaseUrl).post(webhookPath).reply(200) + const res = await request .post('/svc/pubsub/webhooks/trigger/created?token=' + token) .send(data)