Last active
September 12, 2019 13:58
-
-
Save lyleunderwood/24f8b540157be93435896a3bfe921e37 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// @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); | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// @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