feat: add v2 manifest bootstrap endpoints
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled

This commit is contained in:
2026-05-16 09:06:15 +02:00
parent 4ebc04e1f4
commit 50735bb7cf
9 changed files with 408 additions and 4 deletions

View File

@@ -9,6 +9,8 @@ import Web.Controller.Api.V2.InteractionEvents
, metadataParamOrEmpty , metadataParamOrEmpty
) )
import Web.Controller.Api.V2.Hubs (missingRequiredFields, validCreateHubKind) import Web.Controller.Api.V2.Hubs (missingRequiredFields, validCreateHubKind)
import Web.Controller.Api.V2.HubCapabilityManifests
( jsonArrayTexts, textArrayFieldFromJsonBody )
import Web.Controller.Api.V2.Widgets (missingWidgetCreateFields, validWidgetStatus) import Web.Controller.Api.V2.Widgets (missingWidgetCreateFields, validWidgetStatus)
main :: IO () main :: IO ()
@@ -74,4 +76,15 @@ main = hspec do
] ]
`shouldBe` ["hubId", "widgetType"] `shouldBe` ["hubId", "widgetType"]
describe "API v2 manifest vocabulary parsing" do
it "decodes declared vocabulary arrays from JSON request bodies" do
textArrayFieldFromJsonBody
"declaredPolicyScopes"
(object ["declaredPolicyScopes" .= (["ops-internal", "ops-external"] :: [Text])])
`shouldBe` Just ["ops-internal", "ops-external"]
it "extracts manifest-declared text arrays for activation" do
jsonArrayTexts (toJSON (["ops-endpoint-card", "ops-alert-panel"] :: [Text]))
`shouldBe` ["ops-endpoint-card", "ops-alert-panel"]
LayerBoundary.spec LayerBoundary.spec

View File

