@selfage/service_handler

Http-based service handlers on top of expressjs.

Usage no npm install needed!

<script type="module">
  import selfageServiceHandler from 'https://cdn.skypack.dev/@selfage/service_handler';
</script>

README

@selfage/service_handler

Install

npm install @selfage/service_handler

Overview

Written in TypeScript and compiled to ES6 with inline source map & source. See @selfage/tsconfig for full compiler options. Provides type-safe service handler interfaces to be implemented and hooked onto Express.js. The service here only refers to one simple kind of client-server interaction: Sending a HTTP POST request in JSON as request body and receiving a response in JSON.

UnauthedServiceHandler

UnauthedServiceHandler is an interface that can be implemented as the following example.

import { UnauthedServiceHandler } from '@selfage/service_handler';
import { GetCommentsRequest, GetCommentsResponse, GET_COMMENTS } from './get_comments';

export class GetCommentsHandler implements UnauthedServiceHandler<GetCommentsRequest, GetCommentsResponse> {
  public serviceDescriptor = GET_COMMENTS;

  public async handle(logContext: string, request: GetCommentsRequest): Promise<GetCommentsResponse> {
    // await database operations.
    return {
      texts: ["comment1", "comment2"]
    };
  }
}

logContext contains a randomly generated request id, though not guaranteed to be universally unique, that can be prepended to any logging happened within the life of a request processing, making it easier to group logs associated with a certain request.

get_comments.ts(source) is typically generated by installing @selfage/cli and running selfage gen get_comments which requires an input file get_comments.json(source), specifying the url endpoint/path as /get_comments.

See @selfage/service_descriptor and @selfage/message for more explanation of the JSON file. Typically, get_comments.json and get_comments.ts will be shared between client-side and server-side code.

Partial implementation of AuthedServiceHandler

Authentication is done through validating a signed session string, passed from the request body, i.e., signedSession field from the request. See @selfage/service_descriptor for an example of generating an AuthedServiceDescriptor. Also read further below for how to obtain a signed session string.

AuthedServiceHandler is an interface requiring sessionDescriptor to help parse the validated session string into a structured data. See @selfage/message for how to generate a MessageDescriptor.

Typically for one project, the session strucutre is always the same. By partially implementing AuthedServiceHandler, i.e., by only providing sessionDescriptor, the rest of the project can extends it for consistent session parsing.

In the example below, we import session defined in get_history.json which also contains GetHistory service, and name the file as authed_service_handler.ts.

import { AuthedServiceHandler } from '@selfage/service_handler';
import { AuthedServiceDescriptor } from '@selfage/service_descriptor';
import { MySession, MY_SESSION } from './get_history';

export abstract class AuthedServiceHandlerWithSession<ServiceRequest, ServiceResponse> implements AuthedServiceHandler<ServiceRequest, ServiceResponse, MySession> {
  public sessionDescriptor = MY_SESSION;
  abstract serviceDescriptor: AuthedServiceDescriptor<ServiceRequest, ServiceResponse>;
  abstract handle: handle: (logContext: string, request: ServiceRequest, session: MySession ) => Promise<ServiceResponse>;
}

Full implementation of AuthedServiceHandler

An example that extends the partially implemented AuthedServiceHandler above looks like the following.

import { AuthedServiceHandlerWithSession } from './authed_service_handler';
import { MySession, GetHistoryRequest, GetHistoryResponse, GET_HISTORY } from './get_history';

export class GetHistoryHandler extends AuthedServiceHandlerWithSession<GetHistoryRequest, GetHistoryResponse> {
  public serviceDescriptor = GET_HISTORY;

  public async handle(logContext: string, request: GetHistoryRequest, session: MySession): Promise<GetHistoryResponse> {
    // await database operations.
    return {
      // ...
    };
  }
}

Except extending AuthedServiceHandlerWithSession and the additional session argument in method handle(), see UnauthedServiceHandler for a full example.

