generated from coulomb/repo-seed
fix: honor v2 interaction event contract
This commit is contained in:
34
Test/Main.hs
34
Test/Main.hs
@@ -3,6 +3,11 @@ module Main where
|
|||||||
import Test.Hspec
|
import Test.Hspec
|
||||||
import IHP.Prelude
|
import IHP.Prelude
|
||||||
import qualified Test.Architecture.LayerBoundarySpec as LayerBoundary
|
import qualified Test.Architecture.LayerBoundarySpec as LayerBoundary
|
||||||
|
import Data.Aeson (object, toJSON, (.=))
|
||||||
|
import Web.Controller.Api.V2.InteractionEvents
|
||||||
|
( declaredEventTypeNames, manifestAllowsEvent, metadataFromJsonBody
|
||||||
|
, metadataParamOrEmpty
|
||||||
|
)
|
||||||
|
|
||||||
main :: IO ()
|
main :: IO ()
|
||||||
main = hspec do
|
main = hspec do
|
||||||
@@ -10,4 +15,33 @@ main = hspec do
|
|||||||
it "should pass" do
|
it "should pass" do
|
||||||
1 + 1 `shouldBe` (2 :: Int)
|
1 + 1 `shouldBe` (2 :: Int)
|
||||||
|
|
||||||
|
describe "API v2 interaction-event manifest validation" do
|
||||||
|
let opsEventTypes = toJSON
|
||||||
|
( [ "ops-endpoint-verified"
|
||||||
|
, "ops-workflow-started"
|
||||||
|
] :: [Text]
|
||||||
|
)
|
||||||
|
|
||||||
|
it "decodes manifest-declared event types from JSON arrays" do
|
||||||
|
declaredEventTypeNames opsEventTypes
|
||||||
|
`shouldBe` ["ops-endpoint-verified", "ops-workflow-started"]
|
||||||
|
|
||||||
|
it "allows manifest-declared ops-owned domain events" do
|
||||||
|
manifestAllowsEvent "ops-endpoint-verified" opsEventTypes
|
||||||
|
`shouldBe` True
|
||||||
|
|
||||||
|
it "rejects events absent from an active manifest declaration" do
|
||||||
|
manifestAllowsEvent "clicked" opsEventTypes
|
||||||
|
`shouldBe` False
|
||||||
|
|
||||||
|
it "keeps empty declarations unrestricted for legacy manifests" do
|
||||||
|
manifestAllowsEvent "clicked" (toJSON ([] :: [Text]))
|
||||||
|
`shouldBe` True
|
||||||
|
|
||||||
|
it "preserves submitted metadata values and defaults missing metadata" do
|
||||||
|
let metadata = object ["source" .= ("ops-hub" :: Text)]
|
||||||
|
metadataFromJsonBody (object ["metadata" .= metadata]) `shouldBe` Just metadata
|
||||||
|
metadataParamOrEmpty (Just metadata) `shouldBe` metadata
|
||||||
|
metadataParamOrEmpty Nothing `shouldBe` object []
|
||||||
|
|
||||||
LayerBoundary.spec
|
LayerBoundary.spec
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import Web.Types
|
|||||||
import Generated.Types
|
import Generated.Types
|
||||||
import IHP.Prelude
|
import IHP.Prelude
|
||||||
import IHP.ControllerPrelude
|
import IHP.ControllerPrelude
|
||||||
import Data.Aeson (object, (.=))
|
import Data.Aeson (Value(..), object, (.=))
|
||||||
import qualified Data.Text as T
|
import IHP.ControllerSupport (ControllerContext, getHeader, requestBodyJSON)
|
||||||
import Web.Controller.Api.V2.Auth
|
import Web.Controller.Api.V2.Auth
|
||||||
( requireApiConsumer, paginatedResponse, getPageParams
|
( requireApiConsumer, paginatedResponse, getPageParams
|
||||||
, respondWithStatus )
|
, respondWithStatus )
|
||||||
@@ -13,8 +13,13 @@ import Application.Helper.TypeRegistry (validateEventType)
|
|||||||
import Web.Job.WebhookDeliveryJob (dispatchWebhooks)
|
import Web.Job.WebhookDeliveryJob (dispatchWebhooks)
|
||||||
import Control.Concurrent (forkIO)
|
import Control.Concurrent (forkIO)
|
||||||
import Control.Monad (void)
|
import Control.Monad (void)
|
||||||
|
import Data.Maybe (fromMaybe, mapMaybe)
|
||||||
|
import qualified Data.Aeson.KeyMap as KM
|
||||||
|
import qualified Data.ByteString as BS
|
||||||
|
import qualified Data.ByteString.Lazy.Char8 as LBSC
|
||||||
import qualified Data.UUID as UUID
|
import qualified Data.UUID as UUID
|
||||||
import qualified Data.Aeson as A
|
import qualified Data.Aeson as A
|
||||||
|
import qualified Data.Vector as V
|
||||||
|
|
||||||
instance Controller ApiV2InteractionEventsController where
|
instance Controller ApiV2InteractionEventsController where
|
||||||
|
|
||||||
@@ -47,6 +52,7 @@ instance Controller ApiV2InteractionEventsController where
|
|||||||
let widgetIdText = paramOrNothing @Text "widgetId"
|
let widgetIdText = paramOrNothing @Text "widgetId"
|
||||||
eventType = paramOrNothing @Text "eventType"
|
eventType = paramOrNothing @Text "eventType"
|
||||||
viewContext = paramOrNothing @Text "viewContext"
|
viewContext = paramOrNothing @Text "viewContext"
|
||||||
|
metadata = metadataFromRequest
|
||||||
|
|
||||||
let missing = catMaybes
|
let missing = catMaybes
|
||||||
[ if isNothing widgetIdText then Just ("widgetId" :: Text) else Nothing
|
[ if isNothing widgetIdText then Just ("widgetId" :: Text) else Nothing
|
||||||
@@ -76,9 +82,7 @@ instance Controller ApiV2InteractionEventsController where
|
|||||||
forM_ consumer.hubCapabilityManifestId $ \manifestId -> do
|
forM_ consumer.hubCapabilityManifestId $ \manifestId -> do
|
||||||
manifest <- fetch manifestId
|
manifest <- fetch manifestId
|
||||||
when (manifest.status == "active") do
|
when (manifest.status == "active") do
|
||||||
let declared = case manifest.declaredEventTypes of
|
unless (manifestAllowsEvent evType manifest.declaredEventTypes) do
|
||||||
_ -> [] :: [Text] -- JSONB array decoded via aeson
|
|
||||||
unless (null declared || evType `elem` declared) do
|
|
||||||
respondWithStatus 422 $ object
|
respondWithStatus 422 $ object
|
||||||
[ "error" .= ("Event type not declared in hub manifest" :: Text)
|
[ "error" .= ("Event type not declared in hub manifest" :: Text)
|
||||||
, "code" .= ("event_type_not_in_manifest" :: Text)
|
, "code" .= ("event_type_not_in_manifest" :: Text)
|
||||||
@@ -100,6 +104,7 @@ instance Controller ApiV2InteractionEventsController where
|
|||||||
|> set #eventType evType
|
|> set #eventType evType
|
||||||
|> set #actorType "api"
|
|> set #actorType "api"
|
||||||
|> set #viewContextRef viewContext
|
|> set #viewContextRef viewContext
|
||||||
|
|> set #metadata metadata
|
||||||
|> createRecord
|
|> createRecord
|
||||||
-- Dispatch webhooks fire-and-forget
|
-- Dispatch webhooks fire-and-forget
|
||||||
let webhookPayload = object
|
let webhookPayload = object
|
||||||
@@ -109,7 +114,7 @@ instance Controller ApiV2InteractionEventsController where
|
|||||||
, "eventType" .= event.eventType
|
, "eventType" .= event.eventType
|
||||||
, "occurredAt" .= event.occurredAt
|
, "occurredAt" .= event.occurredAt
|
||||||
]
|
]
|
||||||
liftIO $ void $ forkIO $ dispatchWebhooks "clicked" webhookPayload
|
liftIO $ void $ forkIO $ dispatchWebhooks evType webhookPayload
|
||||||
renderJson (eventToJson event)
|
renderJson (eventToJson event)
|
||||||
|
|
||||||
eventToJson :: InteractionEvent -> Value
|
eventToJson :: InteractionEvent -> Value
|
||||||
@@ -123,3 +128,33 @@ eventToJson e = object
|
|||||||
, "metadata" .= e.metadata
|
, "metadata" .= e.metadata
|
||||||
, "occurredAt" .= e.occurredAt
|
, "occurredAt" .= e.occurredAt
|
||||||
]
|
]
|
||||||
|
|
||||||
|
declaredEventTypeNames :: A.Value -> [Text]
|
||||||
|
declaredEventTypeNames (Array values) = mapMaybe extractText (V.toList values)
|
||||||
|
where
|
||||||
|
extractText (String value) = Just value
|
||||||
|
extractText _ = Nothing
|
||||||
|
declaredEventTypeNames _ = []
|
||||||
|
|
||||||
|
manifestAllowsEvent :: Text -> A.Value -> Bool
|
||||||
|
manifestAllowsEvent eventType declaredEventTypes =
|
||||||
|
let declared = declaredEventTypeNames declaredEventTypes
|
||||||
|
in null declared || eventType `elem` declared
|
||||||
|
|
||||||
|
metadataParamOrEmpty :: Maybe A.Value -> A.Value
|
||||||
|
metadataParamOrEmpty = fromMaybe (object [])
|
||||||
|
|
||||||
|
metadataFromRequest :: (?context :: ControllerContext) => A.Value
|
||||||
|
metadataFromRequest =
|
||||||
|
case getHeader "Content-Type" of
|
||||||
|
Just contentType | "application/json" `BS.isPrefixOf` contentType ->
|
||||||
|
metadataParamOrEmpty (metadataFromJsonBody requestBodyJSON)
|
||||||
|
_ ->
|
||||||
|
metadataParamOrEmpty (metadataFromText =<< paramOrNothing @Text "metadata")
|
||||||
|
|
||||||
|
metadataFromJsonBody :: A.Value -> Maybe A.Value
|
||||||
|
metadataFromJsonBody (Object body) = KM.lookup "metadata" body
|
||||||
|
metadataFromJsonBody _ = Nothing
|
||||||
|
|
||||||
|
metadataFromText :: Text -> Maybe A.Value
|
||||||
|
metadataFromText raw = A.decode (LBSC.pack (cs raw))
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ command while preserving the one-time secret display invariant.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: IHUB-WP-0019-T05
|
id: IHUB-WP-0019-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "1febfdb6-757b-420a-b4bd-709ce3cd1252"
|
state_hub_task_id: "1febfdb6-757b-420a-b4bd-709ce3cd1252"
|
||||||
```
|
```
|
||||||
@@ -193,6 +193,13 @@ Fix the current v2 interaction event create behavior:
|
|||||||
Done when: `ops-endpoint-verified` can be submitted with metadata and routed
|
Done when: `ops-endpoint-verified` can be submitted with metadata and routed
|
||||||
as an ops-owned event.
|
as an ops-owned event.
|
||||||
|
|
||||||
|
Implementation note (2026-05-16): v2 interaction-event creation now validates
|
||||||
|
against active manifest-declared event types, persists submitted metadata from
|
||||||
|
JSON request bodies, dispatches webhooks with the submitted event type, and has
|
||||||
|
focused Hspec coverage for manifest-declared ops domain events. Local
|
||||||
|
`git diff --check` passed; `scripts/compile-check` could not run because this
|
||||||
|
shell does not have `IHP_LIB`/the IHP dev environment loaded.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### T06 — Update OpenAPI request schemas and hub quickstart docs
|
### T06 — Update OpenAPI request schemas and hub quickstart docs
|
||||||
|
|||||||
Reference in New Issue
Block a user