Skip to content

Commit

Permalink
feat: Remove classes in favour of interfaces + functions (#113)
Browse files Browse the repository at this point in the history
* feat: class -> interfaces + creation functions

* HeadersMap as Record<string, string>

* chore: some internal changes

* refactor: types deprecation

* docs: fix examples Decoders
  • Loading branch information
StefanoMagrassi committed Feb 5, 2019
1 parent e846931 commit c03514e
Show file tree
Hide file tree
Showing 7 changed files with 233 additions and 169 deletions.
34 changes: 17 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@ The module tries to be as more compliant as possible with the `fetch()` interfac
- request `method` is always explicit (no implicit "GET");
- accepted methods are definened by the `Method` union type;
- `fetch`'s input is always a `string` (no `Request` objects allowed);
- `Response` is mapped into a specific `AppyResponse<Mixed>` interface;
- `AppyResponse` `headers` property is always a `HeadersMap` (alias for a map of string);
- `AppyResponse` has a `body` property that is the result of parsing to JSON the string returned from `response.text()`; if it cannot be parsed as JSON, `body` value is just the string (both types of data are covered by the `Mixed` type).
- standard `Response` is mapped into a specific Appy's `Response<Mixed>` interface;
- Appy's `Response` `headers` property is always a `HeadersMap` (alias for a `Record<string, string>`);
- Appy's `Response` has a `body` property that is the result of parsing to JSON the string returned from `response.text()`; if it cannot be parsed as JSON, `body` value is just the string (both types of data are covered by the `Mixed` type).

`RequestInit` configuration object instead remains the same.

Expand All @@ -78,42 +78,42 @@ declare function request(
m: Method,
u: string,
o?: RequestInit
): TaskEither<AppyError, AppyResponse<Mixed>>;
): TaskEither<RequestError, Response<Mixed>>;
```

```typescript
declare function get(
u: string,
o?: RequestInit
): TaskEither<AppyError, AppyResponse<Mixed>>;
): TaskEither<RequestError, Response<Mixed>>;
```

```typescript
declare function post(
u: string,
o?: RequestInit
): TaskEither<AppyError, AppyResponse<Mixed>>;
): TaskEither<RequestError, Response<Mixed>>;
```

```typescript
declare function put(
u: string,
o?: RequestInit
): TaskEither<AppyError, AppyResponse<Mixed>>;
): TaskEither<RequestError, Response<Mixed>>;
```

```typescript
declare function patch(
u: string,
o?: RequestInit
): TaskEither<AppyError, AppyResponse<Mixed>>;
): TaskEither<RequestError, Response<Mixed>>;
```

```typescript
declare function del(
u: string,
o?: RequestInit
): TaskEither<AppyError, AppyResponse<Mixed>>;
): TaskEither<RequestError, Response<Mixed>>;
```

### api
Expand Down Expand Up @@ -157,9 +157,9 @@ So, it is a little more opinionated:
- the `options` parameter is mandatory and it is an extension of the `RequestInit` interface;
- `options` has a required `token` (string) key which will be passed as request's `Authorization: Bearer ${token}` header;
- `options` has a required `decoder` (`Decoder<Mixed, A>`) key which will be used to decode the service's JSON payload;
- decoder errors are expressed with a `DecoderError` class which extends the `AppyError` tagged union type;
- decoder errors are expressed with a `DecoderError` interface which extends the `RequestError` tagged union type;
- thus, the returned type of `api` methods is `TaskEither<ApiError, A>`
- `headers` in `options` object can only be a map of strings (`{[k: string]: string}`); if you need to work with a `Header` object you have to transform it;
- `headers` in `options` object can only be a map of strings (`Record<string, string>`); if you need to work with a `Header` object you have to transform it;
- `options` is merged with a predefined object in order to set some default values:
- `mode: 'cors'`
- `headers: {'Accept': 'application/json', 'Content-type': 'application/json'}`
Expand All @@ -170,17 +170,17 @@ See [here](src/api.ts) for the complete list of types.

```typescript
interface ApiMethods {
request: <A>(m: Method, u: string, o: ApiOptions<A>): TaskEither<ApiError, AppyResponse<A>>;
request: <A>(m: Method, u: string, o: ApiOptions<A>): TaskEither<ApiError, Response<A>>;

get: <A>(u: string, o: ApiOptions<A>): TaskEither<ApiError, AppyResponse<A>>;
get: <A>(u: string, o: ApiOptions<A>): TaskEither<ApiError, Response<A>>;

post: <A>(u: string, o: ApiOptions<A>): TaskEither<ApiError, AppyResponse<A>>;
post: <A>(u: string, o: ApiOptions<A>): TaskEither<ApiError, Response<A>>;

put: <A>(u: string, o: ApiOptions<A>): TaskEither<ApiError, AppyResponse<A>>;
put: <A>(u: string, o: ApiOptions<A>): TaskEither<ApiError, Response<A>>;

patch: <A>(u: string, o: ApiOptions<A>): TaskEither<ApiError, AppyResponse<A>>;
patch: <A>(u: string, o: ApiOptions<A>): TaskEither<ApiError, Response<A>>;

del: <A>(u: string, o: ApiOptions<A>): TaskEither<ApiError, AppyResponse<A>>;
del: <A>(u: string, o: ApiOptions<A>): TaskEither<ApiError, Response<A>>;
}

declare function api(c: ApiConfig): ApiMethods
Expand Down
112 changes: 64 additions & 48 deletions examples/user-and-posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,55 +11,71 @@ import {taskEither} from 'fp-ts/lib/TaskEither';
import * as t from 'io-ts';
import {failure} from 'io-ts/lib/PathReporter';
import 'isomorphic-fetch';
import {ApiError, ApiTask, AppyResponse, api} from '../src/index';
import {ApiError, ApiFetch, Response, api} from '../src/index';

const Post = t.type({
userId: t.number,
id: t.number,
title: t.string,
body: t.string
});

type Post = t.TypeOf<typeof Post>;
interface Post extends t.TypeOf<typeof Post> {}
type PostPayload = Pick<Post, Exclude<keyof Post, 'id' | 'userId'>>;
const Post = t.type(
{
userId: t.number,
id: t.number,
title: t.string,
body: t.string
},
'Post'
);

const Geo = t.type(
{
lat: t.string,
lng: t.string
},
'Geo'
);

const Geo = t.type({
lat: t.string,
lng: t.string
});

const Address = t.type({
street: t.string,
suite: t.string,
city: t.string,
zipcode: t.string,
geo: Geo
});

const Company = t.type({
name: t.string,
catchPhrase: t.string,
bs: t.string
});

const BaseUser = t.type({
id: t.number,
firstname: t.string,
username: t.string,
email: t.string,
address: Address,
phone: t.string,
website: t.string,
company: Company
});

const WithPosts = t.partial({
posts: t.array(Post)
});
const Address = t.type(
{
street: t.string,
suite: t.string,
city: t.string,
zipcode: t.string,
geo: Geo
},
'Address'
);

const User = t.intersection([BaseUser, WithPosts]);
const Company = t.type(
{
name: t.string,
catchPhrase: t.string,
bs: t.string
},
'Company'
);

const BaseUser = t.type(
{
id: t.number,
name: t.string,
username: t.string,
email: t.string,
address: Address,
phone: t.string,
website: t.string,
company: Company
},
'Base user'
);

type User = t.TypeOf<typeof User>;
const WithPosts = t.partial(
{
posts: t.array(Post)
},
'With posts'
);

interface User extends t.TypeOf<typeof User> {}
const User = t.intersection([BaseUser, WithPosts]);

const myApi = api({baseUri: 'http://jsonplaceholder.typicode.com'});
const token = 'secret';
Expand All @@ -68,25 +84,25 @@ const createPost = (
userId: number,
title: string,
body: string
): ApiTask<Post> =>
): ApiFetch<Post> =>
myApi.post('/posts', {
token,
decoder: Post,
body: JSON.stringify({userId, title, body})
});

