Skip to content

Commit

Permalink
jsonschema: Switch to map based encoding
Browse files Browse the repository at this point in the history
Provides a public interface for #35, and switches the internal encoding
logic to rely on maps, as propsed in #51. This makes the implementation
simpler and more straight forward.

Compared to the poposed solution 1 in #35 that would rely on passing an
io.Writer to an encoder method, this solution will more easily handle a
corner-case where a custom validator writes zero bytes to the io.Writer.

The reduction in complexity does not come for free though. This looks to
be 4x slower for very tiny schemas, and aproximately 2x slower for small
schemas. As jsonschema is used as API documentation, and can be easily
cached, this performance loss should still be quite acceptable.
  • Loading branch information
smyrman committed Jan 2, 2017
1 parent 358e806 commit 6598b35
Show file tree
Hide file tree
Showing 12 changed files with 245 additions and 278 deletions.
19 changes: 18 additions & 1 deletion README.md
Expand Up @@ -1463,7 +1463,24 @@ fmt.Println(b.String()) // Valid JSON Document describing the schema
- [ ] schema.Dict
- [ ] schema.AllOf
- [ ] schema.AnyOf
- [ ] Custom validators
- [/] Custom FieldValidators

### Custom FieldValidators

For custom validators to support encoding to JSON Schema, they must implement the `jsonschema.Builder` interface:

```go
// The Builder interface should be implemented by custom schema.FieldValidator implementations to allow JSON Schema
// serialization.
type Builder interface {
// JSONSchema should return a map containing JSON Schema Draft 4 properties that can be set based on
// FieldValidator data. Application specific properties can be added as well, but should not conflict with any
// legal JSON Schema keys.
JSONSchema() (map[string]interface{}, error)
}
```

TODO: Make it easier to extend built-in FieldValidators by exposing a way to fetch their Builder implementations and/or the map constructed by their builders.

## Licenses

Expand Down
85 changes: 55 additions & 30 deletions schema/encoding/jsonschema/all_test.go
Expand Up @@ -88,24 +88,23 @@ func (v dummyValidator) Validate(value interface{}) (interface{}, error) {
return value, nil
}

