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