get_history.ts(source) is typically generated by installing @selfage/cli and running selfage gen get_history which requires an input file get_history.json(source), specifying the url endpoint/path as /get_history.

Register unauthed/authed handlers

The following is an exmaple to register the handlers above to Express.js. Under the hood, it takes path field in a service descriptor as the routing path in Express.js and wraps handlers to parse request and send response.

import express = require('express');
import { registerUnauthed, registerAuthed } from '@selfage/service_handler/register';
import { GetCommentsHandler } from './get_comments_handler';
import { GetHistoryHandler } from './get_history_handler';

let app = express();
registerUnauthed(app, new GetCommentsHandler());
registerAuthed(app, new GetHistoryHandler());

In particular, handlers are wrapped with the following common process.

  1. Allows CORS for all sites.
  2. Use express.json() middleware to read and parse JSON string.
  3. Validate the JSON request object into a type-safe request object.
  4. Validate and parse signedSession field into a type-safe session object, when call registerAuthed() with an AuthedServiceHandler.
  5. Pass request and session into the handler.
  6. Catch error thrown from handlers.
  7. If an error is caught, check the presence of statusCode field of the error and respond with it, or respond 500 if not present. E.g., you could throw an HttpError from @selfage/http_error when encounter issues.
  8. If no error is caught, respond with the response object as a JSON object.

CORS & preflight handler

Allowing CORS for all domains is an opinionated decision that restricting CORS doesn't help account/data security at all, but might annoy future development. We should guarantee security by other approaches.

Before making any cross-site request, browsers might send a preflight request to ask for valid domain/site. We provide a simple preflight handler to allow all sites.

import express = require('express');
import { registerCorsAllowedPreflightHandler } from '@selfage/service_handler/preflight_handler';

let app = express();
registerCorsAllowedPreflightHandler(app);

Sign a session string

You have to configure your secret key for signing at the startup of your server, i.e., a secret key for sha256 algorithm. Please refer to other instructions on the best practice of generating a secret key and storing it.

import { SessionSigner } from '@selfage/service_handler/session_signer';

SessionSigner.SECRET_KEY = 'Configure a secrect key';
// Configure routing and start server.

A typical example showing below is to return the signed session string when signing in, supposing SignInResponse containing a signedSession field.

import { SessionBuilder } from '@selfage/service_handler/session_signer';
import { UnauthedServiceHandler } from '@selfage/service_handler';
import { SignInRequest, SignInResponse, SIGN_IN } from './sign_in';

export class GetCommentsHandler implements UnauthedServiceHandler<SignInRequest, SignInResponse> {
  public serviceDescriptor = SIGN_IN;
  private sessionBuilder = SessionBuilder.create();

  public async handle(logContext: string, request: SignInRequest): Promise<SignInResponse> {
    // await database operations.
    let signedSession = this.sessionBuilder.build(JSON.stringify({sessionId: '1234', userId: '5678'}));
    return {
      signedSession: signedSession
    };
  }
}

Session expiration

Regardless of the data structure of your session, the signed session string always contains the timestamp when signing. By default, a session is expired 30 days after the signing timestamp. You have to re-sign a session the same way as a new session and return it to the client to refresh the timestamp.

You can configure the session longevity as the following, usually before starting your server.

import { SessionExtractor } from '@selfage/service_handler/session_signer';

SessionExtractor.SESSION_LONGEVITY = 30 * 24 * 60 * 60; // seconds
// Configure routing and start server.

Session validation error

All AuthedServiceHandlers registered with registerAuthed() will validate incoming requests' signedSession field, and either catch validation errors or proceed to the implemented AuthedServiceHandlers. For any validatoin error caught, we will return the 401 error code to the client, regardless of missing session, invalid signature or expired timestamp.

Request body size

We choose 1MiB or 1024*1024 bytes as the limit of the request body size, making the same assumption as Google's Datastore which imposes the same size limit for an entity.