func TestErrNotImplemented(t *testing.T) {
s := schema.Schema{
Fields: schema.Fields{
"i": {
Validator: &dummyValidator{},
},
},
}
enc := jsonschema.NewEncoder(new(bytes.Buffer))
assert.Equal(t, jsonschema.ErrNotImplemented, enc.Encode(&s))
type dummyBuilder struct {
dummyValidator
}

func (f dummyBuilder) JSONSchema() (map[string]interface{}, error) {
return map[string]interface{}{
"type": "string",
"enum": []string{"this", "is", "a", "test"},
}, nil
}

// encoderTestCase is used to test the Encoder.Encode() function.
type encoderTestCase struct {
name string
schema schema.Schema
expect string
customValidate encoderValidator
name string
schema schema.Schema
expect, expectError string
customValidate encoderValidator
}

func (tc *encoderTestCase) Run(t *testing.T) {
Expand All @@ -114,13 +113,18 @@ func (tc *encoderTestCase) Run(t *testing.T) {

b := new(bytes.Buffer)
enc := jsonschema.NewEncoder(b)
assert.NoError(t, enc.Encode(&tc.schema))

if tc.customValidate == nil {
assert.JSONEq(t, tc.expect, b.String())
if tc.expectError == "" {
assert.NoError(t, enc.Encode(&tc.schema))
if tc.customValidate == nil {
assert.JSONEq(t, tc.expect, b.String())
} else if tc.expect != "" {
tc.customValidate(t, b.Bytes())
}
} else {
tc.customValidate(t, b.Bytes())
assert.EqualError(t, enc.Encode(&tc.schema), tc.expectError)
}

})
}

Expand All @@ -144,6 +148,37 @@ func fieldValidator(fieldName, expected string) encoderValidator {

func TestEncoder(t *testing.T) {
testCases := []encoderTestCase{
{
name: "Validator=&dummyValidator{}",
schema: schema.Schema{
Fields: schema.Fields{
"i": {
Validator: &dummyValidator{},
},
},
},
expectError: "not implemented",
},
{
name: "Validator=&dummyBuilder{}",
schema: schema.Schema{
Fields: schema.Fields{
"i": {
Validator: &dummyBuilder{},
},
},
},
expect: `{
"type": "object",
"additionalProperties": false,
"properties": {
"i": {
"type": "string",
"enum": ["this", "is", "a", "test"]
}
}
}`,
},
// readOnly is a custom extension to JSON Schema, also defined by the Swagger 2.0 Schema Object
// specification. See http://swagger.io/specification/#schemaObject.
{
Expand Down Expand Up @@ -385,8 +420,7 @@ func TestEncoder(t *testing.T) {
assert.Contains(t, v["required"], "age", `Unexpected "required" value`)
},
},
// Documenting the current behavior, but unsure what's the best approach. schema.Object with no schema
// appears to be invalid and will cause an error if used.

{
name: "Validator=Array,ValuesValidator=Object{Schema:nil}",
schema: schema.Schema{
Expand All @@ -398,16 +432,7 @@ func TestEncoder(t *testing.T) {
},
},
},
expect: `{
"type": "object",
"additionalProperties": false,
"properties": {
"students": {
"type": "array",
"items": {}
}
}
}`,
expectError: "no schema defined for object",
},
{
name: "Validator=Array,ValuesValidator=Object{Schema:Student}",
Expand Down
33 changes: 17 additions & 16 deletions schema/encoding/jsonschema/array.go
@@ -1,28 +1,29 @@
package jsonschema

import (
"io"
"strconv"
import "github.com/rs/rest-layer/schema"

"github.com/rs/rest-layer/schema"
)
type arrayBuilder schema.Array

func encodeArray(w io.Writer, v *schema.Array) error {
ew := errWriter{w: w}

ew.writeString(`"type": "array"`)
func (v arrayBuilder) JSONSchema() (map[string]interface{}, error) {
m := map[string]interface{}{
"type": "array",
}
if v.MinLen > 0 {
ew.writeFormat(`, "minItems": %s`, strconv.FormatInt(int64(v.MinLen), 10))
m["minItems"] = v.MinLen
}
if v.MaxLen > 0 {
ew.writeFormat(`, "maxItems": %s`, strconv.FormatInt(int64(v.MaxLen), 10))
m["maxItems"] = v.MaxLen
}
if v.ValuesValidator != nil {
ew.writeString(`, "items": {`)
if ew.err == nil {
ew.err = encodeValidator(w, v.ValuesValidator)
builder, err := validatorBuilder(v.ValuesValidator)
if err != nil {
return nil, err
}
items, err := builder.JSONSchema()
if err != nil {
return nil, err
}
ew.writeString("}")
m["items"] = items
}
return ew.err
return m, nil
}
12 changes: 4 additions & 8 deletions schema/encoding/jsonschema/bool.go
@@ -1,13 +1,9 @@
package jsonschema

import (
"io"
import "github.com/rs/rest-layer/schema"

"github.com/rs/rest-layer/schema"
)
type boolBuilder schema.Bool

func encodeBool(w io.Writer, v *schema.Bool) error {
ew := errWriter{w: w}
ew.writeString(`"type": "boolean"`)
return ew.err
func (v boolBuilder) JSONSchema() (map[string]interface{}, error) {
return map[string]interface{}{"type": "boolean"}, nil
}
58 changes: 19 additions & 39 deletions schema/encoding/jsonschema/encoder.go
@@ -1,7 +1,7 @@
package jsonschema

import (
"fmt"
"encoding/json"
"io"

"github.com/rs/rest-layer/schema"
Expand All @@ -11,57 +11,37 @@ import (
// FieldValidator types in the schema package is supported at the moment. Custom validators are also not yet handled.
// Attempting to encode a schema containing such fields will result in a ErrNotImplemented error.
type Encoder struct {
io.Writer
w io.Writer
}

// NewEncoder returns a new JSONSchema Encoder that writes to w.
func NewEncoder(w io.Writer) *Encoder {
return &Encoder{w}
return &Encoder{w: w}
}

// Encode writes the JSON Schema representation of s to the stream, followed by a newline character.
func (e *Encoder) Encode(s *schema.Schema) error {
ew := errWriter{w: e.Writer}
ew.writeString("{")
if ew.err == nil {
ew.err = encodeSchema(e.Writer, s)
m := make(map[string]interface{})
if err := addSchemaProperties(m, s); err != nil {
return err
}
ew.writeString("}\n")
return ew.err
}

// Wrap IO writer so we can consolidate error handling in a single place. Also track properties written so we know when
// to emit a separator.
type errWriter struct {
w io.Writer // writer instance
err error // track errors
}
enc := json.NewEncoder(e.w)
return enc.Encode(m)

// Write ensures compatibility with the io.Writer interface.
func (ew errWriter) Write(p []byte) (int, error) {
if ew.err != nil {
return 0, ew.err
}
return ew.w.Write(p)
}

func (ew errWriter) writeFormat(format string, a ...interface{}) {
if ew.err != nil {
return
}
_, ew.err = fmt.Fprintf(ew.w, format, a...)
// The Builder interface should be implemented by custom schema.FieldValidator implementations to allow JSON Schema
// serialization.
type Builder interface {
// JSONSchema should return a map containing JSON Schema Draft 4 properties that can be set based on
// FieldValidator data. Application specific properties can be added as well, but should not conflict with any
// legal JSON Schema keys.
JSONSchema() (map[string]interface{}, error)
}

func (ew errWriter) writeString(s string) {
if ew.err != nil {
return
}
_, ew.err = ew.w.Write([]byte(s))
}
// builderFunc is an adapter that allows pure functions to implement the Builder interface.
type builderFunc func() (map[string]interface{}, error)

func (ew errWriter) writeBytes(b []byte) {
if ew.err != nil {
return
}
_, ew.err = ew.w.Write(b)
func (f builderFunc) JSONSchema() (map[string]interface{}, error) {
return f()
}
29 changes: 17 additions & 12 deletions schema/encoding/jsonschema/example_test.go
Expand Up @@ -14,18 +14,20 @@ func ExampleEncoder() {
s := schema.Schema{
Fields: schema.Fields{
"foo": schema.Field{
Required: true,
// NOTE: Min is currently encoded as '0E+00', not '0'.
Required: true,
Validator: &schema.Float{Boundaries: &schema.Boundaries{Min: 0, Max: math.Inf(1)}},
},
"bar": schema.Field{
Validator: &schema.Integer{},
},
"baz": schema.Field{
ReadOnly: true,
Validator: &schema.String{MaxLen: 42},
Description: "baz can not be set by the user",
ReadOnly: true,
Validator: &schema.String{MaxLen: 42},
},
"foobar": schema.Field{
Description: "foobar can hold any valid JSON value",
},
"foobar": schema.Field{},
},
}
b := new(bytes.Buffer)
Expand All @@ -35,25 +37,28 @@ func ExampleEncoder() {
json.Indent(b2, b.Bytes(), "", "| ")
fmt.Println(b2)
// Output: {
// | "type": "object",
// | "additionalProperties": false,
// | "properties": {
// | | "bar": {
// | | | "type": "integer"
// | | },
// | | "baz": {
// | | | "description": "baz can not be set by the user",
// | | | "maxLength": 42,
// | | | "readOnly": true,
// | | | "type": "string",
// | | | "maxLength": 42
// | | | "type": "string"
// | | },
// | | "foo": {
// | | | "type": "number",
// | | | "minimum": 0E+00
// | | | "minimum": 0,
// | | | "type": "number"
// | | },
// | | "foobar": {}
// | | "foobar": {
// | | | "description": "foobar can hold any valid JSON value"
// | | }
// | },
// | "required": [
// | | "foo"
// | ]
// | ],
// | "type": "object"
// }
}

0 comments on commit 6598b35

Please sign in to comment.