const getUser = (id: number): ApiTask<User> =>
const getUser = (id: number): ApiFetch<User> =>
myApi.get(`/users/${id}`, {token, decoder: User});

const concatPosts = liftA2(taskEither)(
(a: AppyResponse<Post>) => (b: AppyResponse<Post>) => [a, b]
(a: Response<Post>) => (b: Response<Post>) => [a, b]
);

const main = (
userId: number,
post1: PostPayload,
post2: PostPayload
): ApiTask<User> =>
): ApiFetch<User> =>
concatPosts(createPost(userId, post1.title, post1.body))(
createPost(userId, post2.title, post2.body)
)
Expand Down
57 changes: 34 additions & 23 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
/*tslint:disable:max-classes-per-file*/

/**
* @module api
* @since 1.0.0
Expand All @@ -12,12 +10,12 @@ import {identity} from 'fp-ts/lib/function';
import {Decoder, ValidationError} from 'io-ts';
import {optsToRequestInit} from './opts-to-request-init';
import {
AppyError,
AppyResponse,
AppyTask,
Fetch,
HeadersMap,
Method,
Mixed,
RequestError,
Response,
request
} from './request';

Expand All @@ -37,11 +35,11 @@ export interface ApiMethods {
}

export interface ApiRequest {
<A>(m: Method, u: string, o: ApiOptions<A>): ApiTask<A>;
<A>(m: Method, u: string, o: ApiOptions<A>): ApiFetch<A>;
}

export interface ApiRequestNoMethod {
<A>(u: string, o: ApiOptions<A>): ApiTask<A>;
<A>(u: string, o: ApiOptions<A>): ApiFetch<A>;
}

export interface ApiOptions<A> extends RequestInit {
Expand All @@ -50,32 +48,31 @@ export interface ApiOptions<A> extends RequestInit {
decoder: Decoder<Mixed, A>;
}

export type ApiTask<A> = AppyTask<ApiError, A>;
export type ApiFetch<A> = Fetch<ApiError, A>;
/**
* @deprecated since version 1.3.0
*/
export type ApiTask<A> = ApiFetch<A>; // temporary type alias

export type ApiError = AppyError | DecoderError;
export type ApiError = RequestError | DecoderError;

export class DecoderError {
public readonly type: 'DecoderError' = 'DecoderError';
constructor(readonly errors: ValidationError[]) {}
export interface DecoderError {
readonly type: 'DecoderError';
readonly errors: ValidationError[];
}

const fullPath = (a: string, b: string) => `${a}${b}`;

const applyDecoder = <A>(
aresponse: AppyResponse<Mixed>,
decoder: Decoder<Mixed, A>
): Either<ApiError, AppyResponse<A>> =>
decoder
.decode(aresponse.body)
.bimap(err => new DecoderError(err), body => ({...aresponse, body}));
const decoderError = (errors: ValidationError[]): DecoderError => ({
type: 'DecoderError',
errors
});

const makeRequest = <A>(
c: ApiConfig,
m: Method,
u: string,
o: ApiOptions<A>
): ApiTask<A> =>
request(m, fullPath(c.baseUri, u), optsToRequestInit(c, o))
): ApiFetch<A> =>
request(m, `${c.baseUri}${u}`, optsToRequestInit(c, o))
.mapLeft<ApiError>(identity) // type-level mapping... ;)
.chain(b => fromEither(applyDecoder(b, o.decoder)));

Expand All @@ -87,3 +84,17 @@ export const api = (c: ApiConfig): ApiMethods => ({
patch: (uri, options) => makeRequest(c, 'PATCH', uri, options),
del: (uri, options) => makeRequest(c, 'DELETE', uri, options)
});

// --- Helpers
function applyDecoder<A>(
aresponse: Response<Mixed>,
decoder: Decoder<Mixed, A>
): Either<ApiError, Response<A>> {
return decoder
.decode(aresponse.body)
.bimap(decoderError, withBody(aresponse));
}

function withBody<A>(response: Response<Mixed>): (a: A) => Response<A> {
return (body: A) => ({...response, body});
}

0 comments on commit c03514e

Please sign in to comment.