UNPKG

@atproto/xrpc

Version:

atproto HTTP API (XRPC) client library

296 lines 12.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.isErrorResponseBody = isErrorResponseBody; exports.getMethodSchemaHTTPMethod = getMethodSchemaHTTPMethod; exports.constructMethodCallUri = constructMethodCallUri; exports.constructMethodCallUrl = constructMethodCallUrl; exports.encodeQueryParam = encodeQueryParam; exports.constructMethodCallHeaders = constructMethodCallHeaders; exports.combineHeaders = combineHeaders; exports.isBodyInit = isBodyInit; exports.isIterable = isIterable; exports.encodeMethodCallBody = encodeMethodCallBody; exports.httpResponseBodyParse = httpResponseBodyParse; const lexicon_1 = require("@atproto/lexicon"); const types_1 = require("./types"); const ReadableStream = globalThis.ReadableStream || class { constructor() { // This anonymous class will never pass any "instanceof" check and cannot // be instantiated. throw new Error('ReadableStream is not supported in this environment'); } }; function isErrorResponseBody(v) { return types_1.errorResponseBody.safeParse(v).success; } function getMethodSchemaHTTPMethod(schema) { if (schema.type === 'procedure') { return 'post'; } return 'get'; } function constructMethodCallUri(nsid, schema, serviceUri, params) { const uri = new URL(constructMethodCallUrl(nsid, schema, params), serviceUri); return uri.toString(); } function constructMethodCallUrl(nsid, schema, params) { const pathname = `/xrpc/${encodeURIComponent(nsid)}`; if (!params) return pathname; const searchParams = []; for (const [key, value] of Object.entries(params)) { const paramSchema = schema.parameters?.properties?.[key]; if (!paramSchema) { throw new Error(`Invalid query parameter: ${key}`); } if (value !== undefined) { if (paramSchema.type === 'array') { const values = Array.isArray(value) ? value : [value]; for (const val of values) { searchParams.push([ key, encodeQueryParam(paramSchema.items.type, val), ]); } } else { searchParams.push([key, encodeQueryParam(paramSchema.type, value)]); } } } if (!searchParams.length) return pathname; return `${pathname}?${new URLSearchParams(searchParams).toString()}`; } function encodeQueryParam(type, value) { if (type === 'string' || type === 'unknown') { return String(value); } if (type === 'float') { return String(Number(value)); } else if (type === 'integer') { return String(Number(value) | 0); } else if (type === 'boolean') { return value ? 'true' : 'false'; } else if (type === 'datetime') { if (value instanceof Date) { return value.toISOString(); } return String(value); } throw new Error(`Unsupported query param type: ${type}`); } function constructMethodCallHeaders(schema, data, opts) { // Not using `new Headers(opts?.headers)` to avoid duplicating headers values // due to inconsistent casing in headers name. In case of multiple headers // with the same name (but using a different case), the last one will be used. // new Headers({ 'content-type': 'foo', 'Content-Type': 'bar' }).get('content-type') // => 'foo, bar' const headers = new Headers(); if (opts?.headers) { for (const name in opts.headers) { if (headers.has(name)) { throw new TypeError(`Duplicate header: ${name}`); } const value = opts.headers[name]; if (value != null) { headers.set(name, value); } } } if (schema.type === 'procedure') { if (opts?.encoding) { headers.set('content-type', opts.encoding); } else if (!headers.has('content-type') && typeof data !== 'undefined') { // Special handling of BodyInit types before falling back to JSON encoding if (data instanceof ArrayBuffer || data instanceof ReadableStream || ArrayBuffer.isView(data)) { headers.set('content-type', 'application/octet-stream'); } else if (data instanceof FormData) { // Note: The multipart form data boundary is missing from the header // we set here, making that header invalid. This special case will be // handled in encodeMethodCallBody() headers.set('content-type', 'multipart/form-data'); } else if (data instanceof URLSearchParams) { headers.set('content-type', 'application/x-www-form-urlencoded;charset=UTF-8'); } else if (isBlobLike(data)) { headers.set('content-type', data.type || 'application/octet-stream'); } else if (typeof data === 'string') { headers.set('content-type', 'text/plain;charset=UTF-8'); } // At this point, data is not a valid BodyInit type. else if (isIterable(data)) { headers.set('content-type', 'application/octet-stream'); } else if (typeof data === 'boolean' || typeof data === 'number' || typeof data === 'string' || typeof data === 'object' // covers "null" ) { headers.set('content-type', 'application/json'); } else { // symbol, function, bigint throw new types_1.XRPCError(types_1.ResponseType.InvalidRequest, `Unsupported data type: ${typeof data}`); } } } return headers; } function combineHeaders(headersInit, defaultHeaders) { if (!defaultHeaders) return headersInit; let headers = undefined; for (const [name, definition] of defaultHeaders) { // Ignore undefined values (allowed for convenience when using // Object.entries). if (definition === undefined) continue; // Lazy initialization of the headers object headers ?? (headers = new Headers(headersInit)); if (headers.has(name)) continue; const value = typeof definition === 'function' ? definition() : definition; if (typeof value === 'string') headers.set(name, value); else if (value === null) headers.delete(name); else throw new TypeError(`Invalid "${name}" header value: ${typeof value}`); } return headers ?? headersInit; } function isBlobLike(value) { if (value == null) return false; if (typeof value !== 'object') return false; if (typeof Blob === 'function' && value instanceof Blob) return true; // Support for Blobs provided by libraries that don't use the native Blob // (e.g. fetch-blob from node-fetch). // https://github.com/node-fetch/fetch-blob/blob/a1a182e5978811407bef4ea1632b517567dda01f/index.js#L233-L244 const tag = value[Symbol.toStringTag]; if (tag === 'Blob' || tag === 'File') { return 'stream' in value && typeof value.stream === 'function'; } return false; } function isBodyInit(value) { switch (typeof value) { case 'string': return true; case 'object': return (value instanceof ArrayBuffer || value instanceof FormData || value instanceof URLSearchParams || value instanceof ReadableStream || ArrayBuffer.isView(value) || isBlobLike(value)); default: return false; } } function isIterable(value) { return (value != null && typeof value === 'object' && (Symbol.iterator in value || Symbol.asyncIterator in value)); } function encodeMethodCallBody(headers, data) { // Silently ignore the body if there is no content-type header. const contentType = headers.get('content-type'); if (!contentType) { return undefined; } if (typeof data === 'undefined') { // This error would be returned by the server, but we can catch it earlier // to avoid un-necessary requests. Note that a content-length of 0 does not // necessary mean that the body is "empty" (e.g. an empty txt file). throw new types_1.XRPCError(types_1.ResponseType.InvalidRequest, `A request body is expected but none was provided`); } if (isBodyInit(data)) { if (data instanceof FormData && contentType === 'multipart/form-data') { // fetch() will encode FormData payload itself, but it won't override the // content-type header if already present. This would cause the boundary // to be missing from the content-type header, resulting in a 400 error. // Deleting the content-type header here to let fetch() re-create it. headers.delete('content-type'); } // Will be encoded by the fetch API. return data; } if (isIterable(data)) { // Note that some environments support using Iterable & AsyncIterable as the // body (e.g. Node's fetch), but not all of them do (browsers). return iterableToReadableStream(data); } if (contentType.startsWith('text/')) { return new TextEncoder().encode(String(data)); } if (contentType.startsWith('application/json')) { const json = (0, lexicon_1.stringifyLex)(data); // Server would return a 400 error if the JSON is invalid (e.g. trying to // JSONify a function, or an object that implements toJSON() poorly). if (json === undefined) { throw new types_1.XRPCError(types_1.ResponseType.InvalidRequest, `Failed to encode request body as JSON`); } return new TextEncoder().encode(json); } // At this point, "data" is not a valid BodyInit value, and we don't know how // to encode it into one. Passing it to fetch would result in an error. Let's // throw our own error instead. const type = !data || typeof data !== 'object' ? typeof data : data.constructor !== Object && typeof data.constructor === 'function' && typeof data.constructor?.name === 'string' ? data.constructor.name : 'object'; throw new types_1.XRPCError(types_1.ResponseType.InvalidRequest, `Unable to encode ${type} as ${contentType} data`); } /** * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream/from_static} */ function iterableToReadableStream(iterable) { // Use the native ReadableStream.from() if available. if ('from' in ReadableStream && typeof ReadableStream.from === 'function') { return ReadableStream.from(iterable); } // If you see this error, consider using a polyfill for ReadableStream. For // example, the "web-streams-polyfill" package: // https://github.com/MattiasBuelens/web-streams-polyfill throw new TypeError('ReadableStream.from() is not supported in this environment. ' + 'It is required to support using iterables as the request body. ' + 'Consider using a polyfill or re-write your code to use a different body type.'); } function httpResponseBodyParse(mimeType, data) { try { if (mimeType) { if (mimeType.includes('application/json')) { const str = new TextDecoder().decode(data); return (0, lexicon_1.jsonStringToLex)(str); } if (mimeType.startsWith('text/')) { return new TextDecoder().decode(data); } } if (data instanceof ArrayBuffer) { return new Uint8Array(data); } return data; } catch (cause) { throw new types_1.XRPCError(types_1.ResponseType.InvalidResponse, undefined, `Failed to parse response body: ${String(cause)}`, undefined, { cause }); } } //# sourceMappingURL=util.js.map