Files
inter-hub/Web/Controller/Api/V2/Sdk.hs
tegwick 50735bb7cf
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
feat: add v2 manifest bootstrap endpoints
2026-05-16 09:06:15 +02:00

231 lines
12 KiB
Haskell

module Web.Controller.Api.V2.Sdk where
-- GET /api/v2/sdk — SDK index page
-- GET /api/v2/sdk/ihf-client.ts — TypeScript SDK (live-generated from registries)
-- GET /api/v2/sdk/ihf-client.py — Python SDK (live-generated from registries)
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.ByteString.Lazy as LBS
import Network.HTTP.Types (status200)
import Network.Wai (responseLBS)
import Application.Helper.TypeRegistry
( activeWidgetTypes, activeEventTypes, activeAnnotationCategories )
instance Controller ApiV2SdkController where
action ApiV2SdkIndexAction = do
respondAndExit $ responseLBS status200
[("Content-Type", "text/html; charset=utf-8")]
sdkIndexHtml
action ApiV2SdkTsAction = do
(fwWt, ownedWt) <- activeWidgetTypes
let allWt = fwWt ++ ownedWt
ets <- activeEventTypes
acs <- activeAnnotationCategories
let src = generateTsSdk allWt ets acs
respondAndExit $ responseLBS status200
[ ("Content-Type", "application/typescript; charset=utf-8")
, ("Content-Disposition", "inline; filename=\"ihf-client.ts\"")
]
(LBS.fromStrict (TE.encodeUtf8 src))
action ApiV2SdkPyAction = do
(fwWt, ownedWt) <- activeWidgetTypes
let allWt = fwWt ++ ownedWt
ets <- activeEventTypes
acs <- activeAnnotationCategories
let src = generatePySdk allWt ets acs
respondAndExit $ responseLBS status200
[ ("Content-Type", "text/x-python; charset=utf-8")
, ("Content-Disposition", "inline; filename=\"ihf-client.py\"")
]
(LBS.fromStrict (TE.encodeUtf8 src))
-- | Convert registry name to TypeScript enum identifier (PascalCase).
-- e.g. "data-table" -> "DataTable", "ux-friction" -> "UxFriction"
toPascalCase :: Text -> Text
toPascalCase = T.concat . map capitalise . T.splitOn "-"
where capitalise "" = ""
capitalise t = T.toUpper (T.take 1 t) <> T.drop 1 t
-- | Convert registry name to Python UPPER_SNAKE identifier.
-- e.g. "data-table" -> "DATA_TABLE", "ux-friction" -> "UX_FRICTION"
toUpperSnake :: Text -> Text
toUpperSnake = T.toUpper . T.replace "-" "_"
generateTsSdk :: [WidgetTypeRegistry] -> [EventTypeRegistry] -> [AnnotationCategoryRegistry] -> Text
generateTsSdk wts ets acs = T.unlines
[ "// Auto-generated by IHF. Do not edit manually."
, "// Regenerate: curl <base>/api/v2/sdk/ihf-client.ts > ihf-client.ts"
, ""
, "export enum WidgetType {"
] <> T.unlines (map (\r -> " " <> toPascalCase r.name <> " = \"" <> r.name <> "\",") wts)
<> "}\n\nexport enum EventType {\n"
<> T.unlines (map (\r -> " " <> toPascalCase r.name <> " = \"" <> r.name <> "\",") ets)
<> "}\n\nexport enum AnnotationCategory {\n"
<> T.unlines (map (\r -> " " <> toPascalCase r.name <> " = \"" <> r.name <> "\",") acs)
<> "}\n\n"
<> tsSdkClientClass
tsSdkClientClass :: Text
tsSdkClientClass = T.unlines
[ "export interface IhfApiOptions {"
, " baseUrl: string;"
, " bearerToken: string;"
, "}"
, ""
, "export class IhfClient {"
, " constructor(private opts: IhfApiOptions) {}"
, ""
, " private async fetch(path: string, method = 'GET', body?: object): Promise<Response> {"
, " return fetch(this.opts.baseUrl + path, {"
, " method,"
, " headers: {"
, " 'Authorization': 'Bearer ' + this.opts.bearerToken,"
, " 'Content-Type': 'application/json',"
, " },"
, " body: body ? JSON.stringify(body) : undefined,"
, " });"
, " }"
, ""
, " async createHub(body: { slug: string; name: string; domain: string; hubKind?: 'domain' | 'shared' }) {"
, " 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 }) {"
, " const q = params ? `?page=${params.page ?? 1}&per_page=${params.perPage ?? 50}` : '';"
, " return this.fetch('/widgets' + q).then(r => r.json());"
, " }"
, ""
, " async createWidget(body: { hubId: string; name: string; widgetType: WidgetType; capabilityRef?: string; viewContext?: string; policyScope?: string; status?: 'active' | 'deprecated' | 'draft' }) {"
, " return this.fetch('/widgets', 'POST', body).then(r => r.json());"
, " }"
, ""
, " async getInteractionEvents(params?: { widgetId?: string; eventType?: EventType }) {"
, " const qs = new URLSearchParams();"
, " if (params?.widgetId) qs.set('widgetId', params.widgetId);"
, " if (params?.eventType) qs.set('eventType', params.eventType);"
, " return this.fetch('/interaction-events?' + qs).then(r => r.json());"
, " }"
, ""
, " async submitInteractionEvent(body: { widgetId: string; eventType: EventType; viewContext?: string; metadata?: object }) {"
, " return this.fetch('/interaction-events', 'POST', body).then(r => r.json());"
, " }"
, ""
, " async submitAnnotation(body: { widgetId: string; category: AnnotationCategory; body: string }) {"
, " return this.fetch('/annotations', 'POST', body).then(r => r.json());"
, " }"
, "}"
]
generatePySdk :: [WidgetTypeRegistry] -> [EventTypeRegistry] -> [AnnotationCategoryRegistry] -> Text
generatePySdk wts ets acs = T.unlines
[ "# Auto-generated by IHF. Do not edit manually."
, "# Regenerate: curl <base>/api/v2/sdk/ihf-client.py > ihf_client.py"
, ""
, "from enum import Enum"
, "from typing import Optional, Any"
, "import urllib.request, urllib.parse, json"
, ""
, "class WidgetType(str, Enum):"
] <> T.unlines (map (\r -> " " <> toUpperSnake r.name <> " = \"" <> r.name <> "\"") wts)
<> "\nclass EventType(str, Enum):\n"
<> T.unlines (map (\r -> " " <> toUpperSnake r.name <> " = \"" <> r.name <> "\"") ets)
<> "\nclass AnnotationCategory(str, Enum):\n"
<> T.unlines (map (\r -> " " <> toUpperSnake r.name <> " = \"" <> r.name <> "\"") acs)
<> "\n"
<> pyClientClass
pyClientClass :: Text
pyClientClass = T.unlines
[ "class IhfClient:"
, " def __init__(self, base_url: str, bearer_token: str):"
, " self.base_url = base_url.rstrip('/')"
, " self.token = bearer_token"
, ""
, " def _request(self, path: str, method: str = 'GET', body: Optional[dict] = None) -> dict:"
, " url = self.base_url + path"
, " data = json.dumps(body).encode('utf-8') if body else None"
, " req = urllib.request.Request(url, data=data, method=method,"
, " headers={'Authorization': 'Bearer ' + self.token, 'Content-Type': 'application/json'})"
, " with urllib.request.urlopen(req) as resp:"
, " return json.loads(resp.read())"
, ""
, " 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})"
, ""
, " 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:"
, " return self._request(f'/widgets?page={page}&per_page={per_page}')"
, ""
, " def create_widget(self, hub_id: str, name: str, widget_type: WidgetType, capability_ref: Optional[str] = None, view_context: Optional[str] = None, policy_scope: Optional[str] = None, status: Optional[str] = None) -> dict:"
, " body: dict = {'hubId': hub_id, 'name': name, 'widgetType': str(widget_type)}"
, " if capability_ref: body['capabilityRef'] = capability_ref"
, " if view_context: body['viewContext'] = view_context"
, " if policy_scope: body['policyScope'] = policy_scope"
, " if status: body['status'] = status"
, " return self._request('/widgets', 'POST', body)"
, ""
, " def get_interaction_events(self, widget_id: Optional[str] = None, event_type: Optional[EventType] = None) -> dict:"
, " qs = urllib.parse.urlencode({k: v for k, v in {'widgetId': widget_id, 'eventType': event_type and str(event_type)}.items() if v})"
, " return self._request('/interaction-events' + ('?' + qs if qs else ''))"
, ""
, " def submit_interaction_event(self, widget_id: str, event_type: EventType, view_context: Optional[str] = None, metadata: Optional[dict] = None) -> dict:"
, " body: dict = {'widgetId': widget_id, 'eventType': str(event_type)}"
, " if view_context: body['viewContext'] = view_context"
, " if metadata: body['metadata'] = metadata"
, " return self._request('/interaction-events', 'POST', body)"
, ""
, " def submit_annotation(self, widget_id: str, category: AnnotationCategory, body: str) -> dict:"
, " return self._request('/annotations', 'POST', {'widgetId': widget_id, 'category': str(category), 'body': body})"
]
sdkIndexHtml :: LBS.ByteString
sdkIndexHtml = LBS.fromStrict $ TE.encodeUtf8 $ T.unlines
[ "<!DOCTYPE html><html lang='en'><head><meta charset='UTF-8'/>"
, "<title>IHF API v2 — SDKs</title>"
, "<link rel='stylesheet' href='/app.css'/></head><body class='bg-gray-50 p-8'>"
, "<h1 class='text-2xl font-bold mb-4'>IHF Consumer SDKs</h1>"
, "<p class='mb-6 text-gray-600'>Both SDKs are generated live from the type registries. Download and import directly.</p>"
, "<div class='space-y-4'>"
, "<div class='bg-white border rounded p-4'>"
, "<h2 class='font-semibold'>TypeScript / Node.js</h2>"
, "<p class='text-sm text-gray-500 mb-2'>ES2020 module. Typed enums for all widget types, event types, annotation categories.</p>"
, "<a href='/api/v2/sdk/ihf-client.ts' class='text-indigo-600 text-sm'>Download ihf-client.ts</a>"
, "</div>"
, "<div class='bg-white border rounded p-4'>"
, "<h2 class='font-semibold'>Python</h2>"
, "<p class='text-sm text-gray-500 mb-2'>stdlib-only (no third-party deps). str-Enum classes for all registered types.</p>"
, "<a href='/api/v2/sdk/ihf-client.py' class='text-indigo-600 text-sm'>Download ihf-client.py</a>"
, "</div>"
, "</div>"
, "<p class='mt-6 text-sm text-gray-400'>See <a href='/api/v2/docs' class='text-indigo-500'>API documentation</a> for full endpoint reference.</p>"
, "</body></html>"
]