Skip to content

Commit

Permalink
Implement oapi-codegen/nullable
Browse files Browse the repository at this point in the history
This bootstraps a new repository for the `oapi-codegen` organisation's
standards, and then implements the `Nullable` type as per [0] and [1].

Using a `map` as the underlying type allows us to take advantage of
`json.Marshal`'s inbuilt checks to determine whether to `omitempty` a
JSON value, which isn't possible with a `struct`.

We can make sure this is a multi-module project, similar to
other projects, so we can isolate test-only dependencies from the core
project, which has zero dependencies.

We can also add convenience helpers for `NewNullableWithValue` and
`NewNullNullable` as they can be useful when constructing `struct`s in
tests.

In the top-level project we can use runnable examples to indicate the
example usage and cover all the test cases we need, and then use the
`internal/test` package to perform further checks.

Co-authored-by: Sebastien Guilloux <[email protected]>
Co-authored-by: Ashutosh Kumar <[email protected]>

[0]: golang/go#64515 (comment)
[1]: https://github.com/sebgl/nullable/
  • Loading branch information
jamietanna committed Jan 9, 2024
0 parents commit d2fce25
Show file tree
Hide file tree
Showing 16 changed files with 813 additions and 0 deletions.
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* @oapi-codegen/maintainers
1 change: 1 addition & 0 deletions .github/release-drafter.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
_extends: .github
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Build project
on: [ push, pull_request ]
jobs:
build:
name: Build
runs-on: ubuntu-latest
strategy:
fail-fast: false
# perform matrix testing to give us an earlier insight into issues with different versions of supported major versions of Go
matrix:
version:
- "1.20"
- "1.21"
steps:
- name: Check out source code
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.version }}

- name: Test
run: make test
24 changes: 24 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Lint project
on: [push, pull_request]
jobs:
build:
name: Build
runs-on: ubuntu-latest
strategy:
fail-fast: false
# perform matrix testing to give us an earlier insight into issues with different versions of supported major versions of Go
matrix:
version:
- "1.20"
- "1.21"
steps:
- name: Check out source code
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.version }}

- name: Run `make lint-ci`
run: make lint-ci
25 changes: 25 additions & 0 deletions .github/workflows/release-drafter.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: Release Drafter

on:
push:
branches:
- main
workflow_dispatch: {}

permissions:
contents: read

jobs:
update_release_draft:
permissions:
contents: write
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@v5
with:
name: next
tag: next
version: next
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
27 changes: 27 additions & 0 deletions .github/workflows/tidy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: Ensure `go mod tidy` has been run
on: [ push, pull_request ]
jobs:
build:
name: Build
runs-on: ubuntu-latest
strategy:
fail-fast: false
# perform matrix testing to give us an earlier insight into issues with different versions of supported major versions of Go
matrix:
version:
- "1.20"
- "1.21"
steps:
- name: Check out source code
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.version }}

- name: Install `tidied`
run: go install gitlab.com/jamietanna/tidied@latest

- name: Check for no untracked files
run: tidied -verbose
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/bin
13 changes: 13 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
Copyright 2024 oapi-codegen

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
28 changes: 28 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
GOBASE=$(shell pwd)
GOBIN=$(GOBASE)/bin

help:
@echo "This is a helper makefile for oapi-codegen"
@echo "Targets:"
@echo " test: run all tests"
@echo " tidy tidy go mod"
@echo " lint run linting"

$(GOBIN)/golangci-lint:
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(GOBIN) v1.55.2

.PHONY: tools
tools: $(GOBIN)/golangci-lint

lint: tools
git ls-files go.mod '**/*go.mod' -z | xargs -0 -I{} bash -xc 'cd $$(dirname {}) && $(GOBIN)/golangci-lint run ./...'

lint-ci: tools
git ls-files go.mod '**/*go.mod' -z | xargs -0 -I{} bash -xc 'cd $$(dirname {}) && $(GOBIN)/golangci-lint run ./... --out-format=github-actions --timeout=5m'

test:
git ls-files go.mod '**/*go.mod' -z | xargs -0 -I{} bash -xc 'cd $$(dirname {}) && go test -cover ./...'

tidy:
@echo "tidy..."
git ls-files go.mod '**/*go.mod' -z | xargs -0 -I{} bash -xc 'cd $$(dirname {}) && go mod tidy'
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# oapi-codegen/nullable

> An implementation of a `Nullable` type for JSON bodies, indicating whether the field is absent, set to null, or set to a value
Unlike other known implementations, this makes it possible to both marshal and unmarshal the value, as well as represent all three states:

- the field is _not set_
- the field is _explicitly set to null_
- the field is _explicitly set to a given value_

