generated from coulomb/repo-seed
A2 — Compilation fixes: - Remove inline FK constraints from Schema.sql; IHP schema compiler cannot parse them. Add 1744329600-restore-fk-constraints.sql migration to restore referential integrity at the DB level. - Rename `#label` → `#label_` throughout to avoid clash with Haskell built-in. - Fix `hub.id == hid` UUID comparisons to use `toUUID hub.id`. - Replace non-existent `setStatus`/`respondJson` calls with `renderJsonWithStatusCode` throughout Api controllers. - Fix qualified package import for `cryptohash-sha256` in Auth.hs. - Add `CanSelect (Text, Text)` instance in Helper.View. - Refactor HSX inline lambdas to named helper functions in 100+ views (GHC cannot infer types for anonymous functions inside quasi-quoted HSX). - Fix missing imports (IHP.QueryBuilder, IHP.Fetch, Web.Routes, Only, etc.) across helpers and controllers. - Remove duplicate `diffUTCTime` definition in BottleneckDetector. - Change `createEventForHub` return type from `IO ResponseReceived` to `IO ()`. - Seed type-registry vocabulary via 1744502400-seed-type-registries.sql (moved from Schema.sql where IHP does not execute INSERT statements). A3 — Tailwind build pipeline: - Add `tailwindcss` to flake.nix native packages. - Uncomment `tailwind.exec` process in devenv shell config. - Add tailwind/tailwind.config.js (scans Web/View/**/*.hs). - Add tailwind/app.css with @tailwind directives. A4 — Admin user seed: - Add 1744416000-seed-admin-user.sql: inserts admin@inter-hub.local with bcrypt-hashed password admin1234! (cost 10). - Add .env.example documenting all required environment variables and default admin credentials. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
191 lines
8.9 KiB
Haskell
191 lines
8.9 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 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 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 get_widgets(self, page: int = 1, per_page: int = 50) -> dict:"
|
|
, " return self._request(f'/widgets?page={page}&per_page={per_page}')"
|
|
, ""
|
|
, " 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>"
|
|
]
|