generated from coulomb/repo-seed
Some checks failed
Build and Deploy / build-push-deploy (push) Has been cancelled
231 lines
12 KiB
Haskell
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>"
|
|
]
|