diff --git a/packages/plugin-adapter-netlify/src/index.js b/packages/plugin-adapter-netlify/src/index.js index 3c24c684e..5ec6c9d06 100644 --- a/packages/plugin-adapter-netlify/src/index.js +++ b/packages/plugin-adapter-netlify/src/index.js @@ -5,19 +5,40 @@ import { zip } from 'zip-a-folder'; // https://docs.netlify.com/functions/create/?fn-language=js function generateOutputFormat(id) { + const variableNameSafeId = id.replace(/-/g, ''); + return ` - import { handler as ${id} } from './__${id}.js'; + import { handler as ${variableNameSafeId} } from './__${id}.js'; export async function handler (event, context = {}) { - const { rawUrl, body, headers, httpMethod } = event; + const { rawUrl, body, headers = {}, httpMethod } = event; + const contentType = headers['content-type'] || ''; + let format = body; + + if (['GET', 'HEAD'].includes(httpMethod.toUpperCase())) { + format = null + } else if (contentType.includes('application/x-www-form-urlencoded')) { + const formData = new FormData(); + + for (const key of Object.keys(body)) { + formData.append(key, body[key]); + } + + // when using FormData, let Request set the correct headers + // or else it will come out as multipart/form-data + // https://stackoverflow.com/a/43521052/417806 + format = formData; + delete headers['content-type']; + } else if(contentType.includes('application/json')) { + format = JSON.stringify(body); + } + const request = new Request(rawUrl, { - body: ['GET', 'HEAD'].includes(httpMethod.toUpperCase()) - ? null - : JSON.stringify(body), + body: format, method: httpMethod, headers: new Headers(headers) }); - const response = await ${id}(request, context); + const response = await ${variableNameSafeId}(request, context); return { statusCode: response.status, diff --git a/packages/plugin-adapter-netlify/test/cases/build.default/build.default.spec.js b/packages/plugin-adapter-netlify/test/cases/build.default/build.default.spec.js index b48ddc3be..7b9457929 100644 --- a/packages/plugin-adapter-netlify/test/cases/build.default/build.default.spec.js +++ b/packages/plugin-adapter-netlify/test/cases/build.default/build.default.spec.js @@ -76,11 +76,11 @@ describe('Build Greenwood With: ', function() { }); it('should output the expected number of serverless function zip files', function() { - expect(zipFiles.length).to.be.equal(5); + expect(zipFiles.length).to.be.equal(6); }); it('should output the expected number of serverless function API zip files', function() { - expect(zipFiles.filter(file => path.basename(file).startsWith('api-')).length).to.be.equal(3); + expect(zipFiles.filter(file => path.basename(file).startsWith('api-')).length).to.be.equal(4); }); it('should output the expected number of serverless function SSR page zip files', function() { @@ -156,11 +156,11 @@ describe('Build Greenwood With: ', function() { }); }); - describe('Submit API Route adapter', function() { + describe('Submit JSON API Route adapter', function() { let apiFunctions; before(async function() { - apiFunctions = await glob.promise(path.join(normalizePathnameForWindows(netlifyFunctionsOutputUrl), 'api-submit.zip')); + apiFunctions = await glob.promise(path.join(normalizePathnameForWindows(netlifyFunctionsOutputUrl), 'api-submit-json.zip')); }); it('should output one API route as a serverless function zip file', function() { @@ -176,7 +176,7 @@ describe('Build Greenwood With: ', function() { }); const { handler } = await import(new URL(`./${name}/${name}.js`, netlifyFunctionsOutputUrl)); const response = await handler({ - rawUrl: 'http://localhost:8080/api/submit', + rawUrl: 'http://localhost:8080/api/submit-json', body: { name: param }, httpMethod: 'POST', headers: { @@ -192,6 +192,41 @@ describe('Build Greenwood With: ', function() { }); }); + describe('Submit FormData API Route adapter', function() { + let apiFunctions; + + before(async function() { + apiFunctions = await glob.promise(path.join(normalizePathnameForWindows(netlifyFunctionsOutputUrl), 'api-submit-form-data.zip')); + }); + + it('should output one API route as a serverless function zip file', function() { + expect(apiFunctions.length).to.be.equal(1); + }); + + it('should return the expected response when the serverless adapter entry point handler is invoked', async function() { + const param = 'Greenwood'; + const name = path.basename(apiFunctions[0]).replace('.zip', ''); + + await extract(apiFunctions[0], { + dir: path.join(normalizePathnameForWindows(netlifyFunctionsOutputUrl), name) + }); + const { handler } = await import(new URL(`./${name}/${name}.js`, netlifyFunctionsOutputUrl)); + const response = await handler({ + rawUrl: 'http://localhost:8080/api/submit-form-data', + body: { name: param }, + httpMethod: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded' + } + }, {}); + const { statusCode, body, headers } = response; + + expect(statusCode).to.be.equal(200); + expect(body).to.be.equal(`Thank you ${param} for your submission!`); + expect(headers.get('Content-Type')).to.be.equal('text/html'); + }); + }); + describe('Artists SSR Page adapter', function() { const count = 2; let pageFunctions; diff --git a/packages/plugin-adapter-netlify/test/cases/build.default/src/api/submit-form-data.js b/packages/plugin-adapter-netlify/test/cases/build.default/src/api/submit-form-data.js new file mode 100644 index 000000000..5a4716f26 --- /dev/null +++ b/packages/plugin-adapter-netlify/test/cases/build.default/src/api/submit-form-data.js @@ -0,0 +1,11 @@ +export async function handler(request) { + const formData = await request.formData(); + const name = formData.get('name'); + const body = `Thank you ${name} for your submission!`; + + return new Response(body, { + headers: new Headers({ + 'Content-Type': 'text/html' + }) + }); +} \ No newline at end of file diff --git a/packages/plugin-adapter-netlify/test/cases/build.default/src/api/submit.js b/packages/plugin-adapter-netlify/test/cases/build.default/src/api/submit-json.js similarity index 100% rename from packages/plugin-adapter-netlify/test/cases/build.default/src/api/submit.js rename to packages/plugin-adapter-netlify/test/cases/build.default/src/api/submit-json.js diff --git a/packages/plugin-adapter-vercel/src/index.js b/packages/plugin-adapter-vercel/src/index.js index 22766c29c..9ad5c7e3d 100644 --- a/packages/plugin-adapter-vercel/src/index.js +++ b/packages/plugin-adapter-vercel/src/index.js @@ -4,23 +4,43 @@ import { checkResourceExists } from '@greenwood/cli/src/lib/resource-utils.js'; // https://vercel.com/docs/functions/serverless-functions/runtimes/node-js#node.js-helpers function generateOutputFormat(id, type) { + const variableNameSafeId = id.replace(/-/g, ''); const path = type === 'page' ? `__${id}` : id; return ` - import { handler as ${id} } from './${path}.js'; + import { handler as ${variableNameSafeId} } from './${path}.js'; export default async function handler (request, response) { - const { body, url, headers, method } = request; + const { body, url, headers = {}, method } = request; + const contentType = headers['content-type'] || ''; + let format = body; + + if (['GET', 'HEAD'].includes(method.toUpperCase())) { + format = null + } else if (contentType.includes('application/x-www-form-urlencoded')) { + const formData = new FormData(); + + for (const key of Object.keys(body)) { + formData.append(key, body[key]); + } + + // when using FormData, let Request set the correct headers + // or else it will come out as multipart/form-data + // https://stackoverflow.com/a/43521052/417806 + format = formData; + delete headers['content-type']; + } else if(contentType.includes('application/json')) { + format = JSON.stringify(body); + } + const req = new Request(new URL(url, \`http://\${headers.host}\`), { - body: ['GET', 'HEAD'].includes(method.toUpperCase()) - ? null - : JSON.stringify(body), + body: format, headers: new Headers(headers), method }); - const res = await ${id}(req); + const res = await ${variableNameSafeId}(req); res.headers.forEach((value, key) => { response.setHeader(key, value); diff --git a/packages/plugin-adapter-vercel/test/cases/build.default/build.default.spec.js b/packages/plugin-adapter-vercel/test/cases/build.default/build.default.spec.js index d5a6177b2..77f3d0678 100644 --- a/packages/plugin-adapter-vercel/test/cases/build.default/build.default.spec.js +++ b/packages/plugin-adapter-vercel/test/cases/build.default/build.default.spec.js @@ -77,7 +77,7 @@ describe('Build Greenwood With: ', function() { }); it('should output the expected number of serverless function output folders', function() { - expect(functionFolders.length).to.be.equal(5); + expect(functionFolders.length).to.be.equal(6); }); it('should output the expected configuration file for the build output', function() { @@ -181,19 +181,20 @@ describe('Build Greenwood With: ', function() { }); }); - describe('Submit API Route adapter', function() { + describe('Submit JSON API Route adapter', function() { const name = 'Greenwood'; it('should return the expected response when the serverless adapter entry point handler is invoked', async function() { - const handler = (await import(new URL('./api/submit.func/index.js', vercelFunctionsOutputUrl))).default; + const handler = (await import(new URL('./api/submit-json.func/index.js', vercelFunctionsOutputUrl))).default; const response = { headers: new Headers() }; await handler({ - url: 'http://localhost:8080/api/submit', + url: 'http://localhost:8080/api/submit-json', headers: { - host: 'http://localhost:8080' + 'host': 'http://localhost:8080', + 'content-type': 'application/json' }, body: { name }, method: 'POST' @@ -217,6 +218,42 @@ describe('Build Greenwood With: ', function() { }); }); + describe('Submit FormData JSON API Route adapter', function() { + const name = 'Greenwood'; + + it('should return the expected response when the serverless adapter entry point handler is invoked', async function() { + const handler = (await import(new URL('./api/submit-form-data.func/index.js', vercelFunctionsOutputUrl))).default; + const response = { + headers: new Headers() + }; + + await handler({ + url: 'http://localhost:8080/api/submit-form-data', + headers: { + 'host': 'http://localhost:8080', + 'content-type': 'application/x-www-form-urlencoded' + }, + body: { name }, + method: 'POST' + }, { + status: function(code) { + response.status = code; + }, + send: function(body) { + response.body = body; + }, + setHeader: function(key, value) { + response.headers.set(key, value); + } + }); + const { status, body, headers } = response; + + expect(status).to.be.equal(200); + expect(body).to.be.equal(`Thank you ${name} for your submission!`); + expect(headers.get('Content-Type')).to.be.equal('text/html'); + }); + }); + describe('Artists SSR Page adapter', function() { it('should return the expected response when the serverless adapter entry point handler is invoked', async function() { const handler = (await import(new URL('./artists.func/index.js', vercelFunctionsOutputUrl))).default; diff --git a/packages/plugin-adapter-vercel/test/cases/build.default/src/api/submit-form-data.js b/packages/plugin-adapter-vercel/test/cases/build.default/src/api/submit-form-data.js new file mode 100644 index 000000000..5a4716f26 --- /dev/null +++ b/packages/plugin-adapter-vercel/test/cases/build.default/src/api/submit-form-data.js @@ -0,0 +1,11 @@ +export async function handler(request) { + const formData = await request.formData(); + const name = formData.get('name'); + const body = `Thank you ${name} for your submission!`; + + return new Response(body, { + headers: new Headers({ + 'Content-Type': 'text/html' + }) + }); +} \ No newline at end of file diff --git a/packages/plugin-adapter-vercel/test/cases/build.default/src/api/submit.js b/packages/plugin-adapter-vercel/test/cases/build.default/src/api/submit-json.js similarity index 100% rename from packages/plugin-adapter-vercel/test/cases/build.default/src/api/submit.js rename to packages/plugin-adapter-vercel/test/cases/build.default/src/api/submit-json.js