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
|
, 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
|
||||||
|
|||||||
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.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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
]
|
||||||
|
|||||||
@@ -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}')"
|
||||||
, ""
|
, ""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user