Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add everything #1

Merged
merged 1 commit into from
Sep 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: PR

on:
pull_request:

jobs:
test:
name: Test
runs-on: ubuntu-latest
env:
node-version: 20.x

steps:
- name: Checkout Code
uses: actions/checkout@v3

- name: Install NodeJS
uses: actions/setup-node@v3
with:
node-version: ${{ env.node-version }}

- name: Cache Node Modules
uses: actions/cache@v3
id: cache-node-modules
with:
path: node_modules
key: ${{ runner.OS }}-node${{ env.node-version }}-ci-${{ hashFiles('**/package-lock.json') }}

- name: Install Dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm install --ignore-scripts

- name: Run unit tests
run: npm run test
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/lib/
/node_modules/
13 changes: 13 additions & 0 deletions LICENSE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
Copyright 2023 Aldwin Vlasblom

Permission to use, copy, modify, and/or distribute this software for any purpose
with or without fee is hereby granted, provided that the above copyright notice
and this permission notice appear in all copies.

THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
THIS SOFTWARE.
237 changes: 237 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,238 @@
# FP-TS Bootstrap

This is a module aimed at application bootstrapping using types from [fp-ts][].
Its ideas and most of the code were ported from the [fluture-hooks][] library.

This module mainly provides a [Bracket type](#bracket) with accompanying type
class instances. The Bracket type is a drop-in replacement for the Cont type
from [fp-ts-cont][], but specialized in returning `TaskEither`. This solves the
problem stipulated at the end of [application bootstrapping with fp-ts][] by
allowing the return type to be threaded through the program. Furthermore, it
makes the `ApplicativePar` instance possible, which allows for parallel
composition of bracketed resources.

Besides the Bracket type, this module also provides a [Service type](#service)
which is a small layer on top for managing dependencies through the Reader monad.

[fp-ts]: https://gcanti.github.io/fp-ts/
[fluture-hooks]: https://github.com/fluture-js/fluture-hooks
[fp-ts-cont]: https://github.com/joshburgess/fp-ts-cont
[application bootstrapping with fp-ts]: https://dev.to/avaq/application-bootstrapping-with-fp-ts-59b5

## Example

Define your service. See the full example in
[`./example/services/server.ts`](./example/services/server.ts).

```ts
export const withServer: Service.Service<Error, Dependencies, HTTP.Server> = (
({port, app}) => Bracket.bracket(
() => new Promise(resolve => {
const server = HTTP.createServer(app);
server.listen(port, () => resolve(E.right(server)));
}),
server => () => new Promise(resolve => {
server.close((e: unknown) => resolve(
e instanceof Error ? E.left(e) : E.right(undefined)
));
}),
)
);
```

Combine multiple such services with ease using Do notation. See the full example
in [`./example/services/index.ts`](./example/services/index.ts).

```ts
export const withServices = pipe(
withEnv,
Bracket.bindTo('env'),
Bracket.bind('logger', ({env}) => withLogger({level: env.LOG_LEVEL})),
Bracket.bind('database', ({env, logger}) => withDatabase({
url: env.DATABASE_URL,
logger: logger
})),
Bracket.bind('app', ({database}) => withApp({database})),
Bracket.bind('server', ({env, app}) => withServer({
port: env.PORT,
app: app,
})),
);
```

Consume your service. See the full example in [`./example/index.ts`](./example/index.ts).

```ts
const program = withServices(({server, logger}) => pipe(
TE.fromIO(logger.info(`Server listening on ${JSON.stringify(server.address())}`)),
TE.apSecond(TE.fromTask(() => new Promise(resolve => {
process.once('SIGINT', resolve);
}))),
TE.chain(() => TE.fromIO(logger.info('Shutting down app'))),
));
```

And finally, run your program:

```ts
program().then(E.fold(console.error, console.log), console.error);
```

## Types

### Bracket

```ts
import {Bracket} from 'fp-ts-bootstrap';
```

```ts
type Bracket<E, R> = (
<T>(consume: (resource: R) => TaskEither<E, T>) => TaskEither<E, T>
);
```

The Bracket type aliases the structure that's encountered when using a curried
variant of [fp-ts' `TaskEither.bracket` function][]. This curried variant is
also exported from the Bracket module as `bracket`. It models a bracketed
resource for which the consumption hasn't been specified yet.

[fp-ts' `TaskEither.bracket` function]: https://gcanti.github.io/fp-ts/modules/TaskEither.ts.html#bracket

The Bracket module defines various type class instances for `Bracket` that allow
you to compose and combine multiple bracketed resources. From most instances,
some derivative functions are exported as well.

- Pointed: `of`, `Do`
- Functor: `map`, `flap`, `bindTo`, `let`
- Apply: `ap`, `apFirst`, `apSecond`, `apS`, `getApplySemigroup`, `sequenceT`, `sequenceS`
- Applicative: Pointed Apply
- Chain: `chain`, `chainFirst`, `bind`
- Monad: Pointed Chain
- ApplyPar: `apPar`, `apFirstPar`, `apSecondPar`, `apSPar`, `getApplySemigroupPar`, `sequenceTPar`, `sequenceSPar`
- ApplicativePar: Pointed ApplyPar

### Service

```ts
import {Service} from 'fp-ts-bootstrap';
```

```ts
type Service<E, D, S> = Reader<D, Bracket<E, S>>;
```

The Service type is a small layer on top of Reader that formalizes the
type of a Bracket with dependencies. The Service type can also be composed and
combined using the utilities provided by `ReaderT<Bracket>`. These utilities
are re-exported from [the Service module](./src/Service.ts).

## Cookbook

### Defining a service with acquisition and disposal

```ts
import * as FS from 'fs/promises';
import * as TE from 'fp-ts/TaskEither';
import * as E from 'fp-ts/Either';
import {Bracket} from 'fp-ts-bootstrap';

const acquireFileHandle = (url: string) => (
TE.tryCatch(() => FS.open(url, 'a'), E.toError)
);

const disposeFileHandle = (file: FS.FileHandle) => (
TE.tryCatch(() => file.close(), E.toError)
);

const withMyFile = Bracket.bracket(
acquireFileHandle('/tmp/my-file.txt'),
disposeFileHandle,
);
```

### Defining a service with dependencies

This recipe builds on the previous one by adding dependencies to the service.

```ts
import {Service} from 'fp-ts-bootstrap/lib/Service';

type Dependencies = {
url: string;
};

const withMyFile: Service<Error, Dependencies, FS.FileHandle> = (
({url}) => Bracket.bracket(
acquireFileHandle(url),
disposeFileHandle,
)
);
```

### Combining services in parallel

The Bracket type has a sequential `Applicative` instance that it uses by
default, but there's also a parallel `ApplicativePar` instance that you can use
to combine services in parallel\*. Two very useful derivative function using
`ApplicativePar` are

- `sequenceSPar` for building a Struct of resources from a Struct of Brackets; and
- `apSPar` for adding another property to an existing Struct of services:

```ts
import {pipe} from 'fp-ts/function';
import {Bracket} from 'fp-ts-bootstrap';

const withServices = pipe(
Bracket.sequenceSPar({
env: withEnv,
logger: withLogger({level: 'info'}),
}),
Bracket.apSPar('database', withDatabase({url: 'postgres://localhost:5432'}))
);

const program = withServices(({env, logger, database}) => pipe(
// ...
));
```

\* By "in parallel" we mean that the services are *acquired* in parallel, but
disposed in sequence. This is a technical limitation that exists to ensure that
the `ApplyPar` instance is lawful.

### Threading dependencies during service composition

```ts
import {pipe} from 'fp-ts/function';
import {Bracket} from 'fp-ts-bootstrap';

const withServices = pipe(
withEnv,
Bracket.bindTo('env'),
Bracket.bind('logger', ({env}) => withLogger({level: env.LOG_LEVEL})),
Bracket.bind('database', ({env, logger}) => withDatabase({
url: env.DATABASE_URL,
logger: logger
})),
Bracket.bind('server', ({env, database}) => withServer({
port: env.PORT,
app: app,
database: database,
})),
);
```

### Creating a full-fledged program by composing services

There's a fully working example app in the [`./example`](./example) directory.
To run it, clone this repo and run the following commands:

```console
$ npm install
$ ./node_modules/.bin/ts-node ./example/index.ts
```

You should now be able to visit http://localhost:3000/arbitrary/path,
which should give you a Hello World response, and log your request URL
to `./database.txt`.
15 changes: 15 additions & 0 deletions example/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {pipe} from 'fp-ts/function';
import * as TE from 'fp-ts/TaskEither';
import * as E from 'fp-ts/Either';

import {withServices} from './services';

const program = withServices(({server, logger}) => pipe(
TE.fromIO(logger.info(`Server listening on ${JSON.stringify(server.address())}`)),
TE.apSecond(TE.fromTask(() => new Promise(resolve => {
process.once('SIGINT', resolve);
}))),
TE.chain(() => TE.fromIO(logger.info('Shutting down app'))),
));

program().then(E.fold(console.error, console.log), console.error);
47 changes: 47 additions & 0 deletions example/services/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import * as HTTP from 'node:http';

import {pipe} from 'fp-ts/function';
import * as TE from 'fp-ts/TaskEither';
import * as E from 'fp-ts/Either';

import * as Bracket from '../../src/Bracket';
import * as Service from '../../src/Service';

import {Database} from './database';

/*\
*
* This service provides an HTTP Request Listener that logs the request URL
* and the current time to the database, and returns a "Hello, world!" response.
*
* Its purpose is to demonstrate how to use the Bracket module to create a
* service that depends on other services.
*
\*/

export type Dependencies = {
database: Database;
};

export const withApp: Service.Service<Error, Dependencies, HTTP.RequestListener> = (
({database}) => Bracket.of((req, res) => {
const task = pipe(
TE.fromIO(() => new Date()),
TE.chain(now => database.save(`Visit to ${req.url} at ${now.toISOString()}`)),
TE.map(() => 'Hello, world!'),
);

task().then(E.fold(
e => {
res.writeHead(500, {'Content-Type': 'text/plain'});
res.end(e.message);
},
data => {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end(data);
},
))
})
);

export type App = Service.ResourceOf<typeof withApp>;
Loading