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 IHP.Prelude
|
||||
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 = hspec do
|
||||
@@ -10,4 +15,33 @@ main = hspec do
|
||||
it "should pass" do
|
||||
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
|
||||
|
||||
@@ -4,8 +4,8 @@ import Web.Types
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ControllerPrelude
|
||||
import Data.Aeson (object, (.=))
|
||||
import qualified Data.Text as T
|
||||
import Data.Aeson (Value(..), object, (.=))
|
||||
import IHP.ControllerSupport (ControllerContext, getHeader, requestBodyJSON)
|
||||
import Web.Controller.Api.V2.Auth
|
||||
( requireApiConsumer, paginatedResponse, getPageParams
|
||||
, respondWithStatus )
|
||||
@@ -13,8 +13,13 @@ import Application.Helper.TypeRegistry (validateEventType)
|
||||
import Web.Job.WebhookDeliveryJob (dispatchWebhooks)
|
||||
import Control.Concurrent (forkIO)
|
||||
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.Aeson as A
|
||||
import qualified Data.Vector as V
|
||||
|
||||
instance Controller ApiV2InteractionEventsController where
|
||||
|
||||
@@ -47,6 +52,7 @@ instance Controller ApiV2InteractionEventsController where
|
||||
let widgetIdText = paramOrNothing @Text "widgetId"
|
||||
eventType = paramOrNothing @Text "eventType"
|
||||
viewContext = paramOrNothing @Text "viewContext"
|
||||
metadata = metadataFromRequest
|
||||
|
||||
let missing = catMaybes
|
||||
[ if isNothing widgetIdText then Just ("widgetId" :: Text) else Nothing
|
||||
@@ -76,9 +82,7 @@ instance Controller ApiV2InteractionEventsController where
|
||||
forM_ consumer.hubCapabilityManifestId $ \manifestId -> do
|
||||
manifest <- fetch manifestId
|
||||
when (manifest.status == "active") do
|
||||
let declared = case manifest.declaredEventTypes of
|
||||
_ -> [] :: [Text] -- JSONB array decoded via aeson
|
||||
unless (null declared || evType `elem` declared) do
|
||||
unless (manifestAllowsEvent evType manifest.declaredEventTypes) do
|
||||
respondWithStatus 422 $ object
|
||||
[ "error" .= ("Event type not declared in hub manifest" :: Text)
|
||||
, "code" .= ("event_type_not_in_manifest" :: Text)
|
||||
@@ -100,6 +104,7 @@ instance Controller ApiV2InteractionEventsController where
|
||||
|> set #eventType evType
|
||||
|> set #actorType "api"
|
||||
|> set #viewContextRef viewContext
|
||||
|> set #metadata metadata
|
||||
|> createRecord
|
||||
-- Dispatch webhooks fire-and-forget
|
||||
let webhookPayload = object
|
||||
@@ -109,7 +114,7 @@ instance Controller ApiV2InteractionEventsController where
|
||||
, "eventType" .= event.eventType
|
||||
, "occurredAt" .= event.occurredAt
|
||||
]
|
||||
liftIO $ void $ forkIO $ dispatchWebhooks "clicked" webhookPayload
|
||||
liftIO $ void $ forkIO $ dispatchWebhooks evType webhookPayload
|
||||
renderJson (eventToJson event)
|
||||
|
||||
eventToJson :: InteractionEvent -> Value
|
||||
@@ -123,3 +128,33 @@ eventToJson e = object
|
||||
, "metadata" .= e.metadata
|
||||
, "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
|
||||
id: IHUB-WP-0019-T05
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
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
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user