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:
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>"
|
||||
Reference in New Issue
Block a user