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

Low Level API spike #19

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
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
94 changes: 94 additions & 0 deletions src/http/low-level.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import {Body, Headers, isBody, Method, Request, Response, Uri} from "./index";
import {reduce} from "../transducers";
import {single} from "../collections";

export type Trailers = Headers;

export interface RequestStart {
readonly method: Method
readonly uri: Uri,
readonly version?: string,
}

export interface ResponseStart {
readonly status: number,
readonly statusDescription?: string,
}

export type LowLevelRequest = AsyncGenerator<RequestStart | Headers | Body | Trailers, void>
export type LowLevelResponse = AsyncGenerator<ResponseStart | Headers | Body | Trailers, void>

export type LowLevelHandler = (request: LowLevelRequest) => LowLevelResponse;

export function message(start: RequestStart, headers?: Headers, body?: Body, trailers?: Headers): LowLevelRequest;
export function message(start: ResponseStart, headers?: Headers, body?: Body, trailers?: Headers): LowLevelResponse;
export async function* message(start: RequestStart | ResponseStart, headers?: Headers, body?: Body, trailers?: Headers): any {
yield start;
if (headers) yield headers;
if (body) yield body;
if (trailers) yield trailers;
}

export function get(uri: string, headers?: Headers, body?: Body, trailers?: Headers): LowLevelRequest {
return message({method: "GET", uri: new Uri(uri)}, headers, body, trailers);
}

export function ok(headers?: Headers, body?: Body, trailers?: Headers): LowLevelResponse {
return message({status: 200, statusDescription: 'OK'}, headers, body, trailers);
}

export function notFound(headers?: Headers, body?: Body, trailers?: Headers): LowLevelResponse {
return message({status: 404, statusDescription: 'Not Found'}, headers, body, trailers);
}

export function isRequestStart(value: any): value is RequestStart {
return value && typeof value.method === "string" &&
value.uri instanceof Uri &&
typeof value.version === "string" || typeof value.version === "undefined";
}

export function isResponseStart(value: any): value is ResponseStart {
return value && typeof value.status === "number" &&
typeof value.statusDescription === "string" || typeof value.statusDescription === "undefined";
}

export function isHeaders(value: any): value is Headers {
return value && typeof value === "object" &&
Object.values(value).every(v => typeof v === "string");
}

export async function request(source: LowLevelRequest): Promise<Request> {
// TODO: enforce order (combinators to the rescue?)
return single(source, reduce((a, block) => {
if (isRequestStart(block)) return {...a, ...block};
if (isBody(block)) return {...a, body: block};
// TODO: High Level API needs to support Trailers
if (isHeaders(block)) return {...a, headers: block};
return a;
}, {} as Request));
}

export async function response(source: LowLevelResponse): Promise<Response> {
// TODO: enforce order (combinators to the rescue?)
return single(source, reduce((a, block) => {
if (isResponseStart(block)) return {...a, ...block};
if (isBody(block)) return {...a, body: block};
// TODO: High Level API needs to support Trailers
if (isHeaders(block)) return {...a, headers: block};
return a;
}, {} as Response));
}

export async function consumeRequestStart(source: LowLevelRequest): Promise<RequestStart> {
const {done, value} = await source.next();
if (done) throw new Error('Request has already consumed');
if (!isRequestStart(value)) throw new Error(`Expected RequestStart but got: ${value}`);
return value;
}

export async function consumeHeaders(source: LowLevelRequest): Promise<Headers> {
const {done, value} = await source.next();
if (done) throw new Error('Request has already consumed');
if (!isHeaders(value)) throw new Error(`Expected Headers but got: ${value}`);
return value;
}
122 changes: 122 additions & 0 deletions test/http/low-level.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import {expect} from 'chai';
import {
consumeHeaders, consumeRequestStart,
get,
LowLevelHandler,
notFound,
ok,
request,
response
} from "../../src/http/low-level";
import {StringBody} from "../../src/http";

describe('Low Level API', function () {
describe("via high level API", () => {
it('can convert response', async () => {
const http: LowLevelHandler = request => ok();

const res = await response(http(get('/')));

expect(res.status).to.eql(200);
});

it('can route via request', async () => {
const http: LowLevelHandler = async function* (raw) {
const {uri: {path}} = await request(raw);
if (path === "/path") yield* ok();
else yield* notFound();
};

const res = await response(http(get('/path')));

expect(res.status).to.eql(200);
});
});

describe("low level routing", () => {
it('can route via RequestStart', async () => {
const http: LowLevelHandler = async function* (raw) {
const {uri: {path}} = await consumeRequestStart(raw);
// Note: the "raw" request is no longer a full request as the first line has been consumed
// So we no longer have type safety and if we passed the request down
// bad things would happen
if (path === "/path") yield* ok();
else yield* notFound();
};

const res = await response(http(get('/path')));

expect(res.status).to.eql(200);
});

it('can route via Headers', async () => {
const http: LowLevelHandler = async function* (raw) {
// TODO: convenience vs explicitness (I chose explicit in low level API
// convenience would have allowed consumeHeader to throw away RequestLine
await consumeRequestStart(raw);
const {Accept} = await consumeHeaders(raw);
if (Accept === 'plain/text') yield* ok();
else yield* notFound();
};

const res = await response(http(get('/path', {Accept: 'plain/text'})));

expect(res.status).to.eql(200);
});
});

describe("closable", () => {
it('can be cancelled early', async () => {
let closed = false;
const http: LowLevelHandler = async function* (raw) {
try {
yield {status: 100, statusDescription: 'Continue'};
yield {'Content-Type': 'plain/text'};
yield new StringBody('Hello');
} finally {
closed = true;
}
};

const response = http(get('/another'));

// If you don't call next at least once you won't be inside the try / finally
expect(await response.next()).to.eql({value: {status: 100, statusDescription: 'Continue'}, done: false});
expect(closed).to.eql(false);
expect(await response.return(undefined)).to.eql({value: undefined, done: true});
expect(closed).to.eql(true);
});

it('will close if you iterate to the end', async () => {
let closed = false;
const http: LowLevelHandler = async function* (raw) {
try {
yield {status: 100, statusDescription: 'Continue'};
yield {'Content-Type': 'plain/text'};
} finally {
closed = true;
}
};

await response(http(get('/another')));
expect(closed).to.eql(true);
});

it('still closes even if you throw', async () => {
let closed = false;
const http: LowLevelHandler = async function* (raw) {
try {
throw new Error('Ignore me');
} finally {
closed = true;
}
};

try {
await response(http(get('/another')));
} catch (e) {
}
expect(closed).to.eql(true);
});
})
});