Skip to content

Commit 4450f7d

Browse files
authored
Merge pull request #157 from unisoncomputing/cp/webhook-examples
Webhook Examples
2 parents 3b7036d + d9fbfb6 commit 4450f7d

File tree

12 files changed

+733
-109
lines changed

12 files changed

+733
-109
lines changed

share-api.cabal

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
cabal-version: 1.12
22

3-
-- This file has been generated from package.yaml by hpack version 0.37.0.
3+
-- This file has been generated from package.yaml by hpack version 0.38.1.
44
--
55
-- see: https://github.com/sol/hpack
66

@@ -40,6 +40,7 @@ library
4040
Share.BackgroundJobs.Search.DefinitionSync
4141
Share.BackgroundJobs.Search.DefinitionSync.Types
4242
Share.BackgroundJobs.Webhooks.Queries
43+
Share.BackgroundJobs.Webhooks.Types
4344
Share.BackgroundJobs.Webhooks.Worker
4445
Share.BackgroundJobs.Workers
4546
Share.Branch
@@ -188,6 +189,8 @@ library
188189
Share.Web.Share.Tickets.Types
189190
Share.Web.Share.Types
190191
Share.Web.Share.Users.API
192+
Share.Web.Share.Webhooks.API
193+
Share.Web.Share.Webhooks.Impl
191194
Share.Web.Support.API
192195
Share.Web.Support.Impl
193196
Share.Web.Support.Types
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
{-# LANGUAGE StandaloneDeriving #-}
2+
3+
module Share.BackgroundJobs.Webhooks.Types
4+
( WebhookSendFailure (..),
5+
WebhookEventPayload (..),
6+
)
7+
where
8+
9+
import Control.Lens hiding ((.=))
10+
import Crypto.JWT (JWTError)
11+
import Data.Aeson (FromJSON (..), ToJSON (..))
12+
import Data.Aeson qualified as Aeson
13+
import Data.ByteString.Lazy.Char8 qualified as BL
14+
import Data.Text qualified as Text
15+
import Data.Text.Encoding qualified as Text
16+
import Data.Time (UTCTime)
17+
import Network.HTTP.Types qualified as HTTP
18+
import Servant.Server
19+
import Share.IDs
20+
import Share.JWT (JWTParam (..))
21+
import Share.JWT qualified as JWT
22+
import Share.Notifications.Types
23+
import Share.Notifications.Webhooks.Secrets (WebhookSecretError)
24+
import Share.Prelude
25+
import Share.Utils.Logging qualified as Logging
26+
import Share.Web.Errors
27+
import UnliftIO qualified
28+
29+
data WebhookSendFailure
30+
= ReceiverError NotificationEventId NotificationWebhookId HTTP.Status BL.ByteString
31+
| InvalidRequest NotificationEventId NotificationWebhookId UnliftIO.SomeException
32+
| WebhookSecretFetchError NotificationEventId NotificationWebhookId WebhookSecretError
33+
| JWTError NotificationEventId NotificationWebhookId JWTError
34+
deriving stock (Show)
35+
36+
instance ToServerError WebhookSendFailure where
37+
toServerError = \case
38+
ReceiverError _eventId _webhookId status _body ->
39+
( ErrorID "webhook:receiver-error",
40+
err500
41+
{ errBody =
42+
BL.fromStrict $
43+
Text.encodeUtf8 $
44+
"Webhook receiver returned error status: "
45+
<> Text.pack (show status)
46+
}
47+
)
48+
InvalidRequest _eventId _webhookId _err ->
49+
( ErrorID "webhook:invalid-request",
50+
err400
51+
{ errBody =
52+
BL.fromStrict $
53+
Text.encodeUtf8 $
54+
"Invalid webhook request."
55+
}
56+
)
57+
WebhookSecretFetchError _eventId _webhookId _err ->
58+
( ErrorID "webhook:secret-fetch-error",
59+
err500
60+
{ errBody =
61+
BL.fromStrict $
62+
Text.encodeUtf8 $
63+
"Failed to fetch webhook secret."
64+
}
65+
)
66+
JWTError _eventId _webhookId _err ->
67+
( ErrorID "webhook:jwt-error",
68+
err500
69+
{ errBody =
70+
BL.fromStrict $
71+
Text.encodeUtf8 $
72+
"Failed to generate or verify JWT."
73+
}
74+
)
75+
76+
instance Logging.Loggable WebhookSendFailure where
77+
toLog = \case
78+
ReceiverError eventId webhookId status body ->
79+
Logging.textLog
80+
( "Webhook receiver error: "
81+
<> Text.pack (show status)
82+
<> " "
83+
<> Text.decodeUtf8 (BL.toStrict body)
84+
)
85+
& Logging.withTag ("status", tShow status)
86+
& Logging.withTag ("event_id", tShow eventId)
87+
& Logging.withTag ("webhook_id", tShow webhookId)
88+
& Logging.withSeverity Logging.UserFault
89+
InvalidRequest eventId webhookId err ->
90+
Logging.textLog ("Invalid request: " <> Text.pack (show err))
91+
& Logging.withTag ("event_id", tShow eventId)
92+
& Logging.withTag ("webhook_id", tShow webhookId)
93+
& Logging.withSeverity Logging.UserFault
94+
WebhookSecretFetchError eventId webhookId err ->
95+
Logging.textLog ("Failed to fetch webhook secret: " <> Text.pack (show err))
96+
& Logging.withTag ("event_id", tShow eventId)
97+
& Logging.withTag ("webhook_id", tShow webhookId)
98+
& Logging.withSeverity Logging.Error
99+
JWTError eventId webhookId err ->
100+
Logging.textLog ("JWT error: " <> Text.pack (show err))
101+
& Logging.withTag ("event_id", tShow eventId)
102+
& Logging.withTag ("webhook_id", tShow webhookId)
103+
& Logging.withSeverity Logging.Error
104+
105+
data WebhookEventPayload jwt = WebhookEventPayload
106+
{ -- | The event ID of the notification event.
107+
eventId :: NotificationEventId,
108+
-- | The time at which the event occurred.
109+
occurredAt :: UTCTime,
110+
-- | The topic of the notification event.
111+
topic :: NotificationTopic,
112+
-- | The data associated with the notification event.
113+
data_ :: HydratedEvent,
114+
-- | A signed token containing all of the same data.
115+
jwt :: jwt
116+
}
117+
deriving stock (Show, Eq)
118+
119+
deriving via JWT.JSONJWTClaims (WebhookEventPayload ()) instance JWT.AsJWTClaims (WebhookEventPayload ())
120+
121+
instance ToJSON (WebhookEventPayload JWTParam) where
122+
toJSON WebhookEventPayload {eventId, occurredAt, topic, data_, jwt} =
123+
Aeson.object
124+
[ "eventId" Aeson..= eventId,
125+
"occurredAt" Aeson..= occurredAt,
126+
"topic" Aeson..= topic,
127+
"data" Aeson..= data_,
128+
"signed" Aeson..= jwt
129+
]
130+
131+
instance ToJSON (WebhookEventPayload ()) where
132+
toJSON WebhookEventPayload {eventId, occurredAt, topic, data_} =
133+
Aeson.object
134+
[ "eventId" Aeson..= eventId,
135+
"occurredAt" Aeson..= occurredAt,
136+
"topic" Aeson..= topic,
137+
"data" Aeson..= data_
138+
]
139+
140+
instance FromJSON (WebhookEventPayload ()) where
141+
parseJSON = Aeson.withObject "WebhookEventPayload" $ \o -> do
142+
eventId <- o Aeson..: "eventId"
143+
occurredAt <- o Aeson..: "occurredAt"
144+
topic <- o Aeson..: "topic"
145+
data_ <- o Aeson..: "data"
146+
pure WebhookEventPayload {eventId, occurredAt, topic, data_, jwt = ()}

src/Share/BackgroundJobs/Webhooks/Worker.hs

Lines changed: 3 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,10 @@ module Share.BackgroundJobs.Webhooks.Worker (worker) where
88

99
import Control.Lens hiding ((.=))
1010
import Control.Monad.Except (ExceptT (..), runExceptT)
11-
import Crypto.JWT (JWTError)
12-
import Data.Aeson (FromJSON (..), ToJSON (..))
11+
import Data.Aeson (ToJSON (..))
1312
import Data.Aeson qualified as Aeson
14-
import Data.ByteString.Lazy.Char8 qualified as BL
1513
import Data.List.Extra qualified as List
1614
import Data.Text qualified as Text
17-
import Data.Text.Encoding qualified as Text
1815
import Data.Time (UTCTime)
1916
import Ki.Unlifted qualified as Ki
2017
import Network.HTTP.Client qualified as HTTPClient
@@ -24,6 +21,7 @@ import Network.URI qualified as URI
2421
import Share.BackgroundJobs.Errors (reportError)
2522
import Share.BackgroundJobs.Monad (Background)
2623
import Share.BackgroundJobs.Webhooks.Queries qualified as WQ
24+
import Share.BackgroundJobs.Webhooks.Types
2725
import Share.BackgroundJobs.Workers (newWorker)
2826
import Share.ChatApps (Author (..))
2927
import Share.ChatApps qualified as ChatApps
@@ -37,7 +35,7 @@ import Share.Metrics qualified as Metrics
3735
import Share.Notifications.Ops qualified as NotOps
3836
import Share.Notifications.Queries qualified as NQ
3937
import Share.Notifications.Types
40-
import Share.Notifications.Webhooks.Secrets (WebhookConfig (..), WebhookSecretError)
38+
import Share.Notifications.Webhooks.Secrets (WebhookConfig (..))
4139
import Share.Notifications.Webhooks.Secrets qualified as Webhooks
4240
import Share.Postgres qualified as PG
4341
import Share.Postgres.Notifications qualified as Notif
@@ -53,42 +51,6 @@ import Share.Web.Share.DisplayInfo.Types qualified as DisplayInfo
5351
import Share.Web.UI.Links qualified as Links
5452
import UnliftIO qualified
5553

56-
data WebhookSendFailure
57-
= ReceiverError NotificationEventId NotificationWebhookId HTTP.Status BL.ByteString
58-
| InvalidRequest NotificationEventId NotificationWebhookId UnliftIO.SomeException
59-
| WebhookSecretFetchError NotificationEventId NotificationWebhookId WebhookSecretError
60-
| JWTError NotificationEventId NotificationWebhookId JWTError
61-
deriving stock (Show)
62-
63-
instance Logging.Loggable WebhookSendFailure where
64-
toLog = \case
65-
ReceiverError eventId webhookId status body ->
66-
Logging.textLog
67-
( "Webhook receiver error: "
68-
<> Text.pack (show status)
69-
<> " "
70-
<> Text.decodeUtf8 (BL.toStrict body)
71-
)
72-
& Logging.withTag ("status", tShow status)
73-
& Logging.withTag ("event_id", tShow eventId)
74-
& Logging.withTag ("webhook_id", tShow webhookId)
75-
& Logging.withSeverity Logging.UserFault
76-
InvalidRequest eventId webhookId err ->
77-
Logging.textLog ("Invalid request: " <> Text.pack (show err))
78-
& Logging.withTag ("event_id", tShow eventId)
79-
& Logging.withTag ("webhook_id", tShow webhookId)
80-
& Logging.withSeverity Logging.UserFault
81-
WebhookSecretFetchError eventId webhookId err ->
82-
Logging.textLog ("Failed to fetch webhook secret: " <> Text.pack (show err))
83-
& Logging.withTag ("event_id", tShow eventId)
84-
& Logging.withTag ("webhook_id", tShow webhookId)
85-
& Logging.withSeverity Logging.Error
86-
JWTError eventId webhookId err ->
87-
Logging.textLog ("JWT error: " <> Text.pack (show err))
88-
& Logging.withTag ("event_id", tShow eventId)
89-
& Logging.withTag ("webhook_id", tShow webhookId)
90-
& Logging.withSeverity Logging.Error
91-
9254
-- | Check every 10 minutes if we haven't heard on the notifications channel.
9355
-- Just in case we missed a notification.
9456
maxPollingIntervalSeconds :: Int
@@ -140,49 +102,6 @@ processWebhook authZReceipt = withSpan "background:webhooks:process-webhook" mem
140102
webhookTimeout :: HTTPClient.ResponseTimeout
141103
webhookTimeout = HTTPClient.responseTimeoutMicro (20 * 1000000 {- 20 seconds -})
142104

143-
data WebhookEventPayload jwt = WebhookEventPayload
144-
{ -- | The event ID of the notification event.
145-
eventId :: NotificationEventId,
146-
-- | The time at which the event occurred.
147-
occurredAt :: UTCTime,
148-
-- | The topic of the notification event.
149-
topic :: NotificationTopic,
150-
-- | The data associated with the notification event.
151-
data_ :: HydratedEvent,
152-
-- | A signed token containing all of the same data.
153-
jwt :: jwt
154-
}
155-
deriving stock (Show, Eq)
156-
157-
deriving via JWT.JSONJWTClaims (WebhookEventPayload ()) instance JWT.AsJWTClaims (WebhookEventPayload ())
158-
159-
instance ToJSON (WebhookEventPayload JWTParam) where
160-
toJSON WebhookEventPayload {eventId, occurredAt, topic, data_, jwt} =
161-
Aeson.object
162-
[ "eventId" Aeson..= eventId,
163-
"occurredAt" Aeson..= occurredAt,
164-
"topic" Aeson..= topic,
165-
"data" Aeson..= data_,
166-
"signed" Aeson..= jwt
167-
]
168-
169-
instance ToJSON (WebhookEventPayload ()) where
170-
toJSON WebhookEventPayload {eventId, occurredAt, topic, data_} =
171-
Aeson.object
172-
[ "eventId" Aeson..= eventId,
173-
"occurredAt" Aeson..= occurredAt,
174-
"topic" Aeson..= topic,
175-
"data" Aeson..= data_
176-
]
177-
178-
instance FromJSON (WebhookEventPayload ()) where
179-
parseJSON = Aeson.withObject "WebhookEventPayload" $ \o -> do
180-
eventId <- o Aeson..: "eventId"
181-
occurredAt <- o Aeson..: "occurredAt"
182-
topic <- o Aeson..: "topic"
183-
data_ <- o Aeson..: "data"
184-
pure WebhookEventPayload {eventId, occurredAt, topic, data_, jwt = ()}
185-
186105
tryWebhook ::
187106
NotificationEvent NotificationEventId UnifiedDisplayInfo UTCTime HydratedEvent ->
188107
NotificationWebhookId ->

src/Share/Notifications/Types.hs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ data NotificationTopic
7171
| ProjectTicketStatusUpdated
7272
| ProjectTicketComment
7373
| ProjectReleaseCreated
74-
deriving (Eq, Show, Ord)
74+
deriving (Eq, Show, Ord, Enum, Bounded)
7575

7676
instance PG.EncodeValue NotificationTopic where
7777
encodeValue = HasqlEncoders.enum \case

src/Share/Notifications/Webhooks/Secrets.hs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import Servant.Client qualified as ServantClient
1919
import Servant.Server (err500)
2020
import Share.App (AppM)
2121
import Share.Env qualified as Env
22-
import Share.IDs (NotificationWebhookId)
22+
import Share.IDs
2323
import Share.IDs qualified as IDs
2424
import Share.Prelude
2525
import Share.Utils.Logging qualified as Logging

src/Share/Web/API.hs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import Share.Web.Share.API qualified as Share
1414
import Share.Web.Share.Orgs.API qualified as Orgs
1515
import Share.Web.Share.Projects.API qualified as Projects
1616
import Share.Web.Share.Users.API qualified as Users
17+
import Share.Web.Share.Webhooks.API qualified as Webhooks
1718
import Share.Web.Support.API qualified as Support
1819
import Share.Web.Types
1920
import Share.Web.UCM.SyncV2.API qualified as SyncV2
@@ -55,6 +56,7 @@ type API =
5556
:<|> ("ucm" :> "v1" :> "projects" :> MaybeAuthenticatedSession :> UCMProjects.ProjectsAPI)
5657
:<|> ("ucm" :> "v2" :> "sync" :> MaybeAuthenticatedUserId :> SyncV2.API)
5758
:<|> ("admin" :> Admin.API)
59+
:<|> ("webhooks" :> Webhooks.API)
5860

5961
api :: Proxy API
6062
api = Proxy @API

src/Share/Web/Impl.hs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import Share.Web.OAuth.Impl qualified as OAuth
2424
import Share.Web.Share.Impl qualified as Share
2525
import Share.Web.Share.Orgs.Impl qualified as Orgs
2626
import Share.Web.Share.Projects.Impl qualified as Projects
27+
import Share.Web.Share.Webhooks.Impl qualified as Webhooks
2728
import Share.Web.Support.Impl qualified as Support
2829
import Share.Web.Types
2930
import Share.Web.UCM.Projects.Impl qualified as UCMProjects
@@ -91,3 +92,4 @@ server =
9192
:<|> UCMProjects.server
9293
:<|> SyncV2.server
9394
:<|> Admin.server
95+
:<|> Webhooks.server
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{-# LANGUAGE DataKinds #-}
2+
{-# LANGUAGE TypeOperators #-}
3+
4+
module Share.Web.Share.Webhooks.API
5+
( API,
6+
Routes (..),
7+
WebhookPayloadExamples,
8+
)
9+
where
10+
11+
import Servant
12+
import Share.BackgroundJobs.Webhooks.Types
13+
import Share.JWT
14+
import Share.Prelude
15+
16+
type API = NamedRoutes Routes
17+
18+
data Routes mode
19+
= Routes
20+
{ payloadExamples :: mode :- "examples" :> WebhookExamplesEndpoint
21+
}
22+
deriving stock (Generic)
23+
24+
type WebhookPayloadExamples = [WebhookEventPayload JWTParam]
25+
26+
type WebhookExamplesEndpoint =
27+
Get '[JSON] WebhookPayloadExamples

0 commit comments

Comments
 (0)