And can be embedded in structs, for instance with the following definition:

```go
obj := struct {
// RequiredID is a required, nullable field
RequiredID nullable.Nullable[int] `json:"id"`
// RequiredID is an optional, nullable field
OptionalString *nullable.Nullable[string] `json:"optionalString,omitempty"`
}{}
```

## Usage

> [!IMPORTANT]
> Although this project is under the [oapi-codegen org](https://github.com/oapi-codegen) for the `oapi-codegen` OpenAPI-to-Go code generator, this is intentionally released as a separate, standalone library which can be used by other projects.
First, add to your project with:

```sh
go get github.com/oapi-codegen/nullable
```

Check out the examples in [the package documentation on pkg.go.dev](https://pkg.go.dev/github.com/oapi-codegen/nullable) for more details.

## Credits

- [KumanekoSakura](https://github.com/KumanekoSakura), [via](https://github.com/golang/go/issues/64515#issuecomment-1841057182)
- [Sebastien Guilloux], [via](https://github.com/sebgl/nullable/)

As well as contributions from:

- [Jamie Tanna](https://www.jvt.me)
- [Ashutosh Kumar](https://github.com/sonasingh46)

## License

Licensed under the Apache-2.0 license.
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/oapi-codegen/nullable

go 1.20
16 changes: 16 additions & 0 deletions internal/test/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
module github.com/oapi-codegen/nullable/internal/test

go 1.20

replace github.com/oapi-codegen/nullable => ../../

require (
github.com/oapi-codegen/nullable v0.0.0-00010101000000-000000000000
github.com/stretchr/testify v1.8.4
)

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
10 changes: 10 additions & 0 deletions internal/test/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
88 changes: 88 additions & 0 deletions internal/test/nullable_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package nullable_test

import (
"encoding/json"
"testing"

"github.com/oapi-codegen/nullable"

"github.com/stretchr/testify/require"
)

type Obj struct {
Foo nullable.Nullable[string] `json:"foo,omitempty"` // note "omitempty" is important for fields that are optional
}

func TestNullable(t *testing.T) {
// --- parsing from json and serializing back to JSON

// -- case where there is an actual value
data := `{"foo":"bar"}`
// deserialize from json
myObj := parse(data, t)
require.Equal(t, myObj, Obj{Foo: nullable.Nullable[string]{true: "bar"}})
require.False(t, myObj.Foo.IsNull())
require.True(t, myObj.Foo.IsSpecified())
value, err := myObj.Foo.Get()
require.NoError(t, err)
require.Equal(t, "bar", value)
// serialize back to json: leads to the same data
require.Equal(t, data, serialize(myObj, t))

// -- case where no value is specified: parsed from JSON
data = `{}`
// deserialize from json
myObj = parse(data, t)
require.Equal(t, myObj, Obj{Foo: nil})
require.False(t, myObj.Foo.IsNull())
require.False(t, myObj.Foo.IsSpecified())
_, err = myObj.Foo.Get()
require.ErrorContains(t, err, "value is not specified")
// serialize back to json: leads to the same data
require.Equal(t, data, serialize(myObj, t))

// -- case where the specified value is explicitly null
data = `{"foo":null}`
// deserialize from json
myObj = parse(data, t)
require.Equal(t, myObj, Obj{Foo: nullable.Nullable[string]{false: ""}})
require.True(t, myObj.Foo.IsNull())
require.True(t, myObj.Foo.IsSpecified())
_, err = myObj.Foo.Get()
require.ErrorContains(t, err, "value is null")
// serialize back to json: leads to the same data
require.Equal(t, data, serialize(myObj, t))

// --- building objects from a Go client

// - case where there is an actual value
myObj = Obj{}
myObj.Foo.Set("bar")
require.Equal(t, `{"foo":"bar"}`, serialize(myObj, t))

// - case where the value should be unspecified
myObj = Obj{}
// do nothing: unspecified by default
require.Equal(t, `{}`, serialize(myObj, t))
// explicitly mark unspecified
myObj.Foo.SetUnspecified()
require.Equal(t, `{}`, serialize(myObj, t))

// - case where the value should be null
myObj = Obj{}
myObj.Foo.SetNull()
require.Equal(t, `{"foo":null}`, serialize(myObj, t))
}

func parse(data string, t *testing.T) Obj {
var myObj Obj
err := json.Unmarshal([]byte(data), &myObj)
require.NoError(t, err)
return myObj
}

func serialize(o Obj, t *testing.T) string {
data, err := json.Marshal(o)
require.NoError(t, err)
return string(data)
}
Loading

0 comments on commit d2fce25

Please sign in to comment.