@vuoma/sdk 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ import type { OrganizationReadInputs, OrganizationReadResults, OrganizationWriteInputs, OrganizationWriteResults } from './organization-contracts.generated.js'
2
+ export type { OrganizationReadInputs, OrganizationReadResults, OrganizationReadOperationId, OrganizationWriteInputs, OrganizationWriteResults, OrganizationWriteOperationId } from './organization-contracts.generated.js'
3
+
4
+ export interface OrganizationApiClientOptions {
5
+ apiOrigin: string
6
+ organizationSlug: string
7
+ /** Return the current exact organization API token; never a member session. */
8
+ getApiToken(): string | null | Promise<string | null>
9
+ fetch?: typeof globalThis.fetch
10
+ maximumResponseBytes?: number
11
+ }
12
+ export interface OrganizationApiClient {
13
+ readonly operations: {
14
+ readonly [Id in keyof OrganizationReadInputs]: {} extends OrganizationReadInputs[Id]
15
+ ? (input?: OrganizationReadInputs[Id], options?: { signal?: AbortSignal }) => Promise<OrganizationReadResults[Id]>
16
+ : (input: OrganizationReadInputs[Id], options?: { signal?: AbortSignal }) => Promise<OrganizationReadResults[Id]>
17
+ }
18
+ /** Explicit writes execute once; reconcile uncertain message sends before repeating. */
19
+ readonly writeOperations: {
20
+ readonly [Id in keyof OrganizationWriteInputs]: (input: OrganizationWriteInputs[Id], options?: { signal?: AbortSignal }) => Promise<OrganizationWriteResults[Id]>
21
+ }
22
+ }
23
+ export function createOrganizationApiClient(options: OrganizationApiClientOptions): OrganizationApiClient
@@ -0,0 +1,95 @@
1
+ import Ajv from 'ajv'
2
+ import addFormats from 'ajv-formats'
3
+ import { organizationReadContracts, organizationWriteContracts } from './organization-contracts.generated.js'
4
+ import { VuomaApiError } from './index.js'
5
+
6
+ const validator = new Ajv({ strict: false, allErrors: false, validateFormats: true })
7
+ addFormats(validator)
8
+ const contracts = new Map(organizationReadContracts.map(contract => [contract.id, {
9
+ ...contract, validateInput: validator.compile(contract.query_schema), validateOutput: validator.compile(contract.response_schema),
10
+ }]))
11
+ const writeContracts = new Map(organizationWriteContracts.map(contract => [contract.id, {
12
+ ...contract, validateInput: validator.compile(contract.request_schema), validateOutput: validator.compile(contract.response_schema),
13
+ }]))
14
+ const problem = (status, code, message) => new VuomaApiError(status, code, message)
15
+ const unavailable = () => problem(502, 'invalid_response', 'Vuoma returned an invalid response.')
16
+
17
+ /** Organization business API client. Uses the caller's exact scoped API token;
18
+ * never stores it or substitutes a member/account/browser credential.
19
+ */
20
+ export function createOrganizationApiClient({ apiOrigin, organizationSlug, getApiToken, fetch: fetcher = globalThis.fetch, maximumResponseBytes = 2_097_152 }) {
21
+ let origin
22
+ try {
23
+ const url = new URL(apiOrigin)
24
+ if (url.username || url.password || url.search || url.hash || url.pathname !== '/' ||
25
+ !(url.protocol === 'https:' || url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) throw Error()
26
+ origin = url.origin
27
+ } catch { throw new TypeError('Choose a valid Vuoma API origin.') }
28
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(organizationSlug ?? '') || organizationSlug.length > 80) throw new TypeError('Choose a valid organization slug.')
29
+ if (typeof getApiToken !== 'function' || typeof fetcher !== 'function') throw new TypeError('Provide an API token adapter and fetch implementation.')
30
+ if (!Number.isSafeInteger(maximumResponseBytes) || maximumResponseBytes < 1024 || maximumResponseBytes > 16_777_216) throw new TypeError('Choose a bounded response size.')
31
+
32
+ async function execute(contract, path, { signal, body } = {}) {
33
+ const token = await getApiToken()
34
+ if (!/^chapi_v1_[A-Za-z0-9_-]{16}_[A-Za-z0-9_-]{43}$/u.test(token ?? '')) throw problem(401, 'api_authorization_required', 'Provide an organization API token.')
35
+ const response = await fetcher(`${origin}${path}`, {
36
+ method: contract.method, credentials: 'omit', redirect: 'manual', signal,
37
+ headers: { authorization: `Bearer ${token}`, accept: 'application/json', ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
38
+ ...(body === undefined ? {} : { body }),
39
+ })
40
+ if (!response.ok) {
41
+ await response.body?.cancel().catch(() => {})
42
+ throw problem(response.status, 'api_request_failed', response.status === 401 || response.status === 403
43
+ ? 'This API token or current organization permissions do not allow this operation.' : 'The API request could not be completed.')
44
+ }
45
+ const contentType = response.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase()
46
+ if (response.redirected || !(contentType === 'application/json' || /^application\/[a-z0-9.+-]+\+json$/u.test(contentType ?? '')) ||
47
+ Number(response.headers.get('content-length')) > maximumResponseBytes) {
48
+ await response.body?.cancel().catch(() => {})
49
+ throw unavailable()
50
+ }
51
+ const reader = response.body?.getReader()
52
+ if (!reader) throw unavailable()
53
+ const chunks = []
54
+ let bytes = 0
55
+ try {
56
+ for (;;) {
57
+ signal?.throwIfAborted()
58
+ const { done, value } = await reader.read()
59
+ if (done) break
60
+ bytes += value.byteLength
61
+ if (bytes > maximumResponseBytes) throw unavailable()
62
+ chunks.push(value)
63
+ }
64
+ const body = new Uint8Array(bytes)
65
+ let offset = 0
66
+ for (const chunk of chunks) { body.set(chunk, offset); offset += chunk.byteLength }
67
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(body)
68
+ if (text.includes(token)) throw unavailable()
69
+ const data = JSON.parse(text)
70
+ if (!contract.validateOutput(data)) throw unavailable()
71
+ return data
72
+ } catch (error) {
73
+ await reader.cancel().catch(() => {})
74
+ if (signal?.aborted) throw signal.reason
75
+ throw unavailable()
76
+ } finally { reader.releaseLock() }
77
+ }
78
+ const operations = Object.fromEntries([...contracts].map(([id, contract]) => [id, async (args = {}, { signal } = {}) => {
79
+ if (!contract.validateInput(args)) throw new TypeError('Provide the query fields declared by this API contract.')
80
+ const query = new URLSearchParams(Object.entries(args).map(([name, value]) => [name, String(value)]))
81
+ const path = contract.path_template.replace('{organization_slug}', encodeURIComponent(organizationSlug))
82
+ return execute(contract, `${path}${query.size ? `?${query}` : ''}`, { signal })
83
+ }]))
84
+ // Mutation calls execute exactly once. Even idempotent preference writes do
85
+ // not retry internally, and message sends must be reconciled after uncertainty.
86
+ const writeOperations = Object.fromEntries([...writeContracts].map(([id, contract]) => [id, async (args, { signal } = {}) => {
87
+ if (!contract.validateInput(args)) throw new TypeError('Provide the path and body fields declared by this API contract.')
88
+ const body = JSON.stringify(args.body)
89
+ if (new TextEncoder().encode(body).byteLength > contract.maximum_body_bytes) throw new TypeError('The API request exceeds its contract byte limit.')
90
+ const values = { ...args.path, organization_slug: organizationSlug }
91
+ const path = contract.path_template.replace(/\{([a-z_]+)\}/gu, (_, name) => encodeURIComponent(values[name]))
92
+ return execute(contract, path, { signal, body })
93
+ }]))
94
+ return Object.freeze({ operations: Object.freeze(operations), writeOperations: Object.freeze(writeOperations) })
95
+ }