Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/issue 1007 api routes #1017

Merged
merged 20 commits into from
Dec 17, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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