Skip to content

Commit

Permalink
feat(rest/server): adds RESTServer
Browse files Browse the repository at this point in the history
  • Loading branch information
rafamel committed Oct 29, 2019
1 parent 01b80d8 commit 39d34b0
Show file tree
Hide file tree
Showing 9 changed files with 352 additions and 6 deletions.
23 changes: 21 additions & 2 deletions packages/rest/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion packages/rest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@
"typescript": "^3.6.4"
},
"dependencies": {
"@karmic/core": "0.0.0"
"@karmic/core": "0.0.0",
"query-string": "^6.8.3"
},
"peerDependencies": {},
"@pika/pack": {
Expand Down
4 changes: 1 addition & 3 deletions packages/rest/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
export default function main(): null {
return null;
}
export * from './server';
77 changes: 77 additions & 0 deletions packages/rest/src/server/RESTServer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {
CollectionTreeDeclaration,
CollectionTreeImplementation,
application,
toUnary,
filter,
isElementService,
isServiceSubscription,
CollectionError
} from '@karmic/core';
import { createDefaults } from './defaults';
import { ServerRouter } from './ServerRouter';
import {
RESTServerOptions,
RESTServerMethod,
RESTServerContextFn,
RESTServerResponse
} from './types';
import mapError from './map-error';

export class RESTServer {
public declaration: CollectionTreeDeclaration;
private options: Required<Omit<RESTServerOptions, 'default'>>;
private router: ServerRouter;
public constructor(
collection: CollectionTreeImplementation,
options?: RESTServerOptions
) {
this.options = Object.assign(createDefaults(), options);
const app = application(
this.options.subscriptions
? toUnary(collection)
: filter(
collection,
(element) =>
!isElementService(element) || !isServiceSubscription(element)
),
options && options.default ? { default: options.default } : {}
);

this.router = new ServerRouter(
app.flatten('/'),
app.default,
this.options.crud
);
}
public async request(
method: RESTServerMethod,
url: string,
body?: object | null,
context?: RESTServerContextFn
): Promise<RESTServerResponse> {
method = method.toUpperCase() as RESTServerMethod;
const resolve = this.router.route(method, url);

try {
const data = await resolve(
method !== 'GET' && body ? body : {},
context ? await context() : {}
);
return {
status: 200,
data: this.options.envelope(null, data)
};
} catch (err) {
const item = mapError(err);
const error = item
? item.error
: new CollectionError(this.declaration, 'ServerError', err, true);

return {
status: item ? item.status : 500,
data: this.options.envelope(error, null)
};
}
}
}
151 changes: 151 additions & 0 deletions packages/rest/src/server/ServerRouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import {
ApplicationServices,
ApplicationResolve,
isServiceQuery,
ApplicationService,
UnaryApplicationResolve
} from '@karmic/core';
import { RESTServerMethod } from './types';
import qs, { ParsedQuery } from 'query-string';

export interface ServerMethodHash {
GET: ServerParamHash;
POST: ServerParamHash;
PUT: ServerParamHash;
PATCH: ServerParamHash;
DELETE: ServerParamHash;
}

export interface ServerParamHash {
parametized: ServerRouteHash;
nonparametized: ServerRouteHash;
}

export interface ServerRouteHash {
[key: string]: ApplicationResolve;
}

export class ServerRouter {
private fallback: UnaryApplicationResolve;
private routes: ServerMethodHash;
public constructor(
services: ApplicationServices,
fallback: UnaryApplicationResolve,
crud?: boolean
) {
this.fallback = fallback;
this.routes = {
GET: { parametized: {}, nonparametized: {} },
POST: { parametized: {}, nonparametized: {} },
PUT: { parametized: {}, nonparametized: {} },
PATCH: { parametized: {}, nonparametized: {} },
DELETE: { parametized: {}, nonparametized: {} }
};

for (const [route, service] of Object.entries(services)) {
this.categorize(route, service, crud);
}
}
public route(method: RESTServerMethod, url: string): ApplicationResolve {
const parsed = qs.parseUrl(url);

if (!url) return this.resolver(method, this.fallback, parsed.query);
let route = parsed.url.replace(/^\//, '').replace(/\/$/, '');

const hash = this.routes[method];
if (!hash) return this.resolver(method, this.fallback, parsed.query);

if (Object.hasOwnProperty.call(hash.nonparametized, route)) {
return this.resolver(method, hash.nonparametized[route], parsed.query);
}

const split = route.split('/');
route = split.slice(0, -1).join('/');
if (Object.hasOwnProperty.call(hash.parametized, route)) {
return this.resolver(
method,
hash.parametized[route],
parsed.query,
split[split.length - 1]
);
}

return this.resolver(method, this.fallback, parsed.query);
}
private resolver(
method: RESTServerMethod,
resolve: ApplicationResolve,
query?: ParsedQuery<string>,
id?: string
): ApplicationResolve {
// Only passes query on get
if (method === 'GET') {
return id
? (data: any, context: any) =>
resolve(
data ? { ...data, ...query, id } : { ...query, id },
context
)
: (data: any, context: any) =>
resolve(data ? { ...data, ...query, id } : { ...query }, context);
}

return id
? (data: any, context: any) =>
resolve(data ? { ...data, id } : { id }, context)
: resolve;
}
private categorize(
route: string,
service: ApplicationService,
crud?: boolean
): void {
const isQuery = isServiceQuery(service.declaration);
if (!crud) {
this.routes[isQuery ? 'GET' : 'POST'].nonparametized[route] =
service.resolve;
return;
}

const arr = route.split('/');
const name = arr[arr.length - 1];
const sliced = arr.slice(0, -1).join('/');

switch (name) {
case 'get': {
if (!isQuery) throw Error(`CRUD service "get" can't be a mutation`);
this.routes.GET.parametized[sliced] = service.resolve;
break;
}
case 'list': {
if (!isQuery) throw Error(`CRUD service "list" can't be a mutation`);
this.routes.GET.nonparametized[sliced] = service.resolve;
break;
}
case 'create': {
if (isQuery) throw Error(`CRUD service "create" must be a mutation`);
this.routes.POST.nonparametized[sliced] = service.resolve;
break;
}
case 'update': {
if (isQuery) throw Error(`CRUD service "update" must be a mutation`);
this.routes.PUT.parametized[sliced] = service.resolve;
break;
}
case 'patch': {
if (!isQuery) throw Error(`CRUD service "patch" must be a mutation`);
this.routes.PATCH.parametized[sliced] = service.resolve;
break;
}
case 'remove': {
if (!isQuery) throw Error(`CRUD service "remove" must be a mutation`);
this.routes.DELETE.parametized[sliced] = service.resolve;
break;
}
default: {
this.routes[isQuery ? 'GET' : 'POST'].nonparametized[route] =
service.resolve;
}
}
}
}
25 changes: 25 additions & 0 deletions packages/rest/src/server/defaults.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { RESTServerOptions } from './types';

export function createDefaults(): Required<Omit<RESTServerOptions, 'default'>> {
return {
crud: true,
children: true,
subscriptions: true,
declaration: true,
envelope(error, data) {
return error
? {
status: 'error',
error: {
id: error.id,
label: error.label,
description: error.message || null
}
}
: {
status: 'success',
data: data
};
}
};
}
2 changes: 2 additions & 0 deletions packages/rest/src/server/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './RESTServer';
export * from './types';
29 changes: 29 additions & 0 deletions packages/rest/src/server/map-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { ErrorLabel, PublicError } from '@karmic/core';

export const hash: { [P in ErrorLabel]: number } = {
// Client
ClientError: 400,
ClientUnauthorized: 401,
ClientForbidden: 403,
ClientNotFound: 404,
ClientUnsupported: 406,
ClientConflict: 409,
ClientInvalid: 422,
ClientTooEarly: 425,
ClientRateLimit: 429,
ClientLegal: 451,
// Server
ServerError: 500,
ServerNotImplemented: 501,
ServerGateway: 502,
ServerUnavailable: 503,
ServerTimeout: 504
};

export default function mapError(
error: PublicError
): null | { error: PublicError; status: number } {
const status = hash[error.label];

return status ? { error, status } : null;
}
44 changes: 44 additions & 0 deletions packages/rest/src/server/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { PublicError, QueryServiceImplementation } from '@karmic/core';

export interface RESTServerOptions {
/**
* Whether to modify the routes of services named `get`, `list`, `create`, `update`, `patch`, and `remove` to follow traditional RESTful conventions. Default: `true`.
*/
crud?: boolean;
/**
* Whether to create routes for type's children services. Default: `true`.
*/
children?: boolean;
/**
* Whether to create routes for subscription services, serving their first result. Default: `true`.
*/
subscriptions?: boolean;
/**
* Whether to serve the collection declaration JSON at `/:declaration`. Default: `true`.
*/
declaration?: boolean;
// TODO: we need to know how the types are modified by the envelope
/**
* A function returning the final data to serve.
*/
envelope?: RESTServerEnvelopeFn;
/**
* A default service for adapters to use when the route is non existent.
* Defaults to a `ClientNotFound` error throwing service.
*/
default?: QueryServiceImplementation;
}

export type RESTServerContextFn<T = any> = () => Promise<T> | T;

export type RESTServerEnvelopeFn<T = any> = (
error: null | PublicError,
data: any
) => T;

export type RESTServerMethod = 'POST' | 'GET' | 'PUT' | 'PATCH' | 'DELETE';

export interface RESTServerResponse<T = any> {
status: number;
data: T;
}

0 comments on commit 39d34b0

Please sign in to comment.