Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
254 changes: 194 additions & 60 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,112 +3,246 @@
[![Build Status](https://travis-ci.org/zweidenker/JSONSchema.svg?branch=master)](https://travis-ci.org/zweidenker/JSONSchema)
[![Test Status](https://api.bob-bench.org/v1/badgeByUrl?branch=master&hosting=github&ci=travis-ci&repo=zweidenker%2FJSONSchema&subNumber=1)](https://bob-bench.org/r/gh/zweidenker/JSONSchema)

This is an implementation of [JSON Schema](https://json-schema.org/) for the [pharo](http://pharo.org) language. It is used to define the structure and values of a JSON string and to validate it. The schema itself can be externalized for being consumed by a third party.
This is an implementation of [JSON Schema](https://json-schema.org/) for the [Pharo](http://pharo.org) language. It is used to define the structure and values of a JSON document, to validate a value against that structure, and to read/write values through it. A schema can also be externalized to JSON so it can be consumed by a third party.

**DISCLAIMER**: This is not a full implementation of the json schema spec. Basic types and constraints should work. If you need support for something not implemented you are invited to provide a pull request for it. If you cannot develop it you still can open a ticket in this repository
The framework is a single package, `JSONSchema-Core`, with [NeoJSON](https://github.com/svenvc/NeoJSON) as its only dependency.

It can be loaded by downloading it in pharo via
## Contents

- [Installation](#installation)
- [Quick start](#quick-start)
- [Defining a schema in Pharo](#defining-a-schema-in-pharo)
- [Loading a schema from JSON](#loading-a-schema-from-json)
- [Validating a value](#validating-a-value)
- [Reading and writing values](#reading-and-writing-values)
- [Serializing a schema](#serializing-a-schema)
- [Constraints](#constraints)
- [Supported keywords](#supported-keywords)
- [Supported formats](#supported-formats)
- [Adding a custom format](#adding-a-custom-format)
- [What is not (yet) supported](#what-is-not-yet-supported)

## Installation

Load it into Pharo via Metacello:

```smalltalk
Metacello new
repository: 'github://zweidenker/JSONSchema';
baseline: #JSONSchema;
load.
```
Metacello new
repository: 'github://zweidenker/JSONSchema';
baseline: #JSONSchema;
load

This loads the `default` group (`JSONSchema-Core` + its tests). The baseline also declares a separate `Testsuite` group that adds the generated tests of the official, cross-language [JSON-Schema-Test-Suite](https://github.com/json-schema-org/JSON-Schema-Test-Suite); load it with `load: #('Testsuite')` if you want to run them.

## Quick start

There are two ways to get a schema: build it with the Pharo DSL, or load it from a JSON Schema document. Either way you get a `JSONSchema` object you can validate values with.

```smalltalk
"Load a schema from a JSON Schema document..."
schema := JSONSchema fromString: '{
"type": "object",
"properties": {
"name": { "type": "string" },
"age": { "type": "integer", "minimum": 0 }
},
"required": [ "name" ]
}'.

"...and validate a parsed value against it."
value := NeoJSONReader fromString: '{ "name": "John Doe", "age": 42 }'.
schema validate: value. "returns the schema; raises a JSONSchemaError if invalid"
```
## Defining a schema

These are the expression to create a schema model inside pharo.
## Defining a schema in Pharo

The class side of `JSONSchema` offers a small, readable DSL for the primitive types:

```smalltalk
JSONSchema string.
JSONSchema number.
JSONSchema integer.
JSONSchema boolean.
JSONSchema any. "accepts anything"
JSONSchema stringWithFormat: 'date-time'. "a typed string with a format"
JSONSchema string enum: #('red' 'green' 'blue').
```

An **object** schema is written with the literal array syntax and `asJSONSchema`:

```smalltalk
schema := {
#name -> JSONSchema string.
#name -> JSONSchema string.
#dateAndTime -> (JSONSchema stringWithFormat: 'date-time').
#numberOfPets -> JSONSchema number } asJSONSchema.
```

which describes the following JSON:

```json
{
"name": "John Doe",
"dateAndTime": "1970-01-01T14:00:00+00:00",
"numberOfPets": 3
}
```

defines as schema that can parse the following JSON:
Schemas can be **nested** to any depth — a property value is just another schema:

```smalltalk
schema := {
#name -> JSONSchema string.
#address -> {
#street -> JSONSchema string.
#number -> JSONSchema number } } asJSONSchema.
```
jsonString := '{
"name" : "John Doe",
"dateAndTime" : "1970-01-01T14:00:00",
"numberOfPets" : 3
}'.

**Arrays** are defined with `JSONSchemaArray`:

```smalltalk
JSONSchemaArray new items: JSONSchema integer. "an array of integers"
```

## Reading/Writing a value using a schema
## Loading a schema from JSON

To parse the value from JSON we only need to invoke:
Given a JSON Schema document as a string, instantiate a schema with:

```
value := schema readString: jsonString
```smalltalk
schema := JSONSchema fromString: schemaString.
```

The object in ```value``` will have name as a string, dateAndTime as a DateAndTime object and numberOfPets as a SmallInteger object.
This is the counterpart of [serializing a schema](#serializing-a-schema) and understands the [supported keywords](#supported-keywords) below.

The schema can also be used to write out the value as JSON. This is especially useful if we want to ensure that only valid JSON is written. For this invoke
## Validating a value

`validate:` is the core operation. It takes an **already parsed** value (a `Dictionary`, `Array`, `String`, number, `Boolean` or `nil` — for example the result of `NeoJSONReader fromString:`) and checks it against the schema:

```smalltalk
schema := JSONSchema fromString: '{ "type": "integer", "minimum": 10 }'.

schema validate: 42. "OK — returns the schema"
schema validate: 3. "raises JSONConstraintError: 3 must be greater than or equal to 10"
```
jsonString := schema write: value.

On success `validate:` returns the schema. On failure it **signals an exception**, so wrap it if you want a boolean result:

```smalltalk
isValid := [ schema validate: value. true ]
on: JSONSchemaError
do: [ :error | false ].
```

## Serialize/Materialize a schema
All validation errors are subclasses of `JSONSchemaError`, so you can catch them at the granularity you need:

Addtionally to reading and writing objects a schema can be serialized to string.
| Exception | Raised when |
| --- | --- |
| `JSONTypeError` | the value has the wrong JSON type |
| `JSONConstraintError` | a constraint fails (range, length, pattern, format, `enum`, `const`, composition, …) |
| `JSONSchemaMissingRequiredProperty` | a `required` property is absent |
| `JSONInvalidPropertyError` | a property is not allowed |
| `JSONFormatError` | a value does not match its declared `format` |

```
schemaString := NeoJSONWriter toStringPretty: schema.
```
## Reading and writing values

gives
Beyond plain validation a schema can **materialize** a value from JSON and **write** it back out. Reading validates and converts formatted values (e.g. a `date-time` string becomes a `DateAndTime`):

```
{
"type" : "object",
"properties" : {
"name" : {
"type" : "string"
},
"numberOfPets" : {
"type" : "number"
},
"dateAndTime" : {
"type" : "string",
"format" : "date-time"
}
}
}
```smalltalk
value := schema readString: jsonString.
```

For the object schema above, `value` has `name` as a `String`, `dateAndTime` as a `DateAndTime` and `numberOfPets` as a `SmallInteger`.

If we would get a schema as string we can instantiate by invoking
Writing produces JSON and guarantees that only valid JSON leaves your system:

```
schema := JSONSchema fromString: schemaString.
```smalltalk
jsonString := schema write: value.
```

## Nested schemas
## Serializing a schema

Schemas can be nested in any depth. And it can be specified by using the literal Array syntax.
A schema is itself serializable to a JSON Schema document:

```smalltalk
schema jsonString. "compact"
schema jsonStringPretty. "indented"
```
schema := {
#name -> JSONSchema string.
#address -> {
#street -> JSONSchema string.
#number -> JSONSchema number
} } asJSONSchema

For the `{ name, numberOfPets, dateAndTime }` schema this yields:

```json
{
"type": "object",
"properties": {
"name": { "type": "string" },
"numberOfPets": { "type": "number" },
"dateAndTime": { "type": "string", "format": "date-time" }
}
}
```

## Constraints

JSON Schema has a defined set of constraints that can be specified. E.g. for a number the inerval of the value can be specified by
Constraints refine a type. When building schemas in Pharo they are reachable through accessors on the schema object, e.g. the numeric interval:

```
```smalltalk
numberSchema := JSONSchema number.
numberSchema interval
minimum: 1;
exclusiveMaximum: 100
exclusiveMaximum: 100. "1 <= value < 100"
```

The same constraints are available as keywords when [loading a schema from JSON](#loading-a-schema-from-json) — see the table below.

## Supported keywords

The core covers the Draft-04 keyword set plus several keywords from later drafts. All of the following are honored by `fromString:` and enforced by `validate:`:

| Group | Keywords |
| --- | --- |
| Type / value | `type` (a single name **or** an array of names), `enum`, `const` |
| Numbers | `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf` |
| Strings | `minLength`, `maxLength`, `pattern`, `format` |
| Arrays | `items` (single schema **or** tuple), `additionalItems`, `minItems`, `maxItems`, `uniqueItems`, `contains` |
| Objects | `properties`, `required`, `patternProperties`, `propertyNames`, `dependencies`, `minProperties`, `maxProperties` |
| Composition | `allOf`, `anyOf`, `oneOf`, `not` |
| Conditional | `if` / `then` / `else` |
| References | `$ref` to a local pointer (`#/definitions/…`, `#/$defs/…`) |
| Boolean schemas | a bare `true` (accept anything) or `false` (reject everything) as a whole schema |

## Supported formats

`format` is validated for these string formats:

`date`, `date-time`, `time`, `email`, `idn-email`, `hostname`, `idn-hostname`, `ipv4`, `ipv6`, `uri`, `uri-reference`, `iri`, `iri-reference`, `uri-template`, `json-pointer`, `relative-json-pointer`, `regex`.

Unknown or unsupported format names are **tolerated** (ignored), matching the JSON Schema default that `format` is an annotation unless a validator exists for it.

## Adding a custom format

Create a subclass of `JSONFormat` (or of `JSONBasicStringFormat` for string formats) and implement three **class-side** methods: `formatName`, `basicConvertString:` and a `validateString:` that signals a `JSONSchemaError` for a malformed value. Formats are looked up by name, so the new one is picked up automatically wherever it is referenced:

```smalltalk
JSONFormatSlug class >> formatName [
^ 'slug' ]

JSONFormatSlug class >> basicConvertString: aString [
^ aString ]

JSONFormatSlug class >> validateString: aString [
(aString allSatisfy: [ :c | c isLowercase or: [ c isDigit or: [ c = $- ] ] ])
ifFalse: [ JSONConstraintError signal: aString printString, ' is not a valid slug' ] ]
```
constraining the number value to be greater or equal to 1 and smaller than 100.

`{ "type": "string", "format": "slug" }` will now reject values that are not slugs.

## What is not (yet) supported

This is intentionally not a full implementation of the specification. Known gaps:

- **Remote / external `$ref`** — only local pointers within the same document are resolved.
- **The 2020-12 vocabulary machinery** — `$dynamicRef` / `$dynamicAnchor`, `unevaluatedProperties` / `unevaluatedItems`, `$vocabulary`.
- **`contentEncoding` / `contentMediaType`.**
- Some **ECMA-262 regex edge semantics** differ from Pharo's regex engine.

If you need something that is missing you are welcome to open a pull request, or a ticket in this repository.
Loading