diff --git a/.github/workflows/sdk-build-validation.yml b/.github/workflows/sdk-build-validation.yml index 839f559535..f1c9d85964 100644 --- a/.github/workflows/sdk-build-validation.yml +++ b/.github/workflows/sdk-build-validation.yml @@ -37,6 +37,9 @@ jobs: platform: client # Server SDKs + - sdk: web + platform: server + - sdk: node platform: server diff --git a/src/SDK/Language/Web.php b/src/SDK/Language/Web.php index 28002b7c09..de2769f330 100644 --- a/src/SDK/Language/Web.php +++ b/src/SDK/Language/Web.php @@ -476,6 +476,147 @@ public function getSubSchema(array $property, array $spec, string $methodName = return $this->getTypeName($property); } + /** + * Determine whether a method supports client-side platforms. + */ + protected function methodSupportsClient(array $method): bool + { + return in_array('client', $method['platforms'] ?? [], true); + } + + /** + * Determine whether a method supports server/console platforms. + */ + protected function methodSupportsServer(array $method): bool + { + $platforms = $method['platforms'] ?? []; + return in_array('server', $platforms, true) || in_array('console', $platforms, true); + } + + /** + * Compute auth-related flags for a Web service. + * + * @return array + */ + public function webServiceAuth(array $service): array + { + $hasClientTier = false; + $hasServerTier = false; + $hasServerOnly = false; + $hasClientOnly = false; + $hasUpload = false; + $serverOnlyMethods = []; + $clientOnlyMethods = []; + + foreach ($service['methods'] ?? [] as $method) { + $hasClient = $this->methodSupportsClient($method); + $hasServer = $this->methodSupportsServer($method); + $methodName = $this->toCamelCase($method['name'] ?? ''); + + if ($hasClient) { + $hasClientTier = true; + } + if ($hasServer) { + $hasServerTier = true; + } + if ($hasServer && !$hasClient) { + $hasServerOnly = true; + $serverOnlyMethods[] = $methodName; + } + if ($hasClient && !$hasServer) { + $hasClientOnly = true; + $clientOnlyMethods[] = $methodName; + } + if (in_array('multipart/form-data', $method['consumes'] ?? [], true)) { + $hasUpload = true; + } + } + + $hasMixedTier = $hasClientTier && $hasServerTier; + + return [ + 'hasClientTier' => $hasClientTier, + 'hasServerTier' => $hasServerTier, + 'hasMixedTier' => $hasMixedTier, + 'needsServerAuth' => $hasServerTier && (!$hasMixedTier || $hasServerOnly), + 'needsClientAuth' => $hasClientTier && (!$hasMixedTier || $hasClientOnly), + 'hasUpload' => $hasUpload, + 'serverOnlyMethods' => array_values(array_unique($serverOnlyMethods)), + 'clientOnlyMethods' => array_values(array_unique($clientOnlyMethods)), + ]; + } + + /** + * Build the TypeScript `this:` gate string for a method in a Web service. + */ + public function webMethodThisGate(array $method, array $service): string + { + $auth = $this->webServiceAuth($service); + if (!$auth['hasMixedTier']) { + return ''; + } + + $serviceName = $this->toPascalCase($service['name'] ?? ''); + + if (!$this->methodSupportsClient($method)) { + return 'this: ' . $serviceName . ', '; + } + if (!$this->methodSupportsServer($method)) { + return 'this: ' . $serviceName . ', '; + } + + return ''; + } + + public function webClientBaseParams(array $headers): array + { + $params = [ + 'Project' => [ + 'name' => 'projectId', + 'required' => true, + 'setter' => 'setProject', + ], + 'Locale' => [ + 'name' => 'locale', + 'required' => false, + 'setter' => 'setLocale', + ], + 'Mode' => [ + 'name' => 'mode', + 'required' => false, + 'setter' => 'setMode', + ], + 'Platform' => [ + 'name' => 'platform', + 'required' => false, + 'setter' => 'setPlatform', + ], + ]; + + $baseParams = []; + foreach ($headers as $header) { + $key = $header['key'] ?? ''; + if (isset($params[$key])) { + $baseParams[] = $params[$key]; + } + } + + return $baseParams; + } + + public function webClientSetterReturnType(array $header): string + { + return match ($header['key'] ?? '') { + 'Key' => "ClientRuntime<'apiKey'>", + 'Cookie' => "ClientRuntime<'cookie'>", + 'JWT' => "ClientRuntime<'jwt'>", + 'Session' => "ClientRuntime<'session'>", + 'DevKey' => "ClientRuntime<'devKey'>", + 'ImpersonateUserId', 'ImpersonateUserEmail', 'ImpersonateUserPhone' => "ClientRuntime<'impersonation'>", + default => 'this', + }; + } + public function getFilters(): array { return \array_merge(parent::getFilters(), [ @@ -536,6 +677,18 @@ public function getFilters(): array return $condition; }, ['is_safe' => ['html']]), + new TwigFilter('webServiceAuth', function (array $service) { + return $this->webServiceAuth($service); + }), + new TwigFilter('webMethodThisGate', function (array $method, array $service) { + return $this->webMethodThisGate($method, $service); + }, ['is_safe' => ['html']]), + new TwigFilter('webClientBaseParams', function (array $headers) { + return $this->webClientBaseParams($headers); + }), + new TwigFilter('webClientSetterReturnType', function (array $header) { + return $this->webClientSetterReturnType($header); + }, ['is_safe' => ['html']]), new TwigFilter('comment2', function ($value) { $value = explode("\n", $value); foreach ($value as $key => $line) { diff --git a/templates/web/src/client.ts.twig b/templates/web/src/client.ts.twig index 4cbeecb6f4..64fd2e5677 100644 --- a/templates/web/src/client.ts.twig +++ b/templates/web/src/client.ts.twig @@ -344,8 +344,53 @@ class {{spec.title | caseUcfirst}}Exception extends Error { /** * Client that handles requests to {{spec.title | caseUcfirst}} */ -class Client { +type SDKPlatform = 'client' | 'server'; +type ClientAuth = 'browser' | 'session' | 'devKey' | 'impersonation'; +type ServerAuth = 'apiKey' | 'jwt' | 'cookie'; +type Auth = ClientAuth | ServerAuth; +declare const clientAuthBrand: unique symbol; + +// Forces TypeScript to display the expanded shape on hover instead of an alias name. +type Prettify = { [K in keyof T]: T[K] } & {}; + +type BaseClientParams = { + endpoint: string; + endpointRealtime?: string; + selfSigned?: boolean; +{%~ for param in spec.global.headers | webClientBaseParams %} + {{ param.name }}{% if not param.required %}?{% endif %}: string; +{%~ endfor %} +}; + +type ImpersonationTarget = + | { userId: string; email?: never; phone?: never } + | { email: string; userId?: never; phone?: never } + | { phone: string; userId?: never; email?: never }; + +type LegacyClientSetter = Extract, `set${string}`>; +type ClientAuthBuilder = Extract, `with${string}`>; +type ClientInternalMethod = LegacyClientSetter | ClientAuthBuilder; +export type Client = Omit, ClientInternalMethod>; +type LegacyClient = Omit, ClientAuthBuilder>; + +type ClientConstructor = { + new (): LegacyClient; + fromBrowser(params: Prettify): Client<'browser'>; + fromSession(params: Prettify): Client<'session'>; +{%~ if sdk.platform == 'server' or sdk.platform == 'console' %} + fromAPIKey(params: Prettify): Client<'apiKey'>; +{%~ endif %} + fromCookie(params: Prettify): Client<'cookie'>; +{%~ if sdk.platform == 'server' or sdk.platform == 'console' %} + fromJWT(params: Prettify): Client<'jwt'>; +{%~ endif %} + fromDevKey(params: Prettify): Client<'devKey'>; + fromImpersonation(params: Prettify): Client<'impersonation'>; +}; + +class ClientRuntime { static CHUNK_SIZE = 1024 * 1024 * 5; + declare readonly [clientAuthBrand]?: TAuth; /** * Holds configuration such as project. @@ -356,19 +401,18 @@ class Client { {%~ for header in spec.global.headers %} {{ header.key | caseLower }}: string; {%~ endfor %} -{%~ if sdk.platform == 'console' %} selfSigned: boolean; -{%~ endif %} } = { endpoint: '{{ spec.endpoint }}', endpointRealtime: '', {%~ for header in spec.global.headers %} {{ header.key | caseLower }}: '', {%~ endfor %} -{%~ if sdk.platform == 'console' %} selfSigned: false, -{%~ endif %} }; + + private sdkPlatform: SDKPlatform = '{{ sdk.platform == 'server' ? 'server' : 'client' }}'; + /** * Custom headers for API requests. */ @@ -382,6 +426,112 @@ class Client { {%~ endfor %} }; + static fromBrowser(params: Prettify): Client<'browser'> { + return new ClientRuntime<'browser'>().applyBase<'browser'>(params, 'client'); + } + + static fromSession(params: Prettify): Client<'session'> { + return new ClientRuntime<'session'>() + .applyBase<'session'>(params, 'client') + .setSession(params.session); + } + +{%~ if sdk.platform == 'server' or sdk.platform == 'console' %} + static fromAPIKey(params: Prettify): Client<'apiKey'> { + return new ClientRuntime<'apiKey'>() + .applyBase<'apiKey'>(params, 'server') + .setKey(params.apiKey); + } + +{%~ endif %} + + static fromCookie(params: Prettify): Client<'cookie'> { + return new ClientRuntime<'cookie'>() +{%~ if sdk.platform == 'console' %} + .applyBase<'cookie'>({ mode: 'admin', ...params }, 'server') +{%~ else %} + .applyBase<'cookie'>(params, 'server') +{%~ endif %} + .setCookie(params.cookie); + } + +{%~ if sdk.platform == 'server' or sdk.platform == 'console' %} + static fromJWT(params: Prettify): Client<'jwt'> { + return new ClientRuntime<'jwt'>() + .applyBase<'jwt'>(params, 'server') + .setJWT(params.jwt); + } + +{%~ endif %} + + static fromDevKey(params: Prettify): Client<'devKey'> { + return new ClientRuntime<'devKey'>() + .applyBase<'devKey'>(params, 'client') + .setDevKey(params.devKey); + } + + static fromImpersonation(params: Prettify): Client<'impersonation'> { + const targets = [ + params.userId !== undefined, + params.email !== undefined, + params.phone !== undefined + ].filter(Boolean).length; + + if (targets !== 1) { + throw new {{spec.title | caseUcfirst}}Exception('Exactly one impersonation target must be provided'); + } + + const client = new ClientRuntime<'impersonation'>() + .applyBase<'impersonation'>(params, 'client') + .setSession(params.session); + + if (params.userId !== undefined) { + return client.setImpersonateUserId(params.userId); + } + if (params.email !== undefined) { + return client.setImpersonateUserEmail(params.email); + } + return client.setImpersonateUserPhone(params.phone); + } + + withJWT(jwt: string): this { + this.headers['X-{{ spec.title | caseUcfirst }}-JWT'] = jwt; + this.config.jwt = jwt; + return this; + } + + withForwardedUserAgent(forwardedUserAgent: string): this { + this.headers['X-Forwarded-User-Agent'] = forwardedUserAgent; + return this; + } + + private applyBase(params: BaseClientParams, sdkPlatform: SDKPlatform): ClientRuntime { + const client = this as unknown as ClientRuntime; + client.sdkPlatform = sdkPlatform; + client.headers['x-sdk-platform'] = sdkPlatform === 'server' ? '{{ sdk.platform == 'console' ? 'console' : 'server' }}' : 'client'; + client.setEndpoint(params.endpoint); +{%~ for param in spec.global.headers | webClientBaseParams %} +{%~ if param.required %} + client.{{ param.setter }}(params.{{ param.name }}); + +{%~ else %} + if (params.{{ param.name }} !== undefined) { + client.{{ param.setter }}(params.{{ param.name }}); + } + +{%~ endif %} +{%~ endfor %} + if (params.endpointRealtime !== undefined) { + client.setEndpointRealtime(params.endpointRealtime); + } + + if (params.selfSigned !== undefined) { + client.setSelfSigned(params.selfSigned); + } + + return client; + } + /** * Get Headers * @@ -403,6 +553,9 @@ class Client { * * @returns {this} */ + /** + * @deprecated Use `Client.fromBrowser`, `Client.fromSession`, `Client.fromAPIKey`, or another static factory instead. + */ setEndpoint(endpoint: string): this { if (!endpoint || typeof endpoint !== 'string') { throw new {{spec.title | caseUcfirst}}Exception('Endpoint must be a valid string'); @@ -425,6 +578,9 @@ class Client { * * @returns {this} */ + /** + * @deprecated Use the `endpointRealtime` field on a static factory params object instead. + */ setEndpointRealtime(endpointRealtime: string): this { if (!endpointRealtime || typeof endpointRealtime !== 'string') { throw new {{spec.title | caseUcfirst}}Exception('Endpoint must be a valid string'); @@ -438,7 +594,6 @@ class Client { return this; } -{%~ if sdk.platform == 'console' %} /** * Set self-signed * @@ -446,30 +601,25 @@ class Client { * * @returns {this} */ + /** + * @deprecated Use the `selfSigned` field on a static factory params object instead. + */ setSelfSigned(selfSigned: boolean): this { this.config.selfSigned = selfSigned; return this; } -{%~ endif %} - {%~ for header in spec.global.headers %} +{%~ for header in spec.global.headers %} /** - * Set {{header.key | caseUcfirst}} - * - {%~ if header.description %} - * {{header.description}} - * - {%~ endif %} - * @param value string - * - * @return {this} + * @deprecated Use a static client factory or factory params object instead. */ - set{{header.key | caseUcfirst}}(value: string): this { - this.headers['{{header.name}}'] = value; + set{{ header.key | caseUcfirst }}(value: string): {{ header | webClientSetterReturnType }} { + this.headers['{{ header.name }}'] = value; this.config.{{ header.key | caseLower }} = value; - return this; + return this as unknown as {{ header | webClientSetterReturnType }}; } - {%~ endfor %} + +{%~ endfor %} private realtime: Realtime = { socket: undefined, @@ -484,9 +634,13 @@ class Client { lastMessage: undefined, connect: () => { clearTimeout(this.realtime.timeout); - this.realtime.timeout = window?.setTimeout(() => { - this.realtime.createSocket(); - }, 50); + this.realtime.timeout = typeof window !== 'undefined' + ? window.setTimeout(() => { + this.realtime.createSocket(); + }, 50) + : setTimeout(() => { + this.realtime.createSocket(); + }, 50) as unknown as TimeoutHandle; }, getTimeout: () => { switch (true) { @@ -502,14 +656,20 @@ class Client { }, createHeartbeat: () => { if (this.realtime.heartbeat) { - clearTimeout(this.realtime.heartbeat); + clearInterval(this.realtime.heartbeat as any); } - this.realtime.heartbeat = window?.setInterval(() => { - this.realtime.socket?.send(JSONbig.stringify({ - type: 'ping' - })); - }, 20_000); + this.realtime.heartbeat = typeof window !== 'undefined' + ? window.setInterval(() => { + this.realtime.socket?.send(JSONbig.stringify({ + type: 'ping' + })); + }, 20_000) + : setInterval(() => { + this.realtime.socket?.send(JSONbig.stringify({ + type: 'ping' + })); + }, 20_000) as unknown as TimeoutHandle; }, createSocket: () => { if (this.realtime.subscriptions.size < 1) { @@ -595,8 +755,14 @@ class Client { let session = this.config.session; if (!session) { - const cookie = JSONbig.parse(window.localStorage.getItem('cookieFallback') ?? '{}'); - session = cookie?.[`a_session_${this.config.project}`]; + try { + if (typeof window !== 'undefined' && window.localStorage) { + const cookie = JSONbig.parse(window.localStorage.getItem('cookieFallback') ?? '{}'); + session = cookie?.[`a_session_${this.config.project}`]; + } + } catch (error) { + console.error('Failed to parse cookie fallback:', error); + } } if (session && !messageData?.user) { this.realtime.socket?.send(JSONbig.stringify({ @@ -761,7 +927,7 @@ class Client { headers = Object.assign({}, this.headers, headers); - if (typeof window !== 'undefined' && window.localStorage) { + if (this.sdkPlatform === 'client' && typeof window !== 'undefined' && window.localStorage) { const cookieFallback = window.localStorage.getItem('cookieFallback'); if (cookieFallback) { headers['X-Fallback-Cookies'] = cookieFallback; @@ -773,12 +939,12 @@ class Client { headers, }; - if (headers['X-Appwrite-Dev-Key'] === undefined) { + if (this.sdkPlatform === 'client' && headers['X-Appwrite-Dev-Key'] === undefined) { options.credentials = 'include'; } if (method === 'GET') { - for (const [key, value] of Object.entries(Client.flatten(params))) { + for (const [key, value] of Object.entries(ClientRuntime.flatten(params))) { url.searchParams.append(key, value); } } else { @@ -788,6 +954,10 @@ class Client { break; case 'multipart/form-data': + if (typeof FormData === 'undefined' || typeof File === 'undefined') { + throw new {{spec.title | caseUcfirst}}Exception('Multipart requests require File and FormData globals'); + } + const formData = new FormData(); for (const [key, value] of Object.entries(params)) { @@ -812,13 +982,17 @@ class Client { } async chunkedUpload(method: string, url: URL, headers: Headers = {}, originalPayload: Payload = {}, onProgress: (progress: UploadProgress) => void) { + if (typeof File === 'undefined' || typeof FormData === 'undefined') { + throw new {{spec.title | caseUcfirst}}Exception('Chunked uploads require File and FormData globals'); + } + const [fileParam, file] = Object.entries(originalPayload).find(([_, value]) => value instanceof File) ?? []; if (!file || !fileParam) { throw new Error('File not found in payload'); } - if (file.size <= Client.CHUNK_SIZE) { + if (file.size <= ClientRuntime.CHUNK_SIZE) { return await this.call(method, url, headers, originalPayload); } @@ -826,26 +1000,27 @@ class Client { let response = null; while (start < file.size) { - let end = start + Client.CHUNK_SIZE; // Prepare end for the next chunk + let end = start + ClientRuntime.CHUNK_SIZE; // Prepare end for the next chunk if (end >= file.size) { end = file.size; // Adjust for the last chunk to include the last byte } - headers['content-range'] = `bytes ${start}-${end-1}/${file.size}`; + const chunkHeaders = { ...headers }; + chunkHeaders['content-range'] = `bytes ${start}-${end-1}/${file.size}`; const chunk = file.slice(start, end); let payload = { ...originalPayload }; payload[fileParam] = new File([chunk], file.name); - response = await this.call(method, url, headers, payload); + response = await this.call(method, url, chunkHeaders, payload); if (onProgress && typeof onProgress === 'function') { onProgress({ $id: response.$id, progress: Math.round((end / file.size) * 100), sizeUploaded: end, - chunksTotal: Math.ceil(file.size / Client.CHUNK_SIZE), - chunksUploaded: Math.ceil(end / Client.CHUNK_SIZE) + chunksTotal: Math.ceil(file.size / ClientRuntime.CHUNK_SIZE), + chunksUploaded: Math.ceil(end / ClientRuntime.CHUNK_SIZE) }); } @@ -871,9 +1046,9 @@ class Client { const response = await fetch(uri, options); // type opaque: No-CORS, different-origin response (CORS-issue) - if (response.type === 'opaque') { + if (this.sdkPlatform === 'client' && typeof window !== 'undefined' && response.type === 'opaque') { throw new {{spec.title | caseUcfirst}}Exception( - `Invalid Origin. Register your new client (${window.location.host}) as a new Web platform on your project console dashboard`, + `Invalid Origin. Register your new client (${typeof window !== 'undefined' ? window.location.host : 'unknown'}) as a new Web platform on your project console dashboard`, 403, "forbidden", "" @@ -907,7 +1082,7 @@ class Client { const cookieFallback = response.headers.get('X-Fallback-Cookies'); - if (typeof window !== 'undefined' && window.localStorage && cookieFallback) { + if (this.sdkPlatform === 'client' && typeof window !== 'undefined' && window.localStorage && cookieFallback) { window.console.warn('{{spec.title | caseUcfirst}} is using localStorage for session management. Increase your security by adding a custom domain as your API endpoint.'); window.localStorage.setItem('cookieFallback', cookieFallback); } @@ -925,7 +1100,7 @@ class Client { for (const [key, value] of Object.entries(data)) { let finalKey = prefix ? prefix + '[' + key +']' : key; if (Array.isArray(value)) { - output = { ...output, ...Client.flatten(value, finalKey) }; + output = { ...output, ...ClientRuntime.flatten(value, finalKey) }; } else { output[finalKey] = value; } @@ -935,8 +1110,9 @@ class Client { } } +const Client = ClientRuntime as unknown as ClientConstructor; + export { Client, {{spec.title | caseUcfirst}}Exception }; +export type { Models, SDKPlatform, ClientAuth, ServerAuth, Payload, RealtimeResponseEvent, UploadProgress }; export { Query } from './query'; -export type { Models, Payload, UploadProgress }; -export type { RealtimeResponseEvent }; export type { QueryTypes, QueryTypesList } from './query'; diff --git a/templates/web/src/index.ts.twig b/templates/web/src/index.ts.twig index c8595159f6..ce5c36a6d3 100644 --- a/templates/web/src/index.ts.twig +++ b/templates/web/src/index.ts.twig @@ -10,7 +10,7 @@ export { Client, Query, {{spec.title | caseUcfirst}}Exception } from './client'; export { {{service.name | caseUcfirst}} } from './services/{{service.name | caseKebab}}'; {% endfor %} export { Realtime } from './services/realtime'; -export type { Models, Payload, RealtimeResponseEvent, UploadProgress } from './client'; +export type { Models, Payload, RealtimeResponseEvent, UploadProgress, SDKPlatform, ClientAuth, ServerAuth } from './client'; export type { RealtimeSubscription } from './services/realtime'; export type { QueryTypes, QueryTypesList } from './query'; export { Permission } from './permission'; @@ -20,4 +20,4 @@ export { Channel } from './channel'; export { Operator, Condition } from './operator'; {% for enum in spec.allEnums %} export { {{ enum.name | caseUcfirst }} } from './enums/{{enum.name | caseKebab}}'; -{% endfor %} \ No newline at end of file +{% endfor %} diff --git a/templates/web/src/services/realtime.ts.twig b/templates/web/src/services/realtime.ts.twig index ec7d3cf593..18c66f132d 100644 --- a/templates/web/src/services/realtime.ts.twig +++ b/templates/web/src/services/realtime.ts.twig @@ -1,4 +1,8 @@ +{% if language.name == 'Web' %} +import { {{ spec.title | caseUcfirst}}Exception, Client, JSONbig, type ClientAuth } from '../client'; +{% else %} import { {{ spec.title | caseUcfirst}}Exception, Client, JSONbig } from '../client'; +{% endif %} import { Channel, ActionableChannel, ResolvedChannel } from '../channel'; import { Query } from '../query'; import { ID } from '../id'; @@ -80,7 +84,7 @@ export class Realtime { private readonly DEBOUNCE_MS = 1; private readonly HEARTBEAT_INTERVAL = 20000; // 20 seconds in milliseconds - private client: Client; + private client: Client{% if language.name == 'Web' %}{% endif %}; private socket?: WebSocket; private activeSubscriptions = new Map>(); private pendingSubscribes = new Map(); @@ -95,7 +99,7 @@ export class Realtime { private onCloseCallbacks: Array<() => void> = []; private onOpenCallbacks: Array<() => void> = []; - constructor(client: Client) { + constructor(client: Client{% if language.name == 'Web' %}{% endif %}) { this.client = client; } @@ -131,16 +135,26 @@ export class Realtime { private startHeartbeat(): void { this.stopHeartbeat(); - this.heartbeatTimer = window?.setInterval(() => { - if (this.socket && this.socket.readyState === WebSocket.OPEN) { - this.socket.send(JSONbig.stringify({ type: 'ping' })); - } - }, this.HEARTBEAT_INTERVAL); + this.heartbeatTimer = typeof window !== 'undefined' + ? window.setInterval(() => { + if (this.socket && this.socket.readyState === WebSocket.OPEN) { + this.socket.send(JSONbig.stringify({ type: 'ping' })); + } + }, this.HEARTBEAT_INTERVAL) + : setInterval(() => { + if (this.socket && this.socket.readyState === WebSocket.OPEN) { + this.socket.send(JSONbig.stringify({ type: 'ping' })); + } + }, this.HEARTBEAT_INTERVAL) as unknown as number; } private stopHeartbeat(): void { if (this.heartbeatTimer) { - window?.clearInterval(this.heartbeatTimer); + if (typeof window !== 'undefined') { + window.clearInterval(this.heartbeatTimer); + } else { + clearInterval(this.heartbeatTimer as any); + } this.heartbeatTimer = undefined; } } @@ -578,8 +592,10 @@ export class Realtime { let session = this.client.config.session; if (!session) { try { - const cookie = JSONbig.parse(window.localStorage.getItem('cookieFallback') ?? '{}'); - session = cookie?.[`a_session_${this.client.config.project}`]; + if (typeof window !== 'undefined' && window.localStorage) { + const cookie = JSONbig.parse(window.localStorage.getItem('cookieFallback') ?? '{}'); + session = cookie?.[`a_session_${this.client.config.project}`]; + } } catch (error) { console.error('Failed to parse cookie fallback:', error); } diff --git a/templates/web/src/services/template.ts.twig b/templates/web/src/services/template.ts.twig index 4066c3f600..51b8f8c80c 100644 --- a/templates/web/src/services/template.ts.twig +++ b/templates/web/src/services/template.ts.twig @@ -1,5 +1,7 @@ +{# Detect service shape and imports before emitting TypeScript. #} +{% set auth = service | webServiceAuth %} import { Service } from '../service'; -import { {{ spec.title | caseUcfirst}}Exception, Client, type Payload, UploadProgress } from '../client'; +import { {{ spec.title | caseUcfirst}}Exception, Client, {% if auth.needsClientAuth or auth.hasMixedTier %}type ClientAuth, {% endif %}{% if auth.needsServerAuth or auth.hasMixedTier %}type ServerAuth, {% endif %}type Payload{% if auth.hasUpload %}, UploadProgress{% endif %} } from '../client'; import type { Models } from '../models'; {% set added = [] %} @@ -14,14 +16,45 @@ import { {{ parameter.enumName | caseUcfirst }} } from '../enums/{{ parameter.en {% endfor %} {% endfor %} -export class {{ service.name | caseUcfirst }} { - client: Client; +{%~ if auth.hasMixedTier %} +type {{ service.name | caseUcfirst }}ServerOnlyMethod = {% if auth.serverOnlyMethods is empty %}never{% else %}{% for methodName in auth.serverOnlyMethods %}'{{ methodName }}'{% if not loop.last %} | {% endif %}{% endfor %}{% endif %}; +type {{ service.name | caseUcfirst }}ClientOnlyMethod = {% if auth.clientOnlyMethods is empty %}never{% else %}{% for methodName in auth.clientOnlyMethods %}'{{ methodName }}'{% if not loop.last %} | {% endif %}{% endfor %}{% endif %}; - constructor(client: Client) { +export type {{ service.name | caseUcfirst }} = + TAuth extends ClientAuth + ? Omit<{{ service.name | caseUcfirst }}Runtime, 'client' | {{ service.name | caseUcfirst }}ServerOnlyMethod> + : TAuth extends ServerAuth + ? Omit<{{ service.name | caseUcfirst }}Runtime, 'client' | {{ service.name | caseUcfirst }}ClientOnlyMethod> + : Omit<{{ service.name | caseUcfirst }}Runtime, 'client'>; + +class {{ service.name | caseUcfirst }}Runtime { + client: Client; + + constructor(client: Client) { + this.client = client; + } +{%~ elseif auth.hasServerTier %} +export type {{ service.name | caseUcfirst }} = Omit<{{ service.name | caseUcfirst }}Runtime, 'client'>; + +class {{ service.name | caseUcfirst }}Runtime { + client: Client; + + constructor(client: Client) { + this.client = client; + } +{%~ else %} +export type {{ service.name | caseUcfirst }} = Omit<{{ service.name | caseUcfirst }}Runtime, 'client'>; + +class {{ service.name | caseUcfirst }}Runtime { + client: Client; + + constructor(client: Client) { this.client = client; } +{%~ endif %} {%~ for method in service.methods %} + {%~ set thisGate = '' %} /** {%~ if method.description %} * {{ method.description | replace({'\n': '\n * '}) | raw }} @@ -41,7 +74,7 @@ export class {{ service.name | caseUcfirst }} { {%~ endif %} */ {%~ if method.parameters.all|length > 0 %} - {{ method.name | caseCamel }}{{ method.responseModel | getGenerics(spec) | raw }}(params{% set hasRequiredParams = false %}{% for parameter in method.parameters.all %}{% if parameter.required %}{% set hasRequiredParams = true %}{% endif %}{% endfor %}{% if not hasRequiredParams %}?{% endif %}: { {% for parameter in method.parameters.all %}{{ parameter.name | caseCamel | escapeKeyword }}{% if not parameter.required or parameter.nullable %}?{% endif %}: {{ parameter | getPropertyType(method) | raw }}{% if not loop.last %}, {% endif %}{% endfor %}{% if 'multipart/form-data' in method.consumes %}, onProgress?: (progress: UploadProgress) => void{% endif %} }): {{ method | getReturn(spec) | raw }}; + {{ method.name | caseCamel }}{{ method.responseModel | getGenerics(spec) | raw }}({{ thisGate | raw }}params{% set hasRequiredParams = false %}{% for parameter in method.parameters.all %}{% if parameter.required %}{% set hasRequiredParams = true %}{% endif %}{% endfor %}{% if not hasRequiredParams %}?{% endif %}: { {% for parameter in method.parameters.all %}{{ parameter.name | caseCamel | escapeKeyword }}{% if not parameter.required or parameter.nullable %}?{% endif %}: {{ parameter | getPropertyType(method) | raw }}{% if not loop.last %}, {% endif %}{% endfor %}{% if 'multipart/form-data' in method.consumes %}, onProgress?: (progress: UploadProgress) => void{% endif %} }): {{ method | getReturn(spec) | raw }}; /** {%~ if method.description %} * {{ method.description | replace({'\n': '\n * '}) | raw }} @@ -54,9 +87,9 @@ export class {{ service.name | caseUcfirst }} { * @returns {{ '{' }}{{ method | getReturn(spec) | raw }}{{ '}' }} * @deprecated Use the object parameter style method for a better developer experience. */ - {{ method.name | caseCamel }}{{ method.responseModel | getGenerics(spec) | raw }}({% for parameter in method.parameters.all %}{{ parameter.name | caseCamel | escapeKeyword }}{% if not parameter.required or parameter.nullable %}?{% endif %}: {{ parameter | getPropertyType(method) | raw }}{% if not loop.last %}, {% endif %}{% endfor %}{% if 'multipart/form-data' in method.consumes %}, onProgress?: (progress: UploadProgress) => void{% endif %}): {{ method | getReturn(spec) | raw }}; + {{ method.name | caseCamel }}{{ method.responseModel | getGenerics(spec) | raw }}({{ thisGate | raw }}{% for parameter in method.parameters.all %}{{ parameter.name | caseCamel | escapeKeyword }}{% if not parameter.required or parameter.nullable %}?{% endif %}: {{ parameter | getPropertyType(method) | raw }}{% if not loop.last %}, {% endif %}{% endfor %}{% if 'multipart/form-data' in method.consumes %}, onProgress?: (progress: UploadProgress) => void{% endif %}): {{ method | getReturn(spec) | raw }}; {{ method.name | caseCamel }}{{ method.responseModel | getGenerics(spec) | raw }}( - {% if method.parameters.all|length > 0 %}paramsOrFirst{% if not method.parameters.all[0].required or method.parameters.all[0].nullable %}?{% endif %}: { {% for parameter in method.parameters.all %}{{ parameter.name | caseCamel | escapeKeyword }}{% if not parameter.required or parameter.nullable %}?{% endif %}: {{ parameter | getPropertyType(method) | raw }}{% if not loop.last %}, {% endif %}{% endfor %}{% if 'multipart/form-data' in method.consumes %}, onProgress?: (progress: UploadProgress) => void{% endif %} } | {{ method.parameters.all[0] | getPropertyType(method) | raw }}{% if method.parameters.all|length > 1 %}, + {{ thisGate | raw }}{% if method.parameters.all|length > 0 %}paramsOrFirst{% if not method.parameters.all[0].required or method.parameters.all[0].nullable %}?{% endif %}: { {% for parameter in method.parameters.all %}{{ parameter.name | caseCamel | escapeKeyword }}{% if not parameter.required or parameter.nullable %}?{% endif %}: {{ parameter | getPropertyType(method) | raw }}{% if not loop.last %}, {% endif %}{% endfor %}{% if 'multipart/form-data' in method.consumes %}, onProgress?: (progress: UploadProgress) => void{% endif %} } | {{ method.parameters.all[0] | getPropertyType(method) | raw }}{% if method.parameters.all|length > 1 %}, ...rest: [{% for parameter in method.parameters.all[1:] %}({{ parameter | getPropertyType(method) | raw }})?{% if not loop.last %}, {% endif %}{% endfor %}{% if 'multipart/form-data' in method.consumes %},((progress: UploadProgress) => void)?{% endif %}]{% endif %}{% endif %} ): {{ method | getReturn(spec) | raw }} { @@ -96,7 +129,7 @@ export class {{ service.name | caseUcfirst }} { {%~ endif %} {%~ endif %} {%~ else %} - {{ method.name | caseCamel }}{{ method.responseModel | getGenerics(spec) | raw }}({% for parameter in method.parameters.all %}{{ parameter.name | caseCamel | escapeKeyword }}{% if not parameter.required or parameter.nullable %}?{% endif %}: {{ parameter | getPropertyType(method) | raw }}{% if not loop.last %}, {% endif %}{% endfor %}{% if 'multipart/form-data' in method.consumes %}, onProgress = (progress: UploadProgress) => void{% endif %}): {{ method | getReturn(spec) | raw }} { + {{ method.name | caseCamel }}{{ method.responseModel | getGenerics(spec) | raw }}({{ thisGate | raw }}{% for parameter in method.parameters.all %}{{ parameter.name | caseCamel | escapeKeyword }}{% if not parameter.required or parameter.nullable %}?{% endif %}: {{ parameter | getPropertyType(method) | raw }}{% if not loop.last %}, {% endif %}{% endfor %}{% if 'multipart/form-data' in method.consumes %}, onProgress = (progress: UploadProgress) => void{% endif %}): {{ method | getReturn(spec) | raw }} { {%~ endif %} {%~ for parameter in method.parameters.all %} {%~ if parameter.required %} @@ -174,3 +207,15 @@ export class {{ service.name | caseUcfirst }} { {%~ endif %} {%~ endfor %} } + +const {{ service.name | caseUcfirst }} = {{ service.name | caseUcfirst }}Runtime as unknown as { +{%~ if auth.hasMixedTier %} + new (client: Client): {{ service.name | caseUcfirst }}; +{%~ elseif auth.hasServerTier %} + new (client: Client): {{ service.name | caseUcfirst }}; +{%~ else %} + new (client: Client): {{ service.name | caseUcfirst }}; +{%~ endif %} +}; + +export { {{ service.name | caseUcfirst }} }; diff --git a/tests/resources/spec.json b/tests/resources/spec.json index 0ab4eacf20..9fa3299e62 100644 --- a/tests/resources/spec.json +++ b/tests/resources/spec.json @@ -59,6 +59,12 @@ "description": "The user session to authenticate with", "in": "header" }, + "Cookie": { + "type": "apiKey", + "name": "Cookie", + "description": "Cookie header", + "in": "header" + }, "Locale": { "type": "apiKey", "name": "X-Appwrite-Locale", @@ -68,6 +74,30 @@ "demo": "en" } }, + "DevKey": { + "type": "apiKey", + "name": "X-Appwrite-Dev-Key", + "description": "Your development key", + "in": "header" + }, + "ImpersonateUserId": { + "type": "apiKey", + "name": "X-Appwrite-Impersonate-User-Id", + "description": "User ID to impersonate", + "in": "header" + }, + "ImpersonateUserEmail": { + "type": "apiKey", + "name": "X-Appwrite-Impersonate-User-Email", + "description": "User email to impersonate", + "in": "header" + }, + "ImpersonateUserPhone": { + "type": "apiKey", + "name": "X-Appwrite-Impersonate-User-Phone", + "description": "User phone to impersonate", + "in": "header" + }, "Mode": { "type": "apiKey", "name": "X-Appwrite-Mode",