@@ -0,0 +1,264 @@
module Web.Controller.Api.V2.HubCapabilityManifests where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
import Data.Aeson (Value(..), object, toJSON, (.=))
import IHP.ControllerSupport (getHeader, requestBodyJSON)
import Network.Wai (requestMethod)
import Web.Controller.Api.V2.Auth
( requireApiConsumer, paginatedResponse, getPageParams
, respondWithStatus )
import Control.Monad (void)
import Data.Maybe (mapMaybe)
import Data.String (fromString)
import qualified Data.Aeson.Key as K
import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteString as BS
import qualified Data.UUID as UUID
import qualified Data.Vector as V
import Database.PostgreSQL.Simple (Only(..))
instance Controller ApiV2HubCapabilityManifestsController where
action ApiV2IndexHubCapabilityManifestsAction = do
case requestMethod ?request of
"GET" -> listManifests
"POST" -> createManifest
_ -> respondWithStatus 405 $ object ["error" .= ("Method not allowed" :: Text)]
action ApiV2ShowHubCapabilityManifestAction { hubCapabilityManifestId } = do
case requestMethod ?request of
"GET" -> showManifest hubCapabilityManifestId
"PATCH" -> updateManifest hubCapabilityManifestId
_ -> respondWithStatus 405 $ object ["error" .= ("Method not allowed" :: Text)]
action ApiV2CreateHubCapabilityManifestAction = createManifest
action ApiV2UpdateHubCapabilityManifestAction { hubCapabilityManifestId } =
updateManifest hubCapabilityManifestId
action ApiV2ActivateHubCapabilityManifestAction { hubCapabilityManifestId } = do
when (requestMethod ?request /= "POST") do
respondWithStatus 405 $ object ["error" .= ("Method not allowed" :: Text)]
activateManifest hubCapabilityManifestId
listManifests :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO ()
listManifests = do
_consumer <- requireApiConsumer
(page, perPage) <- getPageParams
let pageOffset = (page - 1) * perPage
mHubId = paramOrNothing @(Id Hub) "hubId"
mStatus = paramOrNothing @Text "status"
baseQ = query @HubCapabilityManifest |> orderByDesc #createdAt
q1 = case mHubId of
Just hubId -> baseQ |> filterWhere (#hubId, hubId)
Nothing -> baseQ
q2 = case mStatus of
Just status -> q1 |> filterWhere (#status, status)
Nothing -> q1
total <- q2 |> fetchCount
manifests <- q2
|> limit perPage
|> offset pageOffset
|> fetch
renderJson $ paginatedResponse (map manifestToJson manifests) page perPage total
showManifest :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => Id HubCapabilityManifest -> IO ()
showManifest manifestId = do
_consumer <- requireApiConsumer
manifest <- fetch manifestId
renderJson (manifestToJson manifest)
createManifest :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO ()
createManifest = do
_consumer <- requireApiConsumer
let hubIdText = paramOrNothing @Text "hubId"
manifestVersion = fromMaybe "1.0" (nonEmptyText =<< paramOrNothing @Text "manifestVersion")
capabilityDescription = paramOrNothing @Text "capabilityDescription"
contact = paramOrNothing @Text "contact"
when (maybe True (== "") hubIdText) do
respondWithStatus 422 $ object
[ "error" .= ("Missing required fields" :: Text)
, "missing" .= (["hubId"] :: [Text])
]
let Just rawHubId = hubIdText
case UUID.fromText rawHubId of
Nothing -> respondWithStatus 422 $ object
["error" .= ("hubId must be a valid UUID" :: Text)]
Just rawId -> do
let hubId = Id rawId :: Id Hub
mHub <- fetchOneOrNothing hubId
case mHub of
Nothing -> respondWithStatus 422 $ object ["error" .= ("Hub not found" :: Text)]
Just _hub -> do
existing <- query @HubCapabilityManifest
|> filterWhere (#hubId, hubId)
|> fetchOneOrNothing
when (isJust existing) do
respondWithStatus 422 $ object
[ "error" .= ("Hub already has a capability manifest" :: Text)
, "code" .= ("manifest_already_exists" :: Text)
]
manifest <- newRecord @HubCapabilityManifest
|> set #hubId hubId
|> set #manifestVersion manifestVersion
|> set #declaredWidgetTypes (toJSON (textArrayFieldFromRequestOrEmpty "declaredWidgetTypes"))
|> set #declaredEventTypes (toJSON (textArrayFieldFromRequestOrEmpty "declaredEventTypes"))
|> set #declaredAnnotationCategories (toJSON (textArrayFieldFromRequestOrEmpty "declaredAnnotationCategories"))
|> set #declaredPolicyScopes (toJSON (textArrayFieldFromRequestOrEmpty "declaredPolicyScopes"))
|> set #capabilityDescription capabilityDescription
|> set #contact contact
|> set #status "draft"
|> createRecord
respondWithStatus 201 (manifestToJson manifest)
updateManifest :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => Id HubCapabilityManifest -> IO ()
updateManifest manifestId = do
_consumer <- requireApiConsumer
manifest <- fetch manifestId
unless (manifest.status == "draft") do
respondWithStatus 422 $ object
[ "error" .= ("Active manifests are read-only" :: Text)
, "code" .= ("manifest_read_only" :: Text)
]
let manifestVersion = fromMaybe manifest.manifestVersion (nonEmptyText =<< paramOrNothing @Text "manifestVersion")
capabilityDescription = fromMaybe manifest.capabilityDescription (Just <$> paramOrNothing @Text "capabilityDescription")
contact = fromMaybe manifest.contact (Just <$> paramOrNothing @Text "contact")
declaredWidgetTypes = maybe manifest.declaredWidgetTypes toJSON (textArrayFieldFromRequest "declaredWidgetTypes")
declaredEventTypes = maybe manifest.declaredEventTypes toJSON (textArrayFieldFromRequest "declaredEventTypes")
declaredAnnotationCategories = maybe manifest.declaredAnnotationCategories toJSON (textArrayFieldFromRequest "declaredAnnotationCategories")
declaredPolicyScopes = maybe manifest.declaredPolicyScopes toJSON (textArrayFieldFromRequest "declaredPolicyScopes")
manifest <- manifest
|> set #manifestVersion manifestVersion
|> set #declaredWidgetTypes declaredWidgetTypes
|> set #declaredEventTypes declaredEventTypes
|> set #declaredAnnotationCategories declaredAnnotationCategories
|> set #declaredPolicyScopes declaredPolicyScopes
|> set #capabilityDescription capabilityDescription
|> set #contact contact
|> updateRecord
renderJson (manifestToJson manifest)
activateManifest :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => Id HubCapabilityManifest -> IO ()
activateManifest manifestId = do
_consumer <- requireApiConsumer
manifest <- fetch manifestId
when (manifest.status == "active") do
respondWithStatus 200 (manifestToJson manifest)
when (manifest.status == "retired") do
respondWithStatus 422 $ object
[ "error" .= ("Retired manifests cannot be activated" :: Text)
, "code" .= ("manifest_retired" :: Text)
]
hub <- fetch manifest.hubId
let wTypes = jsonArrayTexts manifest.declaredWidgetTypes
eTypes = jsonArrayTexts manifest.declaredEventTypes
cats = jsonArrayTexts manifest.declaredAnnotationCategories
scopes = jsonArrayTexts manifest.declaredPolicyScopes
conflicts <- fmap concat $ sequence
[ concat <$> mapM (checkConflict "widget_type_registry" hub.id) wTypes
, concat <$> mapM (checkConflict "event_type_registry" hub.id) eTypes
, concat <$> mapM (checkConflict "annotation_category_registry" hub.id) cats
, concat <$> mapM (checkConflict "policy_scope_registry" hub.id) scopes
]
unless (null conflicts) do
respondWithStatus 422 $ object
[ "error" .= ("Manifest activation blocked by type conflicts" :: Text)
, "code" .= ("manifest_type_conflict" :: Text)
, "conflicts" .= conflicts
]
mapM_ (upsertType "widget_type_registry" hub.id) wTypes
mapM_ (upsertType "event_type_registry" hub.id) eTypes
mapM_ (upsertType "annotation_category_registry" hub.id) cats
mapM_ (upsertType "policy_scope_registry" hub.id) scopes
now <- getCurrentTime
manifest <- manifest
|> set #status "active"
|> set #activatedAt (Just now)
|> updateRecord
renderJson (manifestToJson manifest)
manifestToJson :: HubCapabilityManifest -> Value
manifestToJson manifest = object
[ "id" .= manifest.id
, "hubId" .= manifest.hubId
, "manifestVersion" .= manifest.manifestVersion
, "declaredWidgetTypes" .= manifest.declaredWidgetTypes
, "declaredEventTypes" .= manifest.declaredEventTypes
, "declaredAnnotationCategories" .= manifest.declaredAnnotationCategories
, "declaredPolicyScopes" .= manifest.declaredPolicyScopes
, "capabilityDescription" .= manifest.capabilityDescription
, "contact" .= manifest.contact
, "status" .= manifest.status
, "activatedAt" .= manifest.activatedAt
, "createdAt" .= manifest.createdAt
, "updatedAt" .= manifest.updatedAt
]
textArrayFieldFromRequestOrEmpty :: (?context :: ControllerContext, ?request :: Request) => Text -> [Text]
textArrayFieldFromRequestOrEmpty fieldName =
fromMaybe [] (textArrayFieldFromRequest fieldName)
textArrayFieldFromRequest :: (?context :: ControllerContext, ?request :: Request) => Text -> Maybe [Text]
textArrayFieldFromRequest fieldName =
case getHeader "Content-Type" of
Just contentType | "application/json" `BS.isPrefixOf` contentType ->
textArrayFieldFromJsonBody fieldName requestBodyJSON
_ ->
let values = paramList @Text fieldName
in if null values then Nothing else Just values
textArrayFieldFromJsonBody :: Text -> Value -> Maybe [Text]
textArrayFieldFromJsonBody fieldName (Object body) =
case KM.lookup (K.fromText fieldName) body of
Just (Array values) -> Just (mapMaybe extractText (V.toList values))
_ -> Nothing
where
extractText (String value) = Just value
extractText _ = Nothing
textArrayFieldFromJsonBody _ _ = Nothing
jsonArrayTexts :: Value -> [Text]
jsonArrayTexts (Array values) = mapMaybe extractText (V.toList values)
where
extractText (String value) = Just value
extractText _ = Nothing
jsonArrayTexts _ = []
checkConflict ::
(?modelContext :: ModelContext) =>
Text -> Id Hub -> Text -> IO [Text]
checkConflict tableName hubId name = do
rows <- sqlQuery
(fromString $ cs ("SELECT owner_hub_id FROM " <> tableName <> " WHERE name = ?"))
(Only name)
case rows of
[] -> pure []
[Only Nothing] -> pure []
[Only (Just ownerId)] ->
if ownerId == hubId
then pure []
else pure ["Type '" <> name <> "' in " <> tableName <> " is already owned by another hub"]
_ -> pure []
upsertType ::
(?modelContext :: ModelContext) =>
Text -> Id Hub -> Text -> IO ()
upsertType tableName hubId name =
void $ sqlExec
(fromString $ cs ("INSERT INTO " <> tableName <> " (name, label, owner_hub_id, status) "
<> "VALUES (?, ?, ?, 'active') ON CONFLICT (name) DO NOTHING"))
(name, name, hubId)
nonEmptyText :: Text -> Maybe Text
nonEmptyText "" = Nothing
nonEmptyText value = Just value

View File

@@ -16,7 +16,8 @@ import qualified Data.Text.Encoding as TE
import qualified Data.Yaml as Yaml -- yaml package import qualified Data.Yaml as Yaml -- yaml package
import qualified Data.ByteString.Lazy as LBS import qualified Data.ByteString.Lazy as LBS
import Application.Helper.TypeRegistry import Application.Helper.TypeRegistry
( activeWidgetTypes, activeEventTypes, activeAnnotationCategories ) ( activeWidgetTypes, activeEventTypes, activeAnnotationCategories
, activePolicyScopes )
import Network.HTTP.Types (status200) import Network.HTTP.Types (status200)
import Network.Wai (responseLBS) import Network.Wai (responseLBS)
@@ -47,10 +48,12 @@ buildOpenApiSpec = do
let allWidgetTypes = fwWidgetTypes ++ ownedWidgetTypes let allWidgetTypes = fwWidgetTypes ++ ownedWidgetTypes
eventTypes <- activeEventTypes eventTypes <- activeEventTypes
annCats <- activeAnnotationCategories annCats <- activeAnnotationCategories
policyScopes <- activePolicyScopes
let wtEnum = toJSON $ map (.name) allWidgetTypes let wtEnum = toJSON $ map (.name) allWidgetTypes
let etEnum = toJSON $ map (.name) eventTypes let etEnum = toJSON $ map (.name) eventTypes
let acEnum = toJSON $ map (.name) annCats let acEnum = toJSON $ map (.name) annCats
let psEnum = toJSON $ map (.name) policyScopes
pure $ object pure $ object
[ "openapi" .= ("3.1.0" :: Text) [ "openapi" .= ("3.1.0" :: Text)
@@ -76,6 +79,10 @@ buildOpenApiSpec = do
[ "type" .= ("string" :: Text) [ "type" .= ("string" :: Text)
, "enum" .= acEnum , "enum" .= acEnum
] ]
, "PolicyScope" .= object
[ "type" .= ("string" :: Text)
, "enum" .= psEnum
]
, "PaginationMeta" .= object , "PaginationMeta" .= object
[ "type" .= ("object" :: Text) [ "type" .= ("object" :: Text)
, "properties" .= object , "properties" .= object
@@ -85,6 +92,7 @@ buildOpenApiSpec = do
] ]
] ]
, "Hub" .= hubSchema , "Hub" .= hubSchema
, "HubCapabilityManifest" .= manifestSchema
, "Widget" .= widgetSchema , "Widget" .= widgetSchema
, "InteractionEvent" .= interactionEventSchema , "InteractionEvent" .= interactionEventSchema
, "Annotation" .= annotationSchema , "Annotation" .= annotationSchema
@@ -114,6 +122,20 @@ buildPaths = object
, "post" .= writeOp "Hub" "CreateHubRequest" , "post" .= writeOp "Hub" "CreateHubRequest"
] ]
, "/hubs/{id}" .= getShowPath "Hub" , "/hubs/{id}" .= getShowPath "Hub"
, "/hub-capability-manifests" .= object
[ "get" .= listOp "HubCapabilityManifest"
[ ("hubId", "string", "uuid")
, ("status", "string", "")
]
, "post" .= writeOp "HubCapabilityManifest" "CreateHubCapabilityManifestRequest"
]
, "/hub-capability-manifests/{id}" .= object
[ "get" .= showOp "HubCapabilityManifest"
, "patch" .= writeOpWithSummary "Update HubCapabilityManifest" "HubCapabilityManifest" "UpdateHubCapabilityManifestRequest"
]
, "/hub-capability-manifests/{id}/activate" .= object
[ "post" .= writeOpWithSummary "Activate HubCapabilityManifest" "HubCapabilityManifest" "ActivateHubCapabilityManifestRequest"
]
, "/widgets" .= object , "/widgets" .= object
[ "get" .= listOp "Widget" [] [ "get" .= listOp "Widget" []
, "post" .= writeOp "Widget" "CreateWidgetRequest" , "post" .= writeOp "Widget" "CreateWidgetRequest"
@@ -144,6 +166,7 @@ buildPaths = object
, "/widget-types" .= publicListPath "WidgetTypeRegistry" , "/widget-types" .= publicListPath "WidgetTypeRegistry"
, "/event-types" .= publicListPath "EventTypeRegistry" , "/event-types" .= publicListPath "EventTypeRegistry"
, "/annotation-categories" .= publicListPath "AnnotationCategoryRegistry" , "/annotation-categories" .= publicListPath "AnnotationCategoryRegistry"
, "/policy-scopes" .= publicListPath "PolicyScopeRegistry"
, "/token" .= tokenPath , "/token" .= tokenPath
-- Phase 10 — Hub Registry and Widget Marketplace -- Phase 10 — Hub Registry and Widget Marketplace
, "/hub-registry" .= getListPath "HubRegistryEntry" , "/hub-registry" .= getListPath "HubRegistryEntry"
@@ -214,8 +237,11 @@ showOp schemaName = object
] ]
writeOp :: Text -> Text -> Value writeOp :: Text -> Text -> Value
writeOp schemaName _reqSchema = object writeOp schemaName reqSchema = writeOpWithSummary ("Create " <> schemaName) schemaName reqSchema
[ "summary" .= ("Create " <> schemaName)
writeOpWithSummary :: Text -> Text -> Text -> Value
writeOpWithSummary summaryText schemaName _reqSchema = object
[ "summary" .= summaryText
, "security" .= [object ["BearerAuth" .= ([] :: [Text])]] , "security" .= [object ["BearerAuth" .= ([] :: [Text])]]
, "requestBody" .= object , "requestBody" .= object
[ "required" .= True [ "required" .= True
@@ -305,6 +331,26 @@ widgetSchema = object
] ]
] ]
manifestSchema :: Value
manifestSchema = object
[ "type" .= ("object" :: Text)
, "properties" .= object
[ "id" .= uuidProp
, "hubId" .= uuidProp
, "manifestVersion" .= strProp
, "declaredWidgetTypes" .= object ["type" .= ("array" :: Text), "items" .= object ["$ref" .= ("#/components/schemas/WidgetType" :: Text)]]
, "declaredEventTypes" .= object ["type" .= ("array" :: Text), "items" .= object ["$ref" .= ("#/components/schemas/EventType" :: Text)]]
, "declaredAnnotationCategories" .= object ["type" .= ("array" :: Text), "items" .= object ["$ref" .= ("#/components/schemas/AnnotationCategory" :: Text)]]
, "declaredPolicyScopes" .= object ["type" .= ("array" :: Text), "items" .= object ["$ref" .= ("#/components/schemas/PolicyScope" :: Text)]]
, "capabilityDescription" .= strProp
, "contact" .= strProp
, "status" .= strProp
, "activatedAt" .= dtProp
, "createdAt" .= dtProp
, "updatedAt" .= dtProp
]
]
interactionEventSchema :: Value interactionEventSchema :: Value
interactionEventSchema = object interactionEventSchema = object
[ "type" .= ("object" :: Text) [ "type" .= ("object" :: Text)

View File

@@ -4,6 +4,7 @@ module Web.Controller.Api.V2.Registries where
-- GET /api/v2/widget-types -- GET /api/v2/widget-types
-- GET /api/v2/event-types -- GET /api/v2/event-types
-- GET /api/v2/annotation-categories -- GET /api/v2/annotation-categories
-- GET /api/v2/policy-scopes
import Web.Types import Web.Types
import Generated.Types import Generated.Types
@@ -34,6 +35,13 @@ instance Controller ApiV2RegistriesController where
|> fetch |> fetch
renderJson $ map acToJson cats renderJson $ map acToJson cats
action ApiV2ListPolicyScopesAction = do
scopes <- query @PolicyScopeRegistry
|> filterWhere (#status, "active")
|> orderByAsc #name
|> fetch
renderJson $ map psToJson scopes
wtToJson :: WidgetTypeRegistry -> Value wtToJson :: WidgetTypeRegistry -> Value
wtToJson r = object wtToJson r = object
[ "name" .= r.name [ "name" .= r.name
@@ -60,3 +68,12 @@ acToJson r = object
, "ownerHubId" .= r.ownerHubId , "ownerHubId" .= r.ownerHubId
, "status" .= r.status , "status" .= r.status
] ]
psToJson :: PolicyScopeRegistry -> Value
psToJson r = object
[ "name" .= r.name
, "label" .= r.label_
, "description" .= r.description
, "ownerHubId" .= r.ownerHubId
, "status" .= r.status
]

View File

@@ -98,6 +98,18 @@ tsSdkClientClass = T.unlines
, " return this.fetch('/hubs', 'POST', body).then(r => r.json());" , " return this.fetch('/hubs', 'POST', body).then(r => r.json());"
, " }" , " }"
, "" , ""
, " async createHubCapabilityManifest(body: { hubId: string; manifestVersion?: string; declaredWidgetTypes?: WidgetType[]; declaredEventTypes?: EventType[]; declaredAnnotationCategories?: AnnotationCategory[]; declaredPolicyScopes?: string[]; capabilityDescription?: string; contact?: string }) {"
, " return this.fetch('/hub-capability-manifests', 'POST', body).then(r => r.json());"
, " }"
, ""
, " async updateHubCapabilityManifest(id: string, body: { manifestVersion?: string; declaredWidgetTypes?: WidgetType[]; declaredEventTypes?: EventType[]; declaredAnnotationCategories?: AnnotationCategory[]; declaredPolicyScopes?: string[]; capabilityDescription?: string; contact?: string }) {"
, " return this.fetch('/hub-capability-manifests/' + id, 'PATCH', body).then(r => r.json());"
, " }"
, ""
, " async activateHubCapabilityManifest(id: string) {"
, " return this.fetch('/hub-capability-manifests/' + id + '/activate', 'POST').then(r => r.json());"
, " }"
, ""
, " async getWidgets(params?: { page?: number; perPage?: number }) {" , " async getWidgets(params?: { page?: number; perPage?: number }) {"
, " const q = params ? `?page=${params.page ?? 1}&per_page=${params.perPage ?? 50}` : '';" , " const q = params ? `?page=${params.page ?? 1}&per_page=${params.perPage ?? 50}` : '';"
, " return this.fetch('/widgets' + q).then(r => r.json());" , " return this.fetch('/widgets' + q).then(r => r.json());"
@@ -160,6 +172,15 @@ pyClientClass = T.unlines
, " def create_hub(self, slug: str, name: str, domain: str, hub_kind: str = 'domain') -> dict:" , " def create_hub(self, slug: str, name: str, domain: str, hub_kind: str = 'domain') -> dict:"
, " return self._request('/hubs', 'POST', {'slug': slug, 'name': name, 'domain': domain, 'hubKind': hub_kind})" , " return self._request('/hubs', 'POST', {'slug': slug, 'name': name, 'domain': domain, 'hubKind': hub_kind})"
, "" , ""
, " def create_hub_capability_manifest(self, body: dict) -> dict:"
, " return self._request('/hub-capability-manifests', 'POST', body)"
, ""
, " def update_hub_capability_manifest(self, manifest_id: str, body: dict) -> dict:"
, " return self._request('/hub-capability-manifests/' + manifest_id, 'PATCH', body)"
, ""
, " def activate_hub_capability_manifest(self, manifest_id: str) -> dict:"
, " return self._request('/hub-capability-manifests/' + manifest_id + '/activate', 'POST')"
, ""
, " def get_widgets(self, page: int = 1, per_page: int = 50) -> dict:" , " def get_widgets(self, page: int = 1, per_page: int = 50) -> dict:"
, " return self._request(f'/widgets?page={page}&per_page={per_page}')" , " return self._request(f'/widgets?page={page}&per_page={per_page}')"
, "" , ""

View File

@@ -49,6 +49,7 @@ import Web.Controller.Api.V2.OpenApi ()
import Web.Controller.Api.V2.Token () import Web.Controller.Api.V2.Token ()
import Web.Controller.Api.V2.Sdk () import Web.Controller.Api.V2.Sdk ()
import Web.Controller.Api.V2.Hubs () import Web.Controller.Api.V2.Hubs ()
import Web.Controller.Api.V2.HubCapabilityManifests ()
-- Phase 10 — Hub Registry and Widget Marketplace (IHUB-WP-0011) -- Phase 10 — Hub Registry and Widget Marketplace (IHUB-WP-0011)
import Web.Controller.HubRegistry () import Web.Controller.HubRegistry ()
import Web.Controller.WidgetPatterns () import Web.Controller.WidgetPatterns ()
@@ -118,6 +119,7 @@ instance FrontController WebApplication where
, parseRoute @ApiV2TokenController , parseRoute @ApiV2TokenController
, parseRoute @ApiV2SdkController , parseRoute @ApiV2SdkController
, parseRoute @ApiV2HubsController , parseRoute @ApiV2HubsController
, parseRoute @ApiV2HubCapabilityManifestsController
-- Phase 10 — Hub Registry and Widget Marketplace (IHUB-WP-0011) -- Phase 10 — Hub Registry and Widget Marketplace (IHUB-WP-0011)
, parseRoute @HubRegistryController , parseRoute @HubRegistryController
, parseRoute @WidgetPatternsController , parseRoute @WidgetPatternsController

View File

@@ -178,12 +178,14 @@ instance CanRoute ApiV2RegistriesController where
[ do _ <- string "widget-types"; endOfInput; pure ApiV2ListWidgetTypesAction [ do _ <- string "widget-types"; endOfInput; pure ApiV2ListWidgetTypesAction
, do _ <- string "event-types"; endOfInput; pure ApiV2ListEventTypesAction , do _ <- string "event-types"; endOfInput; pure ApiV2ListEventTypesAction
, do _ <- string "annotation-categories"; endOfInput; pure ApiV2ListAnnotationCategoriesAction , do _ <- string "annotation-categories"; endOfInput; pure ApiV2ListAnnotationCategoriesAction
, do _ <- string "policy-scopes"; endOfInput; pure ApiV2ListPolicyScopesAction
] ]
instance HasPath ApiV2RegistriesController where instance HasPath ApiV2RegistriesController where
pathTo ApiV2ListWidgetTypesAction = "/api/v2/widget-types" pathTo ApiV2ListWidgetTypesAction = "/api/v2/widget-types"
pathTo ApiV2ListEventTypesAction = "/api/v2/event-types" pathTo ApiV2ListEventTypesAction = "/api/v2/event-types"
pathTo ApiV2ListAnnotationCategoriesAction = "/api/v2/annotation-categories" pathTo ApiV2ListAnnotationCategoriesAction = "/api/v2/annotation-categories"
pathTo ApiV2ListPolicyScopesAction = "/api/v2/policy-scopes"
instance CanRoute ApiV2OpenApiController where instance CanRoute ApiV2OpenApiController where
parseRoute' = do parseRoute' = do
@@ -257,6 +259,27 @@ instance HasPath ApiV2HubsController where
pathTo ApiV2ShowHubAction { hubId } = "/api/v2/hubs/" <> tshow hubId pathTo ApiV2ShowHubAction { hubId } = "/api/v2/hubs/" <> tshow hubId
pathTo ApiV2CreateHubAction = "/api/v2/hubs" pathTo ApiV2CreateHubAction = "/api/v2/hubs"
instance CanRoute ApiV2HubCapabilityManifestsController where
parseRoute' = do
_ <- string "/api/v2/hub-capability-manifests"
choice
[ do endOfInput; pure ApiV2IndexHubCapabilityManifestsAction
, do _ <- string "/"; mId <- parseUUID
choice
[ do _ <- string "/activate"; endOfInput
pure ApiV2ActivateHubCapabilityManifestAction { hubCapabilityManifestId = Id mId }
, do endOfInput
pure ApiV2ShowHubCapabilityManifestAction { hubCapabilityManifestId = Id mId }
]
]
instance HasPath ApiV2HubCapabilityManifestsController where
pathTo ApiV2IndexHubCapabilityManifestsAction = "/api/v2/hub-capability-manifests"
pathTo ApiV2ShowHubCapabilityManifestAction { hubCapabilityManifestId } = "/api/v2/hub-capability-manifests/" <> tshow hubCapabilityManifestId
pathTo ApiV2CreateHubCapabilityManifestAction = "/api/v2/hub-capability-manifests"
pathTo ApiV2UpdateHubCapabilityManifestAction { hubCapabilityManifestId } = "/api/v2/hub-capability-manifests/" <> tshow hubCapabilityManifestId
pathTo ApiV2ActivateHubCapabilityManifestAction { hubCapabilityManifestId } = "/api/v2/hub-capability-manifests/" <> tshow hubCapabilityManifestId <> "/activate"
instance CanRoute ApiV2WidgetPatternsController where instance CanRoute ApiV2WidgetPatternsController where
parseRoute' = do parseRoute' = do
_ <- string "/api/v2/widget-patterns" _ <- string "/api/v2/widget-patterns"

View File

@@ -324,6 +324,7 @@ data ApiV2RegistriesController
= ApiV2ListWidgetTypesAction = ApiV2ListWidgetTypesAction
| ApiV2ListEventTypesAction | ApiV2ListEventTypesAction
| ApiV2ListAnnotationCategoriesAction | ApiV2ListAnnotationCategoriesAction
| ApiV2ListPolicyScopesAction
deriving (Eq, Show, Data) deriving (Eq, Show, Data)
data ApiV2OpenApiController data ApiV2OpenApiController
@@ -407,6 +408,14 @@ data ApiV2HubsController
| ApiV2CreateHubAction | ApiV2CreateHubAction
deriving (Eq, Show, Data) deriving (Eq, Show, Data)
data ApiV2HubCapabilityManifestsController
= ApiV2IndexHubCapabilityManifestsAction
| ApiV2ShowHubCapabilityManifestAction { hubCapabilityManifestId :: !(Id HubCapabilityManifest) }
| ApiV2CreateHubCapabilityManifestAction
| ApiV2UpdateHubCapabilityManifestAction { hubCapabilityManifestId :: !(Id HubCapabilityManifest) }
| ApiV2ActivateHubCapabilityManifestAction { hubCapabilityManifestId :: !(Id HubCapabilityManifest) }
deriving (Eq, Show, Data)
data ApiV2WidgetPatternsController data ApiV2WidgetPatternsController
= ApiV2IndexWidgetPatternsAction = ApiV2IndexWidgetPatternsAction
| ApiV2ShowWidgetPatternAction { widgetPatternId :: !(Id WidgetPattern) } | ApiV2ShowWidgetPatternAction { widgetPatternId :: !(Id WidgetPattern) }

View File

@@ -116,7 +116,7 @@ shell does not have `IHP_LIB`/the IHP dev environment loaded.
```task ```task
id: IHUB-WP-0019-T02 id: IHUB-WP-0019-T02
status: todo status: done
priority: high priority: high
state_hub_task_id: "46a027d0-4831-40af-b8ae-e1f858cdaef7" state_hub_task_id: "46a027d0-4831-40af-b8ae-e1f858cdaef7"
``` ```
@@ -131,6 +131,15 @@ Add documented API or admin-command support for:
Done when: manifest activation can be executed without clicking through the UI Done when: manifest activation can be executed without clicking through the UI
and all four type registries are visible through v2 list endpoints. and all four type registries are visible through v2 list endpoints.
Implementation note (2026-05-16): added authenticated
`/api/v2/hub-capability-manifests` support for draft create, draft update, and
activation, including the same manifest vocabulary conflict checks and
idempotent registry upserts used by the UI flow. Added
`/api/v2/policy-scopes`, OpenAPI path/schema entries, SDK helper methods, and
focused Hspec helper coverage for manifest vocabulary parsing. Local
`git diff --check` passed; `scripts/compile-check` could not run because this
shell does not have `IHP_LIB`/the IHP dev environment loaded.
--- ---
### T03 — Add first-class VSM hub metadata ### T03 — Add first-class VSM hub metadata