From 9a65fe383947c5bda3b82b319c8d2d0e46038922 Mon Sep 17 00:00:00 2001 From: "Thomas R. Honeyman" Date: Thu, 13 Aug 2020 20:24:21 -0700 Subject: [PATCH 1/9] Update spago.dhall, contributing, and issue template files --- .github/ISSUE_TEMPLATE/bug-report.md | 19 +++++++++ .github/ISSUE_TEMPLATE/change-request.md | 21 +++++++++ .github/contributing.md | 54 +++++++++--------------- .github/issue_template.md | 22 ---------- spago.dhall | 9 +++- test/test.dhall | 5 --- 6 files changed, 67 insertions(+), 63 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug-report.md create mode 100644 .github/ISSUE_TEMPLATE/change-request.md delete mode 100644 .github/issue_template.md delete mode 100644 test/test.dhall diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 0000000..91e6047 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,19 @@ +--- +name: Bug report +about: Report an issue +title: "" +labels: bug +assignees: "" +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +A minimal code example (preferably a runnable example on [Try PureScript](https://try.purescript.org)!) or steps to reproduce the issue. + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/change-request.md b/.github/ISSUE_TEMPLATE/change-request.md new file mode 100644 index 0000000..016f45f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/change-request.md @@ -0,0 +1,21 @@ +--- +name: Change request +about: Propose an improvement to this library +title: "" +labels: enhancement +assignees: "" +--- + +**Is your change request related to a problem? Please describe.** +A clear and concise description of what the problem is. + +Examples: + +- It's frustrating to have to [...] +- I was looking for a function to [...] + +**Describe the solution you'd like** +A clear and concise description of what a good solution to you looks like, including any solutions you've already considered. + +**Additional context** +Add any other context about the change request here. diff --git a/.github/contributing.md b/.github/contributing.md index 58b8913..0b33a4d 100644 --- a/.github/contributing.md +++ b/.github/contributing.md @@ -1,51 +1,37 @@ -# Contribution +# Contributing -Thank you for contributing to `purescript-argonaut-codecs`! +Thanks for your interest in contributing to argonaut-codecs! This file is a short, sweet introduction to contributing to this project. We ask that all new contributors read it before their first contribution to make sure we can get your work merged. -Following these guidelines helps ensure we're able to carefully consider your contribution and help you finalize your pull request. Your time (like ours) is valuable, and we try to at least provide constructive feedback on every contribution. +## Getting Started -### Contributions we love -`purescript-argonaut-codecs` is an open-source library and we love to receive contributions. There are many ways you can contribute: +### Do I belong here? -* Help expand our test coverage -* Help fix open issues, even if that simply means adding a helpful comment -* Help push along open pull requests that need more work to be complete -* Improve or add new usage examples to better demonstrate how to use the library in real-world scenarios -* Update documentation and tutorials to be easier to understand, more comprehensive, and above all -- up to date! +Everyone is welcome! People of all experience levels can join, begin contributing, and feel comfortable and safe making mistakes. People of all backgrounds belong here so long as they treat others with dignity and respect and do not harass, belittle, or insult others. -### Contributions we should discuss -Some contributions will take some discussion before we accept an update to the project. If your contribution includes one of the below examples (or seems to be reasonably similar), please consider reaching out to us before putting in a lot of work to build the feature. We're active on the [PureScript user forum](https://discourse.purescript.org/) and the [functional programming Slack](https://functionalprogramming.slack.com/) community (new? [use this link to join](https://fpchat-invite.herokuapp.com/)!). For feature requests, feel free to open an issue with a tag. +### What is the correct way to ask a question? -* New features and functions that will change the library's public API -* New tutorials (while we love seeing new tutorials, we won't always be able to feature them in the readme) +Feel free to ask questions by [opening an issue](https://github.com/purescript-contrib/purescript-argonaut-codecs/issues)! You can also ask questions on the [PureScript Discourse](https://discourse.purescript.org), or on the [Functional Programming Slack](https://functionalprogramming.slack.com) ([join](https://fpchat-invite.herokuapp.com)!) in the `#purescript` and `#purescript-beginners` channels. -# Ground Rules +### I'd like to help, how do I pick something to work on? -We have a small set of quality-of-life guidelines for contributing to `purescript-argonaut-codecs`. These include: +Any [open issue](https://github.com/purescript-contrib/purescript-argonaut-codecs/issues) that is not assigned to anyone is good to work on! If it's your first time contributing it's probably best to pick an issue marked [good first issue](https://github.com/purescript-contrib/purescript-argonaut-codecs/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue). You can also search for issues marked [help-wanted](https://github.com/purescript-contrib/purescript-argonaut-codecs/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted). -* All pull requests must pass continuous integration. -* If you are adding new functionality, you should provide tests and documentation for your code. If you're fixing an existing bug, please provide a failing test case your patch solves. -* If possible, please avoid requiring new dependencies. +The easiest way you can help is by contributing documentation (issues marked [document me](https://github.com/purescript-contrib/purescript-argonaut-codecs/issues?q=is%3Aissue+is%3Aopen+label%3A%22document+me). If you'd like to contribute documentation, we suggest [reading about the four kinds of documentation](https://documentation.divio.com). -### Filing issues -If you have a general question about the project, it's best to ask on the [PureScript user forum](https://discourse.purescript.org/) or the [functional programming Slack](https://functionalprogramming.slack.com/) than to open a new issue. If you have run into a bug in the project, then please do open an issue! When you do, we ask that you follow a few steps which are outlined in our issues template. The gist of it is here: +Your contribution can be as small as copy-pasting some instructions from an issue into the documentation! Everything is welcome. -* Verify the problem is indeed with `purescript-argonaut-codecs` (not with Pulp, Bower, or PureScript); -* Record what versions you are using for PureScript and `purescript-argonaut-codecs` -* Describe the issue with steps to reproduce (as much as you are able). A minimal reproducible example is the absolute best case scenario. +## Developing argonaut-codecs -We promise to address the issue as soon as we can. +All `purescript-contrib` libraries use recent versions of [PureScript](https://github.com/purescript/purescript), [Spago](https://github.com/purescript/spago), and [psa](https://github.com/natefaubion/purescript-psa). Any additional development dependencies can be installed via NPM and are listed in the `package.json` file for the repository. -### Suggesting features or enhancements -We love to hear about ways we could make better. If you're wishing for a feature that doesn't exist in `purescript-argonaut-codecs`, you're probably not alone; there are bound to be others with similar needs. Please feel free to open an issue on GitHub that describes: +### Proposing changes -* the feature you would like to see -* why you need it -* how it should work +If you would like to contribute code, tests, or documentation, please feel free to open a pull request for small changes. For large changes we recommend you first open an issue to propose your change and ensure that the maintainers are on board before you spend time implementing the change. We want to respect your time and effort. We can also assign the issue to you if you would like to make sure you're the one to work on it. -We promise to review your issue, but we aren't always able to accommodate all requests. It helps if you're able to contribute to the implementation, too! +### Merging changes -### Code reviews -The core team looks at pull requests weekly, at which point we will review your code, ensure it meets our ground rules and fits with the philosophy of the project, and -- if necessary -- provide constructive feedback. As soon as at least one member of the team has signed off on your pull request, we will merge your contribution. +All changes must happen through a pull request. Everyone with commit access can merge changes, though by convention we like to wait for two approvals for non-trivial changes. All pull requests must pass continuous integration; if the change adds new code we may also ask that you add a test. -If you opened a pull request and we need to ask a few questions, we expect responses within a month. After that we may close the pull request if it isn't showing any activity to keep things manageable. +## How do I get the "commit bit"? + +If you'd like to take part in maintaining argonaut-codecs, just ask! We hand out the commit bit to folks who display sustained interest in the project. You can ask directly (for example: on Slack or via a DM on Discourse) or by opening an issue -- whichever you prefer! diff --git a/.github/issue_template.md b/.github/issue_template.md deleted file mode 100644 index f43c771..0000000 --- a/.github/issue_template.md +++ /dev/null @@ -1,22 +0,0 @@ -## Pre-check - -* For help and support, try the [PureScript user forum](https://discourse.purescript.org/) or the [functional programming Slack](https://functionalprogramming.slack.com) before opening an issue in the repository. -* For bug reports, do a quick search to ensure the bug has not yet been reported. -* For new feature requests, please remember to describe the feature you would like to see, why you need it, and how it might be implemented. -* Finally -- be nice and have fun! - ---- - -## Environment - -* PureScript **[version]** -* Pulp **[version]** -* `purescript-argonaut-codecs` **[version]** - -## Current behavior - -Please include code samples, errors, steps to reproduce, and compiler output if appropriate. - -If possible, provide a sample minimal app or test case that reproduces the error. For visual bugs that are difficult to demonstrate in text, consider including a .gif using an app like [Recordit](http://www.recordit.co/). - -## Expected behavior diff --git a/spago.dhall b/spago.dhall index 4539016..43f08ec 100644 --- a/spago.dhall +++ b/spago.dhall @@ -1,9 +1,12 @@ { name = "argonaut-codecs" , license = "MIT" -, repository = "https://github.com/purescript-contrib/purescript-argonaut-codecs" +, repository = + "https://github.com/purescript-contrib/purescript-argonaut-codecs" , dependencies = [ "argonaut-core" , "arrays" + , "assert" + , "console" , "effect" , "foreign-object" , "generics-rep" @@ -12,8 +15,10 @@ , "maybe" , "nonempty" , "ordered-collections" + , "psci-support" + , "quickcheck" , "record" ] , packages = ./packages.dhall -, sources = [ "src/**/*.purs" ] +, sources = [ "src/**/*.purs", "test/**/*.purs" ] } diff --git a/test/test.dhall b/test/test.dhall deleted file mode 100644 index 5ddaef9..0000000 --- a/test/test.dhall +++ /dev/null @@ -1,5 +0,0 @@ -let conf = ../spago.dhall -in conf // - { dependencies = conf.dependencies # [ "assert", "console", "quickcheck", "psci-support" ] - , sources = conf.sources # [ "test/**/*.purs" ] - } From 9be29bd9f77e66614c02cbf008d887335a1503e3 Mon Sep 17 00:00:00 2001 From: "Thomas R. Honeyman" Date: Thu, 13 Aug 2020 20:24:28 -0700 Subject: [PATCH 2/9] Add github actions CI --- .github/workflows/ci.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c45d546 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - uses: thomashoneyman/setup-purescript@main + + - name: Cache PureScript dependencies + uses: actions/cache@v2 + with: + key: ${{ runner.os }}-spago-${{ hashFiles('**/*.dhall') }} + path: | + .spago + output + + - name: Build source + run: spago build + + - name: Run tests + run: spago test --no-install From c0e102dc303b305665b7b2713f9782649926e44e Mon Sep 17 00:00:00 2001 From: "Thomas R. Honeyman" Date: Thu, 13 Aug 2020 20:25:32 -0700 Subject: [PATCH 3/9] Update .gitignore and remove Travis file --- .gitignore | 12 +++++------- .travis.yml | 20 -------------------- 2 files changed, 5 insertions(+), 27 deletions(-) delete mode 100644 .travis.yml diff --git a/.gitignore b/.gitignore index 5a89082..137375e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ -/.* -!/.gitignore -!/.github -!/.travis.yml +.* +!.gitignore +!.github +!.eslintrc.json # Dependencies bower_components @@ -9,9 +9,7 @@ node_modules # Generated files output -dce-output generated-docs -# Lockfiles -package-lock.json +# Non-NPM lockfiles *.lock diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index b339f18..0000000 --- a/.travis.yml +++ /dev/null @@ -1,20 +0,0 @@ -language: node_js -dist: trusty -sudo: required -node_js: stable -env: - - PATH=$HOME/purescript:$PATH -install: - - TAG=$(basename $(curl --location --silent --output /dev/null -w %{url_effective} https://github.com/purescript/purescript/releases/latest)) - - curl --location --output $HOME/purescript.tar.gz https://github.com/purescript/purescript/releases/download/$TAG/linux64.tar.gz - - tar -xvf $HOME/purescript.tar.gz -C $HOME/ - - chmod a+x $HOME/purescript - - npm install -script: - - npm run -s build - - npm run -s test -after_success: - - >- - test $TRAVIS_TAG && - echo $GITHUB_TOKEN | pulp login && - echo y | pulp publish --no-push From af69bc3412f774be63649d59658f7f94ad0fc16b Mon Sep 17 00:00:00 2001 From: "Thomas R. Honeyman" Date: Thu, 13 Aug 2020 20:25:54 -0700 Subject: [PATCH 4/9] Update copyright year --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 50bb826..53ab87e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2015 PureScript Contrib +Copyright (c) 2020 PureScript Contrib Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From 506dc3eb1e123b639f32273136433a3518b2982f Mon Sep 17 00:00:00 2001 From: "Thomas R. Honeyman" Date: Thu, 13 Aug 2020 20:26:41 -0700 Subject: [PATCH 5/9] Remove package.json --- package.json | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 package.json diff --git a/package.json b/package.json deleted file mode 100644 index 2a58021..0000000 --- a/package.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "private": true, - "scripts": { - "clean": "rimraf output", - "build": "spago build", - "test": "spago -x test/test.dhall test" - }, - "devDependencies": { - "purescript-psa": "^0.7.3", - "rimraf": "^3.0.2", - "spago": "^0.15.3" - } -} From 1602681fdf4283278893b38502faad84d62ad19a Mon Sep 17 00:00:00 2001 From: "Thomas R. Honeyman" Date: Thu, 13 Aug 2020 20:27:58 -0700 Subject: [PATCH 6/9] Add stale config --- .github/stale.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .github/stale.yml diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 0000000..1975bb3 --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,17 @@ +# Number of days of inactivity before an issue becomes stale +daysUntilStale: 90 +# Number of days of inactivity before a stale issue is closed +daysUntilClose: 21 +# Issues with these labels will never be considered stale +exemptLabels: + - pinned + - security +# Label to use when marking an issue as stale +staleLabel: wontfix +# Comment to post when marking an issue as stale. Set to `false` to disable +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Thank you + for your contributions. +# Comment to post when closing a stale issue. Set to `false` to disable +closeComment: false From bed078ddeb55f82c437c94a031869d49c883437b Mon Sep 17 00:00:00 2001 From: "Thomas R. Honeyman" Date: Thu, 13 Aug 2020 20:28:32 -0700 Subject: [PATCH 7/9] Add editorconfig --- .editorconfig | 13 +++++++++++++ .gitignore | 1 + 2 files changed, 14 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e717f5e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +# http://editorconfig.org +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore index 137375e..b6cb75a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ !.gitignore !.github !.eslintrc.json +!.editorconfig # Dependencies bower_components From 030119eecd2dfc1775f3d6dcaf67b9c851e97f62 Mon Sep 17 00:00:00 2001 From: "Thomas R. Honeyman" Date: Thu, 13 Aug 2020 20:36:53 -0700 Subject: [PATCH 8/9] Update README and add documentation --- README.md | 544 +++---------------------------------------------- docs/README.md | 502 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 534 insertions(+), 512 deletions(-) create mode 100644 docs/README.md diff --git a/README.md b/README.md index a2dcac0..a7c3144 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,35 @@ -# purescript-argonaut-codecs +# Argonaut Codecs -[![Latest release](http://img.shields.io/github/release/purescript-contrib/purescript-argonaut-codecs.svg)](https://github.com/purescript-contrib/purescript-argonaut-codecs/releases) -[![Build status](https://travis-ci.org/purescript-contrib/purescript-argonaut-codecs.svg?branch=master)](https://travis-ci.org/purescript-contrib/purescript-argonaut-codecs) -[![Pursuit](http://pursuit.purescript.org/packages/purescript-argonaut-codecs/badge)](http://pursuit.purescript.org/packages/purescript-argonaut-codecs/) -[![Maintainer: garyb](https://img.shields.io/badge/maintainer-garyb-lightgrey.svg)](http://github.com/garyb) -[![Maintainer: thomashoneyman](https://img.shields.io/badge/maintainer-thomashoneyman-lightgrey.svg)](http://github.com/thomashoneyman) +[![CI](https://github.com/purescript-contrib/purescript-argonaut-codecs/workflows/CI/badge.svg?branch=master)](https://github.com/purescript-contrib/purescript-argonaut-codecs/actions?query=workflow%3ACI+branch%3Amaster) +[![Release](http://img.shields.io/github/release/purescript-contrib/purescript-argonaut-codecs.svg)](https://github.com/purescript-contrib/purescript-argonaut-codecs/releases) +[![Pursuit](http://pursuit.purescript.org/packages/purescript-argonaut-codecs/badge)](http://pursuit.purescript.org/packages/purescript-argonaut-codecs) +[![Maintainer: garyb](https://img.shields.io/badge/maintainer-garyb-teal.svg)](http://github.com/garyb) +[![Maintainer: thomashoneyman](https://img.shields.io/badge/maintainer-thomashoneyman-teal.svg)](http://github.com/thomashoneyman) [Argonaut](https://github.com/purescript-contrib/purescript-argonaut) is a collection of libraries for working with JSON in PureScript. `argonaut-codecs` provides codecs based on the `EncodeJson` and `DecodeJson` type classes, along with instances for common data types and combinators for encoding and decoding `Json` values. +You may also be interested in these other libraries from the Argonaut ecosystem: + +- [purescript-argonaut-core](https://github.com/purescript-contrib/purescript-argonaut-core) defines the `Json` type, along with basic parsing, printing, and folding functions +- [purescript-argonaut-traversals](https://github.com/purescript-contrib/purescript-argonaut-traversals) defines prisms, traversals, and zippers for the `Json` type. +- [purescript-argonaut-generic](https://github.com/purescript-contrib/purescript-argonaut-generic) supports generic encoding and decoding for any type with a `Generic` instance +- [purescript-codec-argonaut](https://github.com/garyb/purescript-codec-argonaut) supports an alternative approach for codecs, which are based on profunctors instead of type classes + ## Installation -This library is bundled as part of [Argonaut](https://github.com/purescript-contrib/purescript-argonaut) and can be installed via that library. To install just `argonaut-codecs`: +Install with [Spago](https://github.com/purescript/spago): ```sh spago install argonaut-codecs ``` -## Documentation - -Module documentation is [published on Pursuit](https://pursuit.purescript.org/packages/purescript-argonaut-codecs). You may also be interested in other libraries in the Argonaut ecosystem: +or install as part of the [Argonaut](https://github.com/purescript-contrib/purescript-argonaut) bundle: -- [purescript-argonaut-core](https://github.com/purescript-contrib/purescript-argonaut-core) defines the `Json` type, along with basic parsing, printing, and folding functions -- [purescript-argonaut-traversals](https://github.com/purescript-contrib/purescript-argonaut-traversals) defines prisms, traversals, and zippers for the `Json` type. -- [purescript-argonaut-generic](https://github.com/purescript-contrib/purescript-argonaut-generic) supports generic encoding and decoding for any type with a `Generic` instance -- [purescript-codec-argonaut](https://github.com/garyb/purescript-codec-argonaut) supports an alternative approach for codecs, which are based on profunctors instead of type classes +```sh +spago install argonaut +``` -## Quick Start +## Quick start Use `encodeJson` to encode PureScript data types as `Json` and `decodeJson` to decode `Json` into PureScript types, with helpful error messages if decoding fails. @@ -63,509 +67,25 @@ Left (AtKey "age" MissingValue) Left "An error occurred while decoding a JSON value:\n At object key 'age':\n No value was found." ``` -## Tutorial - -This library provides provides type classes and combinators for convenient encoding and decoding of `Json` for data types in your application, and includes instances for encoding and decoding most common PureScript types. - -As a brief aside: this library works with `Json` values, not raw JSON strings. - -- If you need to parse `Json` from a JSON string so that you can use `decodeJson`, then you should use the `parseJson` function from `Data.Argonaut.Decode.Parser` (re-exported by `Data.Argonaut.Decode`). -- If you need to print `Json` as a valid JSON string (after using `encodeJson`, for example), then you should use the `stringify` function from `argonaut-core`. - -### Setup - -You can follow along with this tutorial in a repl. You should install these dependencies: - -```sh -spago install argonaut-codecs validation -``` - -> You can also install `argonaut` and only import `Data.Argonaut` instead of all the individual `Data.Argonaut.*` modules, if you prefer a shorter import list. - -Next, import the modules used in this tutorial: - -```purs -import Prelude - -import Control.Alternative -import Data.Argonaut.Core -import Data.Argonaut.Encode -import Data.Argonaut.Decode -import Data.Bifunctor -import Data.Maybe -import Data.Newtype -import Data.Either -import Data.Validation.Semigroup -import Foreign.Object -``` - -> Tip: you can place this snippet in a `.purs-repl` file so the imports are loaded automatically when you run `spago repl` - -### Automatic Encoding & Decoding - -The `EncodeJson` and `DecodeJson` type classes let you rely on instances for common data types to automatically encode and decode `Json`. Let's explore automatic encoding and decoding using a type typical of PureScript applications as our example: - -```purs -type User = - { name :: String - , age :: Maybe Int - , team :: Maybe String - } -``` - -> Tip: If you're following along in the repl, you can either define this type on one line or use `:paste` to input multiple lines followed by Ctrl+D to end the paste. - -#### Automatic encoding with `EncodeJson` and `encodeJson` - -We can automatically encode `Json` using the `EncodeJson` type class ([pursuit](https://pursuit.purescript.org/packages/purescript-argonaut-codecs/docs/Data.Argonaut.Encode#t:EncodeJson)). - -Our `User` type is made up of several other types: `Record`, `Maybe`, `Int`, and `String`. Each of these types have instances for `EncodeJson`, which means that we can use the `encodeJson` function with them. Integers and strings will be encoded directly to `Json`, while container types like `Record` and `Maybe` will require on all of the types they contain to also have `EncodeJson` instances. - -```purs -encodeJson :: EncodeJson a => a -> Json -``` - -> Tip: There is no `Show` instance for `Json`. To print a `Json` value as a valid JSON string, use `stringify` -- it's the same as the [JavaScript `stringify` method](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify). - -```text -> user = { name: "Tom", age: Just 25, team: Just "Red Team" } :: User -> stringify (encodeJson user) -"{\"name\":\"Tom\",\"age\":25,\"team\":\"Red Team\"}" -``` - -#### Automatic decoding with `DecodeJson` and `decodeJson` - -We can automatically decode `Json` using the `DecodeJson` type class ([pursuit](https://pursuit.purescript.org/packages/purescript-argonaut-codecs/docs/Data.Argonaut.Decode#t:DecodeJson)). - -Every type within `User` has an instance for `DecodeJson`, which means we can use the `decodeJson` function to try to decode a `Json` value into our type. Once again, integer and string values will be decoded directly from the `Json`, but containing types like `Record` and `Maybe` will also require instances for the types they contain. - -```purs -decodeJson :: DecodeJson a => Json -> Either String a -``` - -> Tip: To parse a JSON string as a `Json` value, you can use the `parseJson` function (which can fail). If you are sure you have valid JSON, then consider writing it in an FFI file and foreign importing it as `Json` as described in the [`argonaut-core` documentation](https://github.com/purescript-contrib/purescript-argonaut-core#introducing-json-values). - -```text -> userJsonString = """{ "name": "Tom", "age": 25, "team": null }""" -> decodedUser = decodeJson =<< parseJson userJsonString - -# there is no `Show` instance for `Json`, so we'll stringify the decoded result -# so it can be displayed in the repl -> map stringify decodedUser -Right "{\"name\":\"Tom\",\"age\":25,\"team\":null}" -``` - -Decoding can fail if the `Json` doesn't match the shape expected by a `DecodeJson` instance; in that case, an error is returned instead of the decoded value. - -```text -> badUserJsonString = """{ "name": "Tom", "age": null }""" -> decoded = (decodeJson =<< parseJson badUserJsonString) :: Either JsonDecodeError User -> decoded -Left (AtKey "team" MissingValue) -``` - -This library uses an error type to represent possible ways that decoding JSON can fail, and it then uses this error type to create helpful error messages. For example, our input JSON was a valid object, but it was missing the "team" key that we need in order to decode to a valid `User`. We can print our error to get a human-friendly string message: - -```text -> lmap printDecodeJsonError decoded -> printDecodeJsonError (AtKey "team" MissingValue) -Left "An error occurred while decoding a JSON value:\n At object key 'team':\n No value was found." -``` - -### Writing New Instances - -While instances of `EncodeJson` and `DecodeJson` exist for most common data types in the PureScript ecosystem, you will sometimes need to write your own. Common reasons to write your own instances include: - -1. You have defined a new data type -2. You require `encodeJson` or `decodeJson` to behave differently, for a given type, than its existing `EncodeJson` or `DecodeJson` instance -3. You are using a data type which already exists, but does not have an `EncodeJson` or `DecodeJson` instance (typically because there are many reasonable ways to represent the data in JSON types, as is the case with dates). - -It is also common to have a 'default' way to decode or encode a particular data type, but to write alternative decoding and encoding functions that can be used instead of the one supported by the type class. - -Let's explore the combinators provided by `argonaut-codecs` for encoding and decoding `Json` by treating our `User` type as a new data type instead of just a synonym for a record, and turning the `team` field into a sum type instead of just a `String`. - -> Remember that you can write multi-line definitions using by typing :paste in the repl, and then using Ctrl+D to exit when you're done. - -```purs -newtype AppUser = AppUser - { name :: String - , age :: Maybe Int - , team :: Team - } - -data Team - = RedTeam - | BlueTeam -``` - -#### Encoding JSON - -To encode JSON, you must decide on a way to represent your data using only primitive JSON types (strings, numbers, booleans, arrays, objects, or null). Since PureScript's string, number, boolean, and array types already have `EncodeJson` instances, your responsibility is to find a way to transform your data types to those more primitive types so they can be encoded. - -Let's start with our `Team` type, which doesn't have an `EncodeJson` instance yet. It can be represented in JSON by simple strings, so let's write a function to convert `Team` to a `String`: - -```purs -teamToString :: Team -> String -teamToString = case _ of - RedTeam -> "Red Team" - BlueTeam -> "Blue Team" -``` - -We can now write an `EncodeJson` instance for our type. As a brief reminder, this is the type signature required by `encodeJson`: - -```purs -encodeJson :: EncodeJson a => a -> Json -``` - -`String` already has an instance of `EncodeJson`, so all we need to do is convert our type to a string and then use `encodeJson` to encode the resulting string. - -```purs -instance encodeJsonTeam :: EncodeJson Team where - encodeJson team = encodeJson (teamToString team) -``` - -If your type can be converted easily to a `String`, `Number`, or `Boolean`, then its `EncodeJson` instance will most likely look like the one we've written for `Team`. - -Most reasonably complex data types are best represented as objects, however. We can use combinators from `Data.Argonaut.Encode.Combinators` to conveniently encode `Json` objects manually. You'll provide `String` keys and values which can be encoded to `Json`. - -- Use `:=` (`assoc`) to encode a key/value pair where the key must exist; encoding the key `"team"` and value `Nothing` will insert the key `"team"` with the value `null`. -- Use `~>` (`extend`) to provide more key/value pairs after using `:=`. -- Use `:=?` (`assocOptional`) to encode a key/value pair where the key _may_ exist; encoding the key `"age"` and value `Nothing` will not insert the `"age"` key. -- Use `~>?` (`extendOptional`) to provide more key/value pairs after using `:=?`. - -Let's use these combinators to encode a `Json` object from our `AppUser` record. - -```purs -instance encodeJsonAppUser :: EncodeJson AppUser where - encodeJson (AppUser { name, age, team }) = - "name" := name -- inserts "name": "Tom" - ~> "age" :=? age -- inserts "age": "25" (if Nothing, does not insert anything) - ~>? "team" := team -- inserts "team": "Red Team" - ~> jsonEmptyObject -``` - -To recap: manually encoding your data type involves a few steps: - -1. Ensure that all types you are encoding have an `EncodeJson` instance or can be converted to another type which does. -2. Use `:=` or `:=?` to create a key/value pair in a JSON object -3. Use `~>` or `~>?` to chain together multiple key/value pairs. - -Ultimately, this will produce `Json` which can be serialized to a JSON string or manipulated. - -#### Decoding JSON - -Decoding PureScript types from `Json` is similar to encoding them. You'll once again need a mapping from your data type to its representation in primitive JSON types. Booleans, strings, numbers, and arrays are covered by existing `DecodeJson` instances, so if you can convert from any of those types to your PureScript type then you can use that conversion to write a `DecodeJson` instance for your type. - -Let's begin once again with our `Team` type, which can be represented as a string in JSON and does not have a `DecodeJson` instance yet. We'll start by writing a function which tries to produce a `Team` from a `String`: - -```purs -teamFromString :: String -> Maybe Team -teamFromString = case _ of - "Red Team" -> Just RedTeam - "Blue Team" -> Just BlueTeam - _ -> Nothing -``` - -We can use this function to write a `DecodeJson` instance for our type. As a quick reminder, this is the type signature required by `decodeJson`: - -```purs -decodeJson :: DecodeJson a => Json -> Either JsonDecodeError a -``` - -Let's write the instance using `note` from `purescript-either`: - -```purs -instance decodeJsonTeam :: DecodeJson Team where - decodeJson json = do - string <- decodeJson json - note (TypeMismatch "Team") (teamFromString string) -``` - -If your type can be represented easily with a `String`, `Number`, `Boolean`, or array of one of these types, then its `DecodeJson` will most likely look similar to this one. - -However, quite often your data type will require representation as an object. This library provides combinators in `Data.Argonaut.Decode.Combinators` which are useful for decoding objects into PureScript types by looking up keys in the object and decoding them according to their `DecodeJson` instances. - -- Use `.:` (`getField`) to decode a field where the key must exist; if the field is missing, this will fail with a decoding error. -- Use `.:?` (`getFieldOptional'`) to decode a field where the key may exist; if the field is missing or its value is `null` then this will return `Nothing`, and otherwise it will attempt to decode the value at the given key. -- Use `.!=` (`defaultField`) in conjunction with `.:?` to provide a default value for a field which may not exist. If decoding fails, you'll still get an error; if decoding succeeds with a value of type `Maybe a`, then this default value will handle the `Nothing` case. - -Let's use these combinators to decode a `Json` object into our `AppUser` record. - -The `decodeJson` function returns an `Either JsonDecodeErorr a` value; `Either` is a monad, which means we can use convenient `do` syntax to write our decoder. If a step in decoding succeeds, then its result is passed to the next step. If any step in decoding fails, the entire computation will abort with the error it encountered. - -```purs -instance decodeJsonAppUser :: DecodeJson AppUser where - decodeJson json = do - obj <- decodeJson json -- decode `Json` to `Object Json` - name <- obj .: "name" -- decode the "name" key to a `String` - age <- obj .:? "age" -- decode the "age" key to a `Maybe Int` - team <- obj .:? "team" .!= RedTeam -- decode "team" to `Team`, defaulting to `RedTeam` - -- if the field is missing or `null` - pure $ AppUser { name, age, team } -``` - -To recap: manually decoding your data type involves a few steps: - -1. Ensure that all types you are decoding have a `DecodeJson` instance -2. Use `.:` to decode object fields where the key must exist -3. Use `.:?` to decode object fields where the key may exist or its value may be null -4. Use `.!=` to provide a default value for fields which may exist in the `Json`, but must exist in the type you're decoding to (it's like `fromMaybe` for your decoder, unwrapping the decoded value). -5. It's common to use the `Either` monad for convenience when writing decoders. Any failed decoding step will abort the entire computation with that error. See [Solving Common Problems](#solving-common-problems) for alternative approaches to decoding. - -### Deriving Instances - -There are two ways to derive instances of `EncodeJson` and `DecodeJson` for new types. - -#### Newtype Deriving - -We intentionally introduced a newtype around a record, `AppUser`, so that we could hand-write type class instances for it. What if we'd needed the newtype for another reason, and we planned on using the same encoding and decoding as the underlying type's instances provide? - -In that case, we can use newtype deriving to get `EncodeJson` and `DecodeJson` for our newtype for free: - -```purs -newtype AppUser = AppUser { name :: String, age :: Maybe Int, team :: Team } - -derive instance newtypeAppUser :: Newtype AppUser _ - -derive newtype instance encodeJsonAppUser :: EncodeJson AppUser -derive newtype instance decodeJsonAppUser :: DecodeJson AppUser -``` - -#### Generics - -If your data type has an instance of `Generic`, then you can use [purescript-argonaut-generic](https://github.com/purescript-contrib/purescript-argonaut-generic) to leverage `genericEncodeJson` and `genericDecodeJson` to write your instances: - -```purs -import Data.Generic.Rep (class Generic) -import Data.Argonaut.Encode.Generic.Rep (genericEncodeJson) -import Data.Argonaut.Decode.Generic.Rep (genericDecodeJson) - -data Team = RedTeam | BlueTeam - -derive instance genericTeam :: Generic Team _ - -instance encodeJsonTeam :: EncodeJson Team where - encodeJson = genericEncodeJson - -instance decodeJsonTeam :: DecodeJson Team where - decodeJson = genericDecodeJson -``` - -Here is another example of how to derive a generic instance of a type with a type variable. This type also happens to be recursive: - -```purs -data Chain a - = End a - | Link a (Chain a) - -derive instance genericChain :: Generic (Chain a) _ - -instance encodeJsonChain :: EncodeJson a => EncodeJson (Chain a) where - encodeJson chain = genericEncodeJson chain - -instance decodeJsonChain :: DecodeJson a => DecodeJson (Chain a) where - decodeJson chain = genericDecodeJson chain -``` - -Note the addition of instance dependencies for the type variable `a`. Also note that these instances for a recursive type cannot be written in point-free style, as that would likely cause a stack overflow during execution. Instead, we use the variables `chain` to apply eta-expansion. - -More information about how to derive generic instances can be found in this [24-days-of-purescript post](https://github.com/paf31/24-days-of-purescript-2016/blob/master/11.markdown#deriving-generic-instances). - -### Solving Common Problems - -#### Handling Multiple JSON Representations - -Sometimes a data type in your application can be represented in multiple formats. For example, consider a `User` type like this: - -```purs -newtype User = User - { uuid :: String - , name :: String - } -``` - -In previous versions of your API the `uuid` field has been named `uid` and `id`. Unfortunately, you receive data from all three versions, so you need to accommodate each. You only want one canonical type in your application, though: the `User` type above. - -There are several ways to handle the case in which a data type has multiple JSON representations. - -##### 1. Use `Alternative` to provide fallback decoders - -The first option is to use the `Alternative` type class and its `<|>` operator to provide multiple ways to decode a particular field in an object. For example: - -```purs -instance decodeJsonUser :: DecodeJson User where - decodeJson json = do - obj <- decodeJson json - name <- obj .: "name" - uuid <- obj .: "uuid" <|> obj .: "uid" <|> obj .: "id" - pure $ User { name, uuid } -``` - -You may sometimes need to do additional processing so that `uuid` always ends up being decoded to the correct type. For example, if in a previous API version the `id` field was actually an object with a `value` field containing the id, then you could provide a two-step decoder for that case. - -```purs -instance decodeJsonUser :: DecodeJson User where - decodeJson json = do - ... - uuid <- obj .: "uuid" <|> obj .: "uid" <|> ((_ .: "value") =<< obj .: "id") -``` - -##### 2. Write multiple `encodeJson` or `decodeJson` functions - -Another option is to have a default representation for the type implemented as the type class instance, but alternative `decodeJson` and `encodeJson` functions which can be used directly. For example, consider the case in which our `User` data can be sent to multiple sources. One source requires the data to be formatted as an object, and another requires it to be formatted as a two-element array. - -In this case, our type class instance can use the default object encoding, and we can supply a separate `encodeJsonAsArray` function for use when required. - -```purs --- our default object encoding -derive newtype instance encodeJsonUser :: EncodeJson User - -encodeUserAsArray :: User -> Json -encodeUserAsArray user = encodeJson [ user.uuid, user.name ] -``` - -#### Decoding With More Arguments than `Json` - -You may occasionally be unable to write `EncodeJson` or `DecodeJson` instances for a data type because it requires more information than just `Json` as its argument. For instance, consider this pair of types: - -```purs -data Author - = Following String -- you are subscribed to this author - | NotFollowing String -- you aren't subscribed to this author - | You -- you are the author - -type BlogPost = - { title :: String - , author :: Author - } -``` - -Our API sends us the author of the blog post as a string and whether we follow them as a boolean. This admits more cases than are actually possible -- you can't follow yourself, for example -- so we are more precise and model an `Author` as a sum type. - -When our application is running we know who the currently-authenticated user is, and we can use that information to determine the `Author` type. That means we can't decode an `Author` from `Json` alone -- we need more information. - -In these cases, unfortunately, you can't write an instance of `DecodeJson` for the data type. You can, however, write `decodeJsonAuthor` and use it without the type class. For instance: - -```purs -decodeJsonAuthor :: Maybe Username -> Json -> Either JsonDecodeError Author -decodeJsonAuthor maybeUsername json = do - obj <- decodeJson json - author <- obj .: "author" - following <- obj .: "following" - pure $ case maybeUsername of - -- user is logged in and is the author - Just (Username username) | author == username -> You - -- user is not the author, or no one is logged in, so use the `following` flag - _ -> author # if following then Following else NotFollowing - -decodeJsonBlogPost :: Maybe Username -> Json -> Either JsonDecodeError BlogPost -decodeJsonBlogPost username json = do - obj <- decodeJson json - title <- obj .: "title" - author <- decodeJsonAuthor username =<< obj .: "author" - pure { title, author } -``` - -#### Writing Instances For Types You Don't Own - -While not an issue specific to `argonaut-codecs`, you may sometimes wish to write an `EncodeJson` or a `DecodeJson` instance for a data type you did not define -- for instance, the `PreciseDateTime` type from `purescript-precise-datetime`. This type has no instances because there are many ways you might wish to represent it in JSON. - -If you want to use an application-specific encoding for this type then you will need to define a newtype wrapper for it and define instances for that new type instead. You cannot simply write an instance for the original `PreciseDateTime` type as that would be creating an orphan instance. - -```purs -module App.Data.PreciseDateTime where - -import Data.PreciseDateTime as PDT -import Data.RFC3339String (RFC3339String(..)) - -newtype PreciseDateTime = PreciseDateTime PDT.PreciseDateTime - -instance decodeJsonPreciseDateTime :: DecodeJson PreciseDateTime where - decodeJson json = fromString =<< decodeJson json - where - fromString :: String -> Either JsonDecodeError PreciseDateTime - fromString = - map PreciseDateTime - <<< note (TypeMismatch "RFC3339String") - <<< PDT.fromRFC3339String - <<< RFC3339String -``` - -You can now use the wrapped `PreciseDateTime` type in your application and the instance will be used by the `DecodeJson` type class. - -#### Accumulating Errors Instead of Short-Circuiting - -You may sometimes want to _accumulate_ errors, rather than short-circuit at the first failure. The `V` type from `purescript-validation` is similar to `Either`, but it allows you to accumulate errors into a semigroup or semiring instead of stopping when the first failure occurs. You can define decoders which work in `V` and then convert them back to `Either` at the end. - -For example, let's say we have a `User` type which occasionally gets bad input, and we want to see _all_ errors in the input rather than one at a time. This is how we might write a decoding function for the type: - -```purs -newtype User = User - { name :: String - , age :: Maybe Int - , location :: String - } - -derive instance newtypeUser :: Newtype User _ -derive newtype instance showUser :: Show User - -decodeUser :: Json -> Either JsonDecodeError User -decodeUser json = do - obj <- decodeJson json - name <- obj .: "name" - age <- obj .:? "age" - location <- obj .: "location" - pure $ User { name, age, location } -``` - -Running this in the REPL with bad input, we only see the first error: - -```text -> decodeUser =<< parseJson "{}" -Left (AtKey "name" MissingValue) -``` +## Documentation -However, by collecting results into `V` instead of into `Either` we will accumulate all errors. We can even make it a little nicer by writing a new operator, `.:|`, which works in `V`: +Contrib library documentation is stored in a few places: -```purs --- a replacement for `decodeJson` -decodeJsonV :: forall a. DecodeJson a => Json -> V (Array JsonDecodeError) a -decodeJsonV = either (invalid <<< pure) pure <<< decodeJson +1. Module documentation is [published on Pursuit](https://pursuit.purescript.org/packages/purescript-argonaut-codecs). +2. The tutorial and other written documentation is kept in [the docs directory](./docs). +3. Usage examples can be found in [the test suite](./test) --- a replacement for `getField` -getFieldV :: forall a. DecodeJson a => Object Json -> String -> V (Array JsonDecodeError) a -getFieldV object key = either (invalid <<< pure) pure (object .: key) +If you get stuck, there are several ways to get help: --- a replacement for .: -infix 7 getFieldV as .:| -``` - -With this new operator and applicative-do we can recreate our original decoder, except with accumulating errors this time: +- [Open an issue](https://github.com/purescript-contrib/purescript-argonaut-codecs/issues) if you have encountered a bug or problem. +- [Search or start a thread on the PureScript Discourse](https://discourse.purescript.org) if you have general questions. You can also ask questions in the `#purescript` and `#purescript-beginners` channels on the [Functional Programming Slack](https://functionalprogramming.slack.com) ([invite link](https://fpchat-invite.herokuapp.com/)). -```purs -decodeUser :: Json -> Either (Array JsonDecodeError) User -decodeUser json = do - user <- toEither $ andThen (decodeJsonV json) \obj -> ado - name <- obj .:| "name" - age <- obj .:| "age" - location <- obj .:| "location" - in { name, age, location } - pure $ User user -``` - -> Note: If you are doing this in the repl, you can't define an infix operator. Use `getFieldV` in place of `.:|`. +## Contributing -This decoder will now print all errors: +You can contribute to `argonaut-codecs` in several ways: -```text -> decodeUser =<< lmap pure (parseJson "{}") -Left - [ AtKey "name" MissingValue - , AtKey "age" MissingValue - , AtKey "location" MissingValue - ] -``` +1. If you encounter a problem or have a question, please [open an issue](https://github.com/purescript-contrib/purescript-argonaut-codecs/issues) issue. We'll do our best to work with you to resolve or answer it. -## Contributing +2. If you would like to contribute code, tests, or documentation, please [read the contributor guide](./.github/CONTRIBUTING.md). It's a short, helpful introduction to contributing to this library, including development instructions. -Read the [contribution guidelines](https://github.com/purescript-contrib/purescript-argonaut-codecs/blob/master/.github/contributing.md) to get started and see helpful related resources. +3. If you have written a library, tutorial, guide, or other resource based on this package, please share it on the [PureScript Discourse](https://discourse.purescript.org)! Writing libraries and learning resources are a great way to help this library succeed. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..eebdd73 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,502 @@ +# Argonaut Codecs Documentation + +This library provides provides type classes and combinators for convenient encoding and decoding of `Json` for data types in your application, and includes instances for encoding and decoding most common PureScript types. + +As a brief aside: this library works with `Json` values, not raw JSON strings. + +- If you need to parse `Json` from a JSON string so that you can use `decodeJson`, then you should use the `parseJson` function from `Data.Argonaut.Decode.Parser` (re-exported by `Data.Argonaut.Decode`). +- If you need to print `Json` as a valid JSON string (after using `encodeJson`, for example), then you should use the `stringify` function from `argonaut-core`. + +## Setup + +You can follow along with this tutorial in a repl. You should install these dependencies: + +```sh +spago install argonaut-codecs validation +``` + +> You can also install `argonaut` and only import `Data.Argonaut` instead of all the individual `Data.Argonaut.*` modules, if you prefer a shorter import list. + +Next, import the modules used in this tutorial: + +```purs +import Prelude + +import Control.Alternative +import Data.Argonaut.Core +import Data.Argonaut.Encode +import Data.Argonaut.Decode +import Data.Bifunctor +import Data.Maybe +import Data.Newtype +import Data.Either +import Data.Validation.Semigroup +import Foreign.Object +``` + +> Tip: you can place this snippet in a `.purs-repl` file so the imports are loaded automatically when you run `spago repl` + +## Automatic Encoding & Decoding + +The `EncodeJson` and `DecodeJson` type classes let you rely on instances for common data types to automatically encode and decode `Json`. Let's explore automatic encoding and decoding using a type typical of PureScript applications as our example: + +```purs +type User = + { name :: String + , age :: Maybe Int + , team :: Maybe String + } +``` + +> Tip: If you're following along in the repl, you can either define this type on one line or use `:paste` to input multiple lines followed by Ctrl+D to end the paste. + +### Automatic encoding with `EncodeJson` and `encodeJson` + +We can automatically encode `Json` using the `EncodeJson` type class ([pursuit](https://pursuit.purescript.org/packages/purescript-argonaut-codecs/docs/Data.Argonaut.Encode#t:EncodeJson)). + +Our `User` type is made up of several other types: `Record`, `Maybe`, `Int`, and `String`. Each of these types have instances for `EncodeJson`, which means that we can use the `encodeJson` function with them. Integers and strings will be encoded directly to `Json`, while container types like `Record` and `Maybe` will require on all of the types they contain to also have `EncodeJson` instances. + +```purs +encodeJson :: EncodeJson a => a -> Json +``` + +> Tip: There is no `Show` instance for `Json`. To print a `Json` value as a valid JSON string, use `stringify` -- it's the same as the [JavaScript `stringify` method](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify). + +```text +> user = { name: "Tom", age: Just 25, team: Just "Red Team" } :: User +> stringify (encodeJson user) +"{\"name\":\"Tom\",\"age\":25,\"team\":\"Red Team\"}" +``` + +### Automatic decoding with `DecodeJson` and `decodeJson` + +We can automatically decode `Json` using the `DecodeJson` type class ([pursuit](https://pursuit.purescript.org/packages/purescript-argonaut-codecs/docs/Data.Argonaut.Decode#t:DecodeJson)). + +Every type within `User` has an instance for `DecodeJson`, which means we can use the `decodeJson` function to try to decode a `Json` value into our type. Once again, integer and string values will be decoded directly from the `Json`, but containing types like `Record` and `Maybe` will also require instances for the types they contain. + +```purs +decodeJson :: DecodeJson a => Json -> Either String a +``` + +> Tip: To parse a JSON string as a `Json` value, you can use the `parseJson` function (which can fail). If you are sure you have valid JSON, then consider writing it in an FFI file and foreign importing it as `Json` as described in the [`argonaut-core` documentation](https://github.com/purescript-contrib/purescript-argonaut-core#introducing-json-values). + +```text +> userJsonString = """{ "name": "Tom", "age": 25, "team": null }""" +> decodedUser = decodeJson =<< parseJson userJsonString + +# there is no `Show` instance for `Json`, so we'll stringify the decoded result +# so it can be displayed in the repl +> map stringify decodedUser +Right "{\"name\":\"Tom\",\"age\":25,\"team\":null}" +``` + +Decoding can fail if the `Json` doesn't match the shape expected by a `DecodeJson` instance; in that case, an error is returned instead of the decoded value. + +```text +> badUserJsonString = """{ "name": "Tom", "age": null }""" +> decoded = (decodeJson =<< parseJson badUserJsonString) :: Either JsonDecodeError User +> decoded +Left (AtKey "team" MissingValue) +``` + +This library uses an error type to represent possible ways that decoding JSON can fail, and it then uses this error type to create helpful error messages. For example, our input JSON was a valid object, but it was missing the "team" key that we need in order to decode to a valid `User`. We can print our error to get a human-friendly string message: + +```text +> lmap printDecodeJsonError decoded +> printDecodeJsonError (AtKey "team" MissingValue) +Left "An error occurred while decoding a JSON value:\n At object key 'team':\n No value was found." +``` + +## Writing New Instances + +While instances of `EncodeJson` and `DecodeJson` exist for most common data types in the PureScript ecosystem, you will sometimes need to write your own. Common reasons to write your own instances include: + +1. You have defined a new data type +2. You require `encodeJson` or `decodeJson` to behave differently, for a given type, than its existing `EncodeJson` or `DecodeJson` instance +3. You are using a data type which already exists, but does not have an `EncodeJson` or `DecodeJson` instance (typically because there are many reasonable ways to represent the data in JSON types, as is the case with dates). + +It is also common to have a 'default' way to decode or encode a particular data type, but to write alternative decoding and encoding functions that can be used instead of the one supported by the type class. + +Let's explore the combinators provided by `argonaut-codecs` for encoding and decoding `Json` by treating our `User` type as a new data type instead of just a synonym for a record, and turning the `team` field into a sum type instead of just a `String`. + +> Remember that you can write multi-line definitions using by typing :paste in the repl, and then using Ctrl+D to exit when you're done. + +```purs +newtype AppUser = AppUser + { name :: String + , age :: Maybe Int + , team :: Team + } + +data Team + = RedTeam + | BlueTeam +``` + +### Encoding JSON + +To encode JSON, you must decide on a way to represent your data using only primitive JSON types (strings, numbers, booleans, arrays, objects, or null). Since PureScript's string, number, boolean, and array types already have `EncodeJson` instances, your responsibility is to find a way to transform your data types to those more primitive types so they can be encoded. + +Let's start with our `Team` type, which doesn't have an `EncodeJson` instance yet. It can be represented in JSON by simple strings, so let's write a function to convert `Team` to a `String`: + +```purs +teamToString :: Team -> String +teamToString = case _ of + RedTeam -> "Red Team" + BlueTeam -> "Blue Team" +``` + +We can now write an `EncodeJson` instance for our type. As a brief reminder, this is the type signature required by `encodeJson`: + +```purs +encodeJson :: EncodeJson a => a -> Json +``` + +`String` already has an instance of `EncodeJson`, so all we need to do is convert our type to a string and then use `encodeJson` to encode the resulting string. + +```purs +instance encodeJsonTeam :: EncodeJson Team where + encodeJson team = encodeJson (teamToString team) +``` + +If your type can be converted easily to a `String`, `Number`, or `Boolean`, then its `EncodeJson` instance will most likely look like the one we've written for `Team`. + +Most reasonably complex data types are best represented as objects, however. We can use combinators from `Data.Argonaut.Encode.Combinators` to conveniently encode `Json` objects manually. You'll provide `String` keys and values which can be encoded to `Json`. + +- Use `:=` (`assoc`) to encode a key/value pair where the key must exist; encoding the key `"team"` and value `Nothing` will insert the key `"team"` with the value `null`. +- Use `~>` (`extend`) to provide more key/value pairs after using `:=`. +- Use `:=?` (`assocOptional`) to encode a key/value pair where the key _may_ exist; encoding the key `"age"` and value `Nothing` will not insert the `"age"` key. +- Use `~>?` (`extendOptional`) to provide more key/value pairs after using `:=?`. + +Let's use these combinators to encode a `Json` object from our `AppUser` record. + +```purs +instance encodeJsonAppUser :: EncodeJson AppUser where + encodeJson (AppUser { name, age, team }) = + "name" := name -- inserts "name": "Tom" + ~> "age" :=? age -- inserts "age": "25" (if Nothing, does not insert anything) + ~>? "team" := team -- inserts "team": "Red Team" + ~> jsonEmptyObject +``` + +To recap: manually encoding your data type involves a few steps: + +1. Ensure that all types you are encoding have an `EncodeJson` instance or can be converted to another type which does. +2. Use `:=` or `:=?` to create a key/value pair in a JSON object +3. Use `~>` or `~>?` to chain together multiple key/value pairs. + +Ultimately, this will produce `Json` which can be serialized to a JSON string or manipulated. + +### Decoding JSON + +Decoding PureScript types from `Json` is similar to encoding them. You'll once again need a mapping from your data type to its representation in primitive JSON types. Booleans, strings, numbers, and arrays are covered by existing `DecodeJson` instances, so if you can convert from any of those types to your PureScript type then you can use that conversion to write a `DecodeJson` instance for your type. + +Let's begin once again with our `Team` type, which can be represented as a string in JSON and does not have a `DecodeJson` instance yet. We'll start by writing a function which tries to produce a `Team` from a `String`: + +```purs +teamFromString :: String -> Maybe Team +teamFromString = case _ of + "Red Team" -> Just RedTeam + "Blue Team" -> Just BlueTeam + _ -> Nothing +``` + +We can use this function to write a `DecodeJson` instance for our type. As a quick reminder, this is the type signature required by `decodeJson`: + +```purs +decodeJson :: DecodeJson a => Json -> Either JsonDecodeError a +``` + +Let's write the instance using `note` from `purescript-either`: + +```purs +instance decodeJsonTeam :: DecodeJson Team where + decodeJson json = do + string <- decodeJson json + note (TypeMismatch "Team") (teamFromString string) +``` + +If your type can be represented easily with a `String`, `Number`, `Boolean`, or array of one of these types, then its `DecodeJson` will most likely look similar to this one. + +However, quite often your data type will require representation as an object. This library provides combinators in `Data.Argonaut.Decode.Combinators` which are useful for decoding objects into PureScript types by looking up keys in the object and decoding them according to their `DecodeJson` instances. + +- Use `.:` (`getField`) to decode a field where the key must exist; if the field is missing, this will fail with a decoding error. +- Use `.:?` (`getFieldOptional'`) to decode a field where the key may exist; if the field is missing or its value is `null` then this will return `Nothing`, and otherwise it will attempt to decode the value at the given key. +- Use `.!=` (`defaultField`) in conjunction with `.:?` to provide a default value for a field which may not exist. If decoding fails, you'll still get an error; if decoding succeeds with a value of type `Maybe a`, then this default value will handle the `Nothing` case. + +Let's use these combinators to decode a `Json` object into our `AppUser` record. + +The `decodeJson` function returns an `Either JsonDecodeErorr a` value; `Either` is a monad, which means we can use convenient `do` syntax to write our decoder. If a step in decoding succeeds, then its result is passed to the next step. If any step in decoding fails, the entire computation will abort with the error it encountered. + +```purs +instance decodeJsonAppUser :: DecodeJson AppUser where + decodeJson json = do + obj <- decodeJson json -- decode `Json` to `Object Json` + name <- obj .: "name" -- decode the "name" key to a `String` + age <- obj .:? "age" -- decode the "age" key to a `Maybe Int` + team <- obj .:? "team" .!= RedTeam -- decode "team" to `Team`, defaulting to `RedTeam` + -- if the field is missing or `null` + pure $ AppUser { name, age, team } +``` + +To recap: manually decoding your data type involves a few steps: + +1. Ensure that all types you are decoding have a `DecodeJson` instance +2. Use `.:` to decode object fields where the key must exist +3. Use `.:?` to decode object fields where the key may exist or its value may be null +4. Use `.!=` to provide a default value for fields which may exist in the `Json`, but must exist in the type you're decoding to (it's like `fromMaybe` for your decoder, unwrapping the decoded value). +5. It's common to use the `Either` monad for convenience when writing decoders. Any failed decoding step will abort the entire computation with that error. See [Solving Common Problems](#solving-common-problems) for alternative approaches to decoding. + +## Deriving Instances + +There are two ways to derive instances of `EncodeJson` and `DecodeJson` for new types. + +### Newtype Deriving + +We intentionally introduced a newtype around a record, `AppUser`, so that we could hand-write type class instances for it. What if we'd needed the newtype for another reason, and we planned on using the same encoding and decoding as the underlying type's instances provide? + +In that case, we can use newtype deriving to get `EncodeJson` and `DecodeJson` for our newtype for free: + +```purs +newtype AppUser = AppUser { name :: String, age :: Maybe Int, team :: Team } + +derive instance newtypeAppUser :: Newtype AppUser _ + +derive newtype instance encodeJsonAppUser :: EncodeJson AppUser +derive newtype instance decodeJsonAppUser :: DecodeJson AppUser +``` + +### Generics + +If your data type has an instance of `Generic`, then you can use [purescript-argonaut-generic](https://github.com/purescript-contrib/purescript-argonaut-generic) to leverage `genericEncodeJson` and `genericDecodeJson` to write your instances: + +```purs +import Data.Generic.Rep (class Generic) +import Data.Argonaut.Encode.Generic.Rep (genericEncodeJson) +import Data.Argonaut.Decode.Generic.Rep (genericDecodeJson) + +data Team = RedTeam | BlueTeam + +derive instance genericTeam :: Generic Team _ + +instance encodeJsonTeam :: EncodeJson Team where + encodeJson = genericEncodeJson + +instance decodeJsonTeam :: DecodeJson Team where + decodeJson = genericDecodeJson +``` + +Here is another example of how to derive a generic instance of a type with a type variable. This type also happens to be recursive: + +```purs +data Chain a + = End a + | Link a (Chain a) + +derive instance genericChain :: Generic (Chain a) _ + +instance encodeJsonChain :: EncodeJson a => EncodeJson (Chain a) where + encodeJson chain = genericEncodeJson chain + +instance decodeJsonChain :: DecodeJson a => DecodeJson (Chain a) where + decodeJson chain = genericDecodeJson chain +``` + +Note the addition of instance dependencies for the type variable `a`. Also note that these instances for a recursive type cannot be written in point-free style, as that would likely cause a stack overflow during execution. Instead, we use the variables `chain` to apply eta-expansion. + +More information about how to derive generic instances can be found in this [24-days-of-purescript post](https://github.com/paf31/24-days-of-purescript-2016/blob/master/11.markdown#deriving-generic-instances). + +## Solving Common Problems + +### Handling Multiple JSON Representations + +Sometimes a data type in your application can be represented in multiple formats. For example, consider a `User` type like this: + +```purs +newtype User = User + { uuid :: String + , name :: String + } +``` + +In previous versions of your API the `uuid` field has been named `uid` and `id`. Unfortunately, you receive data from all three versions, so you need to accommodate each. You only want one canonical type in your application, though: the `User` type above. + +There are several ways to handle the case in which a data type has multiple JSON representations. + +#### 1. Use `Alternative` to provide fallback decoders + +The first option is to use the `Alternative` type class and its `<|>` operator to provide multiple ways to decode a particular field in an object. For example: + +```purs +instance decodeJsonUser :: DecodeJson User where + decodeJson json = do + obj <- decodeJson json + name <- obj .: "name" + uuid <- obj .: "uuid" <|> obj .: "uid" <|> obj .: "id" + pure $ User { name, uuid } +``` + +You may sometimes need to do additional processing so that `uuid` always ends up being decoded to the correct type. For example, if in a previous API version the `id` field was actually an object with a `value` field containing the id, then you could provide a two-step decoder for that case. + +```purs +instance decodeJsonUser :: DecodeJson User where + decodeJson json = do + ... + uuid <- obj .: "uuid" <|> obj .: "uid" <|> ((_ .: "value") =<< obj .: "id") +``` + +#### 2. Write multiple `encodeJson` or `decodeJson` functions + +Another option is to have a default representation for the type implemented as the type class instance, but alternative `decodeJson` and `encodeJson` functions which can be used directly. For example, consider the case in which our `User` data can be sent to multiple sources. One source requires the data to be formatted as an object, and another requires it to be formatted as a two-element array. + +In this case, our type class instance can use the default object encoding, and we can supply a separate `encodeJsonAsArray` function for use when required. + +```purs +-- our default object encoding +derive newtype instance encodeJsonUser :: EncodeJson User + +encodeUserAsArray :: User -> Json +encodeUserAsArray user = encodeJson [ user.uuid, user.name ] +``` + +### Decoding With More Arguments than `Json` + +You may occasionally be unable to write `EncodeJson` or `DecodeJson` instances for a data type because it requires more information than just `Json` as its argument. For instance, consider this pair of types: + +```purs +data Author + = Following String -- you are subscribed to this author + | NotFollowing String -- you aren't subscribed to this author + | You -- you are the author + +type BlogPost = + { title :: String + , author :: Author + } +``` + +Our API sends us the author of the blog post as a string and whether we follow them as a boolean. This admits more cases than are actually possible -- you can't follow yourself, for example -- so we are more precise and model an `Author` as a sum type. + +When our application is running we know who the currently-authenticated user is, and we can use that information to determine the `Author` type. That means we can't decode an `Author` from `Json` alone -- we need more information. + +In these cases, unfortunately, you can't write an instance of `DecodeJson` for the data type. You can, however, write `decodeJsonAuthor` and use it without the type class. For instance: + +```purs +decodeJsonAuthor :: Maybe Username -> Json -> Either JsonDecodeError Author +decodeJsonAuthor maybeUsername json = do + obj <- decodeJson json + author <- obj .: "author" + following <- obj .: "following" + pure $ case maybeUsername of + -- user is logged in and is the author + Just (Username username) | author == username -> You + -- user is not the author, or no one is logged in, so use the `following` flag + _ -> author # if following then Following else NotFollowing + +decodeJsonBlogPost :: Maybe Username -> Json -> Either JsonDecodeError BlogPost +decodeJsonBlogPost username json = do + obj <- decodeJson json + title <- obj .: "title" + author <- decodeJsonAuthor username =<< obj .: "author" + pure { title, author } +``` + +### Writing Instances For Types You Don't Own + +While not an issue specific to `argonaut-codecs`, you may sometimes wish to write an `EncodeJson` or a `DecodeJson` instance for a data type you did not define -- for instance, the `PreciseDateTime` type from `purescript-precise-datetime`. This type has no instances because there are many ways you might wish to represent it in JSON. + +If you want to use an application-specific encoding for this type then you will need to define a newtype wrapper for it and define instances for that new type instead. You cannot simply write an instance for the original `PreciseDateTime` type as that would be creating an orphan instance. + +```purs +module App.Data.PreciseDateTime where + +import Data.PreciseDateTime as PDT +import Data.RFC3339String (RFC3339String(..)) + +newtype PreciseDateTime = PreciseDateTime PDT.PreciseDateTime + +instance decodeJsonPreciseDateTime :: DecodeJson PreciseDateTime where + decodeJson json = fromString =<< decodeJson json + where + fromString :: String -> Either JsonDecodeError PreciseDateTime + fromString = + map PreciseDateTime + <<< note (TypeMismatch "RFC3339String") + <<< PDT.fromRFC3339String + <<< RFC3339String +``` + +You can now use the wrapped `PreciseDateTime` type in your application and the instance will be used by the `DecodeJson` type class. + +### Accumulating Errors Instead of Short-Circuiting + +You may sometimes want to _accumulate_ errors, rather than short-circuit at the first failure. The `V` type from `purescript-validation` is similar to `Either`, but it allows you to accumulate errors into a semigroup or semiring instead of stopping when the first failure occurs. You can define decoders which work in `V` and then convert them back to `Either` at the end. + +For example, let's say we have a `User` type which occasionally gets bad input, and we want to see _all_ errors in the input rather than one at a time. This is how we might write a decoding function for the type: + +```purs +newtype User = User + { name :: String + , age :: Maybe Int + , location :: String + } + +derive instance newtypeUser :: Newtype User _ +derive newtype instance showUser :: Show User + +decodeUser :: Json -> Either JsonDecodeError User +decodeUser json = do + obj <- decodeJson json + name <- obj .: "name" + age <- obj .:? "age" + location <- obj .: "location" + pure $ User { name, age, location } +``` + +Running this in the REPL with bad input, we only see the first error: + +```text +> decodeUser =<< parseJson "{}" +Left (AtKey "name" MissingValue) +``` + +However, by collecting results into `V` instead of into `Either` we will accumulate all errors. We can even make it a little nicer by writing a new operator, `.:|`, which works in `V`: + +```purs +-- a replacement for `decodeJson` +decodeJsonV :: forall a. DecodeJson a => Json -> V (Array JsonDecodeError) a +decodeJsonV = either (invalid <<< pure) pure <<< decodeJson + +-- a replacement for `getField` +getFieldV :: forall a. DecodeJson a => Object Json -> String -> V (Array JsonDecodeError) a +getFieldV object key = either (invalid <<< pure) pure (object .: key) + +-- a replacement for .: +infix 7 getFieldV as .:| +``` + +With this new operator and applicative-do we can recreate our original decoder, except with accumulating errors this time: + +```purs +decodeUser :: Json -> Either (Array JsonDecodeError) User +decodeUser json = do + user <- toEither $ andThen (decodeJsonV json) \obj -> ado + name <- obj .:| "name" + age <- obj .:| "age" + location <- obj .:| "location" + in { name, age, location } + pure $ User user +``` + +> Note: If you are doing this in the repl, you can't define an infix operator. Use `getFieldV` in place of `.:|`. + +This decoder will now print all errors: + +```text +> decodeUser =<< lmap pure (parseJson "{}") +Left + [ AtKey "name" MissingValue + , AtKey "age" MissingValue + , AtKey "location" MissingValue + ] +``` From 3ba8b6c19afffa429ce4e4493ef2efc4e2626ce6 Mon Sep 17 00:00:00 2001 From: "Thomas R. Honeyman" Date: Thu, 13 Aug 2020 20:40:30 -0700 Subject: [PATCH 9/9] update README --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a7c3144..d11c098 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ You may also be interested in these other libraries from the Argonaut ecosystem: - [purescript-argonaut-generic](https://github.com/purescript-contrib/purescript-argonaut-generic) supports generic encoding and decoding for any type with a `Generic` instance - [purescript-codec-argonaut](https://github.com/garyb/purescript-codec-argonaut) supports an alternative approach for codecs, which are based on profunctors instead of type classes +The quick start will get you up and running with the basics of `argonaut-codecs`. For a deeper dive, please see [the full documentation for this library](./docs), which includes an in-depth tutorial. + ## Installation Install with [Spago](https://github.com/purescript/spago): @@ -69,11 +71,11 @@ Left "An error occurred while decoding a JSON value:\n At object key 'age':\n ## Documentation -Contrib library documentation is stored in a few places: +You can find `argonaut-codecs` documentation in a few places: 1. Module documentation is [published on Pursuit](https://pursuit.purescript.org/packages/purescript-argonaut-codecs). 2. The tutorial and other written documentation is kept in [the docs directory](./docs). -3. Usage examples can be found in [the test suite](./test) +3. Additional usage examples can be found in [the PureScript Cookbook](https://github.com/JordanMartinez/purescript-cookbook) If you get stuck, there are several ways to get help: