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

Feature/usability #2

Merged
merged 4 commits into from
Nov 28, 2023
Merged
Show file tree
Hide file tree
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
78 changes: 75 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,76 @@
# Golymorph
The golymorph module enables resolving polymorphic typing at runtime. It's usually used during JSON parsing.
# golymorph [![Pipeline Tests Status](https://github.com/SoulKa/golymorph/actions/workflows/go-test.yaml/badge.svg)](https://github.com/SoulKa/golymorph/actions/workflows/go-test.yaml) [![Godoc](https://godoc.org/github.com/SoulKa/golymorph?status.svg)](https://godoc.org/github.com/SoulKa/golymorph)

[![Pipeline Tests Status](https://github.com/SoulKa/golymorph/actions/workflows/go-test.yaml/badge.svg)](https://github.com/SoulKa/golymorph/actions/workflows/go-test.yaml)
The golymorph module enables resolving polymorphic typing at runtime. It's usually used in
conjunction with
JSON parsing and the `mapstructure` module. In fact, this module takes the use case
of `mapstructure` and takes it
a step further by allowing the user to define a custom type resolver function.

## Installation

Standard `go get`:

```shell
go get github.com/SoulKa/golymorph
```

## Usage & Example

For usage and examples see the [Godoc](http://godoc.org/github.com/SoulKa/golymorph).

```go
package main

import (
"fmt"
"github.com/SoulKa/golymorph"
"reflect"
)

func main() {
// get a JSON that contains a payload with a type field that determines the type of the payload
alertEventJson := `{ "timestamp": "2023-11-27T22:14:09+00:00", "payload": { "type": "alert", "message": "something is broken!" } }`

// the parent type that contains the polymorphic payload
type Event struct {
Timestamp string
Payload any
}

// the polymorphic child types
type AlertPayload struct {
Type string
Message string
}
type PingPayload struct {
Type string
Ip string
}

// define a mapping from the type value to the type of the payload
typeMap := golymorph.TypeMap{
"alert": reflect.TypeOf(AlertPayload{}),
"ping": reflect.TypeOf(PingPayload{}),
}

// create a TypeResolver that assigns the type of the payload based on the type field
err, resolver := golymorph.NewPolymorphismBuilder().
DefineTypeAt("payload").
UsingTypeMap(typeMap).
WithDiscriminatorAt("type").
Build()
if err != nil {
panic(fmt.Sprintf("error building polymorpher: %s", err))
}

// create a new event
var event Event
if err := golymorph.UnmarshalJSON(resolver, []byte(alertEventJson), &event); err != nil {
panic(fmt.Sprintf("error unmarshalling event: %s", err))
}

// continue to work with the event
fmt.Printf("event: %+v\n", event)
fmt.Printf("event payload: %T %+v\n", event.Payload, event.Payload.(AlertPayload))
}
```
13 changes: 13 additions & 0 deletions error/unresolved_type_error.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package error

import "fmt"

// UnresolvedTypeError is an error that occurs when a type cannot be resolved when applying a polymorphism
type UnresolvedTypeError struct {
Err error
TargetPath string
}

func (e *UnresolvedTypeError) Error() string {
return fmt.Sprintf("unresolved type error at [%s]: %s", e.TargetPath, e.Err.Error())
}
115 changes: 115 additions & 0 deletions examples/example_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package examples

import (
"encoding/json"
"fmt"
"github.com/SoulKa/golymorph"
"github.com/mitchellh/mapstructure"
"reflect"
"testing"
)

func TestBasicPolymorphismFromJson(t *testing.T) {

// get a JSON that contains a payload with a type field that determines the type of the payload
alertEventJson := `{ "timestamp": "2023-11-27T22:14:09+00:00", "payload": { "type": "alert", "message": "something is broken!" } }`

// the parent type that contains the polymorphic payload
type Event struct {
Timestamp string
Payload any
}

// the polymorphic child types
type AlertPayload struct {
Type string
Message string
}
type PingPayload struct {
Type string
Ip string
}

// define a mapping from the type value to the type of the payload
typeMap := golymorph.TypeMap{
"alert": reflect.TypeOf(AlertPayload{}),
"ping": reflect.TypeOf(PingPayload{}),
}

// create a TypeResolver that assigns the type of the payload based on the type field
err, resolver := golymorph.NewPolymorphismBuilder().
DefineTypeAt("payload").
UsingTypeMap(typeMap).
WithDiscriminatorAt("type").
Build()
if err != nil {
panic(fmt.Sprintf("error building polymorpher: %s", err))
}

// create a new event
var event Event
if err := golymorph.UnmarshalJSON(resolver, []byte(alertEventJson), &event); err != nil {
panic(fmt.Sprintf("error unmarshalling event: %s", err))
}

// continue to work with the event
fmt.Printf("event: %+v\n", event)
fmt.Printf("event payload: %T %+v\n", event.Payload, event.Payload.(AlertPayload))
}

func TestBasicPolymorphismWithManualParsing(t *testing.T) {

// get a JSON that contains a payload with a type field that determines the type of the payload
alertEventJson := `{ "timestamp": "2023-11-27T22:14:09+00:00", "payload": { "type": "alert", "message": "something is broken!" } }`

type Event struct {
Timestamp string
Payload any
}

type AlertPayload struct {
Type string
Message string
}

type PingPayload struct {
Type string
Ip string
}

typeMap := golymorph.TypeMap{
"alert": reflect.TypeOf(AlertPayload{}),
"ping": reflect.TypeOf(PingPayload{}),
}

// parse the JSON into a map
var jsonMap map[string]any
if err := json.Unmarshal([]byte(alertEventJson), &jsonMap); err != nil {
t.Fatalf("error unmarshalling JSON: %s", err)
}

// create a polymorpher that assigns the type of the payload based on the type field
err, polymorpher := golymorph.NewPolymorphismBuilder().
DefineTypeAt("payload").
UsingTypeMap(typeMap).
WithDiscriminatorAt("type").
Build()
if err != nil {
t.Fatalf("error building polymorpher: %s", err)
}

// create a new event
var event Event
if err := polymorpher.AssignTargetType(&jsonMap, &event); err != nil {
t.Fatalf("error assigning target type: %s", err)
}

// use mapstructure to unmarshal the payload into the event
if err := mapstructure.Decode(jsonMap, &event); err != nil {
t.Fatalf("error decoding JSON map: %s", err)
}

// continue to work with the event
fmt.Printf("event: %+v\n", event)
fmt.Printf("event payload: %T %+v\n", event.Payload, event.Payload.(AlertPayload))
}
47 changes: 47 additions & 0 deletions polymorphism.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package golymorph

import (
"encoding/json"
"github.com/SoulKa/golymorph/objectpath"
"github.com/mitchellh/mapstructure"
)

type Polymorphism struct {
// targetPath is the path to the object to assign the new type to
targetPath objectpath.ObjectPath
}

// UnmarshalJSON unmarshals the given JSON data into the given output object using the given TypeResolver.
func UnmarshalJSON(resolver TypeResolver, data []byte, output any) error {

// parse JSON
var jsonMap map[string]any
if err := json.Unmarshal(data, &jsonMap); err != nil {
return err
}

// resolve polymorphism
if err := Decode(resolver, jsonMap, output); err != nil {
return err
}

// success
return nil
}

// Decode decodes the given source map into the given output object using the given TypeResolver and mapstructure.
func Decode(resolver TypeResolver, source map[string]any, output any) error {

// create a new event
if err := resolver.AssignTargetType(&source, output); err != nil {
return err
}

// use mapstructure to unmarshal the payload into the event
if err := mapstructure.Decode(source, output); err != nil {
return err
}

// success
return nil
}
17 changes: 9 additions & 8 deletions polymorphism_builder.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package golymorph

import (
"errors"
"github.com/SoulKa/golymorph/objectpath"
"strings"
)
Expand Down Expand Up @@ -32,15 +33,15 @@ type PolymorphismBuilderStrategySelector interface {

type PolymorphismBuilderRuleAdder interface {
UsingRule(rule Rule) PolymorphismBuilderRuleAdder
Build() ([]error, Polymorpher)
Build() (error, TypeResolver)
}

type PolymorphismBuilderDiscriminatorKeyDefiner interface {
WithDiscriminatorAt(discriminatorKey string) PolymorphismBuilderFinalizer
}

type PolymorphismBuilderFinalizer interface {
Build() ([]error, Polymorpher)
Build() (error, TypeResolver)
}

func NewPolymorphismBuilder() PolymorphismBuilderEmpty {
Expand Down Expand Up @@ -92,16 +93,16 @@ func (b *PolymorphismTypeMapBuilder) WithDiscriminatorAt(discriminatorKey string
return b
}

func (b *PolymorphismRuleBuilder) Build() ([]error, Polymorpher) {
func (b *PolymorphismRuleBuilder) Build() (error, TypeResolver) {
if len(b.errors) > 0 {
return b.errors, nil
return errors.Join(b.errors...), nil
}
return b.errors, &RulePolymorphism{b.targetPath, b.rules}
return nil, &RulePolymorphism{Polymorphism{b.targetPath}, b.rules}
}

func (b *PolymorphismTypeMapBuilder) Build() ([]error, Polymorpher) {
func (b *PolymorphismTypeMapBuilder) Build() (error, TypeResolver) {
if len(b.errors) > 0 {
return b.errors, nil
return errors.Join(b.errors...), nil
}
return b.errors, &TypeMapPolymorphism{b.targetPath, b.discriminatorPath, b.typeMap}
return nil, &TypeMapPolymorphism{Polymorphism{b.targetPath}, b.discriminatorPath, b.typeMap}
}
12 changes: 6 additions & 6 deletions polymorphism_builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,15 @@ func TestPolymorphismBuilder_UsingRule(t *testing.T) {
}

// Act
errors, polymorphism := NewPolymorphismBuilder().
err, polymorphism := NewPolymorphismBuilder().
DefineTypeAt("foo/bar").
UsingRule(rule1).
UsingRule(rule2).
Build()

// Assert
if HasErrors(t, errors) {
t.Fatalf("expected no errors, but got %d errors", len(errors))
if err != nil {
t.Fatalf("expected no errors, but got %s", err)
} else if polymorphism == nil {
t.Fatalf("expected polymorphism to not be nil")
}
Expand All @@ -50,15 +50,15 @@ func TestPolymorphismBuilder_UsingTypeMap(t *testing.T) {
}

// Act
errors, polymorphism := NewPolymorphismBuilder().
err, polymorphism := NewPolymorphismBuilder().
DefineTypeAt("foo/bar").
UsingTypeMap(typeMap).
WithDiscriminatorAt("foo/bar/discriminator").
Build()

// Assert
if HasErrors(t, errors) {
t.Fatalf("expected no errors, but got %d errors", len(errors))
if err != nil {
t.Fatalf("expected no errors, but got %s", err)
} else if polymorphism == nil {
t.Fatalf("expected polymorphism to not be nil")
}
Expand Down
18 changes: 11 additions & 7 deletions rule_polymorphism.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,35 @@ package golymorph

import (
"errors"
golimorphError "github.com/SoulKa/golymorph/error"
"github.com/SoulKa/golymorph/objectpath"
)

// RulePolymorphism is a mapper that assigns a target type based on the given rules
type RulePolymorphism struct {
// targetPath is the path to the object to assign the new type to
targetPath objectpath.ObjectPath
Polymorphism

// rules is a list of rules to apply. The first rule that matches is used to determine the target type.
rules []Rule
}

func (p *RulePolymorphism) AssignTargetType(source any, target any) (error, bool) {
func (p *RulePolymorphism) AssignTargetType(source any, target any) error {

// check for each rule if it matches and assign type if it does
for _, rule := range p.rules {
if err, matches := rule.Matches(source); err != nil {
return errors.Join(errors.New("error applying rule"), err), false
return errors.Join(errors.New("error applying rule"), err)
} else if matches {
if err := objectpath.AssignTypeAtPath(target, p.targetPath, rule.NewType); err != nil {
return errors.Join(errors.New("error assigning type to target"), err), false
return errors.Join(errors.New("error assigning type to target"), err)
}
return nil, true
return nil
}
}
return nil, false

// no rule matched
return &golimorphError.UnresolvedTypeError{
Err: errors.New("no rule matched"),
TargetPath: p.targetPath.String(),
}
}
Loading