A repository for OpenAPI / AsyncAPI specifications. This ideally eventually can be used to automatically generate code and docs to simplify supporting rippled changes over time.
This will eventually contain specifications for rippled's JSON RPC API and Websocket API.
These apis will aim to eventually include all requests for both rippled
and clio
, supporting both v1 and v2 rippled api interfaces.
The JSON RPC API will be written out using OpenAPI as it has a more direct request / response format.
The Websocket API will be written using [AsyncAPI] which better matches the ways in which the Websocket interface can asynchronously call back with things like stream
.
If you're new to OpenAPI, you should read through this tutorial and use these reference docs to look up any terms you see that are unfamiliar
If you're new to AsyncAPI, you should read through this 2.6.0 tutorial and use these 2.6.0 reference docs to look up any terms you see that are unfamiliar
We currently use Redocly's tooling to test that our api is following the specification. We also use it to verify that we can generate, submit, and receive responses that match our api description with Redocly's "Try It" feature that let's you send requests directly from the auto-generated docs.
-
Install
redocly cli
- https://redocly.com/docs/cli/installation/ -
Run
redocly lint json_api.yaml
(Docs on their lint command: https://redocly.com/docs/cli/commands/lint/)- Resolve any errors that appear by looking up error codes here: https://redocly.com/docs/cli/rules/recommended/
-
Log in if you have Redocly credentials to use the premium version (the community version also works, but does not have the "Try It" feature)
- To Log in run
redocly login
(then follow the instructions)
- To Log in run
-
Run
redocly preview-docs json_api.yaml
- https://redocly.com/docs/cli/commands/preview-docs/ -
In order to use the "Try it!" feature to see if the specification can be used to send a valid request and correctly validate a real response, you'll need to get around CORS errors. One way to do that is to run this script to create an unprotected Chrome, then view your generated docs from there:
open -n -a /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --args --user-data-dir="/tmp/chrome_dev_test" --disable-web-security
For these instructions:
- There's a decent amount of boilerplate code, so each step will include a template you should copy along with a list of which fields need updating.
...
in the template indicates you should edit something (Ex. Replacing...Request
withAccountChannelsRequest
)- If this is for a request documented on xrpl.org, please copy and paste the documentation from there.
- Read through
shared/base.yaml
after doing a draft of your spec to see if there are any re-usable components that make sense for your requests/responses
Run the following command to generate boilerplate code for a new request where you can edit based on the steps in the latter sections:
npm run template <request_name>
For the above command, <request_name>
should be the name of the new request in snake_case convention (e.g. account_channels). This would create 3 different files:
shared/requests/<request_name.yaml>
(instructions to edit here).open_api/requests/<request_name.yaml>
(instructions to edit here).async_api/requests/<request_name.yaml>
(instructions to edit here).
-
If you've already created a spec in the shared folder for this request, skip to the OpenAPI specific steps!
-
Some of the steps mentioned here could be automatically generated by this instruction. If you've already done so, only fill in the details that are missing.
-
Create a new file for the rippled request / response information in
shared/requests/<request_name.yaml>
- For example:
shared/requests/account_channels.yaml
- For example:
-
In that shared file, add the
Request
type....
indicates you should edit something (Ex. Replacing...Request
withAccountChannelsRequest
)- If this is for a request documented on xrpl.org, please copy and paste the documentation from there.
- Read through
shared/base.yaml
to see if there are any re-usable components that make sense for your request
- Fields to update:
...Request
with the name of the request (ex.AccountChannelsRequest
)summary:
with a description of this request- If there are any common fields in
shared/base.yaml
that can be re-used, do so withallOf
- otherwise delete that TODO comment. properties
with the parameters for this request (in alphabetical order)required
with a list of any required parameters (in alphabetical order)
...Request: summary: > ... type: object # TODO: Add any common fields from `shared/base.yaml` that are applicable using `allOf`. Otherwise delete these comments! For example: # allOf: # - $ref: '../base.yaml#/components/schemas/LookupByLedgerRequest' # - ... properties: # Example property # account: # type: string # description: The unique identifier of an account, typically the account's address. ... required: - ...
-
Create the
...SuccessResponse
schema for whenrippled
responds withsuccess
.- Fields to update:
...SuccessResponse
with the name of the request (ex.AccountChannelsSuccessResponse
)- If there are any common fields in
shared/base.yaml
that can be re-used, do so withallOf
- otherwise delete that TODO comment. properties
with the parameters for this request (in alphabetical order)required
with a list of any required parameters (in alphabetical order)
...SuccessResponse: # TODO: Add any common fields from `shared/base.yaml` that are applicable using `allOf`. Otherwise delete these comments! For example: # allOf: # - $ref: '../base.yaml#/components/schemas/LookupByLedgerRequest' # - ... type: object properties: # Example property # account: # type: string # description: The unique identifier of an account, typically the account's address. ... required: - ...
- Fields to update:
-
Create the
...ErrorResponse
schema for whenrippled
responds with an error code.- Fields to update:
...ErrorResponse
with the name of the request (ex.AccountChannelsErrorResponse
)enum:
with a yaml list of the specific error codes that are associated with this specific response (ex. invalidParams). Do not include errors which are already in theUniversalErrorResponseCodes
listed inshared/base.yaml
.request
should have a reference to the shared...Request
. (NOT the...Request
object that is in this file!)
...ErrorResponse: type: object properties: error: type: string oneOf: - $ref: '../base.yaml#/components/schemas/UniversalErrorResponseCodes' # Add the error codes specific to this response here (ex. invalidParams) - enum: - ... # Include a bullet descrip for every description: > ... status: type: string enum: - error request: # This should link to the ...Request type you defined above $ref: ... required: - status - error - request
- Fields to update:
-
If you want to add a request to the OpenAPI spec, follow these steps here (otherwise skip this step)
-
If you want to add a request to the AsyncAPI spec, follow these steps here (otherwise skip this step)
This section assumes you've already completed the shared work steps for this request in How to add a new request
At a high level, we're going to wrap the core types we defined in shared/
in boilerplate so it matches the JSON RPC formatting rippled
expects / produces, then we're going to reference our wrapped types in the core json_api.yaml
file.
Some of the steps mentioned here could be automatically generated by this instruction. If you've already done so, only fill in the details that are missing.
-
Create a new file in
open_api/requests
named..._open_api.yaml
for your new request. Ex.account_channels_open_api.yaml
- The reason to include
_open_api
in the name is to make it easier to tell which file we're referencing throughout the codebase, and to make filename searches less confusing when debugging. Including it at the end also makes it easier to at a glance find the right file in the explorer. - Example file: open_api/requests/account_channels_open_api.yaml
- The reason to include
-
Add a
...Request
schema with the following boilerplate and an example which references the...Request
we defined inshared/requests
. See template below:- Fields to update:
...Request
with the name of the request (Ex.AccountChannelRequest
)description
with a long explanation of what the request is (use xrpl.org text if available)method
with the request name (ex.account_channels
)items
's$ref:
to reference the...Request
we defined inshared/requests
example
with a valid request of this type that includes most if as many optional fields as possible while still being valid
...Request: type: object description: > ... properties: method: type: string enum: # This is the most supported way to define a specific string as the only valid input. `const` is a new keyword which is supported in OpenAPI, but not in all corresponding codegen tools. https://github.com/OAI/OpenAPI-Specification/issues/1313 - ... params: type: array items: $ref: # Reference the shared `...Request` schema required: - method example: # Provide a valid example here, such as: # method: 'account_channels' # params: # - account: 'rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn' # destination_account: 'ra5nK24KXen9AHvsdFTKHSANinZseWnPcX' # ledger_index": 'validated'
- Fields to update:
-
Add the
...Response
object which is mostly boilerplate, but still has a couple fields to update:- Fields to update:
...Response
with the name of the request (Ex.AccountChannelResponse
)- In the
mapping
: 3. Mapsuccess
to a reference to the...SuccessResponse
in this file (NOT the shared file!) 4. Maperror
to the the...ErrorResponse
in the SHARED file - In the oneOf, add BOTH of the above references in a list.
example
with a valid successful response of this type, ideally the exact response to sending the example in...Request
in this file.
...Response: type: object properties: result: type: object discriminator: propertyName: status mapping: success: # Include a reference to ...SuccessResponse from this file error: # Include a reference to the **shared** ...ErrorResponse oneOf: - $ref: # Include a reference to ...SuccessResponse from this file - $ref: # Include a reference to the **shared** ...ErrorResponse required: - result example: # result: # account: rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn # channels: # - account: rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn # amount: '1000' # balance: '0' # channel_id: C7F634794B79DB40E87179A9D1BF05D05797AE7E92DF8E93FD6656E8C4BE3AE7 # destination_account: ra5nK24KXen9AHvsdFTKHSANinZseWnPcX # public_key: aBR7mdD75Ycs8DRhMgQ4EMUEmBArF8SEh1hfjrT2V9DQTLNbJVqw # public_key_hex: 03CFD18E689434F032A4E84C63E2A3A6472D684EAF4FD52CA67742F3E24BAE81B2 # settle_delay: 60 # ledger_hash: 27F530E5C93ED5C13994812787C1ED073C822BAEC7597964608F2C049C2ACD2D # ledger_index: 71766343 # status: success # validated: true ...
- Fields to update:
-
Create the
...SuccessResponse
schema to combine theBaseSuccessResponse
and the shared success schema. (This is done to match the JSON RPC response format while re-using our shared schema)- Fields to update:
...SuccessResponse
- Update the 2nd reference in
allOf
to the SHARED...SuccessResponse
(NOT the one in this file!)
...SuccessResponse: type: object allOf: - $ref: '../../shared/base.yaml#/components/schemas/BaseSuccessResponse' - $ref: # Reference the `...SuccessResponse` in the **SHARED** folder
- Fields to update:
-
Lastly, we need to update the
open_api/json_api.yaml
file:- Fields to update:
-
Update
#paths///post/requestBody/content/application/json/schema/discriminator/mapping
with a mapping between the request name and your...Request
object from the..._open_api.yaml
file (NOT the shared file!)- Ex.
account_channels: 'requests/account_channels_open_api.yaml#/components/schemas/AccountChannelsRequest'
- Ex.
-
Update the
oneOf
just below themapping
you modified with another reference to the...Request
object from the..._open_api.yaml
file- Ex.
- $ref: 'requests/account_channels_open_api.yaml#/components/schemas/AccountChannelsRequest'
- Ex.
-
Update
#paths///post/responses/200/content/application/json/schema/oneOf
with a reference to the...Response
(NOT...Request
) object from the..._open_api.yaml
file.- Ex.
- $ref: 'requests/account_channels_open_api.yaml#/components/schemas/AccountChannelsResponse'
- Ex.
Note: If you want to also add this request to the AsyncAPI, continue by following these steps here
This section assumes you've already completed the shared work steps for this request in How to add a new request
At a high level, we're going to wrap the core types we defined in shared/
in boilerplate so it matches the Websocket formatting rippled
expects / produces, then we're going to reference our wrapped types in the core websocket_api.yaml
file.
Some of the steps mentioned here could be automatically generated by this instruction. If you've already done so, only fill in the details that are missing.
-
Create a new file in
async_api/requests
named..._async_api.yaml
for your new request. Ex.account_channels_async_api.yaml
- The reason to include
_async_api
in the name is to make it easier to tell which file we're referencing throughout the codebase, and to make filename searches less confusing when debugging. Including it at the end also makes it easier to at a glance find the right file in the explorer. - Example file: async_api/requests/account_channels_async_api.yaml
- The reason to include
-
Add a
...Request
schema with the following boilerplate and an example which references the...Request
we defined inshared/requests
. See template below:- Fields to update:
...Request
with the name of the request (Ex.AccountChannelRequest
)description
with a long explanation of what the request is (use xrpl.org text if available)allOf
's-$ref:
to reference the...Request
we defined inshared/requests
command
with the request name (ex.account_channels
)example
with a valid request of this type that includes most if as many optional fields as possible while still being valid
...Request: description: > ... type: object allOf: - $ref: ... # Reference the Request in `shared/requests` here properties: command: type: string enum: # This is the most supported way to define a specific string as the only valid input. `const` is a new keyword which is supported in OpenAPI, but not in all corresponding codegen tools. https://github.com/OAI/OpenAPI-Specification/issues/1313 - ... id: # Not specifying a type is how we express "any" value is acceptable description: 'A unique identifier for the request.' required: - command - id example: # Show a valid request that follows the schema here, for example for account_channels: # id: 1 # command: account_channels # account: rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn # destination_account: ra5nK24KXen9AHvsdFTKHSANinZseWnPcX # ledger_index: validated ...
- Fields to update:
-
Add the
...Response
object which is mostly boilerplate, but still has a couple fields to update:- Fields to update:
...Response
with the name of the request (Ex.AccountChannelResponse
)- In the oneOf, the first entry should reference the
...SuccessResponse
in this file (NOT the shared file!) - In the oneOf, the second entry should reference the
...ErrorResponse
in this file (NOT the shared file!) example
with a valid successful response of this type, ideally the exact response to sending the example in...Request
in this file.
...Response: discriminator: status oneOf: - $ref: # Reference the ...SuccessResponse in this file - $ref: # Reference the ...ErrorResponse in this file type: object properties: id: # Not specifying a type is how we express "any" value is acceptable description: 'A unique identifier for the request.' type: type: string description: The value response indicates a direct response to an API request. Asynchronous notifications use a different value such as `ledgerClosed` or `transaction`. enum: # This is the most supported way to define a specific string as the only valid input. `const` is a new keyword which is supported in OpenAPI, but not in all corresponding codegen tools. https://github.com/OAI/OpenAPI-Specification/issues/1313 - response required: - id - type example: # Example formatting for `account_channels` response # id: 1 # result: # account: rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn # channels: # - account: rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn # amount: '1000' # balance: '0' # channel_id: C7F634794B79DB40E87179A9D1BF05D05797AE7E92DF8E93FD6656E8C4BE3AE7 # destination_account: ra5nK24KXen9AHvsdFTKHSANinZseWnPcX # public_key: aBR7mdD75Ycs8DRhMgQ4EMUEmBArF8SEh1hfjrT2V9DQTLNbJVqw # public_key_hex: 03CFD18E689434F032A4E84C63E2A3A6472D684EAF4FD52CA67742F3E24BAE81B2 # settle_delay: 60 # ledger_hash: 27F530E5C93ED5C13994812787C1ED073C822BAEC7597964608F2C049C2ACD2D # ledger_index: 71766343 # validated: true # status: success # type: response ...
- Fields to update:
-
Create the
...SuccessResponse
schema to combine theBaseSuccessResponse
and the shared success schema. (This is done to match the JSON RPC response format while re-using our shared schema)- Fields to update:
...SuccessResponse
- Update the 2nd reference in
allOf
to the SHARED...SuccessResponse
(NOT the one in this file!)
AccountChannelsSuccessResponse: type: object allOf: - $ref: '../../shared/base.yaml#/components/schemas/BaseSuccessResponse' - $ref: # Reference the `...SuccessResponse` in the **SHARED** folder
- Fields to update:
-
Lastly, we're going to update the
websocket_api.yaml
file to reference our newly createdWebsocket
wrapper of ourrippled
request / response types.-
In
subscribe
add a newmessage
object for your new request which references the...Request
we made in the..._async_api.yaml
(NOT the shared version!)- Fields to update:
messageId
with the name of your request +Request
(Ex.AccountChannelsRequest
)$ref
with a reference to the...Request
object in the..._async_api.yaml
file (NOT the shared version!)- Ex.
'./requests/account_channels_async_api.yaml#/components/schemas/AccountChannelsRequest'
- Ex.
message: messageId: '...Request' payload: $ref: ...
- Fields to update:
-
Do the same in the
publish
section except forResponse
instead ofRequest
- Fields to update:
messageId
->...Response
$ref
should point to the...Response
(not...Request
) schema from the..._async_api.yaml
file (NOT the shared version!)
- Fields to update:
-
Note: If you want to also add this request to the OpenAPI spec and haven't already, continue by following these steps here
- There's a decent amount of boilerplate code, so each step will include a template you should copy along with a list of which fields need updating.
...
in the template indicates you should edit something (Ex. Replacing...Transaction
withPaymentTransaction
)- If this is for a transaction documented on xrpl.org, please copy and paste the documentation from there.
- Read through
shared/base.yaml
after doing a draft of your spec to see if there are any re-usable components that make sense for your transaction. - Please note that the transactions are created as an option for the
tx_json
field of thesubmit
method in sign-and-submit mode.
-
If you've already created a spec in the shared folder for this request, skip to the OpenAPI specific steps!
-
Create a new file for the rippled request / response information in
shared/transactions/<transaction_name.yaml>
- For example:
shared/transactions/payment.yaml
- For example:
-
In that shared file, add the
Transaction
type....
indicates you should edit something (Ex. Replacing...Transaction
withPaymentTransaction
)- If this is for a transaction documented on xrpl.org, please copy and paste the documentation from there.
- Read through
shared/base.yaml
to see if there are any re-usable components that make sense for your transaction
-
Fields to update:
-
...Transaction
with the name of the transaction (ex.Payment
) -
summary:
with a description of this transaction -
If there are any common fields in
shared/base.yaml
that can be re-used, do so withallOf
- otherwise delete that TODO comment. -
properties
with the parameters for this transaction (in alphabetical order) -
required
with a list of any required parameters (in alphabetical order)...Transaction: summary: > ... type: object # TODO: Add any common fields from `shared/base.yaml` that are applicable using `allOf`. Otherwise delete these comments! For example: # allOf: # - $ref: '../base.yaml#/components/schemas/BaseTransaction' # - ... properties: # Example property # account: # type: string # description: The unique identifier of an account, typically the account's address. ... required: - ...
-
-
If there are multiple versions of this transaction across multiple rippled API versions, follow the instructions here
-
Add the transaction to the transaction requests (For example,
submit
,tx
, etc.)
- This example below add the
Payment
transaction tosubmit
request:SignAndSubmitModeV1: type: object allOf: - $ref: '#/components/schemas/SignAndSubmitModeBase' properties: tx_json: type: object discriminator: propertyName: TransactionType mapping: Payment: '../transactions/payment.yaml#/components/schemas/PaymentTransactionV1' oneOf: - $ref: '../transactions/payment.yaml#/components/schemas/PaymentTransactionV1'
For these instructions:
- Fill in the request name in
<request_name>
- Fill in the version number in
<version>
- Please note that this instruction assumes that the
rippled
JSON_RPC format does not change between versions and that to make a JSON-RPC request, one sends an HTTP POST request to the root path (/) on the port and IP where the rippled server is listening for JSON-RPC connections. If there is a major change in this behavior in latter versions, please see the OpenAPI documentation and rippled release notes to create a new version.
- Create a new file in
open_api/
folder namedjson_api_v<version>
- Copy and paste the existing
json_api
file in this same folder to reuse the same structure. - Revise the list of requests inside the discriminator to make sure the list aligns with the available methods for that specific version.
discriminator:
propertyName: method
mapping:
account_channels: 'requests/account_channels_open_api.yaml#/components/schemas/AccountChannelsRequest'
account_info: 'requests/account_info_open_api.yaml#/components/schemas/AccountInfoRequest'
...More request types here...
oneOf:
- $ref: 'requests/account_channels_open_api.yaml#/components/schemas/AccountChannelsRequest'
- $ref: 'requests/account_info_open_api.yaml#/components/schemas/AccountInfoRequest'
...More request types here...
- Revise the list of responses inside the
200
success group to make sure the list aligns with the available methods for that specific version.
'200':
description: JSON-RPC response object
content:
application/json:
schema:
oneOf:
- $ref: 'requests/account_channels_open_api.yaml#/components/schemas/AccountChannelsResponse'
- $ref: 'requests/account_info_open_api.yaml#/components/schemas/AccountInfoResponseV1'
...More response types here...
- Make changes to specific requests that has changed since this version of the API following these steps here.
- Create a new file in
async_api/
folder namedwebsocket_api_v<version>
- Copy and paste the existing
websocket_api
files in this same folder to reuse the same structure. - Revise the list of requests inside
subscibe
andpublish
lists to make sure the lists align with the available methods for that specific version.
subscribe:
message:
oneOf:
- messageId: 'AccountChannelsRequest'
payload:
$ref: './requests/account_channels_async_api.yaml#/components/schemas/AccountChannelsRequest'
- messageId: 'AccountInfoRequest'
payload:
$ref: './requests/account_info_async_api.yaml#/components/schemas/AccountInfoRequest'
...More request types here...
publish:
message:
oneOf:
- messageId: 'AccountChannelsResponse'
payload:
$ref: './requests/account_channels_async_api.yaml#/components/schemas/AccountChannelsResponse'
- messageId: 'AccountInfoResponse'
payload:
$ref: './requests/account_info_async_api.yaml#/components/schemas/AccountInfoResponseV1'
...More response types here...
- Make changes to specific requests that has changed since this version of the API following these steps here.
- Please note that the below instructions will only pertain to successful requests. Error responses will be handled almost the same way.
- Add a base type of the request (if not already) that contains common fields among all versions in
shared\requests\<request_name>.yaml
(e.g.shared\requests\account_info.yaml
).
<request_name>ResponseBase:
allOf:
- $ref: '../base.yaml#/components/schemas/BaseSuccessResponse'
- type: object
properties:
...Add all common properties here...
For example,
AccountInfoSuccessResponseBase:
allOf:
- $ref: '../base.yaml#/components/schemas/BaseSuccessResponse'
- type: object
properties:
account_flags:
$ref: '#/components/schemas/AccountFlags'
description: The account's flag statuses.
... More common fields here ...
- Create different versions of the request that inherit from the base request, along with their distinct properties.
<request_name>SuccessResponseV<version>:
allOf:
- $ref: '#/components/schemas/<request_name>SuccessResponseBase'
- type: object
properties:
...Add unique properties here...
For example,
AccountInfoSuccessResponseV2:
allOf:
- $ref: '#/components/schemas/AccountInfoSuccessResponseBase'
- type: object
properties:
signer_lists:
type: array
description: Array of SignerList ledger objects associated with this account for Multi-Signing.
items:
$ref: '#/components/schemas/SignerList'
...More unique properties here...
In situations where there are multiple equivalent ways to write this spec, this outlines the choices we’ve made that we want to keep consistent. If we update these, please update them for ALL entries in ALL specs for consistency’s sake.
required
is specified at the bottom of request / response schemas by listing required fields - NOT specified in every individual field. (This makes it easier to at-a-glance see if the list of required fields are all there / what they are, but makes it slightly harder to read individual fields and know if they’re required or not).- In order to specify the request / response type for JSON RPC, we need to use a generic path (
/
) and adiscriminator
which allows us to derive the “type” of an object from the value in a specific parameter in the request. (In the case of the JSON RPC API, themethod
field tells us the type of request, which corresponds exactly with 1 or 2 response types)- The one case where this isn’t enough information is when a request has a
binary
option - in which case there are 2 possible response structures.
- The one case where this isn’t enough information is when a request has a
- Error responses in the "path" section represent HTTP response / errors.
rippled
orclio
errors are treated as valid responses, and should be documented asoneOf
the possible representations for each individual request response. Although rippled errors share a similar shape, ultimately we want to be very clear on what the specific error codes that are possible from each request.
-
We'll need to fix the CORS error for the Redocly "Try it" feature when we deploy on xrpl.org. That should be a server setting.
-
We'll need to talk to Redocly about getting them to support "Try it" for AsyncAPI specs (should be similar code).
-
It's unclear the best way to set up the primary api file (websocket_api.yaml and json_api.yaml) to pair specific input parameters to a specific response. As it is now, both api descriptions have a list of possible parameters for each
rippled
request, but it's defined as two lists rather than a list of input/output pairs. One idea for how to solve this is maybe we can define multiplepost
requests -
Should we use AsyncAPI 2.6.0 (currently partially supported by Redocly) or use AsyncAPI 3.0.0 (cleaner interface & resolves input / output pairing problem)
-
Currently, the way we test the spec is through including examples which we can validate against rippled using the "Try It" feature in Redocly previews. That's not a robust way to test all possible inputs / outputs of rippled requests. Some enhancements we can consider for this long-term:
- Having examples which reproduce every error case for a specific request
- Verifying that we have unit tests for every possible error code in our test suite
-
Note on the formatting of this README - for some reason prettier formats code blocks with one-space indents instead of 2 (in the yaml file 2 space indents are used). This makes the examples slightly harder to copy and paste, although they should work. Would be nice to fix that.
-
We should automate the creation of boilerplate with a simple script - these steps are very automatable.