generated from coulomb/repo-seed
feat: add v2 api consumer bootstrap endpoints
This commit is contained in:
@@ -11,6 +11,7 @@ import Web.Controller.Api.V2.InteractionEvents
|
|||||||
import Web.Controller.Api.V2.Hubs (missingRequiredFields, validCreateHubKind)
|
import Web.Controller.Api.V2.Hubs (missingRequiredFields, validCreateHubKind)
|
||||||
import Web.Controller.Api.V2.HubCapabilityManifests
|
import Web.Controller.Api.V2.HubCapabilityManifests
|
||||||
( jsonArrayTexts, textArrayFieldFromJsonBody )
|
( jsonArrayTexts, textArrayFieldFromJsonBody )
|
||||||
|
import Web.Controller.Api.V2.ApiConsumers (positiveLimit)
|
||||||
import Web.Controller.Api.V2.Widgets (missingWidgetCreateFields, validWidgetStatus)
|
import Web.Controller.Api.V2.Widgets (missingWidgetCreateFields, validWidgetStatus)
|
||||||
|
|
||||||
main :: IO ()
|
main :: IO ()
|
||||||
@@ -87,4 +88,10 @@ main = hspec do
|
|||||||
jsonArrayTexts (toJSON (["ops-endpoint-card", "ops-alert-panel"] :: [Text]))
|
jsonArrayTexts (toJSON (["ops-endpoint-card", "ops-alert-panel"] :: [Text]))
|
||||||
`shouldBe` ["ops-endpoint-card", "ops-alert-panel"]
|
`shouldBe` ["ops-endpoint-card", "ops-alert-panel"]
|
||||||
|
|
||||||
|
describe "API v2 API consumer bootstrap validation" do
|
||||||
|
it "requires positive rate-limit and quota values" do
|
||||||
|
positiveLimit 1 `shouldBe` True
|
||||||
|
positiveLimit 0 `shouldBe` False
|
||||||
|
positiveLimit (-1) `shouldBe` False
|
||||||
|
|
||||||
LayerBoundary.spec
|
LayerBoundary.spec
|
||||||
|
|||||||
175
Web/Controller/Api/V2/ApiConsumers.hs
Normal file
175
Web/Controller/Api/V2/ApiConsumers.hs
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
module Web.Controller.Api.V2.ApiConsumers where
|
||||||
|
|
||||||
|
import Web.Types
|
||||||
|
import Generated.Types
|
||||||
|
import IHP.Prelude
|
||||||
|
import IHP.ControllerPrelude
|
||||||
|
import Data.Aeson (Value, object, (.=))
|
||||||
|
import Network.Wai (requestMethod)
|
||||||
|
import Web.Controller.Api.V2.Auth
|
||||||
|
( requireApiConsumer, paginatedResponse, getPageParams
|
||||||
|
, respondWithStatus, hashApiKey )
|
||||||
|
import qualified Data.ByteString.Base16 as Base16
|
||||||
|
import qualified Data.ByteString.Random as Random
|
||||||
|
import qualified Data.Text as T
|
||||||
|
import qualified Data.Text.Encoding as TE
|
||||||
|
import qualified Data.UUID as UUID
|
||||||
|
|
||||||
|
instance Controller ApiV2ApiConsumersController where
|
||||||
|
|
||||||
|
action ApiV2IndexApiConsumersAction = do
|
||||||
|
case requestMethod ?request of
|
||||||
|
"GET" -> listApiConsumers
|
||||||
|
"POST" -> createApiConsumer
|
||||||
|
_ -> respondWithStatus 405 $ object ["error" .= ("Method not allowed" :: Text)]
|
||||||
|
|
||||||
|
action ApiV2ShowApiConsumerAction { apiConsumerId } = do
|
||||||
|
_consumer <- requireApiConsumer
|
||||||
|
apiConsumer <- fetch apiConsumerId
|
||||||
|
renderJson (apiConsumerToJson apiConsumer)
|
||||||
|
|
||||||
|
action ApiV2CreateApiConsumerAction = createApiConsumer
|
||||||
|
|
||||||
|
action ApiV2CreateApiConsumerKeyAction { apiConsumerId } = do
|
||||||
|
when (requestMethod ?request /= "POST") do
|
||||||
|
respondWithStatus 405 $ object ["error" .= ("Method not allowed" :: Text)]
|
||||||
|
createApiConsumerKey apiConsumerId
|
||||||
|
|
||||||
|
listApiConsumers :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO ()
|
||||||
|
listApiConsumers = do
|
||||||
|
_consumer <- requireApiConsumer
|
||||||
|
(page, perPage) <- getPageParams
|
||||||
|
let pageOffset = (page - 1) * perPage
|
||||||
|
total <- query @ApiConsumer |> fetchCount
|
||||||
|
consumers <- query @ApiConsumer
|
||||||
|
|> orderByDesc #createdAt
|
||||||
|
|> limit perPage
|
||||||
|
|> offset pageOffset
|
||||||
|
|> fetch
|
||||||
|
renderJson $ paginatedResponse (map apiConsumerToJson consumers) page perPage total
|
||||||
|
|
||||||
|
createApiConsumer :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO ()
|
||||||
|
createApiConsumer = do
|
||||||
|
_consumer <- requireApiConsumer
|
||||||
|
let name = paramOrNothing @Text "name"
|
||||||
|
description = paramOrNothing @Text "description"
|
||||||
|
rateLimit = fromMaybe 60 (paramOrNothing @Int "rateLimitPerMinute")
|
||||||
|
quota = fromMaybe 10000 (paramOrNothing @Int "quotaPerDay")
|
||||||
|
|
||||||
|
when (maybe True (== "") name) do
|
||||||
|
respondWithStatus 422 $ object
|
||||||
|
[ "error" .= ("Missing required fields" :: Text)
|
||||||
|
, "missing" .= (["name"] :: [Text])
|
||||||
|
]
|
||||||
|
unless (positiveLimit rateLimit) do
|
||||||
|
respondWithStatus 422 $ object
|
||||||
|
[ "error" .= ("rateLimitPerMinute must be positive" :: Text)
|
||||||
|
, "code" .= ("invalid_rate_limit" :: Text)
|
||||||
|
]
|
||||||
|
unless (positiveLimit quota) do
|
||||||
|
respondWithStatus 422 $ object
|
||||||
|
[ "error" .= ("quotaPerDay must be positive" :: Text)
|
||||||
|
, "code" .= ("invalid_quota" :: Text)
|
||||||
|
]
|
||||||
|
|
||||||
|
mManifestId <- parseOptionalActiveManifestId
|
||||||
|
let Just nameText = name
|
||||||
|
apiConsumer <- newRecord @ApiConsumer
|
||||||
|
|> set #name nameText
|
||||||
|
|> set #description description
|
||||||
|
|> set #hubCapabilityManifestId mManifestId
|
||||||
|
|> set #rateLimitPerMinute rateLimit
|
||||||
|
|> set #quotaPerDay quota
|
||||||
|
|> createRecord
|
||||||
|
respondWithStatus 201 (apiConsumerToJson apiConsumer)
|
||||||
|
|
||||||
|
createApiConsumerKey :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => Id ApiConsumer -> IO ()
|
||||||
|
createApiConsumerKey apiConsumerId = do
|
||||||
|
_requestingConsumer <- requireApiConsumer
|
||||||
|
apiConsumer <- fetch apiConsumerId
|
||||||
|
unless apiConsumer.isActive do
|
||||||
|
respondWithStatus 422 $ object
|
||||||
|
[ "error" .= ("API consumer is inactive" :: Text)
|
||||||
|
, "code" .= ("api_consumer_inactive" :: Text)
|
||||||
|
]
|
||||||
|
let scopes = fromMaybe "" (paramOrNothing @Text "scopes")
|
||||||
|
|
||||||
|
fullKey <- generateApiKeySecret
|
||||||
|
let prefix = T.take 8 fullKey
|
||||||
|
keyHash = hashApiKey fullKey
|
||||||
|
apiKey <- newRecord @ApiKey
|
||||||
|
|> set #apiConsumerId apiConsumer.id
|
||||||
|
|> set #keyPrefix prefix
|
||||||
|
|> set #keyHash keyHash
|
||||||
|
|> set #scopes scopes
|
||||||
|
|> set #tokenType "static"
|
||||||
|
|> createRecord
|
||||||
|
respondWithStatus 201 (apiKeyCreatedToJson apiKey fullKey)
|
||||||
|
|
||||||
|
parseOptionalActiveManifestId :: (?context :: ControllerContext, ?modelContext :: ModelContext, ?respond :: Respond, ?request :: Request) => IO (Maybe (Id HubCapabilityManifest))
|
||||||
|
parseOptionalActiveManifestId =
|
||||||
|
case nonEmptyText =<< paramOrNothing @Text "hubCapabilityManifestId" of
|
||||||
|
Nothing -> pure Nothing
|
||||||
|
Just manifestIdRaw ->
|
||||||
|
case UUID.fromText manifestIdRaw of
|
||||||
|
Nothing -> respondWithStatus 422 $ object
|
||||||
|
["error" .= ("hubCapabilityManifestId must be a valid UUID" :: Text)]
|
||||||
|
Just rawId -> do
|
||||||
|
let manifestId = Id rawId :: Id HubCapabilityManifest
|
||||||
|
mManifest <- fetchOneOrNothing manifestId
|
||||||
|
case mManifest of
|
||||||
|
Nothing -> respondWithStatus 422 $ object
|
||||||
|
["error" .= ("Hub capability manifest not found" :: Text)]
|
||||||
|
Just manifest -> do
|
||||||
|
unless (manifest.status == "active") do
|
||||||
|
respondWithStatus 422 $ object
|
||||||
|
[ "error" .= ("Hub capability manifest must be active" :: Text)
|
||||||
|
, "code" .= ("manifest_not_active" :: Text)
|
||||||
|
]
|
||||||
|
pure (Just manifestId)
|
||||||
|
|
||||||
|
generateApiKeySecret :: IO Text
|
||||||
|
generateApiKeySecret = do
|
||||||
|
rawBytes <- Random.random 32
|
||||||
|
pure $ TE.decodeUtf8 (Base16.encode rawBytes)
|
||||||
|
|
||||||
|
apiConsumerToJson :: ApiConsumer -> Value
|
||||||
|
apiConsumerToJson apiConsumer = object
|
||||||
|
[ "id" .= apiConsumer.id
|
||||||
|
, "name" .= apiConsumer.name
|
||||||
|
, "description" .= apiConsumer.description
|
||||||
|
, "hubCapabilityManifestId" .= apiConsumer.hubCapabilityManifestId
|
||||||
|
, "rateLimitPerMinute" .= apiConsumer.rateLimitPerMinute
|
||||||
|
, "quotaPerDay" .= apiConsumer.quotaPerDay
|
||||||
|
, "quotaResetsAt" .= apiConsumer.quotaResetsAt
|
||||||
|
, "isActive" .= apiConsumer.isActive
|
||||||
|
, "createdAt" .= apiConsumer.createdAt
|
||||||
|
, "updatedAt" .= apiConsumer.updatedAt
|
||||||
|
]
|
||||||
|
|
||||||
|
apiKeyToJson :: ApiKey -> Value
|
||||||
|
apiKeyToJson apiKey = object
|
||||||
|
[ "id" .= apiKey.id
|
||||||
|
, "apiConsumerId" .= apiKey.apiConsumerId
|
||||||
|
, "keyPrefix" .= apiKey.keyPrefix
|
||||||
|
, "scopes" .= apiKey.scopes
|
||||||
|
, "tokenType" .= apiKey.tokenType
|
||||||
|
, "expiresAt" .= apiKey.expiresAt
|
||||||
|
, "revokedAt" .= apiKey.revokedAt
|
||||||
|
, "lastUsedAt" .= apiKey.lastUsedAt
|
||||||
|
, "createdAt" .= apiKey.createdAt
|
||||||
|
]
|
||||||
|
|
||||||
|
apiKeyCreatedToJson :: ApiKey -> Text -> Value
|
||||||
|
apiKeyCreatedToJson apiKey fullKey = object
|
||||||
|
[ "apiKey" .= apiKeyToJson apiKey
|
||||||
|
, "fullKey" .= fullKey
|
||||||
|
, "displayOnce" .= True
|
||||||
|
]
|
||||||
|
|
||||||
|
positiveLimit :: Int -> Bool
|
||||||
|
positiveLimit value = value > 0
|
||||||
|
|
||||||
|
nonEmptyText :: Text -> Maybe Text
|
||||||
|
nonEmptyText "" = Nothing
|
||||||
|
nonEmptyText value = Just value
|
||||||
@@ -93,6 +93,8 @@ buildOpenApiSpec = do
|
|||||||
]
|
]
|
||||||
, "Hub" .= hubSchema
|
, "Hub" .= hubSchema
|
||||||
, "HubCapabilityManifest" .= manifestSchema
|
, "HubCapabilityManifest" .= manifestSchema
|
||||||
|
, "ApiConsumer" .= apiConsumerSchema
|
||||||
|
, "ApiKey" .= apiKeySchema
|
||||||
, "Widget" .= widgetSchema
|
, "Widget" .= widgetSchema
|
||||||
, "InteractionEvent" .= interactionEventSchema
|
, "InteractionEvent" .= interactionEventSchema
|
||||||
, "Annotation" .= annotationSchema
|
, "Annotation" .= annotationSchema
|
||||||
@@ -136,6 +138,14 @@ buildPaths = object
|
|||||||
, "/hub-capability-manifests/{id}/activate" .= object
|
, "/hub-capability-manifests/{id}/activate" .= object
|
||||||
[ "post" .= writeOpWithSummary "Activate HubCapabilityManifest" "HubCapabilityManifest" "ActivateHubCapabilityManifestRequest"
|
[ "post" .= writeOpWithSummary "Activate HubCapabilityManifest" "HubCapabilityManifest" "ActivateHubCapabilityManifestRequest"
|
||||||
]
|
]
|
||||||
|
, "/api-consumers" .= object
|
||||||
|
[ "get" .= listOp "ApiConsumer" []
|
||||||
|
, "post" .= writeOp "ApiConsumer" "CreateApiConsumerRequest"
|
||||||
|
]
|
||||||
|
, "/api-consumers/{id}" .= getShowPath "ApiConsumer"
|
||||||
|
, "/api-consumers/{id}/api-keys" .= object
|
||||||
|
[ "post" .= writeOpWithSummary "Create ApiKey" "ApiKey" "CreateApiKeyRequest"
|
||||||
|
]
|
||||||
, "/widgets" .= object
|
, "/widgets" .= object
|
||||||
[ "get" .= listOp "Widget" []
|
[ "get" .= listOp "Widget" []
|
||||||
, "post" .= writeOp "Widget" "CreateWidgetRequest"
|
, "post" .= writeOp "Widget" "CreateWidgetRequest"
|
||||||
@@ -351,6 +361,39 @@ manifestSchema = object
|
|||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|
||||||
|
apiConsumerSchema :: Value
|
||||||
|
apiConsumerSchema = object
|
||||||
|
[ "type" .= ("object" :: Text)
|
||||||
|
, "properties" .= object
|
||||||
|
[ "id" .= uuidProp
|
||||||
|
, "name" .= strProp
|
||||||
|
, "description" .= strProp
|
||||||
|
, "hubCapabilityManifestId" .= uuidProp
|
||||||
|
, "rateLimitPerMinute" .= object ["type" .= ("integer" :: Text)]
|
||||||
|
, "quotaPerDay" .= object ["type" .= ("integer" :: Text)]
|
||||||
|
, "quotaResetsAt" .= dtProp
|
||||||
|
, "isActive" .= object ["type" .= ("boolean" :: Text)]
|
||||||
|
, "createdAt" .= dtProp
|
||||||
|
, "updatedAt" .= dtProp
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
apiKeySchema :: Value
|
||||||
|
apiKeySchema = object
|
||||||
|
[ "type" .= ("object" :: Text)
|
||||||
|
, "properties" .= object
|
||||||
|
[ "id" .= uuidProp
|
||||||
|
, "apiConsumerId" .= uuidProp
|
||||||
|
, "keyPrefix" .= strProp
|
||||||
|
, "scopes" .= strProp
|
||||||
|
, "tokenType" .= strProp
|
||||||
|
, "expiresAt" .= dtProp
|
||||||
|
, "revokedAt" .= dtProp
|
||||||
|
, "lastUsedAt" .= dtProp
|
||||||
|
, "createdAt" .= dtProp
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
interactionEventSchema :: Value
|
interactionEventSchema :: Value
|
||||||
interactionEventSchema = object
|
interactionEventSchema = object
|
||||||
[ "type" .= ("object" :: Text)
|
[ "type" .= ("object" :: Text)
|
||||||
|
|||||||
@@ -110,6 +110,14 @@ tsSdkClientClass = T.unlines
|
|||||||
, " return this.fetch('/hub-capability-manifests/' + id + '/activate', 'POST').then(r => r.json());"
|
, " return this.fetch('/hub-capability-manifests/' + id + '/activate', 'POST').then(r => r.json());"
|
||||||
, " }"
|
, " }"
|
||||||
, ""
|
, ""
|
||||||
|
, " async createApiConsumer(body: { name: string; description?: string; hubCapabilityManifestId?: string; rateLimitPerMinute?: number; quotaPerDay?: number }) {"
|
||||||
|
, " return this.fetch('/api-consumers', 'POST', body).then(r => r.json());"
|
||||||
|
, " }"
|
||||||
|
, ""
|
||||||
|
, " async createApiKey(apiConsumerId: string, body?: { scopes?: string }) {"
|
||||||
|
, " return this.fetch('/api-consumers/' + apiConsumerId + '/api-keys', 'POST', body ?? {}).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());"
|
||||||
@@ -181,6 +189,19 @@ pyClientClass = T.unlines
|
|||||||
, " def activate_hub_capability_manifest(self, manifest_id: str) -> dict:"
|
, " def activate_hub_capability_manifest(self, manifest_id: str) -> dict:"
|
||||||
, " return self._request('/hub-capability-manifests/' + manifest_id + '/activate', 'POST')"
|
, " return self._request('/hub-capability-manifests/' + manifest_id + '/activate', 'POST')"
|
||||||
, ""
|
, ""
|
||||||
|
, " def create_api_consumer(self, name: str, description: Optional[str] = None, hub_capability_manifest_id: Optional[str] = None, rate_limit_per_minute: Optional[int] = None, quota_per_day: Optional[int] = None) -> dict:"
|
||||||
|
, " body: dict = {'name': name}"
|
||||||
|
, " if description: body['description'] = description"
|
||||||
|
, " if hub_capability_manifest_id: body['hubCapabilityManifestId'] = hub_capability_manifest_id"
|
||||||
|
, " if rate_limit_per_minute: body['rateLimitPerMinute'] = rate_limit_per_minute"
|
||||||
|
, " if quota_per_day: body['quotaPerDay'] = quota_per_day"
|
||||||
|
, " return self._request('/api-consumers', 'POST', body)"
|
||||||
|
, ""
|
||||||
|
, " def create_api_key(self, api_consumer_id: str, scopes: Optional[str] = None) -> dict:"
|
||||||
|
, " body: dict = {}"
|
||||||
|
, " if scopes: body['scopes'] = scopes"
|
||||||
|
, " return self._request('/api-consumers/' + api_consumer_id + '/api-keys', 'POST', body)"
|
||||||
|
, ""
|
||||||
, " 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}')"
|
||||||
, ""
|
, ""
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ 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 ()
|
import Web.Controller.Api.V2.HubCapabilityManifests ()
|
||||||
|
import Web.Controller.Api.V2.ApiConsumers ()
|
||||||
-- 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 ()
|
||||||
@@ -120,6 +121,7 @@ instance FrontController WebApplication where
|
|||||||
, parseRoute @ApiV2SdkController
|
, parseRoute @ApiV2SdkController
|
||||||
, parseRoute @ApiV2HubsController
|
, parseRoute @ApiV2HubsController
|
||||||
, parseRoute @ApiV2HubCapabilityManifestsController
|
, parseRoute @ApiV2HubCapabilityManifestsController
|
||||||
|
, parseRoute @ApiV2ApiConsumersController
|
||||||
-- 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
|
||||||
|
|||||||
@@ -280,6 +280,26 @@ instance HasPath ApiV2HubCapabilityManifestsController where
|
|||||||
pathTo ApiV2UpdateHubCapabilityManifestAction { hubCapabilityManifestId } = "/api/v2/hub-capability-manifests/" <> tshow hubCapabilityManifestId
|
pathTo ApiV2UpdateHubCapabilityManifestAction { hubCapabilityManifestId } = "/api/v2/hub-capability-manifests/" <> tshow hubCapabilityManifestId
|
||||||
pathTo ApiV2ActivateHubCapabilityManifestAction { hubCapabilityManifestId } = "/api/v2/hub-capability-manifests/" <> tshow hubCapabilityManifestId <> "/activate"
|
pathTo ApiV2ActivateHubCapabilityManifestAction { hubCapabilityManifestId } = "/api/v2/hub-capability-manifests/" <> tshow hubCapabilityManifestId <> "/activate"
|
||||||
|
|
||||||
|
instance CanRoute ApiV2ApiConsumersController where
|
||||||
|
parseRoute' = do
|
||||||
|
_ <- string "/api/v2/api-consumers"
|
||||||
|
choice
|
||||||
|
[ do endOfInput; pure ApiV2IndexApiConsumersAction
|
||||||
|
, do _ <- string "/"; cId <- parseUUID
|
||||||
|
choice
|
||||||
|
[ do _ <- string "/api-keys"; endOfInput
|
||||||
|
pure ApiV2CreateApiConsumerKeyAction { apiConsumerId = Id cId }
|
||||||
|
, do endOfInput
|
||||||
|
pure ApiV2ShowApiConsumerAction { apiConsumerId = Id cId }
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
instance HasPath ApiV2ApiConsumersController where
|
||||||
|
pathTo ApiV2IndexApiConsumersAction = "/api/v2/api-consumers"
|
||||||
|
pathTo ApiV2ShowApiConsumerAction { apiConsumerId } = "/api/v2/api-consumers/" <> tshow apiConsumerId
|
||||||
|
pathTo ApiV2CreateApiConsumerAction = "/api/v2/api-consumers"
|
||||||
|
pathTo ApiV2CreateApiConsumerKeyAction { apiConsumerId } = "/api/v2/api-consumers/" <> tshow apiConsumerId <> "/api-keys"
|
||||||
|
|
||||||
instance CanRoute ApiV2WidgetPatternsController where
|
instance CanRoute ApiV2WidgetPatternsController where
|
||||||
parseRoute' = do
|
parseRoute' = do
|
||||||
_ <- string "/api/v2/widget-patterns"
|
_ <- string "/api/v2/widget-patterns"
|
||||||
|
|||||||
@@ -416,6 +416,13 @@ data ApiV2HubCapabilityManifestsController
|
|||||||
| ApiV2ActivateHubCapabilityManifestAction { hubCapabilityManifestId :: !(Id HubCapabilityManifest) }
|
| ApiV2ActivateHubCapabilityManifestAction { hubCapabilityManifestId :: !(Id HubCapabilityManifest) }
|
||||||
deriving (Eq, Show, Data)
|
deriving (Eq, Show, Data)
|
||||||
|
|
||||||
|
data ApiV2ApiConsumersController
|
||||||
|
= ApiV2IndexApiConsumersAction
|
||||||
|
| ApiV2ShowApiConsumerAction { apiConsumerId :: !(Id ApiConsumer) }
|
||||||
|
| ApiV2CreateApiConsumerAction
|
||||||
|
| ApiV2CreateApiConsumerKeyAction { apiConsumerId :: !(Id ApiConsumer) }
|
||||||
|
deriving (Eq, Show, Data)
|
||||||
|
|
||||||
data ApiV2WidgetPatternsController
|
data ApiV2WidgetPatternsController
|
||||||
= ApiV2IndexWidgetPatternsAction
|
= ApiV2IndexWidgetPatternsAction
|
||||||
| ApiV2ShowWidgetPatternAction { widgetPatternId :: !(Id WidgetPattern) }
|
| ApiV2ShowWidgetPatternAction { widgetPatternId :: !(Id WidgetPattern) }
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ without hiding that classification inside prose.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: IHUB-WP-0019-T04
|
id: IHUB-WP-0019-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "a50114d7-8719-45d5-9081-948df147d500"
|
state_hub_task_id: "a50114d7-8719-45d5-9081-948df147d500"
|
||||||
```
|
```
|
||||||
@@ -189,6 +189,15 @@ Add either documented v2 endpoints or an admin-only bootstrap command for:
|
|||||||
Done when: an operator can create an ops-hub API credential from a repeatable
|
Done when: an operator can create an ops-hub API credential from a repeatable
|
||||||
command while preserving the one-time secret display invariant.
|
command while preserving the one-time secret display invariant.
|
||||||
|
|
||||||
|
Implementation note (2026-05-19): added authenticated v2
|
||||||
|
`/api/v2/api-consumers` support for consumer create/list/show, including active
|
||||||
|
manifest binding validation, positive rate-limit/quota validation, and
|
||||||
|
`POST /api/v2/api-consumers/:id/api-keys` for one-time static key generation.
|
||||||
|
Key hashes are stored; the raw `fullKey` is returned only in the key creation
|
||||||
|
response. Added OpenAPI/SDK entries and focused Hspec helper coverage. Local
|
||||||
|
`git diff --check` passed; `scripts/compile-check` could not run because this
|
||||||
|
shell does not have `IHP_LIB`/the IHP dev environment loaded.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### T05 — Fix interaction-event create contract gaps
|
### T05 — Fix interaction-event create contract gaps
|
||||||
|
|||||||
Reference in New Issue
Block a user