diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cb0072afd..48c5ebc7a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,7 +13,7 @@ jobs: fail-fast: false matrix: php-version: ['8.1'] - sdk: [Android11Java8, Android11Java11, Android12Java8, Android12Java11, CLINode16, CLINode18, DartBeta, DartStable, Deno1193, Deno1303, DotNet60, DotNet70, FlutterStable, FlutterBeta, Go112, Go118, KotlinJava8, KotlinJava11, KotlinJava17, Node12, Node14, Node16, PHP74, PHP80, Python38, Python39, Python310, Ruby27, Ruby30, Ruby31, AppleSwift55, Swift55, WebChromium, WebNode] + sdk: [Android11Java8, Android11Java11, Android12Java8, Android12Java11, CLINode16, CLINode18, DartBeta, DartStable, Deno1193, Deno1303, DotNet60, DotNet70, FlutterStable, FlutterBeta, Go112, Go118, KotlinJava8, KotlinJava11, KotlinJava17, Node16, Node18, Node20, PHP74, PHP80, Python38, Python39, Python310, Ruby27, Ruby30, Ruby31, AppleSwift55, Swift55, WebChromium, WebNode] steps: - name: Checkout repository diff --git a/templates/deno/src/client.ts.twig b/templates/deno/src/client.ts.twig index dc899958a..365d1941f 100644 --- a/templates/deno/src/client.ts.twig +++ b/templates/deno/src/client.ts.twig @@ -61,70 +61,71 @@ export class Client { return this; } - withoutHeader(key: string, headers: Payload): Payload { - return Object.keys(headers).reduce((acc: Payload, cv: string) => { - if (cv === 'content-type') return acc; - acc[cv] = headers[cv]; - return acc; - }, {}) - } + async call(method: string, path: string = "", headers: Payload = {}, params: Payload = {}, responseType: string = "json") { + headers = {...this.headers, ...headers}; + const url = new URL(this.endpoint + path); - async call(method: string, path: string = '', headers: Payload = {}, params: Payload = {}) { - headers = Object.assign({}, this.headers, headers); + let body: string | FormData | undefined = undefined; - let body; - const url = new URL(this.endpoint + path); - if (method.toUpperCase() === 'GET') { - url.search = new URLSearchParams(this.flatten(params)).toString(); - body = null; - } else if (headers['content-type'].toLowerCase().startsWith('multipart/form-data')) { - headers = this.withoutHeader('content-type', headers); + if (method.toUpperCase() === "GET") { + url.search = new URLSearchParams(Client.flatten(params)).toString(); + } else if (headers["content-type"]?.toLowerCase().startsWith("multipart/form-data")) { + delete headers["content-type"]; const formData = new FormData(); - const flatParams = this.flatten(params); - for (const key in flatParams) { - const value = flatParams[key]; - if(value && value.type && value.type === 'file') { + const flatParams = Client.flatten(params); + + for (const [key, value] of Object.entries(flatParams)) { + if (value && value.type && value.type === "file") { formData.append(key, value.file, value.filename); } else { - formData.append(key, flatParams[key]); + formData.append(key, value); } } + body = formData; } else { body = JSON.stringify(params); } - const options = { - method: method.toUpperCase(), - headers: headers, - body: body, - }; - + let response = undefined; try { - let response = await fetch(url.toString(), options); - const contentType = response.headers.get('content-type'); - - if (contentType && contentType.includes('application/json')) { - if (response.status >= 400) { - let res = await response.json(); - throw new {{ spec.title | caseUcfirst}}Exception(res.message, res.status, res.type ?? "", res); - } + response = await fetch(url.toString(), { + method: method.toUpperCase(), + headers, + body + }); + } catch (error) { + throw new {{spec.title | caseUcfirst}}Exception(error.message); + } - return response.json(); - } else { - if (response.status >= 400) { - let res = await response.text(); - throw new {{ spec.title | caseUcfirst}}Exception(res, response.status, "", null); - } - return response; + if (response.status >= 400) { + const text = await response.text(); + let json = undefined; + try { + json = JSON.parse(text); + } catch (error) { + throw new {{spec.title | caseUcfirst}}Exception(text, response.status, "", text); } - } catch(error) { - throw new {{ spec.title | caseUcfirst}}Exception(error?.response?.message || error.message, error?.response?.code, error?.response?.type, error.response); + throw new {{spec.title | caseUcfirst}}Exception(json.message, json.code, json.type, json); + } + + if (responseType === "arraybuffer") { + const data = await response.arrayBuffer(); + return data; + } + + const text = await response.text(); + let json = undefined; + try { + json = JSON.parse(text); + } catch (error) { + return text; } + return json; } - flatten(data: Payload, prefix = '') { + static flatten(data: Payload, prefix = '') { let output: Payload = {}; for (const key in data) { @@ -132,7 +133,7 @@ export class Client { let finalKey = prefix ? prefix + '[' + key +']' : key; if (Array.isArray(value)) { - output = { ...output, ...this.flatten(value, finalKey) }; // @todo: handle name collision here if needed + output = { ...output, ...Client.flatten(value, finalKey) }; // @todo: handle name collision here if needed } else { output[finalKey] = value; diff --git a/templates/deno/src/inputFile.ts.twig b/templates/deno/src/inputFile.ts.twig index fdda97d4c..b12230284 100644 --- a/templates/deno/src/inputFile.ts.twig +++ b/templates/deno/src/inputFile.ts.twig @@ -19,6 +19,12 @@ export class InputFile { return new InputFile(stream, filename, size); }; + static fromBlob = async (blob: Blob, filename: string) => { + const arrayBuffer = await blob.arrayBuffer(); + const buffer = new Uint8Array(arrayBuffer); + return InputFile.fromBuffer(buffer, filename); + }; + static fromBuffer = (buffer: Uint8Array, filename: string): InputFile => { const stream = _bufferToString(buffer); const size = buffer.byteLength; diff --git a/templates/node/base/requests/file.twig b/templates/node/base/requests/file.twig index 1f6f4a85b..76a20f0d7 100644 --- a/templates/node/base/requests/file.twig +++ b/templates/node/base/requests/file.twig @@ -1,7 +1,8 @@ {% for parameter in method.parameters.all %} {% if parameter.type == 'file' %} - const size = {{ parameter.name | caseCamel | escapeKeyword }}.size; + const size = {{ parameter.name | caseCamel | escapeKeyword }}.size; + const apiHeaders = { {% for parameter in method.parameters.header %} '{{ parameter.name }}': ${{ parameter.name | caseCamel | escapeKeyword }}, @@ -28,117 +29,77 @@ {% endif %} {% endfor %} - let currentChunk = Buffer.from(''); - let currentChunkSize = 0; - let currentChunkStart = 0; + let currentChunk = 1; + let currentPosition = 0; + let uploadableChunk = new Uint8Array(client.CHUNK_SIZE); + - const selfClient = this.client; - - async function uploadChunk(lastUpload = false) { - if(chunksUploaded - 1 >= currentChunkStart / client.CHUNK_SIZE) { + const uploadChunk = async (lastUpload = false) => { + if(currentChunk <= chunksUploaded) { return; } - - const start = currentChunkStart; - const end = currentChunkStart + currentChunkSize - 1; - if(!lastUpload || currentChunkStart !== 0) { + const start = ((currentChunk - 1) * client.CHUNK_SIZE); + let end = start + currentPosition - 1; + + if(!lastUpload || currentChunk !== 1) { apiHeaders['content-range'] = 'bytes ' + start + '-' + end + '/' + size; } + let uploadableChunkTrimmed; + + if(currentPosition + 1 >= client.CHUNK_SIZE) { + uploadableChunkTrimmed = uploadableChunk; + } else { + uploadableChunkTrimmed = new Uint8Array(currentPosition); + for(let i = 0; i <= currentPosition; i++) { + uploadableChunkTrimmed[i] = uploadableChunk[i]; + } + } + if (id) { apiHeaders['x-{{spec.title | caseLower }}-id'] = id; } - payload['{{ parameter.name }}'] = { - type: 'file', - file: currentChunk, - filename: {{ parameter.name }}.filename, - size: currentChunkSize - }; + payload['{{ parameter.name }}'] = { type: 'file', file: new File([uploadableChunkTrimmed], {{ parameter.name | caseCamel | escapeKeyword }}.filename), filename: {{ parameter.name | caseCamel | escapeKeyword }}.filename }; - response = await selfClient.call('{{ method.method | caseLower }}', apiPath, apiHeaders, payload{% if method.type == 'location' %}, 'arraybuffer'{% endif %}); + response = await this.client.call('{{ method.method | caseLower }}', apiPath, apiHeaders, payload{% if method.type == 'location' %}, 'arraybuffer'{% endif %}); if (!id) { id = response['$id']; } - + if (onProgress !== null) { onProgress({ $id: response['$id'], - progress: Math.min((start+client.CHUNK_SIZE) * client.CHUNK_SIZE, size) / size * 100, + progress: Math.min((currentChunk) * client.CHUNK_SIZE, size) / size * 100, sizeUploaded: end+1, chunksTotal: response['chunksTotal'], chunksUploaded: response['chunksUploaded'] }); } - currentChunkStart += client.CHUNK_SIZE; + uploadableChunk = new Uint8Array(client.CHUNK_SIZE); + currentChunk++; + currentPosition = 0; } - return await new Promise((resolve, reject) => { - const writeStream = new Stream.Writable(); - writeStream._write = async (mainChunk, encoding, callback) => { - try { - // Segment incoming chunk into up to 5MB chunks - const mainChunkSize = Buffer.byteLength(mainChunk); - const chunksCount = Math.ceil(mainChunkSize / client.CHUNK_SIZE); - const chunks = []; - - for(let i = 0; i < chunksCount; i++) { - const chunk = mainChunk.slice(i * client.CHUNK_SIZE, (i + 1) * client.CHUNK_SIZE); - chunks.push(chunk); - } - - for (const chunk of chunks) { - const chunkSize = Buffer.byteLength(chunk); - - if(chunkSize + currentChunkSize == client.CHUNK_SIZE) { - // Upload chunk - currentChunk = Buffer.concat([currentChunk, chunk]); - currentChunkSize = Buffer.byteLength(currentChunk); - await uploadChunk(); - currentChunk = Buffer.from(''); - currentChunkSize = 0; - } else if(chunkSize + currentChunkSize > client.CHUNK_SIZE) { - // Upload chunk, put rest into next chunk - const bytesToUpload = client.CHUNK_SIZE - currentChunkSize; - const newChunkSection = chunk.slice(0, bytesToUpload); - currentChunk = Buffer.concat([currentChunk, newChunkSection]); - currentChunkSize = Buffer.byteLength(currentChunk); - await uploadChunk(); - currentChunk = chunk.slice(bytesToUpload, undefined); - currentChunkSize = chunkSize - bytesToUpload; - } else { - // Append into current chunk - currentChunk = Buffer.concat([currentChunk, chunk]); - currentChunkSize = chunkSize + currentChunkSize; - } - } - - callback(); - } catch (e) { - callback(e); + for await (const chunk of {{ parameter.name | caseCamel | escapeKeyword }}.stream) { + for(const b of chunk) { + uploadableChunk[currentPosition] = b; + + currentPosition++; + if(currentPosition >= client.CHUNK_SIZE) { + await uploadChunk(); + currentPosition = 0; } } + } - writeStream.on("finish", async () => { - if(currentChunkSize > 0) { - try { - await uploadChunk(true); - } catch (e) { - reject(e); - } - } - - resolve(response); - }); + if (currentPosition > 0) { // Check if there's any remaining data for the last chunk + await uploadChunk(true); + } - writeStream.on("error", (err) => { - reject(err); - }); - - {{ parameter.name | caseCamel | escapeKeyword }}.stream.pipe(writeStream); - }); + return response; {% endif %} {% endfor %} diff --git a/templates/node/index.d.ts.twig b/templates/node/index.d.ts.twig index 721eae574..eb6680162 100644 --- a/templates/node/index.d.ts.twig +++ b/templates/node/index.d.ts.twig @@ -250,7 +250,7 @@ declare module "{{ language.params.npmPackage|caseDash }}" { * @throws {{ '{' }}{{ spec.title | caseUcfirst}}Exception} * @returns {Promise} */ - {{ method.name | caseCamel }}{% if generics %}<{{generics}}>{% endif %}({% for parameter in method.parameters.all %}{{ parameter.name | caseCamel | escapeKeyword }}{% if not parameter.required or parameter.nullable %}?{% endif %}: {{ parameter | typeName }}{% if not loop.last %}, {% endif %}{% endfor %}): Promise<{% if method.type == 'location' %}Buffer{% else %}{% if method.responseModel and method.responseModel != 'any' %}{% if not spec.definitions[method.responseModel].additionalProperties %}Models.{% endif %}{{method.responseModel | caseUcfirst}}{% if generics_return %}<{{generics_return}}>{% endif %}{% else %}{% if method.method == 'delete' %}string{% else %}any{% endif %}{% endif %}{% endif %}>; + {{ method.name | caseCamel }}{% if generics %}<{{generics}}>{% endif %}({% for parameter in method.parameters.all %}{{ parameter.name | caseCamel | escapeKeyword }}{% if not parameter.required or parameter.nullable %}?{% endif %}: {{ parameter | typeName }}{% if not loop.last %}, {% endif %}{% endfor %}): Promise<{% if method.type == 'location' %}ArrayBuffer{% else %}{% if method.responseModel and method.responseModel != 'any' %}{% if not spec.definitions[method.responseModel].additionalProperties %}Models.{% endif %}{{method.responseModel | caseUcfirst}}{% if generics_return %}<{{generics_return}}>{% endif %}{% else %}{% if method.method == 'delete' %}string{% else %}any{% endif %}{% endif %}{% endif %}>; {% endfor %} } {% endfor %} diff --git a/templates/node/lib/client.js.twig b/templates/node/lib/client.js.twig index 0f68e1c8b..3becdc66e 100644 --- a/templates/node/lib/client.js.twig +++ b/templates/node/lib/client.js.twig @@ -1,8 +1,6 @@ const os = require('os'); const URL = require('url').URL; -const https = require("https"); -const axios = require('axios'); -const FormData = require('form-data'); +const {fetch, FormData, Agent} = require('undici'); const {{spec.title | caseUcfirst}}Exception = require('./exception.js'); class Client { @@ -72,8 +70,12 @@ class Client { } /** + * Sets a header for requests. + * * @param {string} key * @param {string} value + * + * @return this */ addHeader(key, value) { this.headers[key.toLowerCase()] = value; @@ -81,69 +83,73 @@ class Client { return this; } - async call(method, path = '', headers = {}, params = {}, responseType = 'json') { - headers = Object.assign({}, this.headers, headers); + async call(method, path = "", headers = {}, params = {}, responseType = "json") { + headers = {...this.headers, ...headers}; + const url = new URL(this.endpoint + path); - let contentType = headers['content-type'].toLowerCase(); + let body = undefined; - let formData = null; + if (method.toUpperCase() === "GET") { + url.search = new URLSearchParams(Client.flatten(params)).toString(); + } else if (headers["content-type"]?.toLowerCase().startsWith("multipart/form-data")) { + delete headers["content-type"]; + const formData = new FormData(); - // Compute FormData for axios and appwrite. - if (contentType.startsWith('multipart/form-data')) { - const form = new FormData(); - - let flatParams = Client.flatten(params); - - for (const key in flatParams) { - const value = flatParams[key]; + const flatParams = Client.flatten(params); - if(value && value.type && value.type === 'file') { - form.append(key, value.file, { filename: value.filename }); + for (const [key, value] of Object.entries(flatParams)) { + if (value && value.type && value.type === "file") { + formData.append(key, value.file, value.filename); } else { - form.append(key, flatParams[key]); + formData.append(key, value); } } - headers = { - ...headers, - ...form.getHeaders() - }; - - formData = form; + body = formData; + } else { + body = JSON.stringify(params); } - let options = { + let response = undefined; + try { + response = await fetch(url.toString(), { method: method.toUpperCase(), - url: this.endpoint + path, - params: (method.toUpperCase() === 'GET') ? params : {}, - headers: headers, - data: (method.toUpperCase() === 'GET' || contentType.startsWith('multipart/form-data')) ? formData : params, - json: (contentType.startsWith('application/json')), - responseType: responseType - }; - if (this.selfSigned) { - // Allow self signed requests - options.httpsAgent = new https.Agent({ rejectUnauthorized: false }); - } + headers, + body, + dispatcher: new Agent({ + connect: { + rejectUnauthorized: !this.selfSigned, + }, + }), + }); + } catch (error) { + throw new {{spec.title | caseUcfirst}}Exception(error.message); + } + + if (response.status >= 400) { + const text = await response.text(); + let json = undefined; try { - let response = await axios(options); - - return response.data; - } catch(error) { - if('response' in error && error.response !== undefined) { - if(error.response && 'data' in error.response) { - if (typeof(error.response.data) === 'string') { - throw new {{spec.title | caseUcfirst}}Exception(error.response.data, error.response.status, '', error.response.data); - } else { - throw new {{spec.title | caseUcfirst}}Exception(error.response.data.message, error.response.status, error.response.data.type, error.response.data); - } - } else { - throw new {{spec.title | caseUcfirst}}Exception(error.response.statusText, error.response.status, error.response.data); - } - } else { - throw new {{spec.title | caseUcfirst}}Exception(error.message); - } + json = JSON.parse(text); + } catch (error) { + throw new {{spec.title | caseUcfirst}}Exception(text, response.status, "", text); } + throw new {{spec.title | caseUcfirst}}Exception(json.message, json.code, json.type, json); + } + + if (responseType === "arraybuffer") { + const data = await response.arrayBuffer(); + return data; + } + + const text = await response.text(); + let json = undefined; + try { + json = JSON.parse(text); + } catch (error) { + return text; + } + return json; } static flatten(data, prefix = '') { diff --git a/templates/node/lib/inputFile.js.twig b/templates/node/lib/inputFile.js.twig index de7f0d9c7..5790b8b41 100644 --- a/templates/node/lib/inputFile.js.twig +++ b/templates/node/lib/inputFile.js.twig @@ -1,40 +1,112 @@ -const { Readable } = require('stream'); -const fs = require('fs'); +const fs = require("fs"); +const { ReadableStream } = require("stream/web"); + +/** + * @param {fs.ReadStream} readStream + * @returns {ReadableStream} + */ +function convertReadStreamToReadableStream(readStream) { + return new ReadableStream({ + start(controller) { + readStream.on("data", (chunk) => { + controller.enqueue(chunk); + }); + readStream.on("end", () => { + controller.close(); + }); + readStream.on("error", (err) => { + controller.error(err); + }); + }, + cancel() { + readStream.destroy(); + }, + }); +} + +/** + * @param {Buffer} buffer + * @returns {ReadableStream} + */ +function bufferToReadableStream(buffer) { + return new ReadableStream({ + start(controller) { + controller.enqueue(buffer); + controller.close(); + }, + }); +} class InputFile { - stream; // Content of file, readable stream - size; // Total final size of the file content - filename; // File name + /** @type {ReadableStream} Content of file as a stream */ + stream; + /** @type {number} Total final size of the file content */ + size; + + /** @type {string} File name */ + filename; + + /** + * @param {string} filePath + * @param {string} filename + * @returns {InputFile} + */ static fromPath = (filePath, filename) => { - const stream = fs.createReadStream(filePath); - const { size } = fs.statSync(filePath); + const nodeStream = fs.createReadStream(filePath); + const stream = convertReadStreamToReadableStream(nodeStream); + const size = fs.statSync(filePath).size; return new InputFile(stream, filename, size); }; + /** + * @param {Buffer} buffer + * @param {string} filename + * @returns {InputFile} + */ static fromBuffer = (buffer, filename) => { - const stream = Readable.from(buffer); - const size = Buffer.byteLength(buffer); + const stream = bufferToReadableStream(buffer); + const size = buffer.byteLength; return new InputFile(stream, filename, size); }; - static fromBlob = async (blob, filename) => { - const arrayBuffer = await blob.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); + /** + * @param {string} content + * @param {string} filename + * @returns {InputFile} + */ + static fromPlainText = (content, filename) => { + const array = new TextEncoder().encode(content); + const buffer = Buffer.from(array); return InputFile.fromBuffer(buffer, filename); }; + /** + * @param {ReadableStream} stream + * @param {string} filename + * @param {number} size + * @returns {InputFile} + */ static fromStream = (stream, filename, size) => { return new InputFile(stream, filename, size); }; - static fromPlainText = (content, filename) => { - const buffer = Buffer.from(content, "utf-8"); - const stream = Readable.from(buffer); - const size = Buffer.byteLength(buffer); + /** + * @param {Blob} blob + * @param {string} filename + * @returns {InputFile} + */ + static fromBlob = (blob, filename) => { + const stream = blob.stream(); + const size = blob.size; return new InputFile(stream, filename, size); }; + /** + * @param {ReadableStream} stream + * @param {string} filename + * @param {number} size + */ constructor(stream, filename, size) { this.stream = stream; this.filename = filename; diff --git a/templates/node/lib/services/service.js.twig b/templates/node/lib/services/service.js.twig index acfa340a3..e6f849c96 100644 --- a/templates/node/lib/services/service.js.twig +++ b/templates/node/lib/services/service.js.twig @@ -5,6 +5,7 @@ const client = require('../client.js'); const Stream = require('stream'); const { promisify } = require('util'); const fs = require('fs'); +const { File } = require('undici'); class {{ service.name | caseUcfirst }} extends Service { @@ -25,6 +26,9 @@ class {{ service.name | caseUcfirst }} extends Service { {% for parameter in method.parameters.all %} * @param {{ '{' }}{{ parameter | typeName }}{{ '}' }} {{ parameter.name | caseCamel | escapeKeyword }} {% endfor %} +{% if 'multipart/form-data' in method.consumes %} + * @param {CallableFunction} onProgress +{% endif %} * @throws {{ '{' }}{{ spec.title | caseUcfirst}}Exception} * @returns {Promise} */ diff --git a/templates/node/package.json.twig b/templates/node/package.json.twig index 6a8eae40c..468b0094e 100644 --- a/templates/node/package.json.twig +++ b/templates/node/package.json.twig @@ -14,7 +14,6 @@ "@types/node": "^18.16.1" }, "dependencies": { - "axios": "^1.4.0", - "form-data": "^4.0.0" + "undici": "^5.28.2" } } diff --git a/tests/Node14Test.php b/tests/Node18Test.php similarity index 82% rename from tests/Node14Test.php rename to tests/Node18Test.php index 23f46c018..c044e19e7 100644 --- a/tests/Node14Test.php +++ b/tests/Node18Test.php @@ -2,7 +2,7 @@ namespace Tests; -class Node14Test extends Base +class Node18Test extends Base { protected string $sdkName = 'node.js'; protected string $sdkPlatform = 'server'; @@ -12,10 +12,10 @@ class Node14Test extends Base protected string $language = 'node'; protected string $class = 'Appwrite\SDK\Language\Node'; protected array $build = [ - 'docker run --rm -v $(pwd):/app -w /app/tests/sdks/node node:14-alpine npm install', + 'docker run --rm -v $(pwd):/app -w /app/tests/sdks/node node:18-alpine npm install', ]; protected string $command = - 'docker run --network="mockapi" --rm -v $(pwd):/app -w /app node:14-alpine node tests/languages/node/test.js'; + 'docker run --network="mockapi" --rm -v $(pwd):/app -w /app node:18-alpine node tests/languages/node/test.js'; protected array $expectedOutput = [ ...Base::FOO_RESPONSES, diff --git a/tests/Node12Test.php b/tests/Node20Test.php similarity index 82% rename from tests/Node12Test.php rename to tests/Node20Test.php index b2a5a47fb..667fe89d4 100644 --- a/tests/Node12Test.php +++ b/tests/Node20Test.php @@ -2,7 +2,7 @@ namespace Tests; -class Node12Test extends Base +class Node20Test extends Base { protected string $sdkName = 'node.js'; protected string $sdkPlatform = 'server'; @@ -10,13 +10,12 @@ class Node12Test extends Base protected string $version = '0.0.1'; protected string $language = 'node'; - protected string $class = 'Appwrite\SDK\Language\Node'; protected array $build = [ - 'docker run --rm -v $(pwd):/app -w /app/tests/sdks/node node:12-alpine npm install', + 'docker run --rm -v $(pwd):/app -w /app/tests/sdks/node node:20-alpine npm install', ]; protected string $command = - 'docker run --network="mockapi" --rm -v $(pwd):/app -w /app node:12-alpine node tests/languages/node/test.js'; + 'docker run --network="mockapi" --rm -v $(pwd):/app -w /app node:20-alpine node tests/languages/node/test.js'; protected array $expectedOutput = [ ...Base::FOO_RESPONSES,