generated from coulomb/repo-seed
feat: add v2 manifest bootstrap endpoints
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
This commit is contained in:
13
Test/Main.hs
13
Test/Main.hs
@@ -9,6 +9,8 @@ import Web.Controller.Api.V2.InteractionEvents
|
||||
, metadataParamOrEmpty
|
||||
)
|
||||
import Web.Controller.Api.V2.Hubs (missingRequiredFields, validCreateHubKind)
|
||||
import Web.Controller.Api.V2.HubCapabilityManifests
|
||||
( jsonArrayTexts, textArrayFieldFromJsonBody )
|
||||
import Web.Controller.Api.V2.Widgets (missingWidgetCreateFields, validWidgetStatus)
|
||||
|
||||
main :: IO ()
|
||||
@@ -74,4 +76,15 @@ main = hspec do
|
||||
]
|
||||
`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
|
||||
|
||||
264
Web/Controller/Api/V2/HubCapabilityManifests.hs
Normal file
264
Web/Controller/Api/V2/HubCapabilityManifests.hs
Normal 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
|
||||
@@ -16,7 +16,8 @@ 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 )
|
||||
( activeWidgetTypes, activeEventTypes, activeAnnotationCategories
|
||||
, activePolicyScopes )
|
||||
import Network.HTTP.Types (status200)
|
||||
import Network.Wai (responseLBS)
|
||||
|
||||
@@ -47,10 +48,12 @@ buildOpenApiSpec = do
|
||||
let allWidgetTypes = fwWidgetTypes ++ ownedWidgetTypes
|
||||
eventTypes <- activeEventTypes
|
||||
annCats <- activeAnnotationCategories
|
||||
policyScopes <- activePolicyScopes
|
||||
|
||||
let wtEnum = toJSON $ map (.name) allWidgetTypes
|
||||
let etEnum = toJSON $ map (.name) eventTypes
|
||||
let acEnum = toJSON $ map (.name) annCats
|
||||
let psEnum = toJSON $ map (.name) policyScopes
|
||||
|
||||
pure $ object
|
||||
[ "openapi" .= ("3.1.0" :: Text)
|
||||
@@ -76,6 +79,10 @@ buildOpenApiSpec = do
|
||||
[ "type" .= ("string" :: Text)
|
||||
, "enum" .= acEnum
|
||||
]
|
||||
, "PolicyScope" .= object
|
||||
[ "type" .= ("string" :: Text)
|
||||
, "enum" .= psEnum
|
||||
]
|
||||
, "PaginationMeta" .= object
|
||||
[ "type" .= ("object" :: Text)
|
||||
, "properties" .= object
|
||||
@@ -85,6 +92,7 @@ buildOpenApiSpec = do
|
||||
]
|
||||
]
|
||||
, "Hub" .= hubSchema
|
||||
, "HubCapabilityManifest" .= manifestSchema
|
||||
, "Widget" .= widgetSchema
|
||||
, "InteractionEvent" .= interactionEventSchema
|
||||
, "Annotation" .= annotationSchema
|
||||
@@ -114,6 +122,20 @@ buildPaths = object
|
||||
, "post" .= writeOp "Hub" "CreateHubRequest"
|
||||
]
|
||||
, "/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
|
||||
[ "get" .= listOp "Widget" []
|
||||
, "post" .= writeOp "Widget" "CreateWidgetRequest"
|
||||
@@ -144,6 +166,7 @@ buildPaths = object
|
||||
, "/widget-types" .= publicListPath "WidgetTypeRegistry"
|
||||
, "/event-types" .= publicListPath "EventTypeRegistry"
|
||||
, "/annotation-categories" .= publicListPath "AnnotationCategoryRegistry"
|
||||
, "/policy-scopes" .= publicListPath "PolicyScopeRegistry"
|
||||
, "/token" .= tokenPath
|
||||
-- Phase 10 — Hub Registry and Widget Marketplace
|
||||
, "/hub-registry" .= getListPath "HubRegistryEntry"
|
||||
@@ -214,8 +237,11 @@ showOp schemaName = object
|
||||
]
|
||||
|
||||
writeOp :: Text -> Text -> Value
|
||||
writeOp schemaName _reqSchema = object
|
||||
[ "summary" .= ("Create " <> schemaName)
|
||||
writeOp schemaName reqSchema = writeOpWithSummary ("Create " <> schemaName) schemaName reqSchema
|
||||
|
||||
writeOpWithSummary :: Text -> Text -> Text -> Value
|
||||
writeOpWithSummary summaryText schemaName _reqSchema = object
|
||||
[ "summary" .= summaryText
|
||||
, "security" .= [object ["BearerAuth" .= ([] :: [Text])]]
|
||||
, "requestBody" .= object
|
||||
[ "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 = object
|
||||
[ "type" .= ("object" :: Text)
|
||||
|
||||
@@ -4,6 +4,7 @@ module Web.Controller.Api.V2.Registries where
|
||||
-- GET /api/v2/widget-types
|
||||
-- GET /api/v2/event-types
|
||||
-- GET /api/v2/annotation-categories
|
||||
-- GET /api/v2/policy-scopes
|
||||
|
||||
import Web.Types
|
||||
import Generated.Types
|
||||
@@ -34,6 +35,13 @@ instance Controller ApiV2RegistriesController where
|
||||
|> fetch
|
||||
renderJson $ map acToJson cats
|
||||
|
||||
action ApiV2ListPolicyScopesAction = do
|
||||
scopes <- query @PolicyScopeRegistry
|
||||
|> filterWhere (#status, "active")
|
||||
|> orderByAsc #name
|
||||
|> fetch
|
||||
renderJson $ map psToJson scopes
|
||||
|
||||
wtToJson :: WidgetTypeRegistry -> Value
|
||||
wtToJson r = object
|
||||
[ "name" .= r.name
|
||||
@@ -60,3 +68,12 @@ acToJson r = object
|
||||
, "ownerHubId" .= r.ownerHubId
|
||||
, "status" .= r.status
|
||||
]
|
||||
|
||||
psToJson :: PolicyScopeRegistry -> Value
|
||||
psToJson r = object
|
||||
[ "name" .= r.name
|
||||
, "label" .= r.label_
|
||||
, "description" .= r.description
|
||||
, "ownerHubId" .= r.ownerHubId
|
||||
, "status" .= r.status
|
||||
]
|
||||
|
||||
@@ -98,6 +98,18 @@ tsSdkClientClass = T.unlines
|
||||
, " 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 }) {"
|
||||
, " const q = params ? `?page=${params.page ?? 1}&per_page=${params.perPage ?? 50}` : '';"
|
||||
, " 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:"
|
||||
, " 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:"
|
||||
, " return self._request(f'/widgets?page={page}&per_page={per_page}')"
|
||||
, ""
|
||||
|
||||
@@ -49,6 +49,7 @@ import Web.Controller.Api.V2.OpenApi ()
|
||||
import Web.Controller.Api.V2.Token ()
|
||||
import Web.Controller.Api.V2.Sdk ()
|
||||
import Web.Controller.Api.V2.Hubs ()
|
||||
import Web.Controller.Api.V2.HubCapabilityManifests ()
|
||||
-- Phase 10 — Hub Registry and Widget Marketplace (IHUB-WP-0011)
|
||||
import Web.Controller.HubRegistry ()
|
||||
import Web.Controller.WidgetPatterns ()
|
||||
@@ -118,6 +119,7 @@ instance FrontController WebApplication where
|
||||
, parseRoute @ApiV2TokenController
|
||||
, parseRoute @ApiV2SdkController
|
||||
, parseRoute @ApiV2HubsController
|
||||
, parseRoute @ApiV2HubCapabilityManifestsController
|
||||
-- Phase 10 — Hub Registry and Widget Marketplace (IHUB-WP-0011)
|
||||
, parseRoute @HubRegistryController
|
||||
, parseRoute @WidgetPatternsController
|
||||
|
||||
@@ -178,12 +178,14 @@ instance CanRoute ApiV2RegistriesController where
|
||||
[ do _ <- string "widget-types"; endOfInput; pure ApiV2ListWidgetTypesAction
|
||||
, do _ <- string "event-types"; endOfInput; pure ApiV2ListEventTypesAction
|
||||
, do _ <- string "annotation-categories"; endOfInput; pure ApiV2ListAnnotationCategoriesAction
|
||||
, do _ <- string "policy-scopes"; endOfInput; pure ApiV2ListPolicyScopesAction
|
||||
]
|
||||
|
||||
instance HasPath ApiV2RegistriesController where
|
||||
pathTo ApiV2ListWidgetTypesAction = "/api/v2/widget-types"
|
||||
pathTo ApiV2ListEventTypesAction = "/api/v2/event-types"
|
||||
pathTo ApiV2ListAnnotationCategoriesAction = "/api/v2/annotation-categories"
|
||||
pathTo ApiV2ListPolicyScopesAction = "/api/v2/policy-scopes"
|
||||
|
||||
instance CanRoute ApiV2OpenApiController where
|
||||
parseRoute' = do
|
||||
@@ -257,6 +259,27 @@ instance HasPath ApiV2HubsController where
|
||||
pathTo ApiV2ShowHubAction { hubId } = "/api/v2/hubs/" <> tshow hubId
|
||||
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
|
||||
parseRoute' = do
|
||||
_ <- string "/api/v2/widget-patterns"
|
||||
|
||||
@@ -324,6 +324,7 @@ data ApiV2RegistriesController
|
||||
= ApiV2ListWidgetTypesAction
|
||||
| ApiV2ListEventTypesAction
|
||||
| ApiV2ListAnnotationCategoriesAction
|
||||
| ApiV2ListPolicyScopesAction
|
||||
deriving (Eq, Show, Data)
|
||||
|
||||
data ApiV2OpenApiController
|
||||
@@ -407,6 +408,14 @@ data ApiV2HubsController
|
||||
| ApiV2CreateHubAction
|
||||
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
|
||||
= ApiV2IndexWidgetPatternsAction
|
||||
| ApiV2ShowWidgetPatternAction { widgetPatternId :: !(Id WidgetPattern) }
|
||||
|
||||
@@ -116,7 +116,7 @@ shell does not have `IHP_LIB`/the IHP dev environment loaded.
|
||||
|
||||
```task
|
||||
id: IHUB-WP-0019-T02
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
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
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user