Skip to content

Commit

Permalink
Feature/issue 1007 api routes (#1017)
Browse files Browse the repository at this point in the history
* API Routes

* rename ApiRoutes resource class

* add skeleton Request support to handler signature

* Response example from API route

* add local development cache busting for API routes

* refactor out dependency on path

* fix typo

* API Routes

* add local development cache busting for API routes

* refactor out dependency on path

* handle APIs for serve command

* add API development spec

* refactor API development spec to use fetch

* refactor specs

* API route serve specs

* TODOs tracking

* add API routes documentation

* restore API specs using request instead of native fetch

* remove demo code

* app exists check for API routes
  • Loading branch information
thescientist13 authored Dec 17, 2022
1 parent 1de4b7b commit 201b5ae
Show file tree
Hide file tree
Showing 13 changed files with 370 additions and 3 deletions.
3 changes: 2 additions & 1 deletion packages/cli/src/commands/serve.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/lifecycles/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ 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}/`);

const context = {
dataDir,
outputDir,
userWorkspace,
apisDir,
pagesDir,
userTemplatesDir,
scratchDir,
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/src/lifecycles/serve.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand Down Expand Up @@ -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;
}
});

Expand Down
53 changes: 53 additions & 0 deletions packages/cli/src/plugins/resource/plugin-api-routes.js
Original file line number Diff line number Diff line change
@@ -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 };
29 changes: 29 additions & 0 deletions packages/cli/test/cases/develop.default/develop.default.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
*
* User Workspace
* src/
* api/
* greeting.js
* assets/
* data.json
* favicon.ico
Expand Down Expand Up @@ -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() {
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/test/cases/develop.default/src/api/greeting.js
Original file line number Diff line number Diff line change
@@ -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'
}
});
}
137 changes: 137 additions & 0 deletions packages/cli/test/cases/serve.default.api/serve.default.api.spec.js
Original file line number Diff line number Diff line change
@@ -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(`<h1>Hello ${name}!!!</h1>`);
done();
});
});
});

after(function() {
runner.stopCommand();
runner.teardown(getOutputTeardownFiles(outputPath));
});
});
18 changes: 18 additions & 0 deletions packages/cli/test/cases/serve.default.api/src/api/fragment.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { renderFromHTML } from 'wc-compiler';

export async function handler(request) {
const headers = new Headers();
const params = new URLSearchParams(request.url.slice(request.url.indexOf('?')));
const name = params.has('name') ? params.get('name') : 'World';
const { html } = await renderFromHTML(`
<x-card name="${name}"></x-card>
`, [
new URL('../components/card.js', import.meta.url)
]);

headers.append('Content-Type', 'text/html');

return new Response(html, {
headers
});
}
11 changes: 11 additions & 0 deletions packages/cli/test/cases/serve.default.api/src/api/greeting.js
Original file line number Diff line number Diff line change
@@ -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'
}
});
}
11 changes: 11 additions & 0 deletions packages/cli/test/cases/serve.default.api/src/components/card.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export default class Card extends HTMLElement {
connectedCallback() {
const name = this.getAttribute('name');

this.innerHTML = `
<h1>Hello ${name}!!!</h1>
`;
}
}

customElements.define('x-card', Card);
Loading

0 comments on commit 201b5ae

Please sign in to comment.