diff --git a/packages/cli/src/commands/serve.js b/packages/cli/src/commands/serve.js index 4ed04faa1..04e5bdc57 100644 --- a/packages/cli/src/commands/serve.js +++ b/packages/cli/src/commands/serve.js @@ -6,8 +6,9 @@ const runProdServer = async (compilation) => { try { const port = compilation.config.port; + const hasApisDir = compilation.context.apisDir; const hasDynamicRoutes = compilation.graph.filter(page => page.isSSR && ((page.data.hasOwnProperty('static') && !page.data.static) || !compilation.config.prerender)); - const server = hasDynamicRoutes.length > 0 ? getHybridServer : getStaticServer; + const server = hasDynamicRoutes.length > 0 || hasApisDir ? getHybridServer : getStaticServer; (await server(compilation)).listen(port, () => { console.info(`Started server at localhost:${port}`); diff --git a/packages/cli/src/lifecycles/context.js b/packages/cli/src/lifecycles/context.js index 18589faa9..d52a3ccee 100644 --- a/packages/cli/src/lifecycles/context.js +++ b/packages/cli/src/lifecycles/context.js @@ -11,6 +11,7 @@ const initContext = async({ config }) => { try { const projectDirectory = process.cwd(); const userWorkspace = path.join(config.workspace); + const apisDir = path.join(userWorkspace, 'api/'); const pagesDir = path.join(userWorkspace, `${config.pagesDirectory}/`); const userTemplatesDir = path.join(userWorkspace, `${config.templatesDirectory}/`); @@ -18,6 +19,7 @@ const initContext = async({ config }) => { dataDir, outputDir, userWorkspace, + apisDir, pagesDir, userTemplatesDir, scratchDir, diff --git a/packages/cli/src/lifecycles/serve.js b/packages/cli/src/lifecycles/serve.js index 9265ecc20..6f83544e9 100644 --- a/packages/cli/src/lifecycles/serve.js +++ b/packages/cli/src/lifecycles/serve.js @@ -253,14 +253,23 @@ async function getStaticServer(compilation, composable) { async function getHybridServer(compilation) { const app = await getStaticServer(compilation, true); + const apiResource = compilation.config.plugins.filter((plugin) => { + return plugin.isGreenwoodDefaultPlugin + && plugin.type === 'resource' + && plugin.name.indexOf('plugin-api-routes') === 0; + }).map((plugin) => { + return plugin.provider(compilation); + })[0]; app.use(async (ctx) => { const url = ctx.request.url.replace(/\?(.*)/, ''); // get rid of things like query string parameters + const isApiRoute = await apiResource.shouldServe(url); const matchingRoute = compilation.graph.filter((node) => { return node.route === url; })[0] || { data: {} }; if (matchingRoute.isSSR && !matchingRoute.data.static) { + // TODO would be nice to pull these plugins once instead of one every request const headers = { request: { 'accept': 'text/html', 'content-type': 'text/html' }, response: { 'content-type': 'text/html' } @@ -312,6 +321,13 @@ async function getHybridServer(compilation) { ctx.status = 200; ctx.set('content-type', 'text/html'); ctx.body = body; + } else if (isApiRoute) { + // TODO just use response + const { body, resp } = await apiResource.serve(ctx.request.url); + + ctx.status = 200; + ctx.set('content-type', resp.headers.get('content-type')); + ctx.body = body; } }); diff --git a/packages/cli/src/plugins/resource/plugin-api-routes.js b/packages/cli/src/plugins/resource/plugin-api-routes.js new file mode 100644 index 000000000..cd4f1c85c --- /dev/null +++ b/packages/cli/src/plugins/resource/plugin-api-routes.js @@ -0,0 +1,53 @@ +/* + * + * Manages routing to API routes. + * + */ +import fs from 'fs'; +import { ResourceInterface } from '../../lib/resource-interface.js'; + +class ApiRoutesResource extends ResourceInterface { + constructor(compilation, options) { + super(compilation, options); + } + + async shouldServe(url) { + // TODO Could this existance check be derived from the graph instead? + // https://github.com/ProjectEvergreen/greenwood/issues/946 + return url.startsWith('/api') && fs.existsSync(this.compilation.context.apisDir, url); + } + + async serve(url) { + // TODO we assume host here, but eventually we will be getting a Request + // https://github.com/ProjectEvergreen/greenwood/issues/948 + const host = `https://localhost:${this.compilation.config.port}`; + let href = new URL(`${this.getBareUrlPath(url).replace('/api/', '')}.js`, `file://${this.compilation.context.apisDir}`).href; + + // https://github.com/nodejs/modules/issues/307#issuecomment-1165387383 + if (process.env.__GWD_COMMAND__ === 'develop') { // eslint-disable-line no-underscore-dangle + href = `${href}?t=${Date.now()}`; + } + + const { handler } = await import(href); + // TODO we need to pass in headers here + // https://github.com/ProjectEvergreen/greenwood/issues/948 + const req = new Request(new URL(`${host}${url}`)); + const resp = await handler(req); + const contents = resp.headers.get('content-type').indexOf('application/json') >= 0 + ? await resp.json() + : await resp.text(); + + return { + body: contents, + resp + }; + } +} + +const greenwoodApiRoutesPlugin = { + type: 'resource', + name: 'plugin-api-routes', + provider: (compilation, options) => new ApiRoutesResource(compilation, options) +}; + +export { greenwoodApiRoutesPlugin }; \ No newline at end of file diff --git a/packages/cli/test/cases/develop.default/develop.default.spec.js b/packages/cli/test/cases/develop.default/develop.default.spec.js index 6e1f5fc01..5c1666f0b 100644 --- a/packages/cli/test/cases/develop.default/develop.default.spec.js +++ b/packages/cli/test/cases/develop.default/develop.default.spec.js @@ -17,6 +17,8 @@ * * User Workspace * src/ + * api/ + * greeting.js * assets/ * data.json * favicon.ico @@ -1204,6 +1206,33 @@ describe('Develop Greenwood With: ', function() { done(); }); }); + + describe('Develop command with API specific behaviors', function() { + const name = 'Greenwood'; + let response = {}; + let data = {}; + + before(async function() { + response = await fetch(`${hostname}:${port}/api/greeting?name=${name}`); + data = await response.json(); + }); + + it('should return a 200 status', function(done) { + expect(response.ok).to.equal(true); + expect(response.status).to.equal(200); + done(); + }); + + it('should return the correct content type', function(done) { + expect(response.headers.get('content-type')).to.equal('application/json; charset=utf-8'); + done(); + }); + + it('should return the correct response body', function(done) { + expect(data.message).to.equal(`Hello ${name}!!!`); + done(); + }); + }); }); after(function() { diff --git a/packages/cli/test/cases/develop.default/src/api/greeting.js b/packages/cli/test/cases/develop.default/src/api/greeting.js new file mode 100644 index 000000000..f046e6243 --- /dev/null +++ b/packages/cli/test/cases/develop.default/src/api/greeting.js @@ -0,0 +1,11 @@ +export async function handler(request) { + const params = new URLSearchParams(request.url.slice(request.url.indexOf('?'))); + const name = params.has('name') ? params.get('name') : 'World'; + const body = { message: `Hello ${name}!!!` }; + + return new Response(JSON.stringify(body), { + headers: { + 'Content-Type': 'application/json' + } + }); +} \ No newline at end of file diff --git a/packages/cli/test/cases/serve.default.api/serve.default.api.spec.js b/packages/cli/test/cases/serve.default.api/serve.default.api.spec.js new file mode 100644 index 000000000..1e4fde3fd --- /dev/null +++ b/packages/cli/test/cases/serve.default.api/serve.default.api.spec.js @@ -0,0 +1,137 @@ +/* + * Use Case + * Run Greenwood serve command with no config. + * + * User Result + * Should start the production server and render a bare bones Greenwood build. + * + * User Command + * greenwood serve + * + * User Config + * N / A + * + * User Workspace + * src/ + * api/ + * greeting.js + */ +import chai from 'chai'; +import path from 'path'; +import { getSetupFiles, getOutputTeardownFiles } from '../../../../../test/utils.js'; +import request from 'request'; +import { runSmokeTest } from '../../../../../test/smoke-test.js'; +import { Runner } from 'gallinago'; +import { fileURLToPath, URL } from 'url'; + +const expect = chai.expect; + +// TODO why does this test keep stalling out and not closing the command? +describe('Serve Greenwood With: ', function() { + const LABEL = 'API Routes'; + const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js'); + const outputPath = fileURLToPath(new URL('.', import.meta.url)); + const hostname = 'http://127.0.0.1:8080'; + let runner; + + before(function() { + this.context = { + hostname + }; + runner = new Runner(); + }); + + describe(LABEL, function() { + + before(async function() { + await runner.setup(outputPath, getSetupFiles(outputPath)); + + return new Promise(async (resolve) => { + setTimeout(() => { + resolve(); + }, 10000); + + await runner.runCommand(cliPath, 'serve'); + }); + }); + + runSmokeTest(['serve'], LABEL); + + describe('Serve command with API specific behaviors for a JSON API', function() { + const name = 'Greenwood'; + let response = {}; + + before(async function() { + // TODO not sure why native `fetch` doesn't seem to work here, just hangs the test runner + return new Promise((resolve, reject) => { + request.get(`${hostname}/api/greeting?name=${name}`, (err, res, body) => { + if (err) { + reject(); + } + + response = res; + response.body = JSON.parse(body); + + resolve(); + }); + }); + }); + + it('should return a 200 status', function(done) { + expect(response.statusCode).to.equal(200); + done(); + }); + + it('should return the correct content type', function(done) { + expect(response.headers['content-type']).to.equal('application/json; charset=utf-8'); + done(); + }); + + it('should return the correct response body', function(done) { + expect(response.body.message).to.equal(`Hello ${name}!!!`); + done(); + }); + }); + + describe('Serve command with API specific behaviors for an HTML ("fragment") API', function() { + const name = 'Greenwood'; + let response = {}; + + before(async function() { + // TODO not sure why native `fetch` doesn't seem to work here, just hangs the test runner + return new Promise((resolve, reject) => { + request.get(`${hostname}/api/fragment?name=${name}`, (err, res, body) => { + if (err) { + reject(); + } + + response = res; + response.body = body; + + resolve(); + }); + }); + }); + + it('should return a 200 status', function(done) { + expect(response.statusCode).to.equal(200); + done(); + }); + + it('should return the correct content type', function(done) { + expect(response.headers['content-type']).to.equal('text/html'); + done(); + }); + + it('should return the correct response body', function(done) { + expect(response.body).to.contain(`