Skip to content

Instantly share code, notes, and snippets.

@williammartin
Last active June 19, 2021 13:27
Show Gist options
  • Save williammartin/6b1c46711dd484042b4441a284a9b22a to your computer and use it in GitHub Desktop.
Save williammartin/6b1c46711dd484042b4441a284a9b22a to your computer and use it in GitHub Desktop.
Empty GCP bucket
import * as core from '@actions/core';
import { Storage } from '@google-cloud/storage';
import * as E from 'fp-ts/lib/Either';
import { pipe } from 'fp-ts/lib/function';
import * as IOEither from 'fp-ts/lib/IOEither';
import * as T from 'fp-ts/lib/Task';
import * as TE from 'fp-ts/lib/TaskEither';
import * as t from 'io-ts';
import { failure } from 'io-ts/PathReporter';
const ParsedCredentials = t.type({
type: t.string,
project_id: t.string,
private_key_id: t.string,
private_key: t.string,
client_email: t.string,
client_id: t.string,
auth_uri: t.string,
token_uri: t.string,
auth_provider_x509_cert_url: t.string,
client_x509_cert_url: t.string,
});
type ParsedCredentials = t.TypeOf<typeof ParsedCredentials>;
// Utility to convert caught "things" of unknown shape into Errors
const unknownReasonAsError = (reason: unknown) =>
reason instanceof Error ? reason : new Error(String(reason));
// Parse JSON into Either
const safeParseJSON = E.tryCatchK(JSON.parse, unknownReasonAsError);
// IO wrapper around loading GitHub Actions Inputs
// which load from Environment Variables
const safeGetInput = (input: string) =>
IOEither.tryCatch(
() => core.getInput(input, { required: true }),
unknownReasonAsError,
);
const parseCredentials = (serialisedMaybeCredentials: string) =>
pipe(
serialisedMaybeCredentials,
safeParseJSON,
E.chainW(ParsedCredentials.decode),
E.mapLeft(
(e) =>
new Error(
`failed to parse credentials because: ${
e instanceof Error ? e : failure(e).join('\n')
}`,
),
),
);
// Wrapper around creating Google Cloud Storage client
const createStorage = (credentials: ParsedCredentials) => {
return new Storage({
userAgent: 'delete-gcs-bucket-contents/0.0.1',
credentials,
});
};
// Empty the Bucket
const emptyBucket = (storage: Storage) => (bucket: string) =>
pipe(
bucket,
TE.tryCatchK(
(bucket: string) => storage.bucket(bucket).deleteFiles(),
unknownReasonAsError,
),
);
const run: T.Task<void> = pipe(
// This nested pipe seems like something that should be removable, but I'm not sure how?
pipe(safeGetInput('credentials'), IOEither.chainEitherK(parseCredentials)),
IOEither.map(createStorage),
TE.fromIOEither,
TE.chain((storage) =>
// It would be nice if I could avoid the closing over `storage` and instead flow it somehow?
// Again, another nested pipe I'm not sure about
// Also, I lifted fromIOEither in two places which seems like it might be funny?
pipe(
safeGetInput('bucket'),
TE.fromIOEither,
TE.chain(emptyBucket(storage)),
),
),
// This map seems a bit weird, especially with the right side that looks like a unit...
// I was trying to follow patterns from: https://dev.to/anthonyjoeseph/should-i-use-fp-ts-task-h52
T.map(
E.fold(
(error) => {
throw error;
},
() => {},
),
),
);
pipe(run, (invoke) => invoke());
@cdimitroulas
Copy link

cdimitroulas commented Jun 16, 2021

I would run the main Task slightly differently, the final pipe is a bit unnecessary.

You can just write:

run()

In terms of the T.map(E.fold(...)), you could remove this entirely and then leave the error handling up to the top level:

run()
  .then(
    E.fold(
      (error) => {
        console.error(error)
      },
      () => {
        console.log("Completed successfully")
      }
    )
  )

You can often use bind and bindTo to reduce nested pipes. See the do notation docs for a bit more info on how to use these.
Here's a "flatter" run function that I was able to come up with:

const run: TE.TaskEither<Error, void> = pipe(
  safeGetInput('credentials'),
  IOEither.chainEitherK(parseCredentials),
  IOEither.map(createStorage),
  TE.fromIOEither,
  TE.bindTo('storage'),
  TE.bind('bucket', () => pipe(safeGetInput('bucket'), TE.fromIOEither)),
  TE.chain(({ storage, bucket }) => emptyBucket(storage)(bucket))
);

@williammartin
Copy link
Author

Awesome thank you so much @cdimitroulas

Regarding the first point, I was looking at https://dev.to/anthonyjoeseph/should-i-use-fp-ts-task-h52#rule-number-3 which recommends:

You should especially avoid invoking a TaskEither.

Which looks like what is happening in your proposal (though I do like keeping the error handling at the top level). What do you think about this?

I tried out the Do notation in a different way (binding earlier for the inputs) but it didn't seem so nice. This looks way better.

Thanks again!

@cdimitroulas
Copy link

I had a look at the rule in the article you linked to. I think there is some merit to that rule and it may help in certain circumstances to enforce error handling.

Personally I'm not a big fan of enforcing everything to be Task<void>. Often in my real work codebase I would be invoking a Task in an Express route handler which calls the core logic, then converts the success/error to the relevant HTTP response. In those cases I usually end up invoking a Task<Response> (where Response is from the express library), and I wouldn't want to make that a Task<void> as it correctly forces the code to actually return a response in all possible scenarios.

Something like this:

const handler: Handler = (req, res) => {
  return pipe(
    myTaskEither(),
    TE.fold(
      (err) => {
        switch (err._tag) {
          case "NotFound":
            return T.of(res.status(404).send({ msg: "Thing not found" }))
          case "UnexpectedError":
            return T.of(res.sendStatus(500))
        }
      },
      (result) => T.of(res.status(200).send(result))
    )
  )() // <-- invoke it here
} 

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment