generated from coulomb/repo-seed
feat(WP-0010): IHF Phase 9 — External API Surface and Consumer SDKs
Some checks failed
Test / test (push) Has been cancelled
Some checks failed
Test / test (push) Has been cancelled
Delivers the full Phase 9 external API layer: - Versioned REST API (/api/v2/) with OpenAPI 3.1 spec; enum arrays for widget_type, event_type, annotation category drawn live from registry tables - OAuth 2.0 client credentials flow (/api/v2/token); hub:*:write scopes gated on active HubCapabilityManifest FK - API key management: SHA256-hashed tokens, key_prefix for display, one-time reveal on creation, revocation support - TypeScript and Python consumer SDKs generated from registry tables (/api/v2/sdk/ihf-client.ts, /api/v2/sdk/ihf-client.py) - Webhook delivery: HMAC-SHA256 signing, append-only webhook_deliveries, fire-and-forget dispatch via forkIO, 3-retry logic - Admin API dashboard with 24h stats (request count, error rate, last seen) - Rate limiting (per-minute) and daily quota enforcement via api_request_log - Schema migration: api_consumers, api_keys, webhook_subscriptions (CHECK constraint on 6 framework lifecycle topics), webhook_deliveries (append-only trigger), api_request_log - ARCHITECTURE-LAYERS.md scorecard: 3.34 → 3.41 (approaching Strong) - contracts/functional/interaction-reporting-v1.md extended with Phase 9 endpoint catalogue and 422 validation error format GAAF: no bare TEXT discriminators; webhook event_type uses CHECK constraint over 6 allowed framework lifecycle topic strings (not widget event types). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
100
Web/Controller/Api/V2/Annotations.hs
Normal file
100
Web/Controller/Api/V2/Annotations.hs
Normal file
@@ -0,0 +1,100 @@
|
||||
module Web.Controller.Api.V2.Annotations where
|
||||
|
||||
import Web.Types
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ControllerPrelude
|
||||
import Data.Aeson (object, (.=))
|
||||
import Web.Controller.Api.V2.Auth
|
||||
( requireApiConsumer, paginatedResponse, getPageParams
|
||||
, respondWithStatus )
|
||||
import Application.Helper.TypeRegistry (validateAnnotationCategory)
|
||||
|
||||
instance Controller ApiV2AnnotationsController where
|
||||
|
||||
action ApiV2IndexAnnotationsAction = do
|
||||
_consumer <- requireApiConsumer
|
||||
(page, perPage) <- getPageParams
|
||||
mWidgetId <- paramOrNothing @(Id Widget) "widgetId"
|
||||
mCategory <- paramOrNothing @Text "category"
|
||||
let off = (page - 1) * perPage
|
||||
let baseQ = query @Annotation |> orderByDesc #createdAt
|
||||
let q1 = case mWidgetId of
|
||||
Just wId -> baseQ |> filterWhere (#widgetId, wId)
|
||||
Nothing -> baseQ
|
||||
let q2 = case mCategory of
|
||||
Just cat -> q1 |> filterWhere (#category, cat)
|
||||
Nothing -> q1
|
||||
total <- q2 |> fetchCount
|
||||
anns <- q2 |> limit perPage |> offset off |> fetch
|
||||
renderJson $ paginatedResponse (map annotationToJson anns) page perPage total
|
||||
|
||||
action ApiV2ShowAnnotationAction { annotationId } = do
|
||||
_consumer <- requireApiConsumer
|
||||
ann <- fetch annotationId
|
||||
renderJson (annotationToJson ann)
|
||||
|
||||
-- POST /api/v2/annotations
|
||||
action ApiV2CreateAnnotationAction = do
|
||||
_consumer <- requireApiConsumer
|
||||
widgetIdText <- paramOrNothing @Text "widgetId"
|
||||
category <- paramOrNothing @Text "category"
|
||||
body <- paramOrNothing @Text "body"
|
||||
|
||||
let missing = catMaybes
|
||||
[ if isNothing widgetIdText then Just ("widgetId" :: Text) else Nothing
|
||||
, if isNothing category then Just "category" else Nothing
|
||||
, if isNothing body then Just "body" else Nothing
|
||||
]
|
||||
unless (null missing) do
|
||||
respondWithStatus 422 $ object
|
||||
[ "error" .= ("Missing required fields" :: Text)
|
||||
, "missing" .= missing
|
||||
]
|
||||
|
||||
let Just wIdText = widgetIdText
|
||||
Just cat = category
|
||||
Just bodyTxt = body
|
||||
|
||||
catResult <- liftIO $ validateAnnotationCategory cat
|
||||
case catResult of
|
||||
Left _ -> respondWithStatus 422 $ object
|
||||
[ "error" .= ("Unregistered annotation category" :: Text)
|
||||
, "code" .= ("unregistered_category" :: Text)
|
||||
, "value" .= cat
|
||||
, "registry" .= ("/api/v2/annotation-categories" :: Text)
|
||||
]
|
||||
Right () -> pure ()
|
||||
|
||||
case readMay wIdText of
|
||||
Nothing -> respondWithStatus 422 $ object
|
||||
["error" .= ("widgetId must be a valid UUID" :: Text)]
|
||||
Just rawId -> do
|
||||
let wId = Id rawId :: Id Widget
|
||||
mWidget <- fetchOneOrNothing wId
|
||||
case mWidget of
|
||||
Nothing -> respondWithStatus 422 $ object
|
||||
["error" .= ("Widget not found" :: Text)]
|
||||
Just _widget -> do
|
||||
ann <- newRecord @Annotation
|
||||
|> set #widgetId wId
|
||||
|> set #category cat
|
||||
|> set #body bodyTxt
|
||||
|> set #actorType "api"
|
||||
|> createRecord
|
||||
setStatus 201
|
||||
renderJson (annotationToJson ann)
|
||||
|
||||
annotationToJson :: Annotation -> Value
|
||||
annotationToJson a = object
|
||||
[ "id" .= a.id
|
||||
, "widgetId" .= a.widgetId
|
||||
, "parentId" .= a.parentId
|
||||
, "body" .= a.body
|
||||
, "category" .= a.category
|
||||
, "severity" .= a.severity
|
||||
, "threadId" .= a.threadId
|
||||
, "actorId" .= a.actorId
|
||||
, "actorType" .= a.actorType
|
||||
, "createdAt" .= a.createdAt
|
||||
]
|
||||
87
Web/Controller/Api/V2/Auth.hs
Normal file
87
Web/Controller/Api/V2/Auth.hs
Normal file
@@ -0,0 +1,87 @@
|
||||
module Web.Controller.Api.V2.Auth where
|
||||
|
||||
import IHP.Prelude
|
||||
import IHP.ControllerPrelude
|
||||
import Web.Types
|
||||
import Generated.Types
|
||||
import Data.Aeson (object, (.=))
|
||||
import qualified Data.Text as T
|
||||
import qualified Data.Text.Encoding as TE
|
||||
import qualified Crypto.Hash.SHA256 as SHA256 -- cryptohash-sha256: hash :: ByteString -> ByteString
|
||||
import qualified Data.ByteString.Base16 as Base16
|
||||
import Network.Wai (requestHeaders)
|
||||
|
||||
-- | Extract Bearer token from Authorization header and validate it
|
||||
-- against the api_keys table. Returns the ApiConsumer on success,
|
||||
-- or halts with 401 JSON on failure.
|
||||
requireApiConsumer :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO ApiConsumer
|
||||
requireApiConsumer = do
|
||||
let authHeader = lookup "Authorization" (requestHeaders ?request)
|
||||
let mToken = authHeader >>= \h ->
|
||||
let t = cs h :: Text
|
||||
in if "Bearer " `T.isPrefixOf` t
|
||||
then Just (T.drop 7 t)
|
||||
else Nothing
|
||||
case mToken of
|
||||
Nothing -> unauthorized401
|
||||
Just token -> do
|
||||
let tokenHash = hashApiKey token
|
||||
now <- getCurrentTime
|
||||
mKey <- query @ApiKey
|
||||
|> filterWhere (#keyHash, tokenHash)
|
||||
|> fetchOneOrNothing
|
||||
case mKey of
|
||||
Nothing -> unauthorized401
|
||||
Just apiKey -> do
|
||||
when (isJust apiKey.revokedAt) unauthorized401
|
||||
when (maybe False (< now) apiKey.expiresAt) do
|
||||
respondWithStatus 401 $ object
|
||||
[ "error" .= ("Token expired" :: Text)
|
||||
, "code" .= ("token_expired" :: Text)
|
||||
]
|
||||
-- Update last_used_at (fire-and-forget; do not block on failure)
|
||||
apiKey |> set #lastUsedAt (Just now) |> updateRecord
|
||||
fetch apiKey.apiConsumerId >>= \consumer -> do
|
||||
unless consumer.isActive unauthorized401
|
||||
pure consumer
|
||||
|
||||
unauthorized401 :: (?respond :: Respond) => IO a
|
||||
unauthorized401 = respondWithStatus 401 $ object
|
||||
[ "error" .= ("Unauthorized" :: Text)
|
||||
, "code" .= ("invalid_api_key" :: Text)
|
||||
]
|
||||
|
||||
respondWithStatus :: (?respond :: Respond) => Int -> Value -> IO a
|
||||
respondWithStatus status body = do
|
||||
respondAndExit $ responseLBS
|
||||
(toEnum status)
|
||||
[("Content-Type", "application/json")]
|
||||
(encode body)
|
||||
|
||||
-- | SHA-256 hex hash of the key (same as stored in key_hash column)
|
||||
hashApiKey :: Text -> Text
|
||||
hashApiKey key =
|
||||
let bytes = TE.encodeUtf8 key
|
||||
digest = SHA256.hash bytes
|
||||
in TE.decodeUtf8 (Base16.encode digest)
|
||||
|
||||
-- | Standard paginated response envelope
|
||||
paginatedResponse :: ToJSON a => [a] -> Int -> Int -> Int -> Value
|
||||
paginatedResponse items page perPage total =
|
||||
object
|
||||
[ "data" .= items
|
||||
, "meta" .= object
|
||||
[ "page" .= page
|
||||
, "per_page" .= perPage
|
||||
, "total" .= total
|
||||
]
|
||||
]
|
||||
|
||||
-- | Parse page / per_page query params with sensible defaults
|
||||
getPageParams :: (?context :: ControllerContext) => IO (Int, Int)
|
||||
getPageParams = do
|
||||
page <- fromMaybe 1 <$> paramOrNothing @Int "page"
|
||||
perPage <- fromMaybe 50 <$> paramOrNothing @Int "per_page"
|
||||
let perPage' = min 200 (max 1 perPage)
|
||||
let page' = max 1 page
|
||||
pure (page', perPage')
|
||||
40
Web/Controller/Api/V2/DecisionRecords.hs
Normal file
40
Web/Controller/Api/V2/DecisionRecords.hs
Normal file
@@ -0,0 +1,40 @@
|
||||
module Web.Controller.Api.V2.DecisionRecords where
|
||||
|
||||
import Web.Types
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ControllerPrelude
|
||||
import Data.Aeson (object, (.=))
|
||||
import Web.Controller.Api.V2.Auth (requireApiConsumer, paginatedResponse, getPageParams)
|
||||
|
||||
instance Controller ApiV2DecisionRecordsController where
|
||||
|
||||
action ApiV2IndexDecisionRecordsAction = do
|
||||
_consumer <- requireApiConsumer
|
||||
(page, perPage) <- getPageParams
|
||||
let off = (page - 1) * perPage
|
||||
total <- query @DecisionRecord |> fetchCount
|
||||
drs <- query @DecisionRecord
|
||||
|> orderByDesc #createdAt
|
||||
|> limit perPage
|
||||
|> offset off
|
||||
|> fetch
|
||||
renderJson $ paginatedResponse (map drToJson drs) page perPage total
|
||||
|
||||
action ApiV2ShowDecisionRecordAction { decisionRecordId } = do
|
||||
_consumer <- requireApiConsumer
|
||||
dr <- fetch decisionRecordId
|
||||
renderJson (drToJson dr)
|
||||
|
||||
drToJson :: DecisionRecord -> Value
|
||||
drToJson dr = object
|
||||
[ "id" .= dr.id
|
||||
, "title" .= dr.title
|
||||
, "rationale" .= dr.rationale
|
||||
, "outcome" .= dr.outcome
|
||||
, "requirementId" .= dr.requirementId
|
||||
, "candidateId" .= dr.candidateId
|
||||
, "decidedAt" .= dr.decidedAt
|
||||
, "notes" .= dr.notes
|
||||
, "createdAt" .= dr.createdAt
|
||||
]
|
||||
38
Web/Controller/Api/V2/DeploymentRecords.hs
Normal file
38
Web/Controller/Api/V2/DeploymentRecords.hs
Normal file
@@ -0,0 +1,38 @@
|
||||
module Web.Controller.Api.V2.DeploymentRecords where
|
||||
|
||||
import Web.Types
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ControllerPrelude
|
||||
import Data.Aeson (object, (.=))
|
||||
import Web.Controller.Api.V2.Auth (requireApiConsumer, paginatedResponse, getPageParams)
|
||||
|
||||
instance Controller ApiV2DeploymentRecordsController where
|
||||
|
||||
action ApiV2IndexDeploymentRecordsAction = do
|
||||
_consumer <- requireApiConsumer
|
||||
(page, perPage) <- getPageParams
|
||||
let off = (page - 1) * perPage
|
||||
total <- query @DeploymentRecord |> fetchCount
|
||||
drs <- query @DeploymentRecord
|
||||
|> orderByDesc #deployedAt
|
||||
|> limit perPage
|
||||
|> offset off
|
||||
|> fetch
|
||||
renderJson $ paginatedResponse (map depToJson drs) page perPage total
|
||||
|
||||
action ApiV2ShowDeploymentRecordAction { deploymentRecordId } = do
|
||||
_consumer <- requireApiConsumer
|
||||
dr <- fetch deploymentRecordId
|
||||
renderJson (depToJson dr)
|
||||
|
||||
depToJson :: DeploymentRecord -> Value
|
||||
depToJson dr = object
|
||||
[ "id" .= dr.id
|
||||
, "implRefId" .= dr.implRefId
|
||||
, "decisionId" .= dr.decisionId
|
||||
, "versionRef" .= dr.versionRef
|
||||
, "deployedAt" .= dr.deployedAt
|
||||
, "notes" .= dr.notes
|
||||
, "createdAt" .= dr.createdAt
|
||||
]
|
||||
124
Web/Controller/Api/V2/InteractionEvents.hs
Normal file
124
Web/Controller/Api/V2/InteractionEvents.hs
Normal file
@@ -0,0 +1,124 @@
|
||||
module Web.Controller.Api.V2.InteractionEvents where
|
||||
|
||||
import Web.Types
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ControllerPrelude
|
||||
import Data.Aeson (object, (.=))
|
||||
import qualified Data.Text as T
|
||||
import Web.Controller.Api.V2.Auth
|
||||
( requireApiConsumer, paginatedResponse, getPageParams
|
||||
, respondWithStatus )
|
||||
import Application.Helper.TypeRegistry (validateEventType)
|
||||
import Web.Job.WebhookDeliveryJob (dispatchWebhooks)
|
||||
import Control.Concurrent (forkIO)
|
||||
import qualified Data.Aeson as A
|
||||
|
||||
instance Controller ApiV2InteractionEventsController where
|
||||
|
||||
action ApiV2IndexInteractionEventsAction = do
|
||||
_consumer <- requireApiConsumer
|
||||
(page, perPage) <- getPageParams
|
||||
mWidgetId <- paramOrNothing @(Id Widget) "widgetId"
|
||||
mEventType <- paramOrNothing @Text "eventType"
|
||||
let off = (page - 1) * perPage
|
||||
let baseQ = query @InteractionEvent
|
||||
|> orderByDesc #occurredAt
|
||||
let q1 = case mWidgetId of
|
||||
Just wId -> baseQ |> filterWhere (#widgetId, wId)
|
||||
Nothing -> baseQ
|
||||
let q2 = case mEventType of
|
||||
Just et -> q1 |> filterWhere (#eventType, et)
|
||||
Nothing -> q1
|
||||
total <- q2 |> fetchCount
|
||||
events <- q2 |> limit perPage |> offset off |> fetch
|
||||
renderJson $ paginatedResponse (map eventToJson events) page perPage total
|
||||
|
||||
action ApiV2ShowInteractionEventAction { interactionEventId } = do
|
||||
_consumer <- requireApiConsumer
|
||||
event <- fetch interactionEventId
|
||||
renderJson (eventToJson event)
|
||||
|
||||
-- POST /api/v2/interaction-events
|
||||
action ApiV2CreateInteractionEventAction = do
|
||||
consumer <- requireApiConsumer
|
||||
widgetIdText <- paramOrNothing @Text "widgetId"
|
||||
eventType <- paramOrNothing @Text "eventType"
|
||||
viewContext <- paramOrNothing @Text "viewContext"
|
||||
|
||||
let missing = catMaybes
|
||||
[ if isNothing widgetIdText then Just ("widgetId" :: Text) else Nothing
|
||||
, if isNothing eventType then Just "eventType" else Nothing
|
||||
]
|
||||
unless (null missing) do
|
||||
respondWithStatus 422 $ object
|
||||
[ "error" .= ("Missing required fields" :: Text)
|
||||
, "missing" .= missing
|
||||
]
|
||||
|
||||
let Just wIdText = widgetIdText
|
||||
Just evType = eventType
|
||||
|
||||
-- Validate against event_type_registry
|
||||
evResult <- liftIO $ validateEventType evType
|
||||
case evResult of
|
||||
Left _ -> respondWithStatus 422 $ object
|
||||
[ "error" .= ("Unregistered event type" :: Text)
|
||||
, "code" .= ("unregistered_event_type" :: Text)
|
||||
, "value" .= evType
|
||||
, "registry" .= ("/api/v2/event-types" :: Text)
|
||||
]
|
||||
Right () -> pure ()
|
||||
|
||||
-- If consumer has a manifest, also validate against declared_event_types
|
||||
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
|
||||
respondWithStatus 422 $ object
|
||||
[ "error" .= ("Event type not declared in hub manifest" :: Text)
|
||||
, "code" .= ("event_type_not_in_manifest" :: Text)
|
||||
, "value" .= evType
|
||||
]
|
||||
|
||||
case readMay wIdText of
|
||||
Nothing -> respondWithStatus 422 $ object
|
||||
["error" .= ("widgetId must be a valid UUID" :: Text)]
|
||||
Just rawId -> do
|
||||
let wId = Id rawId :: Id Widget
|
||||
mWidget <- fetchOneOrNothing wId
|
||||
case mWidget of
|
||||
Nothing -> respondWithStatus 422 $ object
|
||||
["error" .= ("Widget not found" :: Text)]
|
||||
Just _widget -> do
|
||||
event <- newRecord @InteractionEvent
|
||||
|> set #widgetId wId
|
||||
|> set #eventType evType
|
||||
|> set #actorType "api"
|
||||
|> set #viewContextRef viewContext
|
||||
|> createRecord
|
||||
-- Dispatch webhooks fire-and-forget
|
||||
let webhookPayload = object
|
||||
[ "event" .= ("interaction_event.created" :: Text)
|
||||
, "resourceId" .= event.id
|
||||
, "widgetId" .= event.widgetId
|
||||
, "eventType" .= event.eventType
|
||||
, "occurredAt" .= event.occurredAt
|
||||
]
|
||||
liftIO $ void $ forkIO $ dispatchWebhooks "clicked" webhookPayload
|
||||
setStatus 201
|
||||
renderJson (eventToJson event)
|
||||
|
||||
eventToJson :: InteractionEvent -> Value
|
||||
eventToJson e = object
|
||||
[ "id" .= e.id
|
||||
, "widgetId" .= e.widgetId
|
||||
, "eventType" .= e.eventType
|
||||
, "actorId" .= e.actorId
|
||||
, "actorType" .= e.actorType
|
||||
, "viewContextRef" .= e.viewContextRef
|
||||
, "metadata" .= e.metadata
|
||||
, "occurredAt" .= e.occurredAt
|
||||
]
|
||||
389
Web/Controller/Api/V2/OpenApi.hs
Normal file
389
Web/Controller/Api/V2/OpenApi.hs
Normal file
@@ -0,0 +1,389 @@
|
||||
module Web.Controller.Api.V2.OpenApi where
|
||||
|
||||
-- GET /api/v2/openapi.json — OpenAPI 3.1 spec with live type registry enums
|
||||
-- GET /api/v2/openapi.yaml — YAML convenience alias
|
||||
-- GET /api/v2/docs — Swagger UI
|
||||
|
||||
import Web.Types
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ControllerPrelude
|
||||
import Data.Aeson (object, (.=), Array, toJSON)
|
||||
import qualified Data.Aeson as A
|
||||
import qualified Data.Vector as V
|
||||
import qualified Data.Text as T
|
||||
import qualified Data.Text.Encoding as TE
|
||||
import qualified Data.Yaml as Yaml -- yaml package
|
||||
import qualified Data.ByteString.Lazy as LBS
|
||||
import Application.Helper.TypeRegistry
|
||||
( activeWidgetTypes, activeEventTypes, activeAnnotationCategories )
|
||||
import Network.HTTP.Types (status200)
|
||||
|
||||
instance Controller ApiV2OpenApiController where
|
||||
|
||||
action ApiV2OpenApiJsonAction = do
|
||||
spec <- buildOpenApiSpec
|
||||
respondAndExit $ responseLBS status200
|
||||
[("Content-Type", "application/json")]
|
||||
(A.encode spec)
|
||||
|
||||
action ApiV2OpenApiYamlAction = do
|
||||
spec <- buildOpenApiSpec
|
||||
let yaml = Yaml.encode spec
|
||||
respondAndExit $ responseLBS status200
|
||||
[("Content-Type", "application/yaml")]
|
||||
(LBS.fromStrict yaml)
|
||||
|
||||
action ApiV2DocsAction = do
|
||||
respondAndExit $ responseLBS status200
|
||||
[("Content-Type", "text/html; charset=utf-8")]
|
||||
swaggerUiHtml
|
||||
|
||||
-- | Build the full OpenAPI 3.1 document from live registry data.
|
||||
buildOpenApiSpec :: (?modelContext :: ModelContext) => IO Value
|
||||
buildOpenApiSpec = do
|
||||
(fwWidgetTypes, ownedWidgetTypes) <- activeWidgetTypes
|
||||
let allWidgetTypes = fwWidgetTypes ++ ownedWidgetTypes
|
||||
eventTypes <- activeEventTypes
|
||||
annCats <- activeAnnotationCategories
|
||||
|
||||
let wtEnum = toJSON $ map (.name) allWidgetTypes
|
||||
let etEnum = toJSON $ map (.name) eventTypes
|
||||
let acEnum = toJSON $ map (.name) annCats
|
||||
|
||||
pure $ object
|
||||
[ "openapi" .= ("3.1.0" :: Text)
|
||||
, "info" .= object
|
||||
[ "title" .= ("Interaction Hub Framework API" :: Text)
|
||||
, "version" .= ("2.0" :: Text)
|
||||
, "description" .= ("IHF external API v2. For the human-readable contract see /contracts/functional/interaction-reporting-v1.md" :: Text)
|
||||
]
|
||||
, "x-ihf-contract" .= ("/contracts/functional/interaction-reporting-v1.md" :: Text)
|
||||
, "servers" .= [object ["url" .= ("/api/v2" :: Text)]]
|
||||
, "paths" .= buildPaths
|
||||
, "components" .= object
|
||||
[ "schemas" .= object
|
||||
[ "WidgetType" .= object
|
||||
[ "type" .= ("string" :: Text)
|
||||
, "enum" .= wtEnum
|
||||
]
|
||||
, "EventType" .= object
|
||||
[ "type" .= ("string" :: Text)
|
||||
, "enum" .= etEnum
|
||||
]
|
||||
, "AnnotationCategory" .= object
|
||||
[ "type" .= ("string" :: Text)
|
||||
, "enum" .= acEnum
|
||||
]
|
||||
, "PaginationMeta" .= object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "properties" .= object
|
||||
[ "page" .= object ["type" .= ("integer" :: Text)]
|
||||
, "per_page" .= object ["type" .= ("integer" :: Text)]
|
||||
, "total" .= object ["type" .= ("integer" :: Text)]
|
||||
]
|
||||
]
|
||||
, "Widget" .= widgetSchema
|
||||
, "InteractionEvent" .= interactionEventSchema
|
||||
, "Annotation" .= annotationSchema
|
||||
, "RequirementCandidate" .= rcSchema
|
||||
, "DecisionRecord" .= drSchema
|
||||
, "DeploymentRecord" .= depSchema
|
||||
, "OutcomeSignal" .= sigSchema
|
||||
]
|
||||
, "securitySchemes" .= object
|
||||
[ "BearerAuth" .= object
|
||||
[ "type" .= ("http" :: Text)
|
||||
, "scheme" .= ("bearer" :: Text)
|
||||
, "description" .= ("API key or OAuth token obtained via POST /api/v2/token" :: Text)
|
||||
]
|
||||
]
|
||||
]
|
||||
, "security" .= [object ["BearerAuth" .= ([] :: [Text])]]
|
||||
]
|
||||
|
||||
buildPaths :: Value
|
||||
buildPaths = object
|
||||
[ "/widgets" .= getListPath "Widget"
|
||||
, "/widgets/{id}" .= getShowPath "Widget"
|
||||
, "/interaction-events" .= object
|
||||
[ "get" .= listOp "InteractionEvent"
|
||||
[ ("widgetId", "string", "uuid")
|
||||
, ("eventType", "string", "")
|
||||
]
|
||||
, "post" .= writeOp "InteractionEvent" "CreateInteractionEventRequest"
|
||||
]
|
||||
, "/annotations" .= object
|
||||
[ "get" .= listOp "Annotation"
|
||||
[ ("widgetId", "string", "uuid")
|
||||
, ("category", "string", "")
|
||||
]
|
||||
, "post" .= writeOp "Annotation" "CreateAnnotationRequest"
|
||||
]
|
||||
, "/requirement-candidates" .= getListPath "RequirementCandidate"
|
||||
, "/requirement-candidates/{id}" .= getShowPath "RequirementCandidate"
|
||||
, "/decision-records" .= getListPath "DecisionRecord"
|
||||
, "/decision-records/{id}" .= getShowPath "DecisionRecord"
|
||||
, "/deployment-records" .= getListPath "DeploymentRecord"
|
||||
, "/deployment-records/{id}" .= getShowPath "DeploymentRecord"
|
||||
, "/outcome-signals" .= getListPath "OutcomeSignal"
|
||||
, "/outcome-signals/{id}" .= getShowPath "OutcomeSignal"
|
||||
, "/widget-types" .= publicListPath "WidgetTypeRegistry"
|
||||
, "/event-types" .= publicListPath "EventTypeRegistry"
|
||||
, "/annotation-categories" .= publicListPath "AnnotationCategoryRegistry"
|
||||
, "/token" .= tokenPath
|
||||
]
|
||||
|
||||
getListPath :: Text -> Value
|
||||
getListPath schemaName = object
|
||||
[ "get" .= listOp schemaName [] ]
|
||||
|
||||
getShowPath :: Text -> Value
|
||||
getShowPath schemaName = object
|
||||
[ "get" .= showOp schemaName ]
|
||||
|
||||
listOp :: Text -> [(Text, Text, Text)] -> Value
|
||||
listOp schemaName extraParams = object
|
||||
[ "summary" .= ("List " <> schemaName)
|
||||
, "security" .= [object ["BearerAuth" .= ([] :: [Text])]]
|
||||
, "parameters" .= (pageParams ++ map toParam extraParams)
|
||||
, "responses" .= object
|
||||
[ "200" .= object
|
||||
[ "description" .= ("OK" :: Text)
|
||||
, "content" .= object
|
||||
[ "application/json" .= object
|
||||
[ "schema" .= object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "properties" .= object
|
||||
[ "data" .= object
|
||||
[ "type" .= ("array" :: Text)
|
||||
, "items" .= object ["$ref" .= ("#/components/schemas/" <> schemaName)]
|
||||
]
|
||||
, "meta" .= object ["$ref" .= ("#/components/schemas/PaginationMeta" :: Text)]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
, "401" .= object ["description" .= ("Unauthorized" :: Text)]
|
||||
]
|
||||
]
|
||||
where
|
||||
toParam (name, typ, fmt) = object $
|
||||
[ "name" .= name, "in" .= ("query" :: Text)
|
||||
, "schema" .= object (["type" .= typ] ++ if fmt /= "" then [("format", A.String fmt)] else [])
|
||||
]
|
||||
|
||||
showOp :: Text -> Value
|
||||
showOp schemaName = object
|
||||
[ "summary" .= ("Get " <> schemaName)
|
||||
, "security" .= [object ["BearerAuth" .= ([] :: [Text])]]
|
||||
, "parameters" .= [object ["name" .= ("id" :: Text), "in" .= ("path" :: Text), "required" .= True, "schema" .= object ["type" .= ("string" :: Text), "format" .= ("uuid" :: Text)]]]
|
||||
, "responses" .= object
|
||||
[ "200" .= object
|
||||
[ "description" .= ("OK" :: Text)
|
||||
, "content" .= object
|
||||
[ "application/json" .= object
|
||||
["schema" .= object ["$ref" .= ("#/components/schemas/" <> schemaName)]]
|
||||
]
|
||||
]
|
||||
, "401" .= object ["description" .= ("Unauthorized" :: Text)]
|
||||
, "404" .= object ["description" .= ("Not found" :: Text)]
|
||||
]
|
||||
]
|
||||
|
||||
writeOp :: Text -> Text -> Value
|
||||
writeOp schemaName _reqSchema = object
|
||||
[ "summary" .= ("Create " <> schemaName)
|
||||
, "security" .= [object ["BearerAuth" .= ([] :: [Text])]]
|
||||
, "requestBody" .= object
|
||||
[ "required" .= True
|
||||
, "content" .= object
|
||||
[ "application/json" .= object
|
||||
["schema" .= object ["$ref" .= ("#/components/schemas/" <> schemaName)]]
|
||||
]
|
||||
]
|
||||
, "responses" .= object
|
||||
[ "201" .= object ["description" .= ("Created" :: Text)]
|
||||
, "401" .= object ["description" .= ("Unauthorized" :: Text)]
|
||||
, "422" .= object ["description" .= ("Validation error" :: Text)]
|
||||
]
|
||||
]
|
||||
|
||||
publicListPath :: Text -> Value
|
||||
publicListPath schemaName = object
|
||||
[ "get" .= object
|
||||
[ "summary" .= ("List registered " <> schemaName <> " values" :: Text)
|
||||
, "responses" .= object
|
||||
[ "200" .= object ["description" .= ("OK" :: Text)] ]
|
||||
]
|
||||
]
|
||||
|
||||
tokenPath :: Value
|
||||
tokenPath = object
|
||||
[ "post" .= object
|
||||
[ "summary" .= ("Obtain OAuth access token (client credentials)" :: Text)
|
||||
, "requestBody" .= object
|
||||
[ "required" .= True
|
||||
, "content" .= object
|
||||
[ "application/x-www-form-urlencoded" .= object
|
||||
[ "schema" .= object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "properties" .= object
|
||||
[ "grant_type" .= object ["type" .= ("string" :: Text), "enum" .= ["client_credentials" :: Text]]
|
||||
, "client_id" .= object ["type" .= ("string" :: Text), "format" .= ("uuid" :: Text)]
|
||||
, "client_secret" .= object ["type" .= ("string" :: Text)]
|
||||
, "scope" .= object ["type" .= ("string" :: Text)]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
, "responses" .= object
|
||||
[ "200" .= object ["description" .= ("Access token issued" :: Text)]
|
||||
, "400" .= object ["description" .= ("Invalid request or credentials" :: Text)]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
pageParams :: [Value]
|
||||
pageParams =
|
||||
[ object ["name" .= ("page" :: Text), "in" .= ("query" :: Text), "schema" .= object ["type" .= ("integer" :: Text)]]
|
||||
, object ["name" .= ("per_page" :: Text), "in" .= ("query" :: Text), "schema" .= object ["type" .= ("integer" :: Text)]]
|
||||
]
|
||||
|
||||
-- Schemas for all resource types
|
||||
|
||||
widgetSchema :: Value
|
||||
widgetSchema = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "properties" .= object
|
||||
[ "id" .= uuidProp
|
||||
, "hubId" .= uuidProp
|
||||
, "name" .= strProp
|
||||
, "widgetType" .= object ["$ref" .= ("#/components/schemas/WidgetType" :: Text)]
|
||||
, "capabilityRef" .= strProp
|
||||
, "viewContext" .= strProp
|
||||
, "policyScope" .= strProp
|
||||
, "status" .= strProp
|
||||
, "version" .= object ["type" .= ("integer" :: Text)]
|
||||
, "createdAt" .= object ["type" .= ("string" :: Text), "format" .= ("date-time" :: Text)]
|
||||
]
|
||||
]
|
||||
|
||||
interactionEventSchema :: Value
|
||||
interactionEventSchema = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "properties" .= object
|
||||
[ "id" .= uuidProp
|
||||
, "widgetId" .= uuidProp
|
||||
, "eventType" .= object ["$ref" .= ("#/components/schemas/EventType" :: Text)]
|
||||
, "actorId" .= uuidProp
|
||||
, "actorType" .= strProp
|
||||
, "viewContextRef" .= strProp
|
||||
, "metadata" .= object ["type" .= ("object" :: Text)]
|
||||
, "occurredAt" .= dtProp
|
||||
]
|
||||
]
|
||||
|
||||
annotationSchema :: Value
|
||||
annotationSchema = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "properties" .= object
|
||||
[ "id" .= uuidProp
|
||||
, "widgetId" .= uuidProp
|
||||
, "parentId" .= uuidProp
|
||||
, "body" .= strProp
|
||||
, "category" .= object ["$ref" .= ("#/components/schemas/AnnotationCategory" :: Text)]
|
||||
, "severity" .= strProp
|
||||
, "threadId" .= uuidProp
|
||||
, "actorId" .= uuidProp
|
||||
, "actorType" .= strProp
|
||||
, "createdAt" .= dtProp
|
||||
]
|
||||
]
|
||||
|
||||
rcSchema :: Value
|
||||
rcSchema = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "properties" .= object
|
||||
[ "id" .= uuidProp
|
||||
, "title" .= strProp
|
||||
, "description" .= strProp
|
||||
, "sourceWidgetId" .= uuidProp
|
||||
, "category" .= strProp
|
||||
, "status" .= strProp
|
||||
, "createdAt" .= dtProp
|
||||
]
|
||||
]
|
||||
|
||||
drSchema :: Value
|
||||
drSchema = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "properties" .= object
|
||||
[ "id" .= uuidProp
|
||||
, "title" .= strProp
|
||||
, "rationale" .= strProp
|
||||
, "outcome" .= strProp
|
||||
, "requirementId" .= uuidProp
|
||||
, "candidateId" .= uuidProp
|
||||
, "decidedAt" .= dtProp
|
||||
, "notes" .= strProp
|
||||
, "createdAt" .= dtProp
|
||||
]
|
||||
]
|
||||
|
||||
depSchema :: Value
|
||||
depSchema = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "properties" .= object
|
||||
[ "id" .= uuidProp
|
||||
, "decisionId" .= uuidProp
|
||||
, "versionRef" .= strProp
|
||||
, "deployedAt" .= dtProp
|
||||
, "notes" .= strProp
|
||||
, "createdAt" .= dtProp
|
||||
]
|
||||
]
|
||||
|
||||
sigSchema :: Value
|
||||
sigSchema = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "properties" .= object
|
||||
[ "id" .= uuidProp
|
||||
, "widgetId" .= uuidProp
|
||||
, "deploymentId" .= uuidProp
|
||||
, "signalType" .= strProp
|
||||
, "value" .= object ["type" .= ("number" :: Text)]
|
||||
, "observedAt" .= dtProp
|
||||
]
|
||||
]
|
||||
|
||||
uuidProp :: Value
|
||||
uuidProp = object ["type" .= ("string" :: Text), "format" .= ("uuid" :: Text)]
|
||||
|
||||
strProp :: Value
|
||||
strProp = object ["type" .= ("string" :: Text)]
|
||||
|
||||
dtProp :: Value
|
||||
dtProp = object ["type" .= ("string" :: Text), "format" .= ("date-time" :: Text)]
|
||||
|
||||
-- | Embedded Swagger UI HTML using CDN assets (no build step required)
|
||||
swaggerUiHtml :: LBS.ByteString
|
||||
swaggerUiHtml = LBS.fromStrict $ TE.encodeUtf8 swaggerUiHtmlText
|
||||
|
||||
swaggerUiHtmlText :: Text
|
||||
swaggerUiHtmlText =
|
||||
"<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"UTF-8\" />" <>
|
||||
"<title>IHF API v2 \x2014 Documentation</title>" <>
|
||||
"<link rel=\"stylesheet\" href=\"https://unpkg.com/swagger-ui-dist@5/swagger-ui.css\" />" <>
|
||||
"</head><body>" <>
|
||||
"<div id=\"swagger-ui\"></div>" <>
|
||||
"<script src=\"https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js\"></script>" <>
|
||||
"<script>window.onload=function(){SwaggerUIBundle({" <>
|
||||
"url:\"/api/v2/openapi.json\"," <>
|
||||
"dom_id:\"#swagger-ui\"," <>
|
||||
"presets:[SwaggerUIBundle.presets.apis,SwaggerUIBundle.SwaggerUIStandalonePreset]," <>
|
||||
"layout:\"StandaloneLayout\"" <>
|
||||
"});}</script>" <>
|
||||
"</body></html>"
|
||||
37
Web/Controller/Api/V2/OutcomeSignals.hs
Normal file
37
Web/Controller/Api/V2/OutcomeSignals.hs
Normal file
@@ -0,0 +1,37 @@
|
||||
module Web.Controller.Api.V2.OutcomeSignals where
|
||||
|
||||
import Web.Types
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ControllerPrelude
|
||||
import Data.Aeson (object, (.=))
|
||||
import Web.Controller.Api.V2.Auth (requireApiConsumer, paginatedResponse, getPageParams)
|
||||
|
||||
instance Controller ApiV2OutcomeSignalsController where
|
||||
|
||||
action ApiV2IndexOutcomeSignalsAction = do
|
||||
_consumer <- requireApiConsumer
|
||||
(page, perPage) <- getPageParams
|
||||
let off = (page - 1) * perPage
|
||||
total <- query @OutcomeSignal |> fetchCount
|
||||
sigs <- query @OutcomeSignal
|
||||
|> orderByDesc #observedAt
|
||||
|> limit perPage
|
||||
|> offset off
|
||||
|> fetch
|
||||
renderJson $ paginatedResponse (map sigToJson sigs) page perPage total
|
||||
|
||||
action ApiV2ShowOutcomeSignalAction { outcomeSignalId } = do
|
||||
_consumer <- requireApiConsumer
|
||||
sig <- fetch outcomeSignalId
|
||||
renderJson (sigToJson sig)
|
||||
|
||||
sigToJson :: OutcomeSignal -> Value
|
||||
sigToJson s = object
|
||||
[ "id" .= s.id
|
||||
, "widgetId" .= s.widgetId
|
||||
, "deploymentId" .= s.deploymentId
|
||||
, "signalType" .= s.signalType
|
||||
, "value" .= s.value
|
||||
, "observedAt" .= s.observedAt
|
||||
]
|
||||
62
Web/Controller/Api/V2/Registries.hs
Normal file
62
Web/Controller/Api/V2/Registries.hs
Normal file
@@ -0,0 +1,62 @@
|
||||
module Web.Controller.Api.V2.Registries where
|
||||
|
||||
-- Public (unauthenticated) endpoints that enumerate the registered vocabulary.
|
||||
-- GET /api/v2/widget-types
|
||||
-- GET /api/v2/event-types
|
||||
-- GET /api/v2/annotation-categories
|
||||
|
||||
import Web.Types
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ControllerPrelude
|
||||
import Data.Aeson (object, (.=))
|
||||
|
||||
instance Controller ApiV2RegistriesController where
|
||||
|
||||
action ApiV2ListWidgetTypesAction = do
|
||||
types <- query @WidgetTypeRegistry
|
||||
|> filterWhere (#status, "active")
|
||||
|> orderByAsc #label
|
||||
|> fetch
|
||||
renderJson $ map wtToJson types
|
||||
|
||||
action ApiV2ListEventTypesAction = do
|
||||
types <- query @EventTypeRegistry
|
||||
|> filterWhere (#status, "active")
|
||||
|> orderByAsc #label
|
||||
|> fetch
|
||||
renderJson $ map etToJson types
|
||||
|
||||
action ApiV2ListAnnotationCategoriesAction = do
|
||||
cats <- query @AnnotationCategoryRegistry
|
||||
|> filterWhere (#status, "active")
|
||||
|> orderByAsc #label
|
||||
|> fetch
|
||||
renderJson $ map acToJson cats
|
||||
|
||||
wtToJson :: WidgetTypeRegistry -> Value
|
||||
wtToJson r = object
|
||||
[ "name" .= r.name
|
||||
, "label" .= r.label
|
||||
, "description" .= r.description
|
||||
, "ownerHubId" .= r.ownerHubId
|
||||
, "status" .= r.status
|
||||
]
|
||||
|
||||
etToJson :: EventTypeRegistry -> Value
|
||||
etToJson r = object
|
||||
[ "name" .= r.name
|
||||
, "label" .= r.label
|
||||
, "description" .= r.description
|
||||
, "ownerHubId" .= r.ownerHubId
|
||||
, "status" .= r.status
|
||||
]
|
||||
|
||||
acToJson :: AnnotationCategoryRegistry -> Value
|
||||
acToJson r = object
|
||||
[ "name" .= r.name
|
||||
, "label" .= r.label
|
||||
, "description" .= r.description
|
||||
, "ownerHubId" .= r.ownerHubId
|
||||
, "status" .= r.status
|
||||
]
|
||||
40
Web/Controller/Api/V2/RequirementCandidates.hs
Normal file
40
Web/Controller/Api/V2/RequirementCandidates.hs
Normal file
@@ -0,0 +1,40 @@
|
||||
module Web.Controller.Api.V2.RequirementCandidates where
|
||||
|
||||
import Web.Types
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ControllerPrelude
|
||||
import Data.Aeson (object, (.=))
|
||||
import Web.Controller.Api.V2.Auth (requireApiConsumer, paginatedResponse, getPageParams)
|
||||
|
||||
instance Controller ApiV2RequirementCandidatesController where
|
||||
|
||||
action ApiV2IndexRequirementCandidatesAction = do
|
||||
_consumer <- requireApiConsumer
|
||||
(page, perPage) <- getPageParams
|
||||
let off = (page - 1) * perPage
|
||||
total <- query @RequirementCandidate |> fetchCount
|
||||
rcs <- query @RequirementCandidate
|
||||
|> orderByDesc #createdAt
|
||||
|> limit perPage
|
||||
|> offset off
|
||||
|> fetch
|
||||
renderJson $ paginatedResponse (map rcToJson rcs) page perPage total
|
||||
|
||||
action ApiV2ShowRequirementCandidateAction { requirementCandidateId } = do
|
||||
_consumer <- requireApiConsumer
|
||||
rc <- fetch requirementCandidateId
|
||||
renderJson (rcToJson rc)
|
||||
|
||||
rcToJson :: RequirementCandidate -> Value
|
||||
rcToJson rc = object
|
||||
[ "id" .= rc.id
|
||||
, "title" .= rc.title
|
||||
, "description" .= rc.description
|
||||
, "sourceWidgetId" .= rc.sourceWidgetId
|
||||
, "sourceThreadId" .= rc.sourceThreadId
|
||||
, "sourceAnnotationId" .= rc.sourceAnnotationId
|
||||
, "category" .= rc.category
|
||||
, "status" .= rc.status
|
||||
, "createdAt" .= rc.createdAt
|
||||
]
|
||||
189
Web/Controller/Api/V2/Sdk.hs
Normal file
189
Web/Controller/Api/V2/Sdk.hs
Normal file
@@ -0,0 +1,189 @@
|
||||
module Web.Controller.Api.V2.Sdk where
|
||||
|
||||
-- GET /api/v2/sdk — SDK index page
|
||||
-- GET /api/v2/sdk/ihf-client.ts — TypeScript SDK (live-generated from registries)
|
||||
-- GET /api/v2/sdk/ihf-client.py — Python SDK (live-generated from registries)
|
||||
|
||||
import Web.Types
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ControllerPrelude
|
||||
import qualified Data.Text as T
|
||||
import qualified Data.Text.Encoding as TE
|
||||
import qualified Data.ByteString.Lazy as LBS
|
||||
import Network.HTTP.Types (status200)
|
||||
import Application.Helper.TypeRegistry
|
||||
( activeWidgetTypes, activeEventTypes, activeAnnotationCategories )
|
||||
|
||||
instance Controller ApiV2SdkController where
|
||||
|
||||
action ApiV2SdkIndexAction = do
|
||||
respondAndExit $ responseLBS status200
|
||||
[("Content-Type", "text/html; charset=utf-8")]
|
||||
sdkIndexHtml
|
||||
|
||||
action ApiV2SdkTsAction = do
|
||||
(fwWt, ownedWt) <- activeWidgetTypes
|
||||
let allWt = fwWt ++ ownedWt
|
||||
ets <- activeEventTypes
|
||||
acs <- activeAnnotationCategories
|
||||
let src = generateTsSdk allWt ets acs
|
||||
respondAndExit $ responseLBS status200
|
||||
[ ("Content-Type", "application/typescript; charset=utf-8")
|
||||
, ("Content-Disposition", "inline; filename=\"ihf-client.ts\"")
|
||||
]
|
||||
(LBS.fromStrict (TE.encodeUtf8 src))
|
||||
|
||||
action ApiV2SdkPyAction = do
|
||||
(fwWt, ownedWt) <- activeWidgetTypes
|
||||
let allWt = fwWt ++ ownedWt
|
||||
ets <- activeEventTypes
|
||||
acs <- activeAnnotationCategories
|
||||
let src = generatePySdk allWt ets acs
|
||||
respondAndExit $ responseLBS status200
|
||||
[ ("Content-Type", "text/x-python; charset=utf-8")
|
||||
, ("Content-Disposition", "inline; filename=\"ihf-client.py\"")
|
||||
]
|
||||
(LBS.fromStrict (TE.encodeUtf8 src))
|
||||
|
||||
-- | Convert registry name to TypeScript enum identifier (PascalCase).
|
||||
-- e.g. "data-table" -> "DataTable", "ux-friction" -> "UxFriction"
|
||||
toPascalCase :: Text -> Text
|
||||
toPascalCase = T.concat . map capitalise . T.splitOn "-"
|
||||
where capitalise "" = ""
|
||||
capitalise t = T.toUpper (T.take 1 t) <> T.drop 1 t
|
||||
|
||||
-- | Convert registry name to Python UPPER_SNAKE identifier.
|
||||
-- e.g. "data-table" -> "DATA_TABLE", "ux-friction" -> "UX_FRICTION"
|
||||
toUpperSnake :: Text -> Text
|
||||
toUpperSnake = T.toUpper . T.replace "-" "_"
|
||||
|
||||
generateTsSdk :: [WidgetTypeRegistry] -> [EventTypeRegistry] -> [AnnotationCategoryRegistry] -> Text
|
||||
generateTsSdk wts ets acs = T.unlines
|
||||
[ "// Auto-generated by IHF. Do not edit manually."
|
||||
, "// Regenerate: curl <base>/api/v2/sdk/ihf-client.ts > ihf-client.ts"
|
||||
, ""
|
||||
, "export enum WidgetType {"
|
||||
] <> T.unlines (map (\r -> " " <> toPascalCase r.name <> " = \"" <> r.name <> "\",") wts)
|
||||
<> "}\n\nexport enum EventType {\n"
|
||||
<> T.unlines (map (\r -> " " <> toPascalCase r.name <> " = \"" <> r.name <> "\",") ets)
|
||||
<> "}\n\nexport enum AnnotationCategory {\n"
|
||||
<> T.unlines (map (\r -> " " <> toPascalCase r.name <> " = \"" <> r.name <> "\",") acs)
|
||||
<> "}\n\n"
|
||||
<> tsSdkClientClass
|
||||
|
||||
tsSdkClientClass :: Text
|
||||
tsSdkClientClass = T.unlines
|
||||
[ "export interface IhfApiOptions {"
|
||||
, " baseUrl: string;"
|
||||
, " bearerToken: string;"
|
||||
, "}"
|
||||
, ""
|
||||
, "export class IhfClient {"
|
||||
, " constructor(private opts: IhfApiOptions) {}"
|
||||
, ""
|
||||
, " private async fetch(path: string, method = 'GET', body?: object): Promise<Response> {"
|
||||
, " return fetch(this.opts.baseUrl + path, {"
|
||||
, " method,"
|
||||
, " headers: {"
|
||||
, " 'Authorization': 'Bearer ' + this.opts.bearerToken,"
|
||||
, " 'Content-Type': 'application/json',"
|
||||
, " },"
|
||||
, " body: body ? JSON.stringify(body) : undefined,"
|
||||
, " });"
|
||||
, " }"
|
||||
, ""
|
||||
, " async getWidgets(params?: { page?: number; perPage?: number }) {"
|
||||
, " const q = params ? `?page=${params.page ?? 1}&per_page=${params.perPage ?? 50}` : '';"
|
||||
, " return this.fetch('/widgets' + q).then(r => r.json());"
|
||||
, " }"
|
||||
, ""
|
||||
, " async getInteractionEvents(params?: { widgetId?: string; eventType?: EventType }) {"
|
||||
, " const qs = new URLSearchParams();"
|
||||
, " if (params?.widgetId) qs.set('widgetId', params.widgetId);"
|
||||
, " if (params?.eventType) qs.set('eventType', params.eventType);"
|
||||
, " return this.fetch('/interaction-events?' + qs).then(r => r.json());"
|
||||
, " }"
|
||||
, ""
|
||||
, " async submitInteractionEvent(body: { widgetId: string; eventType: EventType; viewContext?: string; metadata?: object }) {"
|
||||
, " return this.fetch('/interaction-events', 'POST', body).then(r => r.json());"
|
||||
, " }"
|
||||
, ""
|
||||
, " async submitAnnotation(body: { widgetId: string; category: AnnotationCategory; body: string }) {"
|
||||
, " return this.fetch('/annotations', 'POST', body).then(r => r.json());"
|
||||
, " }"
|
||||
, "}"
|
||||
]
|
||||
|
||||
generatePySdk :: [WidgetTypeRegistry] -> [EventTypeRegistry] -> [AnnotationCategoryRegistry] -> Text
|
||||
generatePySdk wts ets acs = T.unlines
|
||||
[ "# Auto-generated by IHF. Do not edit manually."
|
||||
, "# Regenerate: curl <base>/api/v2/sdk/ihf-client.py > ihf_client.py"
|
||||
, ""
|
||||
, "from enum import Enum"
|
||||
, "from typing import Optional, Any"
|
||||
, "import urllib.request, urllib.parse, json"
|
||||
, ""
|
||||
, "class WidgetType(str, Enum):"
|
||||
] <> T.unlines (map (\r -> " " <> toUpperSnake r.name <> " = \"" <> r.name <> "\"") wts)
|
||||
<> "\nclass EventType(str, Enum):\n"
|
||||
<> T.unlines (map (\r -> " " <> toUpperSnake r.name <> " = \"" <> r.name <> "\"") ets)
|
||||
<> "\nclass AnnotationCategory(str, Enum):\n"
|
||||
<> T.unlines (map (\r -> " " <> toUpperSnake r.name <> " = \"" <> r.name <> "\"") acs)
|
||||
<> "\n"
|
||||
<> pyClientClass
|
||||
|
||||
pyClientClass :: Text
|
||||
pyClientClass = T.unlines
|
||||
[ "class IhfClient:"
|
||||
, " def __init__(self, base_url: str, bearer_token: str):"
|
||||
, " self.base_url = base_url.rstrip('/')"
|
||||
, " self.token = bearer_token"
|
||||
, ""
|
||||
, " def _request(self, path: str, method: str = 'GET', body: Optional[dict] = None) -> dict:"
|
||||
, " url = self.base_url + path"
|
||||
, " data = json.dumps(body).encode('utf-8') if body else None"
|
||||
, " req = urllib.request.Request(url, data=data, method=method,"
|
||||
, " headers={'Authorization': 'Bearer ' + self.token, 'Content-Type': 'application/json'})"
|
||||
, " with urllib.request.urlopen(req) as resp:"
|
||||
, " return json.loads(resp.read())"
|
||||
, ""
|
||||
, " def get_widgets(self, page: int = 1, per_page: int = 50) -> dict:"
|
||||
, " return self._request(f'/widgets?page={page}&per_page={per_page}')"
|
||||
, ""
|
||||
, " def get_interaction_events(self, widget_id: Optional[str] = None, event_type: Optional[EventType] = None) -> dict:"
|
||||
, " qs = urllib.parse.urlencode({k: v for k, v in {'widgetId': widget_id, 'eventType': event_type and str(event_type)}.items() if v})"
|
||||
, " return self._request('/interaction-events' + ('?' + qs if qs else ''))"
|
||||
, ""
|
||||
, " def submit_interaction_event(self, widget_id: str, event_type: EventType, view_context: Optional[str] = None, metadata: Optional[dict] = None) -> dict:"
|
||||
, " body: dict = {'widgetId': widget_id, 'eventType': str(event_type)}"
|
||||
, " if view_context: body['viewContext'] = view_context"
|
||||
, " if metadata: body['metadata'] = metadata"
|
||||
, " return self._request('/interaction-events', 'POST', body)"
|
||||
, ""
|
||||
, " def submit_annotation(self, widget_id: str, category: AnnotationCategory, body: str) -> dict:"
|
||||
, " return self._request('/annotations', 'POST', {'widgetId': widget_id, 'category': str(category), 'body': body})"
|
||||
]
|
||||
|
||||
sdkIndexHtml :: LBS.ByteString
|
||||
sdkIndexHtml = LBS.fromStrict $ TE.encodeUtf8 $ T.unlines
|
||||
[ "<!DOCTYPE html><html lang='en'><head><meta charset='UTF-8'/>"
|
||||
, "<title>IHF API v2 — SDKs</title>"
|
||||
, "<link rel='stylesheet' href='/app.css'/></head><body class='bg-gray-50 p-8'>"
|
||||
, "<h1 class='text-2xl font-bold mb-4'>IHF Consumer SDKs</h1>"
|
||||
, "<p class='mb-6 text-gray-600'>Both SDKs are generated live from the type registries. Download and import directly.</p>"
|
||||
, "<div class='space-y-4'>"
|
||||
, "<div class='bg-white border rounded p-4'>"
|
||||
, "<h2 class='font-semibold'>TypeScript / Node.js</h2>"
|
||||
, "<p class='text-sm text-gray-500 mb-2'>ES2020 module. Typed enums for all widget types, event types, annotation categories.</p>"
|
||||
, "<a href='/api/v2/sdk/ihf-client.ts' class='text-indigo-600 text-sm'>Download ihf-client.ts</a>"
|
||||
, "</div>"
|
||||
, "<div class='bg-white border rounded p-4'>"
|
||||
, "<h2 class='font-semibold'>Python</h2>"
|
||||
, "<p class='text-sm text-gray-500 mb-2'>stdlib-only (no third-party deps). str-Enum classes for all registered types.</p>"
|
||||
, "<a href='/api/v2/sdk/ihf-client.py' class='text-indigo-600 text-sm'>Download ihf-client.py</a>"
|
||||
, "</div>"
|
||||
, "</div>"
|
||||
, "<p class='mt-6 text-sm text-gray-400'>See <a href='/api/v2/docs' class='text-indigo-500'>API documentation</a> for full endpoint reference.</p>"
|
||||
, "</body></html>"
|
||||
]
|
||||
128
Web/Controller/Api/V2/Token.hs
Normal file
128
Web/Controller/Api/V2/Token.hs
Normal file
@@ -0,0 +1,128 @@
|
||||
module Web.Controller.Api.V2.Token where
|
||||
|
||||
-- POST /api/v2/token — OAuth 2.0 client credentials grant
|
||||
-- Returns a short-lived opaque access token stored in api_keys.
|
||||
|
||||
import Web.Types
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ControllerPrelude
|
||||
import Data.Aeson (object, (.=))
|
||||
import qualified Data.Text as T
|
||||
import qualified Data.Text.Encoding as TE
|
||||
import qualified Crypto.Hash.SHA256 as SHA256
|
||||
import qualified Data.ByteString.Base16 as Base16
|
||||
import qualified Data.ByteString.Random as Random
|
||||
import Data.Time (addUTCTime)
|
||||
import Network.Wai (requestMethod)
|
||||
import Web.Controller.Api.V2.Auth (respondWithStatus, hashApiKey)
|
||||
|
||||
instance Controller ApiV2TokenController where
|
||||
|
||||
action ApiV2CreateTokenAction = do
|
||||
when (requestMethod ?request /= "POST") do
|
||||
respondWithStatus 405 $ object ["error" .= ("Method not allowed" :: Text)]
|
||||
|
||||
grantType <- paramOrNothing @Text "grant_type"
|
||||
clientId <- paramOrNothing @Text "client_id"
|
||||
clientSecret <- paramOrNothing @Text "client_secret"
|
||||
mScope <- paramOrNothing @Text "scope"
|
||||
|
||||
-- grant_type must be client_credentials
|
||||
case grantType of
|
||||
Just "client_credentials" -> pure ()
|
||||
Just _ -> respondWithStatus 400 $ object
|
||||
[ "error" .= ("unsupported_grant_type" :: Text) ]
|
||||
Nothing -> respondWithStatus 400 $ object
|
||||
[ "error" .= ("invalid_request" :: Text)
|
||||
, "error_description" .= ("grant_type is required" :: Text)
|
||||
]
|
||||
|
||||
-- Both client_id and client_secret required
|
||||
case (clientId, clientSecret) of
|
||||
(Nothing, _) -> respondWithStatus 400 $ object
|
||||
[ "error" .= ("invalid_request" :: Text)
|
||||
, "error_description" .= ("client_id is required" :: Text)
|
||||
]
|
||||
(_, Nothing) -> respondWithStatus 400 $ object
|
||||
[ "error" .= ("invalid_request" :: Text)
|
||||
, "error_description" .= ("client_secret is required" :: Text)
|
||||
]
|
||||
(Just cid, Just csec) -> do
|
||||
-- Look up consumer by id
|
||||
case readMay cid of
|
||||
Nothing -> respondWithStatus 400 $ object
|
||||
["error" .= ("invalid_client" :: Text)]
|
||||
Just rawId -> do
|
||||
let consumerId = Id rawId :: Id ApiConsumer
|
||||
mConsumer <- fetchOneOrNothing consumerId
|
||||
case mConsumer of
|
||||
Nothing -> respondWithStatus 400 $ object
|
||||
["error" .= ("invalid_client" :: Text)]
|
||||
Just consumer -> do
|
||||
unless consumer.isActive $ respondWithStatus 400 $ object
|
||||
["error" .= ("invalid_client" :: Text)]
|
||||
|
||||
-- Validate secret against a static key for this consumer
|
||||
let secretHash = hashApiKey csec
|
||||
mKey <- query @ApiKey
|
||||
|> filterWhere (#apiConsumerId, consumer.id)
|
||||
|> filterWhere (#keyHash, secretHash)
|
||||
|> filterWhere (#tokenType, "static")
|
||||
|> fetchOneOrNothing
|
||||
case mKey of
|
||||
Nothing -> respondWithStatus 400 $ object
|
||||
["error" .= ("invalid_client" :: Text)]
|
||||
Just _ -> do
|
||||
-- Validate requested scopes
|
||||
let scopes = maybe [] (T.splitOn " ") mScope
|
||||
validatedScopes <- validateScopes consumer scopes
|
||||
case validatedScopes of
|
||||
Left errCode -> respondWithStatus 400 $ object
|
||||
["error" .= errCode]
|
||||
Right scopeStr -> do
|
||||
-- Issue token
|
||||
rawToken <- liftIO $ Random.random 32
|
||||
let tokenText = TE.decodeUtf8 (Base16.encode rawToken)
|
||||
let tokenHash = hashApiKey tokenText
|
||||
let prefix = T.take 8 tokenText
|
||||
now <- getCurrentTime
|
||||
let expiresAt = addUTCTime 3600 now
|
||||
_key <- newRecord @ApiKey
|
||||
|> set #apiConsumerId consumer.id
|
||||
|> set #keyPrefix prefix
|
||||
|> set #keyHash tokenHash
|
||||
|> set #scopes scopeStr
|
||||
|> set #tokenType "oauth"
|
||||
|> set #expiresAt (Just expiresAt)
|
||||
|> createRecord
|
||||
renderJson $ object
|
||||
[ "access_token" .= tokenText
|
||||
, "token_type" .= ("Bearer" :: Text)
|
||||
, "expires_in" .= (3600 :: Int)
|
||||
, "scope" .= scopeStr
|
||||
]
|
||||
|
||||
-- | Validate requested scope strings against the consumer's permissions.
|
||||
-- hub:{slug}:write requires an active manifest for that hub.
|
||||
validateScopes :: (?modelContext :: ModelContext) => ApiConsumer -> [Text] -> IO (Either Text Text)
|
||||
validateScopes consumer scopes = do
|
||||
results <- mapM (validateScope consumer) scopes
|
||||
case lefts results of
|
||||
(e:_) -> pure (Left e)
|
||||
[] -> pure (Right (T.intercalate " " scopes))
|
||||
|
||||
validateScope :: (?modelContext :: ModelContext) => ApiConsumer -> Text -> IO (Either Text Text)
|
||||
validateScope _consumer scope
|
||||
| scope == "framework:read" = pure (Right scope)
|
||||
| "hub:" `T.isPrefixOf` scope && ":read" `T.isSuffixOf` scope = pure (Right scope)
|
||||
| "hub:" `T.isPrefixOf` scope && ":write" `T.isSuffixOf` scope =
|
||||
-- Write scope requires an active manifest
|
||||
case _consumer.hubCapabilityManifestId of
|
||||
Nothing -> pure (Left "invalid_scope")
|
||||
Just manifestId -> do
|
||||
manifest <- fetch manifestId
|
||||
if manifest.status == "active"
|
||||
then pure (Right scope)
|
||||
else pure (Left "invalid_scope")
|
||||
| otherwise = pure (Left "invalid_scope")
|
||||
41
Web/Controller/Api/V2/Widgets.hs
Normal file
41
Web/Controller/Api/V2/Widgets.hs
Normal file
@@ -0,0 +1,41 @@
|
||||
module Web.Controller.Api.V2.Widgets where
|
||||
|
||||
import Web.Types
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ControllerPrelude
|
||||
import Data.Aeson (object, (.=), ToJSON, toJSON)
|
||||
import Web.Controller.Api.V2.Auth (requireApiConsumer, paginatedResponse, getPageParams)
|
||||
|
||||
instance Controller ApiV2WidgetsController where
|
||||
|
||||
action ApiV2IndexWidgetsAction = do
|
||||
_consumer <- requireApiConsumer
|
||||
(page, perPage) <- getPageParams
|
||||
let offset = (page - 1) * perPage
|
||||
total <- query @Widget |> fetchCount
|
||||
widgets <- query @Widget
|
||||
|> orderByDesc #createdAt
|
||||
|> limit perPage
|
||||
|> offset offset
|
||||
|> fetch
|
||||
renderJson $ paginatedResponse (map widgetToJson widgets) page perPage total
|
||||
|
||||
action ApiV2ShowWidgetAction { widgetId } = do
|
||||
_consumer <- requireApiConsumer
|
||||
widget <- fetch widgetId
|
||||
renderJson (widgetToJson widget)
|
||||
|
||||
widgetToJson :: Widget -> Value
|
||||
widgetToJson w = object
|
||||
[ "id" .= w.id
|
||||
, "hubId" .= w.hubId
|
||||
, "name" .= w.name
|
||||
, "widgetType" .= w.widgetType
|
||||
, "capabilityRef" .= w.capabilityRef
|
||||
, "viewContext" .= w.viewContext
|
||||
, "policyScope" .= w.policyScope
|
||||
, "status" .= w.status
|
||||
, "version" .= w.version
|
||||
, "createdAt" .= w.createdAt
|
||||
]
|
||||
88
Web/Controller/ApiConsumers.hs
Normal file
88
Web/Controller/ApiConsumers.hs
Normal file
@@ -0,0 +1,88 @@
|
||||
module Web.Controller.ApiConsumers where
|
||||
|
||||
import Web.Types
|
||||
import Web.View.ApiConsumers.Index
|
||||
import Web.View.ApiConsumers.Show
|
||||
import Web.View.ApiConsumers.New
|
||||
import Web.View.ApiConsumers.Edit
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ControllerPrelude
|
||||
|
||||
instance Controller ApiConsumersController where
|
||||
beforeAction = ensureIsUser
|
||||
|
||||
action ApiConsumersAction = autoRefresh do
|
||||
consumers <- query @ApiConsumer
|
||||
|> orderByDesc #createdAt
|
||||
|> fetch
|
||||
render IndexView { consumers }
|
||||
|
||||
action ShowApiConsumerAction { apiConsumerId } = do
|
||||
consumer <- fetch apiConsumerId
|
||||
apiKeys <- query @ApiKey
|
||||
|> filterWhere (#apiConsumerId, consumer.id)
|
||||
|> orderByDesc #createdAt
|
||||
|> fetch
|
||||
webhooks <- query @WebhookSubscription
|
||||
|> filterWhere (#apiConsumerId, consumer.id)
|
||||
|> orderByAsc #eventType
|
||||
|> fetch
|
||||
mManifest <- case consumer.hubCapabilityManifestId of
|
||||
Nothing -> pure Nothing
|
||||
Just mId -> Just <$> fetch mId
|
||||
render ShowView { consumer, apiKeys, webhooks, mManifest }
|
||||
|
||||
action NewApiConsumerAction = do
|
||||
let consumer = newRecord @ApiConsumer
|
||||
manifests <- query @HubCapabilityManifest
|
||||
|> filterWhere (#status, "active")
|
||||
|> orderByAsc #createdAt
|
||||
|> fetch
|
||||
render NewView { consumer, manifests }
|
||||
|
||||
action CreateApiConsumerAction = do
|
||||
let consumer = newRecord @ApiConsumer
|
||||
consumer
|
||||
|> fill @["name", "description", "rateLimitPerMinute", "quotaPerDay"]
|
||||
|> ifValid \case
|
||||
Left consumerWithErrors -> do
|
||||
manifests <- query @HubCapabilityManifest
|
||||
|> filterWhere (#status, "active")
|
||||
|> fetch
|
||||
render NewView { consumer = consumerWithErrors, manifests }
|
||||
Right validConsumer -> do
|
||||
mManifestId <- paramOrNothing @(Id HubCapabilityManifest) "hubCapabilityManifestId"
|
||||
validConsumer
|
||||
|> set #hubCapabilityManifestId mManifestId
|
||||
|> createRecord
|
||||
redirectTo ApiConsumersAction
|
||||
|
||||
action EditApiConsumerAction { apiConsumerId } = do
|
||||
consumer <- fetch apiConsumerId
|
||||
manifests <- query @HubCapabilityManifest
|
||||
|> filterWhere (#status, "active")
|
||||
|> fetch
|
||||
render EditView { consumer, manifests }
|
||||
|
||||
action UpdateApiConsumerAction { apiConsumerId } = do
|
||||
consumer <- fetch apiConsumerId
|
||||
consumer
|
||||
|> fill @["name", "description", "rateLimitPerMinute", "quotaPerDay"]
|
||||
|> ifValid \case
|
||||
Left consumerWithErrors -> do
|
||||
manifests <- query @HubCapabilityManifest
|
||||
|> filterWhere (#status, "active")
|
||||
|> fetch
|
||||
render EditView { consumer = consumerWithErrors, manifests }
|
||||
Right validConsumer -> do
|
||||
mManifestId <- paramOrNothing @(Id HubCapabilityManifest) "hubCapabilityManifestId"
|
||||
validConsumer
|
||||
|> set #hubCapabilityManifestId mManifestId
|
||||
|> updateRecord
|
||||
redirectTo (ShowApiConsumerAction apiConsumerId)
|
||||
|
||||
action DeactivateApiConsumerAction { apiConsumerId } = do
|
||||
consumer <- fetch apiConsumerId
|
||||
consumer |> set #isActive False |> updateRecord
|
||||
redirectTo ApiConsumersAction
|
||||
47
Web/Controller/ApiDashboard.hs
Normal file
47
Web/Controller/ApiDashboard.hs
Normal file
@@ -0,0 +1,47 @@
|
||||
module Web.Controller.ApiDashboard where
|
||||
|
||||
import Web.Types
|
||||
import Web.View.ApiDashboard.Show
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ControllerPrelude
|
||||
import Database.PostgreSQL.Simple (Only(..))
|
||||
|
||||
instance Controller ApiDashboardController where
|
||||
beforeAction = ensureIsUser
|
||||
|
||||
action ShowApiDashboardAction = autoRefresh do
|
||||
consumers <- query @ApiConsumer
|
||||
|> orderByAsc #name
|
||||
|> fetch
|
||||
stats <- mapM fetchStats consumers
|
||||
render ShowView { stats }
|
||||
|
||||
-- | Aggregate per-consumer stats from api_request_log (last 24 hours).
|
||||
fetchStats :: (?modelContext :: ModelContext) => ApiConsumer -> IO ConsumerStats
|
||||
fetchStats consumer = do
|
||||
rows <- sqlQuery
|
||||
"SELECT COUNT(*), \
|
||||
\ COUNT(*) FILTER (WHERE status_code >= 400), \
|
||||
\ MAX(requested_at) \
|
||||
\FROM api_request_log \
|
||||
\WHERE api_consumer_id = ? \
|
||||
\ AND requested_at >= NOW() - INTERVAL '24 hours'"
|
||||
(Only consumer.id)
|
||||
case rows of
|
||||
[(total, errs, lastTs)] ->
|
||||
let errRate = if (total :: Int) == 0
|
||||
then 0.0
|
||||
else fromIntegral (errs :: Int) / fromIntegral total
|
||||
in pure ConsumerStats
|
||||
{ consumer
|
||||
, requests24h = total
|
||||
, errorRate = errRate
|
||||
, lastSeen = lastTs
|
||||
}
|
||||
_ -> pure ConsumerStats
|
||||
{ consumer
|
||||
, requests24h = 0
|
||||
, errorRate = 0.0
|
||||
, lastSeen = Nothing
|
||||
}
|
||||
53
Web/Controller/ApiKeys.hs
Normal file
53
Web/Controller/ApiKeys.hs
Normal file
@@ -0,0 +1,53 @@
|
||||
module Web.Controller.ApiKeys where
|
||||
|
||||
import Web.Types
|
||||
import Web.View.ApiKeys.New
|
||||
import Web.View.ApiKeys.Created
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ControllerPrelude
|
||||
import qualified Data.Text.Encoding as TE
|
||||
import qualified Crypto.Hash.SHA256 as SHA256
|
||||
import qualified Data.ByteString.Base16 as Base16
|
||||
import qualified Data.ByteString.Random as Random
|
||||
|
||||
instance Controller ApiKeysController where
|
||||
beforeAction = ensureIsUser
|
||||
|
||||
action ApiKeysAction { apiConsumerId } = do
|
||||
-- Redirect to consumer show page which displays keys
|
||||
redirectTo (ShowApiConsumerAction apiConsumerId)
|
||||
|
||||
action NewApiKeyAction { apiConsumerId } = do
|
||||
consumer <- fetch apiConsumerId
|
||||
let apiKey = newRecord @ApiKey
|
||||
render NewView { apiKey, consumer }
|
||||
|
||||
action CreateApiKeyAction = do
|
||||
apiConsumerId <- param @(Id ApiConsumer) "apiConsumerId"
|
||||
consumer <- fetch apiConsumerId
|
||||
scopes <- fromMaybe "" <$> paramOrNothing @Text "scopes"
|
||||
|
||||
-- Generate a random 32-byte key, encode as hex (64 chars)
|
||||
rawBytes <- liftIO $ Random.random 32
|
||||
let fullKey = TE.decodeUtf8 (Base16.encode rawBytes)
|
||||
let prefix = T.take 8 fullKey
|
||||
let keyHash = TE.decodeUtf8 $ Base16.encode $ SHA256.hash (TE.encodeUtf8 fullKey)
|
||||
|
||||
_key <- newRecord @ApiKey
|
||||
|> set #apiConsumerId consumer.id
|
||||
|> set #keyPrefix prefix
|
||||
|> set #keyHash keyHash
|
||||
|> set #scopes scopes
|
||||
|> set #tokenType "static"
|
||||
|> createRecord
|
||||
|
||||
-- Show full key once; never again
|
||||
render CreatedView { consumer, fullKey }
|
||||
|
||||
action RevokeApiKeyAction { apiKeyId } = do
|
||||
apiKey <- fetch apiKeyId
|
||||
now <- getCurrentTime
|
||||
apiKey |> set #revokedAt (Just now) |> updateRecord
|
||||
consumer <- fetch apiKey.apiConsumerId
|
||||
redirectTo (ShowApiConsumerAction consumer.id)
|
||||
@@ -14,6 +14,9 @@ import Data.Aeson (decode, Value(..), Array)
|
||||
import Data.Aeson.Lens (key, _String)
|
||||
import Control.Lens ((^?))
|
||||
import Data.ByteString.Lazy (fromStrict)
|
||||
import Web.Job.WebhookDeliveryJob (dispatchWebhooks)
|
||||
import Control.Concurrent (forkIO)
|
||||
import Data.Aeson ((.=), object)
|
||||
import Data.Text.Encoding (encodeUtf8)
|
||||
import Data.HashMap.Strict (HashMap)
|
||||
import qualified Data.HashMap.Strict as HashMap
|
||||
@@ -95,6 +98,15 @@ instance Controller RequirementCandidatesController where
|
||||
Left candidate -> render NewView { candidate, widgets, threads }
|
||||
Right candidate -> do
|
||||
created <- createRecord candidate
|
||||
-- Dispatch webhooks fire-and-forget
|
||||
let webhookPayload = object
|
||||
[ "event" .= ("requirement_candidate.created" :: Text)
|
||||
, "resourceId" .= created.id
|
||||
, "title" .= created.title
|
||||
, "category" .= created.category
|
||||
]
|
||||
liftIO $ void $ forkIO $
|
||||
dispatchWebhooks "requirement_candidate.created" webhookPayload
|
||||
setSuccessMessage "Requirement candidate created"
|
||||
redirectTo ShowRequirementCandidateAction { requirementCandidateId = created.id }
|
||||
|
||||
|
||||
67
Web/Controller/WebhookSubscriptions.hs
Normal file
67
Web/Controller/WebhookSubscriptions.hs
Normal file
@@ -0,0 +1,67 @@
|
||||
module Web.Controller.WebhookSubscriptions where
|
||||
|
||||
import Web.Types
|
||||
import Web.View.WebhookSubscriptions.New
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ControllerPrelude
|
||||
import qualified Data.ByteString.Random as Random
|
||||
import qualified Data.Text.Encoding as TE
|
||||
import qualified Data.ByteString.Base16 as Base16
|
||||
|
||||
-- Webhook event topics are framework lifecycle events, not interaction event types
|
||||
allowedWebhookTopics :: [Text]
|
||||
allowedWebhookTopics =
|
||||
[ "interaction_event.created"
|
||||
, "annotation.created"
|
||||
, "requirement_candidate.created"
|
||||
, "decision_record.created"
|
||||
, "deployment_record.created"
|
||||
, "outcome_signal.created"
|
||||
]
|
||||
|
||||
instance Controller WebhookSubscriptionsController where
|
||||
beforeAction = ensureIsUser
|
||||
|
||||
action WebhookSubscriptionsAction { apiConsumerId } = do
|
||||
redirectTo (ShowApiConsumerAction apiConsumerId)
|
||||
|
||||
action NewWebhookSubscriptionAction { apiConsumerId } = do
|
||||
consumer <- fetch apiConsumerId
|
||||
let subscription = newRecord @WebhookSubscription
|
||||
render NewView { subscription, consumer }
|
||||
|
||||
action CreateWebhookSubscriptionAction = do
|
||||
apiConsumerId <- param @(Id ApiConsumer) "apiConsumerId"
|
||||
consumer <- fetch apiConsumerId
|
||||
eventType <- param @Text "eventType"
|
||||
targetUrl <- param @Text "targetUrl"
|
||||
|
||||
-- Validate against allowed webhook topics
|
||||
unless (eventType `elem` allowedWebhookTopics) $ do
|
||||
setErrorMessage ("Unknown webhook topic: " <> eventType)
|
||||
redirectTo (NewWebhookSubscriptionAction apiConsumerId)
|
||||
Right () -> do
|
||||
-- Generate HMAC signing secret
|
||||
secretBytes <- liftIO $ Random.random 32
|
||||
let secret = TE.decodeUtf8 (Base16.encode secretBytes)
|
||||
_sub <- newRecord @WebhookSubscription
|
||||
|> set #apiConsumerId consumer.id
|
||||
|> set #eventType eventType
|
||||
|> set #targetUrl targetUrl
|
||||
|> set #secret secret
|
||||
|> set #isActive True
|
||||
|> createRecord
|
||||
redirectTo (ShowApiConsumerAction apiConsumerId)
|
||||
|
||||
action ToggleWebhookSubscriptionAction { webhookSubscriptionId } = do
|
||||
sub <- fetch webhookSubscriptionId
|
||||
sub |> set #isActive (not sub.isActive) |> updateRecord
|
||||
consumer <- fetch sub.apiConsumerId
|
||||
redirectTo (ShowApiConsumerAction consumer.id)
|
||||
|
||||
action DeleteWebhookSubscriptionAction { webhookSubscriptionId } = do
|
||||
sub <- fetch webhookSubscriptionId
|
||||
consumerId <- pure sub.apiConsumerId
|
||||
deleteRecord sub
|
||||
redirectTo (ShowApiConsumerAction consumerId)
|
||||
Reference in New Issue
Block a user