Skip to content

Instantly share code, notes, and snippets.

@lyleunderwood
Last active September 12, 2019 13:58
Show Gist options
  • Save lyleunderwood/24f8b540157be93435896a3bfe921e37 to your computer and use it in GitHub Desktop.
Save lyleunderwood/24f8b540157be93435896a3bfe921e37 to your computer and use it in GitHub Desktop.
#!/bin/bash
set -e
set -E
LIST=`find src/modules/api/response-types -regex '.*\.js$'`
for TYPE in $LIST; do
RESPONSE_DEF="\"\$ref\": \"#\/definitions\\/`echo $TYPE | sed 's/\.js$//' | sed 's/\//::/g'`::Response\","
if ! grep --quiet "Response" $TYPE; then
echo "No Response type defined in $TYPE." > /dev/stderr
exit 1
fi
TARGET=`echo $TYPE |sed 's/response-types/schemas/' | sed 's/\.js$/.json/'`
VALIDATOR_TARGET=`echo $TYPE |sed 's/response-types/validators/'`
echo $TARGET > /dev/stderr
flow2schema -t json-schema --indent=2 $TYPE | \
sed "s/\"\$schema.*/$RESPONSE_DEF/" > $TARGET
ajv compile --messages=true -s $TARGET -o $VALIDATOR_TARGET
done
// @flow
/**
* This module models requests and request builders for sending network
* requests. It utilizes ky which is an abstraction on top of the fetch api.
* The fundamental unit of the API is the RequestBuilder, which is a factory
* function for requests. It works like this:
*
* ```javascript
* const myRequestBuilder = getRequestBuilder(id => `/posts/${id}`);
* const myGetRequest = myRequestBuilder(1);
* responseForRequest('/api/', myGetRequest)
* .then(data => console.log(data); // data is `mixed` in this example
* ```
*
* This module also implements validated request builders, which builds in
* response validation in an attempt to guarantee type safety for data that
* comes from external sources. If we wanted to add response validation to
* our example, it would look like this:
*
* ```javascript
* const myRequestBuilder = getRequestBuilder(id => `/posts/${id}`);
* const numberValidator = (data: mixed): number => {
* if (typeof data === number) return ((data: any): number);
*
* throw ValidationError(`${data} is not a number!`);
* };
*
* const myValidatedRequestBuilder = validatedRequestBuilder(
* myRequestBuilder,
* numberValidator,
* // this is a deserializer, we want our number as a string for some reason
* (num): string => num + '',
* );
*
* const myValidatedRequest = validatedRequestForBuilder(
* myValidatedRequestBuilder,
* 1,
* );
*
* responseForValidatedRequest('/api/', myValidatedRequest)
* // this is a `HTTPError | ValidationError`
* .catch((error: RequestError) => console.warn(error))
* // str is guaranteed to be a string
* .then((str: string) => console.log(str));
* ```
*
*/
import ky from 'ky';
import { ValidationError } from './validation';
export type HTTPMethod =
| 'GET'
| 'HEAD'
| 'POST'
| 'PUT'
| 'PATCH'
| 'DELETE'
| 'OPTIONS'
;
export type HTTPError = Error & {
response: Response,
};
export type RequestError = HTTPError | ValidationError;
/**
* Options object to be mixed into ky options on a per-builder basis.
*/
export type HTTPOptions = $ReadOnly<{|
mode?: 'no-cors',
|}>;
/**
* Valid body types for a POST/PUT request.
*
* TODO: what else goes in here? Probably arrays, right?
*/
export type ValidParams = FormData | {} | void;
/**
* Base specification for an HTTP request.
*/
type HTTPRequestBase<
Params: ValidParams,
> = $ReadOnly<{|
url: string,
params?: Params,
options?: HTTPOptions,
|}>;
export type GetRequest = $ReadOnly<{|
...HTTPRequestBase<void>,
method: 'GET',
|}>;
export type DeleteRequest = $ReadOnly<{|
...HTTPRequestBase<void>,
method: 'DELETE',
|}>;
export type PostRequest<Params> = $ReadOnly<{|
...HTTPRequestBase<Params>,
method: 'POST',
|}>;
export type HeadRequest<Params> = $ReadOnly<{|
...HTTPRequestBase<Params>,
method: 'HEAD',
|}>;
/**
* Abstract request type.
*
* This is a disjoint union of request implementations by HTTP method with
* the body type parameterized as `Params`.
*/
export type Request<Params: ValidParams> =
| GetRequest
| DeleteRequest
| PostRequest<Params>
| HeadRequest<Params>
;
/**
* Takes a `mixed` and validates it as the given response type.
*
* The expectation here is that if the given data is _not_ actually of the given
* type then it essentially fails validation, in which case the function
* should throw a `ValidationError`.
*
* @throws {ValidationError}
*/
export type ResponseValidator<Response> = (mixed) => Response;
/**
* A `Request` with associated validation (and deserialization!)
*
* Basically a package of a `Request`, a corresponding `ResponseValidator`, and
* a deserializer function. The response body from the request gets converted to
* the `Response` type by the validator, and the deserializer in turn converts
* the `Response` type to the `Format` type.
*/
export type ValidatedRequest<Params, R: Request<Params>, Response, Format> = $ReadOnly<{|
request: R,
responseValidator: ResponseValidator<Response>,
deserializer: (Response) => Format,
|}>;
/**
* Factory function for GET requests.
*
* GET requests need URL params to build URLs, but don't take body Params.
*/
export type GetRequestBuilder<UrlParams> = (
UrlParams,
?any, // make this consistent with PostRequestBuilder, this arg is just ignored
) => GetRequest;
export type DeleteRequestBuilder<UrlParams> = (
UrlParams,
?any, // make this consistent with PostRequestBuilder, this arg is just ignored
) => DeleteRequest;
/**
* Factory function for POST requests.
*
* POST requests might have both URL Params and body Params.
*/
export type PostRequestBuilder<UrlParams, Params=void, SerializedParams=void> = (
UrlParams,
Params,
) => PostRequest<SerializedParams>;
export type HeadRequestBuilder<UrlParams, Params=void, SerializedParams=void> = (
UrlParams,
Params,
) => HeadRequest<SerializedParams>;
/**
* Union of request builders for different HTTP methods.
*/
export type RequestBuilder<UrlParams, Params, SerializedParams> =
| GetRequestBuilder<UrlParams>
| DeleteRequestBuilder<UrlParams>
| PostRequestBuilder<UrlParams, Params, SerializedParams>
| HeadRequestBuilder<UrlParams, Params, SerializedParams>
;
/**
* Factory function for taking URL params and creating a URL.
*/
type UrlBuilder<Params> = (Params) => string;
/**
* Factory function for taking body params and serializing them for transport as
* an HTTP request body.
*/
type ParamsBuilder<Params, SerializedParams> = (Params) => SerializedParams;
/**
* A RequestBuilder packaged up with a ResponseValidator and a deserializer.
*/
export type ValidatedRequestBuilder<
UrlParams,
Params,
SerializedParams,
RB: RequestBuilder<UrlParams, Params, SerializedParams>,
Response,
Format,
> = {
requestBuilder: RB,
responseValidator: ResponseValidator<Response>,
deserializer: (Response) => Format,
};
/**
* A RequestBuilder which may or may not be a ValidatedRequestBuilder.
*
* This abstracts the API between RequestBuilder and ValidatedRequestBuilder so
* that they can be used interchangeable (this can be used as if it were a
* ValidatedRequestBuilder).
*/
export type AbstractRequestBuilder<
UrlParams,
Params,
SerializedParams,
RB: RequestBuilder<UrlParams, Params, SerializedParams>,
// it's very important that these default to `mixed` for a regular
// `RequestBuilder`, this behavior is relied upon when creating a default
// validator and deserializer for a regular `RequestBuilder`
Response=mixed,
Format=mixed,
> =
| ValidatedRequestBuilder<UrlParams, Params, SerializedParams, RB, Response, Format>
| RB;
/**
* Factory for constructing a `GetRequest`.
*/
export const getRequest = (url: string, options?: HTTPOptions): GetRequest => ({
method: 'GET',
url,
options,
});
export const deleteRequest = (url: string, options?: HTTPOptions): DeleteRequest => ({
method: 'DELETE',
url,
options,
});
/**
* Factory for constructing a `PostRequest`.
*/
export const postRequest = <Params: ValidParams>(
url: string,
params: Params,
options?: HTTPOptions,
): PostRequest<Params> => ({
method: 'POST',
url,
params,
options,
});
export const headRequest = <Params: ValidParams>(
url: string,
params: Params,
options?: HTTPOptions,
): HeadRequest<Params> => ({
method: 'HEAD',
url,
params,
options,
});
/**
* Factory for constructing a `GetRequestBuilder`.
*
* `urlBuilder` is a factory function for taking the `UrlParams` and returning a
* URL `string`.
*/
export const getRequestBuilder = <UrlParams>(
urlBuilder: UrlBuilder<UrlParams>,
options?: HTTPOptions,
): GetRequestBuilder<UrlParams> =>
(urlParams: UrlParams): GetRequest =>
getRequest(urlBuilder(urlParams), options);
export const deleteRequestBuilder = <UrlParams>(
urlBuilder: UrlBuilder<UrlParams>,
options?: HTTPOptions,
): DeleteRequestBuilder<UrlParams> =>
(urlParams: UrlParams): DeleteRequest =>
deleteRequest(urlBuilder(urlParams), options);
/**
* Factory for constructing a `PostRequestBuilder`.
*
* `urlBuilder` is a factory function for taking the `UrlParams` and returning a
* URL `string`.
* `paramsBuilder` is a factory function for taking the body `Params` and
* returning `SerializedParams` for transport in the body position of an HTTP
* request.
*/
export const postRequestBuilder = <UrlParams, Params, SerializedParams: ValidParams>(
urlBuilder: UrlBuilder<UrlParams>,
paramsBuilder: ParamsBuilder<Params, SerializedParams>,
options?: HTTPOptions,
): PostRequestBuilder<UrlParams, Params, SerializedParams> =>
(urlParams: UrlParams, params: Params): PostRequest<SerializedParams> =>
postRequest(urlBuilder(urlParams), paramsBuilder(params), options);
export const headRequestBuilder = <UrlParams, Params, SerializedParams: ValidParams>(
urlBuilder: UrlBuilder<UrlParams>,
paramsBuilder: ParamsBuilder<Params, SerializedParams>,
options?: HTTPOptions,
): HeadRequestBuilder<UrlParams, Params, SerializedParams> =>
(urlParams: UrlParams, params: Params): HeadRequest<SerializedParams> =>
headRequest(urlBuilder(urlParams), paramsBuilder(params), options);
/**
* Joins URL path parts into a URL.
*
* This takes junk like `['/api/, '/presigned_posts/']` and turns it into
* `/api/presigned_posts` (notice slashes).
*/
export const urlJoin = ([head, ...parts]: string[]): string =>
[
...(head ? [head.replace(/\/+$/, '')] : []),
...parts
.map(p => p.replace(/^\/+/, ''))
.map(p => p.replace(/\/+$/, '')),
].join('/');
export const isAbsolute = (url: string): boolean =>
!!url.match(/^[a-z]+:\/\//i);
export const requestPathWithPrefix = (path: string, prefix: string): string =>
(isAbsolute(path) ? path : urlJoin([prefix, path]));
export const defaultHTTPOptions = {
credentials: 'include',
// TODO: some reasonable way to distinguish between JSON and non-JSON response
// types.
headers: {
accept: 'application/javascript, application/json',
'accept-language': 'en-US,en;q=0.9,es;q=0.8,en-AU;q=0.7,ja;q=0.6',
'cache-control': 'no-cache',
'content-type': 'application/json',
},
};
/**
* Builds the ky options object for the given `Request`.
*
* This includes handling the `Params` as the body.
*/
export function kyOptionsForRequest <T: ValidParams>(request: Request<T>): {} {
const baseOpts = {
...defaultHTTPOptions,
...request.options,
};
const body = request.params;
if (body instanceof FormData) {
return {
...baseOpts,
body,
};
}
if (body) {
return {
...baseOpts,
json: body,
};
}
return baseOpts;
}
/**
* Returns a `Promise<mixed>` for the given `Request`.
*
* The response is `mixed` because we have no way of knowing what it might be at
* this point.
*
* `apiUrl` is a URL that will be prefixed to the request URL.
*/
export const responseForRequest = <T: ValidParams>(
apiUrl: string,
request: Request<T>,
): Promise<mixed> =>
new Promise((resolve, reject) =>
ky[request.method.toLowerCase()](
requestPathWithPrefix(request.url, apiUrl),
kyOptionsForRequest(request),
)
// TODO: shouldn't be assuming all responses are JSON.
.json()
.catch(reject)
.then(resolve),
);
/**
* Factory to build a `ValidatedRequestBuilder`.
*
* This should be used for basically all request builders and be the main API
* entry point to this module.
*/
export const validatedRequestBuilder = <
UrlParams,
Params,
SerializedParams,
RB: RequestBuilder<UrlParams, Params, SerializedParams>,
Response,
Format,
>(
requestBuilder: RB,
responseValidator: ResponseValidator<Response>,
deserializer: (Response) => Format,
): ValidatedRequestBuilder<UrlParams, Params, SerializedParams, RB, Response, Format> => ({
requestBuilder,
responseValidator,
deserializer,
});
/**
* Gets a `ValidatedRequest` for the given `AbstractRequestBuilder`,
* `UrlParams`, and body `Params`.
*
* The important thing is that this does the job of differentiating between a
* `RequestBuilder` and a `ValidatedRequestBuilder` and abstracting behavior.
* Basically a `ValidatedRequestBuilder` will have a concrete `Response` and
* `Format`, while a `RequestBuilder` will end up with `mixed`.
*/
export const validatedRequestForBuilder = <
UrlParams,
Params,
SerializedParams: ValidParams,
Response,
Format,
ARB: AbstractRequestBuilder<UrlParams,
Params,
SerializedParams,
RequestBuilder<UrlParams, Params, SerializedParams>,
Response,
Format>,
>(
abstractRequestBuilder: ARB,
urlParams: UrlParams,
params: Params,
): ValidatedRequest<SerializedParams, Request<SerializedParams>, Response, Format> => (
typeof abstractRequestBuilder === 'function'
? {
request: (
abstractRequestBuilder: RequestBuilder<UrlParams,
Params,
SerializedParams>
)(urlParams, params),
responseValidator: data => ((data: any): Response), // Response is always mixed here
deserializer: (data: Response) => ((data: any): Format), // Format is always mixed here
}
: {
request: abstractRequestBuilder.requestBuilder(urlParams, params),
responseValidator: abstractRequestBuilder.responseValidator,
deserializer: abstractRequestBuilder.deserializer,
}
);
/**
* Get a `Promise<Format>` for the given `ValidatedRequest`.
*
* This should be the main way to generated responses for requests from this
* module.
*
* @throws {RequestError}
*/
export const responseForValidatedRequest = <
Params: ValidParams,
R: Request<Params>,
Response,
Format,
VR: ValidatedRequest<Params, R, Response, Format>
>(
apiUrl: string,
{
request,
responseValidator,
deserializer,
}: VR,
): Promise<Format> =>
responseForRequest(apiUrl, request)
.then(responseValidator)
.then(deserializer);
// @flow
/**
* This module implements an interface for type validation and specifically for
* validating plain javascript data against JSON schema to guarantee its type.
*
* The basic idea here is that a validator function takes a type parameter of
* the target type we want to validate the data as, and a `mixed` data object.
* If the data object passes validation it gets cast as the target type and
* returned. If it doesn't pass validation, the validator throws a
* ValidationError. So it's like this:
*
* ```javascript
* const numberValidator: Validator<number> = (data: mixed): number => {
* if (typeof data === number) return ((data: any): number);
*
* throw ValidationError(`${data} is not a number!`);
* };
*
* const myNumber: number = numberValidator(1);
* const fakeNumber: number = numberValidator('string'); // throws
* ```
*
* So the big thing about this is JSON validation.
*
* So, let's say you want to validate an XHR response for the API layer. Here
* are the steps to doing so:
*
* 1. Create a type file in `./response-types/` that exports a type called
* `Response`.
* 2. Run `yarn convert-response-types-to-schemas`. This script runs through all
* the response types in `./response-types/` and generates JSON Schema files
* for them in `./schemas/`. It then also creates validation functions for
* them in `./validators/`.
* 3. Import your generated validator into this file.
* 4. Use the `validator` factory function to wrap your validator and export it.
* This should be added to the list of exported validators at the end of this
* file.
* 5. Use your validator like
* `const myThing: Thing = thingValidator((data: mixed));`
*/
// import response validation materials here
import presignedPostValidationFunction from './validators/presigned-post';
import type { Response as PresignedPostResponse } from './response-types/presigned-post';
/**
* The structure of an error message resulting from JSON validation.
*/
export type ErrorMessage = {
keyword: string,
dataPath: string,
schemaPath: string,
params: mixed, // not sure what this really looks like yet
message: string,
};
export class ValidationError extends Error {
constructor(errorString: string) {
super(
`Response validation failed!
${errorString}`,
);
// eslint-disable-next-line fp/no-mutation
this.name = 'ValidationError';
}
}
/**
* General signature for a validation function.
*
* @throws {ValidationError}
*/
export type Validator<T> = (mixed) => T;
/**
* A string representation of an `ErrorMessage` from JSON validation.
*/
export const errorMessageAsString = (
{
keyword,
dataPath,
schemaPath,
params,
message,
}: ErrorMessage,
idx: number,
): string =>
`Error ${idx + 1}:
${keyword} error: ${dataPath}: ${message}, because ${schemaPath} (${JSON.stringify(params)})
`;
/**
* A string representation of a list of `ErrorMessage`s.
*/
export const errorListAsString = (errorList: ErrorMessage[]): string =>
errorList.map(errorMessageAsString).join('\n');
/**
* Factory function for a `Validator`.
*
* Takes a type parameter `T` which is the target type.
*
* Takes a function of signature `(data: mixed) => boolean` which should
* determine if `data` passes validation as `T`.
*/
export const validator = <T>(validationFunction: any): Validator<T> =>
(data: mixed): T => {
if (validationFunction(data)) {
return ((data: any): T);
}
throw new ValidationError(errorListAsString(validationFunction.errors));
};
// validator list, add your validators here
export const presignedPostValidator =
validator<PresignedPostResponse>(presignedPostValidationFunction);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment