From 75ad691dd67b6c5e72ab0de48de0ca069f225854 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 19 May 2026 01:56:48 +0200 Subject: [PATCH] feat: add v2 api consumer bootstrap endpoints --- Test/Main.hs | 7 + Web/Controller/Api/V2/ApiConsumers.hs | 175 ++++++++++++++++++ Web/Controller/Api/V2/OpenApi.hs | 43 +++++ Web/Controller/Api/V2/Sdk.hs | 21 +++ Web/FrontController.hs | 2 + Web/Routes.hs | 20 ++ Web/Types.hs | 7 + .../IHUB-WP-0019-vsm-hub-bootstrap-api.md | 11 +- 8 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 Web/Controller/Api/V2/ApiConsumers.hs diff --git a/Test/Main.hs b/Test/Main.hs index 3dad294..eb96eb9 100644 --- a/Test/Main.hs +++ b/Test/Main.hs @@ -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 diff --git a/Web/Controller/Api/V2/ApiConsumers.hs b/Web/Controller/Api/V2/ApiConsumers.hs new file mode 100644 index 0000000..331250b --- /dev/null +++ b/Web/Controller/Api/V2/ApiConsumers.hs @@ -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 diff --git a/Web/Controller/Api/V2/OpenApi.hs b/Web/Controller/Api/V2/OpenApi.hs index e223dab..1b088b2 100644 --- a/Web/Controller/Api/V2/OpenApi.hs +++ b/Web/Controller/Api/V2/OpenApi.hs @@ -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) diff --git a/Web/Controller/Api/V2/Sdk.hs b/Web/Controller/Api/V2/Sdk.hs index 2b13de9..d437981 100644 --- a/Web/Controller/Api/V2/Sdk.hs +++ b/Web/Controller/Api/V2/Sdk.hs @@ -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}')" , "" diff --git a/Web/FrontController.hs b/Web/FrontController.hs index 5949ef2..2954f60 100644 --- a/Web/FrontController.hs +++ b/Web/FrontController.hs @@ -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 diff --git a/Web/Routes.hs b/Web/Routes.hs index 4cfefff..dfe73b6 100644 --- a/Web/Routes.hs +++ b/Web/Routes.hs @@ -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" diff --git a/Web/Types.hs b/Web/Types.hs index d6506e6..1ed562a 100644 --- a/Web/Types.hs +++ b/Web/Types.hs @@ -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) } diff --git a/workplans/IHUB-WP-0019-vsm-hub-bootstrap-api.md b/workplans/IHUB-WP-0019-vsm-hub-bootstrap-api.md index b1fe2f1..41dc542 100644 --- a/workplans/IHUB-WP-0019-vsm-hub-bootstrap-api.md +++ b/workplans/IHUB-WP-0019-vsm-hub-bootstrap-api.md @@ -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