feat: add v2 api consumer bootstrap endpoints

This commit is contained in:
2026-05-19 01:56:48 +02:00
parent e1c0f46a67
commit 75ad691dd6
8 changed files with 285 additions and 1 deletions

View File

@@ -11,6 +11,7 @@ import Web.Controller.Api.V2.InteractionEvents
import Web.Controller.Api.V2.Hubs (missingRequiredFields, validCreateHubKind)
import Web.Controller.Api.V2.HubCapabilityManifests
( jsonArrayTexts, textArrayFieldFromJsonBody )
import Web.Controller.Api.V2.ApiConsumers (positiveLimit)
import Web.Controller.Api.V2.Widgets (missingWidgetCreateFields, validWidgetStatus)
main :: IO ()
@@ -87,4 +88,10 @@ main = hspec do
jsonArrayTexts (toJSON (["ops-endpoint-card", "ops-alert-panel"] :: [Text]))
`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

View 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

View File

@@ -93,6 +93,8 @@ buildOpenApiSpec = do
]
, "Hub" .= hubSchema
, "HubCapabilityManifest" .= manifestSchema
, "ApiConsumer" .= apiConsumerSchema
, "ApiKey" .= apiKeySchema
, "Widget" .= widgetSchema
, "InteractionEvent" .= interactionEventSchema
, "Annotation" .= annotationSchema
@@ -136,6 +138,14 @@ buildPaths = object
, "/hub-capability-manifests/{id}/activate" .= object
[ "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
[ "get" .= listOp "Widget" []
, "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 = object
[ "type" .= ("object" :: Text)

View File

@@ -110,6 +110,14 @@ tsSdkClientClass = T.unlines
, " 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 }) {"
, " const q = params ? `?page=${params.page ?? 1}&per_page=${params.perPage ?? 50}` : '';"
, " 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:"
, " 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:"
, " return self._request(f'/widgets?page={page}&per_page={per_page}')"
, ""

View File

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

View File

@@ -280,6 +280,26 @@ instance HasPath ApiV2HubCapabilityManifestsController where
pathTo ApiV2UpdateHubCapabilityManifestAction { hubCapabilityManifestId } = "/api/v2/hub-capability-manifests/" <> tshow hubCapabilityManifestId
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
parseRoute' = do
_ <- string "/api/v2/widget-patterns"

View File

@@ -416,6 +416,13 @@ data ApiV2HubCapabilityManifestsController
| ApiV2ActivateHubCapabilityManifestAction { hubCapabilityManifestId :: !(Id HubCapabilityManifest) }
deriving (Eq, Show, Data)
data ApiV2ApiConsumersController
= ApiV2IndexApiConsumersAction
| ApiV2ShowApiConsumerAction { apiConsumerId :: !(Id ApiConsumer) }
| ApiV2CreateApiConsumerAction
| ApiV2CreateApiConsumerKeyAction { apiConsumerId :: !(Id ApiConsumer) }
deriving (Eq, Show, Data)
data ApiV2WidgetPatternsController
= ApiV2IndexWidgetPatternsAction
| ApiV2ShowWidgetPatternAction { widgetPatternId :: !(Id WidgetPattern) }

View File

@@ -174,7 +174,7 @@ without hiding that classification inside prose.
```task
id: IHUB-WP-0019-T04
status: todo
status: done
priority: high
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
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