@selfage/service_client

Http-based client to call services.

Usage no npm install needed!

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

README

@selfage/service_client

Install

npm install @selfage/service_client

Overview

Written in TypeScript and compiled to ES6 with inline source map & source. See @selfage/tsconfig for full compiler options. Provides a type-safe client to call services described by @selfage/service_descriptor and handled by @selfage/service_handler. 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.

Constructor

It's recommended to create and export a singleton instance of ServiceClient. The constructor takes an implementation of SessionStorage and a fetch function.

import { ServiceClient } from '@selfage/service_client';
import { LocalSessionStorage } from '@selfage/service_client/local_session_storage';

export let client = new ServiceClient(new LocalSessionStorage(), window.fetch.bind(window));

The above example is for browser environment because of window.fetch as well as LocalSessionStorage which implements SessionStorage interface using window.localStorage.

You can also inject implementations for Nodejs environment. In fact, our unit tests provide a mock implementation of SessionStorage and replace window.fetch with node-fetch. However, window.fetch and node-fetch have different function types in TypeScript, though they are effectively the same, and we have to cast node-fetch as any.

import { ServiceClient } from '@selfage/service_client';
import { SessionStorage } from '@selfage/service_client/session_storage';
import fetch = require('node-fetch');

let client = new ServiceClient(new class implements SessionStorage {/* ... */}, fetch as any);

SessionStorage

To not confuse with window.sessionStorage, SessionStorage in this package is simply a TypeScript interface to store and retrieve a session string. See its source. If used on backend servers, you can also provide an implementation using in-memory maps, disks, or database.

As stated above, we provide a LocalSessionStorage implementation using window.localStorage as opposed to using cookie. Therefore the session string will not be sent with every request.

Read further for how it's used during authentication.

Fetch services without authentication

fetchUnauthed() takes a request object as well as an UnauthedServiceDescriptor and returns a Promise of a response object.

import { GET_COMMENTS } from './get_comments';

async function run() {
  // Suppose we created a `ServiceClient`.
  let response = await client.fetchUnauthed({ videoId: "xBivT1" }, GET_COMMENTS);
}

get_comments.ts(source) is typically generated by installing @selfage/cli and runing 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.

Fetch services requiring authentication

fetchAuthed() takes a request object as well as an AuthedServiceDescriptor and returns a Promise of a response object.

import { GET_HISTORY } from './get_history';

async function run() {
  // Suppose we created a `ServiceClient`.
  let response = await client.fetchAuthed({ page: 1 }, GET_HISTORY);
}

As also documented in @selfage/service_descriptor, an authed service requiring its request to contain signedSession field. See get_history.ts(soure) and get_history.json(source) as the example.

You don't need to explicitly set signedSession field. ServiceClient will set it with the session string by calling read() on SessionStorage.

Origin

Every AuthedServiceDescriptor or UnauthedServiceDescriptor only specifies the path of the url that we are calling to. Thus you have to provide all the preceeding part of the path to ServiceClient, i.e. the origin of a URL.

// Suppose we created a `ServiceClient`.
client.origin = 'http://localhost:8080';

Its intended use case is to switch server addresses between PROD and DEV environments.

// Suppose we created a `ServiceClient`.
declare let environment: string;
if (environment === 'PROD') {
  client.origin = 'https://www.my-domain.com';
} else if (environment === 'DEV') {
  client.origin = 'http://dev.my-domain.com'
} else if (environment === 'LOCAL') {
  client.origin = 'http://localhost:8080';
}

If the services you are calling to are distributed through multiple server addresses/domains, you can definitely instantiate multiple ServiceClients as singletons, pointing to each server address.

Catch errors

You can handle all kinds of errors for each service call using try-catch statement, including network connection errors which are thrown by injected fetch function, and server responded errors which are thrown by ServiceClient as HttpError.

async function run() {
  try {
    // Suppose we created a `ServiceClient`.
    let response = await client.fetchUnauthed({ videoId: "xBivT1" }, GET_COMMENTS);
  } catch (e) {
    // Log error or display it to users.
  }
}

Catch HttpError

ServiceClient will construct an HttpError whenever server finishes response without ok status, typically with 4xx or 5xx error code. See @selfage/http_error for more explanation of HttpError.

In addition to the try-catch statement above, you can also add a listener to it, which can serve as a global handler of HttpError.

// Suppose we created a `ServiceClient`.
client.on('httpError', (httpError /* HttpError */) =>  {
  // E.g., redirect to home page.
});

Catch unauthenticated error

When server finishes response with 401 error code, i.e., Unauthorized, ServiceClient treats it as an unauthenticated error by calling clear() on SessionStorage.

In addition to the try-catch statement above, you can also add a listener to it, which can serve as a global handler of unauthenticated error.

// Suppose we created a `ServiceClient`.
client.on('unauthenticated', (/* No args */) =>  {
  // E.g., redirect to home page.
});

It's often confused about unauthenticated and unauthorized error, especially because standard Http error codes did confuse them. Unauthenticated means the user cannot be identified, e.g., because of wrong password, whereas unauthorized means the user might be identified but doesn't have enough privilege/permission to access the web page/call the service, e.g., the user can read documents but cannot edit them.

The closest error code to represent unaunthenticated error is 401, although it's named as "Unauthorized". Its spec also says that "authentication is possible" for 401, whereas "re-authenticating will make no difference" for 403. Please keep that in mind when handling unauthenticatd/unauthorized errors on client-side as well as when returning them on server-side.