Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for default values #140

Open
wants to merge 2 commits into
base: master
Choose a base branch
from

Conversation

injeniero
Copy link
Contributor

@injeniero injeniero commented Feb 26, 2024

This PR implements support for the "default" property. The implementation details are as follow:

  • Object: the provided value is used as JSON and will be unmarshaled to an Object instance.
  • ObjectProperties: Default values are given in the Factory function of the Containing Object.
  • Simple Named types: Default value is stored in a variable named Default{TypeName}.
  • Enum: Default value is stored in a variable named Default{TypeName}.
  • OneOf: Default value is not supported in the main Object, but each referenced type can have their default.

Examples

For a full example please go to: Default

Pet:
  type: object
  properties:
    type:
      $ref: '#/components/schemas/PetType'
    name:
      type: string
    age:
      type: integer
      default: 1
  default:
    type: dog
    name: Fido
    age: 2
// NewPet instantiates a new Pet with default values overriding them as follows:
// 1. Default values specified in the Pet schema
// 2. Default values specified per Pet property
func NewPet() *Pet {
	m := &Pet{
		Age:  1,
		Type: DefaultPetType,
	}

	type alias Pet
	err := json.Unmarshal([]byte(`{"age":2,"name":"Fido","type":"dog"}`), (*alias)(m))
	if err != nil {
		panic(fmt.Errorf("could not unmarshal default values for Pet: %w", err))
	}

	return m
}

Important

A side effect of this PR is moving inline structs to named types. This was required to properly support validation and default handling without needing to have a special case for them.

Ticket

#136

How Has This Been Verified?

Unit tests

Checklist:

  • The change works as expected.
  • New code can be debugged via logs.
  • I have added tests to cover my changes.
  • I have locally run the tests and all new and existing tests passed.
  • Requires updates to the documentation.
  • I have made the required changes to the documents.

@injeniero injeniero requested a review from a team as a code owner February 26, 2024 21:50
@injeniero injeniero requested review from grifonas and removed request for a team February 26, 2024 21:50
@injeniero injeniero changed the title feature: add support for default values feat: add support for default values Feb 26, 2024
@injeniero
Copy link
Contributor Author

@LucasRoesler pinging you, just make sure you saw this. Thanks!!

@LucasRoesler
Copy link
Member

@injeniero I am so sorry, i saw this and then it slipped off my list. There were a few offline events happening and it distracted me. I am going to read through this today

Copy link
Member

@LucasRoesler LucasRoesler left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a couple questions, mostly about the json marshal/unmarshal.

Comment on lines +82 to 88
{{ if .HasDefault }}
type alias {{.Name}}
err := json.Unmarshal([]byte(`{{.DefaultValue}}`), (*alias)({{if .IsMap}}&{{end}}m))
if err != nil {
panic(fmt.Errorf("could not unmarshal default values for {{.Name}}: %w", err))
}
{{- end}}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because we already know the defaults and the fields, why can't we template Range over the default config and set the defaults directlly instead of needing another allocation for json Unmarshal?

// UnmarshalJSON all named properties into {{.Name}} and the rest into the AdditionalProperties map
func (m *{{.Name}}) UnmarshalJSON(data []byte) error {
// Set default values
*m = *New{{.Name}}()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure this is correct or expected behavior in UnmarshalJSON. It certainly differs from the stdlib.

If the object m is already initialized, you should be able to unmarshal json into it while preserving pre-existing values. For example

package main

import (
	"encoding/json"
	"fmt"
)

type Data struct {
	A int
	B string
}

func main() {
	sample := Data{10, "sample data"}
	fmt.Printf("init: %+v\n", sample)
	// init: {A:10 B:sample data}

	input_data := []byte(`{"A": 5}`)

	_ = json.Unmarshal(input_data, &sample)

	fmt.Printf("load: %+v\n", sample)
	// load: {A:5 B:sample data}
}

https://play.golang.com/p/mxpH_x4rGvl

The behavior here makes Patch requests that support several optional fields more difficult to use. Imagine the workflow for an Update endpoint:

  1. get existing value from the database (or return error)
  2. validate the input payload
  3. marshal the input payload on top of the values from the database.

The PR implementation would break this flow by throwing away any pre-existing values. Not saying this is the perfect workflow, but it is one i have seen in the wild.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am trying to think of how it should behave and I am not 100% sure. Perhaps one approach is to use mapstructure a bit more.

  1. unmarshal to a inputData := map[string]interface{}
  2. loop over the defaults (in template or using a Go loop over a dict), for each default check if the key exists _, ok := inputData[name], if it does not, push the default value into the payload
  3. now map this combined input into the object m.
    This has the benefit of not emptying fields that don't have defaults but it will still reset fields that do have a default.

The next alternative would be similar but modifying 2

  1. unmarshal to a inputData := map[string]interface{}
  2. loop over the defaults (in template or using a Go loop over a dict), for each default check if the key exists _, ok := inputData[name] also check if m.Name is the empty (Go) value, if the key is not in the inputData and the current value is not "empty", then we set the inputData[Name] value to the default
  3. now map this combined input into the object m.

I think a case could be made for either approach, but approach 1 is probably more predictable/expected: "treat missing fields as if the default was sent". But this only makes sense if the field is non-optional/non-nillable.

// MarshalJSON {{.Name}} by combining the AdditionalProperties with the named properties in a single JSON object.
// An error will be returned if there are duplicate keys.
func (m {{.Name}}) MarshalJSON() ([]byte, error) {
type alias {{.Name}}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It took me a moment to understand why we needed the alias type, this just let's us use the original unmarshal behavior, right? It might be worth adding a comment in the code so that anyone else using it can follow along a bit more easily.

@@ -47,6 +47,11 @@ type {{$modelName}} struct {
{{- end }}
}

// New{{$modelName}} creates a new {{$modelName}} instance with no internal value.
func New{{$modelName}}() *{{$modelName}} {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A default could still be specified at the schema level right? For example:

Animal:
  oneOf:
  - $ref: '#/components/schemas/Cat'
  - $ref: '#/components/schemas/Dog'
  - $ref: '#/components/schemas/Lizard'
  default: '{"petType": "cat", "name": "Felix"}'

// HasDefault is true when the property has a default value
HasDefault bool
// DefaultValue is the default value of the property
DefaultValue string
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably be a map[string]interface{} or a json.RawMessage

// passedSchemas is a map of pointers to a schema.
// Used for marking already visited types when recursively resolve `allOf` definitions and merging types.
type passedSchemas map[*openapi3.SchemaRef]something
type passedSchemas map[*openapi3.SchemaRef]bool
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why move to a boolean? the empty "something" was intended as a meaningless, but non-empty value, vs the bool which has implied meaning.

Comment on lines +139 to +146
inlineStruct:
type: object
properties:
field1:
type: string
field2:
type: integer
default: 13
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be interesting to have multiple nested inline structs, just to have a solid example of what happens with naming. This is hopefully a very rare case, but worth at least showing what happens. We actually forbid unnamed inline objects like this, because there is always a possibility for a weird name conflict.

Suggested change
inlineStruct:
type: object
properties:
field1:
type: string
field2:
type: integer
default: 13
inlineStruct:
type: object
properties:
field1:
type: string
field2:
type: integer
default: 13
nestedInline:
type: object
properties:
name:
type: string
cost:
type: integer
default: 100

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants