diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aceb10aa..bc53c791 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,8 +6,8 @@ jobs: name: Test implementation in Golang runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-go@v4 + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 with: go-version: ^1.21 id: go @@ -15,14 +15,27 @@ jobs: - run: cd cmd/mkunion; go get -v -t -d ./... - run: cd cmd/mkunion; go build -o mkunion + - run: go install github.com/matryer/moq@latest + - run: go get -v -t -d ./... - run: go generate ./... - # initiate docker-compose services - - run: | - pip install awscli-local + - run: pip install awscli-local - run: dev/bootstrap.sh -nologs + - run: | + find . -type f -name '*.go' -exec grep -C 2 -H 'github.com/opensearch-project/opensearch-go/v2' {} + &2>/dev/null || true + + - run: | + ls -la /home/runner/go/pkg/mod/github.com/opensearch-project/opensearch-go + ls -la /home/runner/go/pkg/mod/cache/download/github.com/opensearch-project/opensearch-go + + tree /home/runner/go/pkg/mod/github.com/opensearch-project/opensearch-go + tree /home/runner/go/pkg/mod/cache/download/github.com/opensearch-project/opensearch-go + + - run: | + cat x/storage/schemaless/types_reg_gen.go + # run tests - run: | export RUN_EXPERIMENTAL_TEST="false" diff --git a/README.md b/README.md index cc2bf9b2..eed847eb 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,10 @@ ## About -Strongly typed **union type** in golang. +Strongly typed **union type** in golang with generics*. -* with full _pattern matching_ support -* with full _json marshalling_ support +* with exhaustive _pattern matching_ support +* with _json marshalling_ including generics * and as a bonus, can generate compatible typescript types for end-to-end type safety in your application ## Why @@ -19,7 +19,7 @@ Visitor pattern requires a lot of boiler plate code and hand crafting of the `Ac On top of that, any data marshalling like to/from JSON requires additional, hand crafted code, to make it work. -MkUnion solves all of those problems, by generating opinionated and strongly typed mindful code for you. +MkUnion solves all of those problems, by generating opinionated and strongly typed meaningful code for you. ## Example diff --git a/cmd/mkunion/main.go b/cmd/mkunion/main.go index 610cb0db..42a82ba1 100644 --- a/cmd/mkunion/main.go +++ b/cmd/mkunion/main.go @@ -50,7 +50,7 @@ func main() { }, &cli.BoolFlag{ Name: "type-registry", - Value: false, + Value: true, }, }, Action: func(c *cli.Context) error { @@ -563,7 +563,20 @@ func GenerateTypeRegistry(inferred *shape.IndexedTypeWalker) (bytes.Buffer, erro shape.WithPkgImportName(), ) + // Register go type contents.WriteString(fmt.Sprintf("\tshared.TypeRegistryStore[%s](%q)\n", instantiatedTypeName, fullTypeName)) + + // Try to register type JSON marshaller + if ref, ok := inst.(*shape.RefName); ok { + some, found := shape.LookupShapeOnDisk(ref) + if !found { + continue + } + some = shape.IndexWith(some, ref) + if shape.IsUnion(some) { + contents.WriteString(fmt.Sprintf("\t%s\n", generators.StrRegisterUnionFuncName(shape.ToGoPkgName(some), some))) + } + } } contents.WriteString("}\n") diff --git a/dev/bootstrap.sh b/dev/bootstrap.sh index 16af449c..f29f2094 100755 --- a/dev/bootstrap.sh +++ b/dev/bootstrap.sh @@ -6,14 +6,24 @@ project_root=$(dirname "$cwd") envrc_file=$project_root/.envrc echo "Check if necessary tools are installed" +command -v go >/dev/null 2>&1 || { echo >&2 "golang is not installed. Aborting."; exit 1; } command -v docker >/dev/null 2>&1 || { echo >&2 "docker is not installed. Aborting."; exit 1; } command -v docker-compose >/dev/null 2>&1 || { echo >&2 "docker-compose is not installed. Aborting."; exit 1; } command -v awslocal >/dev/null 2>&1 || { echo >&2 "awslocal is not installed. Aborting. Please run pip install awscli-local "; exit 1; } +# check for moq +command -v moq >/dev/null 2>&1 || { echo >&2 "moq is not installed. Aborting please run + go install github.com/matryer/moq@latest"; exit 1; } + echo "Creating volume directory" mkdir -p $cwd/_volume +if [ "$1" == "-install-only" ]; then + trap - EXIT + exit 0 +fi + echo "Starting localstack" docker compose -f $cwd/compose.yml up -d # trap exit and stop docker compose diff --git a/dev/docs.sh b/dev/docs.sh index d55008bf..603d78a5 100755 --- a/dev/docs.sh +++ b/dev/docs.sh @@ -5,7 +5,8 @@ cwd=$(dirname "$0") project_root=$(dirname "$cwd") if [ "$1" == "run" ]; then - docker run --rm -it -p 8000:8000 -v ${project_root}:/docs squidfunk/mkdocs-material + echo "Serving documentation at http://localhost:8088" + docker run --rm -it -p 8088:8000 -v ${project_root}:/docs squidfunk/mkdocs-material elif [ "$1" == "build" ]; then docker run --rm -it -v ${project_root}:/docs squidfunk/mkdocs-material build else diff --git a/docs/development/development.md b/docs/development/development.md index f7bc9d31..2660adfa 100644 --- a/docs/development/development.md +++ b/docs/development/development.md @@ -3,6 +3,12 @@ title: Contributing and development --- # Contributing and development +## Contributing + +If you want to contribute to `mkunion` project, please open issue first to discuss your idea. + +I have opinions about how `mkunion` should work, how I want to evolve it, and I want to make sure that your idea fits into the project. + ## Development Checkout repo and run: @@ -28,8 +34,3 @@ To preview documentation run: ``` ./dev/docs.sh run ``` - -## Contributing - -If you want to contribute to `mkunion` project, please open issue first to discuss your idea. -I have opinions about how `mkunion` should work, how I want to evolve it, and I want to make sure that your idea fits into the project. diff --git a/docs/examples/generic_union.md b/docs/examples/generic_union.md index 35d17f9b..61498f96 100644 --- a/docs/examples/generic_union.md +++ b/docs/examples/generic_union.md @@ -1,7 +1,7 @@ --- -title: Generic unions +title: Union and generic types --- -# Generic unions +# Union and generic types MkUnion will generate generic unions for you. You only need to declare each variant type of the union with a type parameter, @@ -27,18 +27,16 @@ type ( ) ``` -After you run generation (as described in [getting started](/getting_started.md)), +After you run generation (as described in [getting started](../getting_started.md)), you have access to the same features as with non-generic unions. ## Matching function -Let's define higher order function `ReduceTree` that will travers leaves in tree and produce a single value. +Let's define higher order function `ReduceTree` that will travers leaves in `Tree` and produce a single value. This function uses `MatchTreeR1` function that is generated automatically for you. ```go title="example/tree.go" - -```go func ReduceTree[A, B any](x Tree[A], f func(A, B) B, init B) B { return MatchTreeR1( x, @@ -78,11 +76,7 @@ func ExampleTreeSumValues() { You can also reduce tree to complex structure, for example to keep track of order of values in the tree, along with sum of all values in the tree. -```go title="example/tree.go" - ```go title="example/tree_test.go" - -```go func ExampleTreeCustomReduction() { tree := &Branch[int]{ L: &Leaf[int]{Value: 1}, @@ -154,4 +148,11 @@ func MapOption[A, B any](x Option[A], f func(A) B) Option[B] { }, ) } -``` \ No newline at end of file +``` + +In above example, we define `MapEither` and `MapOption` functions that will apply function `f` to value inside `Either` or `Option` type. + +It would be much better to have only one `Map` definition, but due to limitations of Go type system, we need to define separate functions for each type. + +I'm considering adding code generation for such behaviours in the future. Not yet due to focus on validating core concepts. + diff --git a/docs/examples/json.md b/docs/examples/json.md new file mode 100644 index 00000000..df92c830 --- /dev/null +++ b/docs/examples/json.md @@ -0,0 +1,81 @@ +--- +title: Marshaling union as JSON +--- + +# Marshaling union as JSON + +MkUnion provides you with utility function that allows you to marshal and unmarshal union types to JSON, +reducing burden of writing custom marshaling and unmarshaling functions for union types. + +- `shared.JSONMarshal[A any](in A) ([]byte, error)` +- `shared.JSONUnmarshal[A any](data []byte) (A, error)` + +Below is an example of how to use those functions and how the output JSON looks like. + + +```go title="example/tree_json_test.go" +import ( + "github.com/widmogrod/mkunion/x/shared" +) + +--8<-- "example/tree_json_test.go:8:30" +``` + +Formated JSON output of the example above: +```json +{ + "$type": "example.Branch", + "example.Branch": { + "L": { + "$type": "example.Leaf", + "example.Leaf": { + "Value": 1 + } + }, + "R": { + "$type": "example.Branch", + "example.Branch": { + "L": { + "$type": "example.Branch", + "example.Branch": { + "L": { + "$type": "example.Leaf", + "example.Leaf": { + "Value": 2 + } + }, + "R": { + "$type": "example.Leaf", + "example.Leaf": { + "Value": 3 + } + } + } + }, + "R": { + "$type": "example.Leaf", + "example.Leaf": { + "Value": 4 + } + } + } + } + } +} +``` + + +There are few things that you can notice in this example: + +- Each union type discriminator field `$type` field that holds the type name, and corresponding key with the name of the type, that holds value of union variant. + - This is opinionated way, and library don't allow to change it. + I was experimenting with making this behaviour customizable, but it make code and API mode complex, and I prefer to keep it simple, and increase interoperability between different libraries and applications, that way. + +- Recursive union types are supported, and they are marshaled as nested JSON objects.] + +- `$type` don't have to have full package import name, nor type parameter, + mostly because in `shared.JSONUnmarshal[Tree[int]](json)` you hint that your code accepts `Tree[int]`. + - I'm considering adding explicit type discriminators like `example.Branch[int]` or `example.Leaf[int]`. + It could increase type strictness on client side, BUT it makes generating TypeScript types more complex, and I'm not sure if it's worth it. + +- It's not shown on this example, but you can also reference types and union types from other packages, and serialization will work as expected. \ No newline at end of file diff --git a/docs/examples/state_machine.md b/docs/examples/state_machine.md new file mode 100644 index 00000000..875e989f --- /dev/null +++ b/docs/examples/state_machine.md @@ -0,0 +1,192 @@ +--- +title: State machines and unions +--- +# MkUnion and state machines in golang + +This document will show how to use `mkunion` to manage application state on example of an Order Service. +You will learn: + +- how to model state machines in golang, and find similarities to "__clean architecture__" +- How to **test state machines** (with fuzzing), and as a bonus you will get mermaid diagrams for free +- How to **persist state in database** and how optimistic concurrency helps __resolve concurrency conflicts__ +- How to **handle errors** in state machines, and build foundations for __self-healing__ systems + + +## Working example + +As an driving example, we will use e-commerce inspired Order Service that can be in one of the following states: + +- `Pending` - order is created, and is waiting for someone to process it +- `Processing` - order is being processed, an human is going to pick up items from warehouse and pack them +- `Cancelled` - order was cancelled, there can be many reason, one of them is that warehouse is out of stock. +- `Completed` - order is completed, and can be shipped to customer. + +Such states, have rules that govern **transitions**, like order cannot be cancelled if it's already completed, and so on. + +And we need to have also to trigger changes in state, like create order that pending for processing, or cancel order. We will call those triggers **commands**. + +Some of those rules could change in future, and we want to be able to change them without rewriting whole application. +This also informs us that our design should be open for extensions. + +Side note, if you want go strait to final code product, then into [example/state/](example/state/) directory and have fun exploring. + +## Modeling commands and states + +Our example can be represented as state machine that looks like this: +[simple_machine_test.go.state_diagram.mmd](example/state/simple_machine_test.go.state_diagram.mmd) +```mermaid +--8<-- "example/state/machine_test.go.state_diagram.mmd" +``` + +In this diagram, we can see that we have 5 states, and 6 commands that can trigger transitions between states shown as arrows. + +Because this diagram is generated from code, it has names that represent types in golang that we use in implementation. + +For example `*state.CreateOrderCMD`: + +- `state` it's a package name +- `CreateOrderCMD` is a struct name in that package. +- `CMD` suffix it's naming convention, that it's optional, but I find it makes code more readable. + + +Below is a code snippet that demonstrate complete model of **state** and **commands** of Order Service, that we talked about. + +**Notice** that we use `mkunion` to group commands and states. (Look for `//go:tag mkunion:"Command"`) + +This is one example how union types can be used in golang. +Historically in golang it would be very hard to achieve such thing, and it would require a lot of boilerplate code. +Here interface that group those types is generated automatically. + +```go title="example/state/model.go" +--8<-- "example/state/model.go" +``` + +## Modeling transitions +One thing that is missing is implementation of transitions between states. +There are few ways to do it. I will show you how to do it using functional approach (think `reduce` or `map` function). + +Let's name function that we will build `Transition` and define it as: + +```go +func Transition(ctx context.Context, dep Dependencies, cmd Command, state State) (State, error) +``` + +Our function has few arguments, let's break them down: + +- `ctx` standard golang context, that is used to pass deadlines, and cancelation signals, etc. +- `dep` encapsulates dependencies like API clients, database connection, configuration, context etc. + everything that is needed for complete production implementation. +- `cmd` it's a command that we want to apply to state, + and it has `Command` interface, that was generate by `mkunion` when it was used to group commands. +- `state` it's a state that we want to apply our command to and change it, + and it has `State` interface, that was generate similarly to `Command` interface. + + +Our function must return either new state, or error when something went wrong during transition, like network error, or validation error. + +Below is snippet of implementation of `Transition` function for our Order Service: + +```go title="example/state/machine.go" +--8<-- "example/state/machine.go:30:81" +// ... +// rest remove for brevity +// ... +``` + +You can notice few patterns in this snippet: + +- `Dependency` interface help us to keep, well dependencies - well defined, which helps greatly in testability and readability of the code. +- Use of generated function `MatchCommandR2` to exhaustively match all commands. + This is powerful, when new command is added, you can be sure that you will get compile time error, if you don't handle it. +- Validation of commands in done in transition function. Current implementation is simple, but you can use go-validate to make it more robust, or refactor code and introduce domain helper functions or methods to the types. +- Each command check state to which is being applied using `switch` statement, it ignore states that it does not care about. + Which means as implementation you have to focus only on small bit of the picture, and not worry about rest of the states. + This is also example where non-exhaustive use of `switch` statement is welcome. + +Simple, isn't it? Simplicity also comes from fact that we don't have to worry about marshalling/unmarshalling data, working with database, those are things that will be done in other parts of the application, keeping this part clean and focused on business logic. + +Note: Implementation for educational purposes is kept in one big function, +but for large projects it may be better to split it into smaller functions, +or define OrderService struct that conforms to visitor pattern interface, that was also generated for you: + +```go title="example/state/model_union_gen.go" +--8<-- "example/state/model_union_gen.go:11:17" +``` + +## Testing state machines & self-documenting +Before we go further, let's talk about testing our implementation. + +Testing will help us not only ensure that our implementation is correct, but also will help us to document our state machine, +and discover transition that we didn't think about, that should or shouldn't be possible. + +Here is how you can test state machine, in declarative way, using `mkunion/x/machine` package: + +```go title="example/state/machine_test.go" +--8<-- "example/state/machine_test.go:15:151" +``` +Few things to notice in this test: + +- We use standard go testing +- We use `machine.NewTestSuite` as an standard way to test state machines +- We start with describing **happy path**, and use `suite.Case` to define test case. +- But most importantly, we define test cases using `GivenCommand` and `ThenState` functions, that help in making test more readable, and hopefully self-documenting. +- You can see use of `ForkCase` command, that allow you to take a definition of a state declared in `ThenState` command, and apply new command to it, and expect new state. +- Less visible is use of `moq` to generate `DependencyMock` for dependencies, but still important to write more concise code. + +I know it's subjective, but I find it very readable, and easy to understand, even for non-programmers. + +## Generating state diagram from tests +Last bit is this line at the bottom: + +```go title="example/state/machine_test.go" +if suite.AssertSelfDocumentStateDiagram(t, "machine_test.go") { + suite.SelfDocumentStateDiagram(t, "machine_test.go") +} +``` + +This code takes all inputs provided in test suit and fuzzy them, apply commands to random states, and records result of those transitions. + + - `SelfDocumentStateDiagram` - produce two `mermaid` diagrams, that show all possible transitions that are possible in our state machine. + - `AssertSelfDocumentStateDiagram` can be used to compare new generated diagrams to diagrams committed in repository, and fail test if they are different. + You don't have to use it, but it's good practice to ensure that your state machine is well tested and don't regress without you noticing. + + +There are two diagrams that are generated. + +One is a diagram of ONLY successful transitions, that you saw at the beginning of this post. + +```mermaid +--8<-- "example/state/machine_test.go.state_diagram.mmd" +``` + +Second is a diagram that includes commands that resulted in an errors: +```mermaid +--8<-- "example/state/machine_test.go.state_diagram_with_errors.mmd" +``` + +Those diagrams are stored in the same directory as test file, and are prefixed with name used in `AssertSelfDocumentStateDiagram` function. +``` +machine_test.go.state_diagram.mmd +machine_test.go.state_diagram_with_errors.mmd +``` + +## State machines builder + +MkUnion provide `*machine.Machine[Dependency, Command, State]` struct that wires Transition, dependencies and state together. +It provide methods like: + +- `Handle(ctx context.Context, cmd C) error` that apply command to state, and return error if something went wrong during transition. +- `State() S` that return current state of the machine +- `Dep() D` that return dependencies that machine was build with. + + +This standard helps build on top of it, for example testing library that we use in [Testing state machines & self-documenting](#testing-state-machines-self-documenting) leverage it. + +Another good practice is that every package that defines state machine in the way described here, +should provide `NewMachine` function that will return bootstrapped machine with package types, like so: + +```go title="example/state/machine.go" +--8<-- "example/state/machine.go:9:11" +``` + + diff --git a/docs/examples/state_storage.md b/docs/examples/state_storage.md new file mode 100644 index 00000000..28fedb3b --- /dev/null +++ b/docs/examples/state_storage.md @@ -0,0 +1,215 @@ +--- +title: Persisting union in database +--- +## Persisting state in database + +TODO complete description! + +At this point of time, we have implemented and tested Order Service state machine. + +Next thing that we need to address in our road to the production is to persist state in database. + +MkUnion aims to support you in this task, by providing you `x/storage/schemaless` package that will take care of: + +- mapping golang structs to database representation and back from database to struct. +- leveraging optimistic concurrency control to resolve conflicts +- providing you with simple API to work with database +- and more + +Below is test case that demonstrate complete example of initializing database, +building an state using `NewMachine` , and saving and loading state from database. + +```go title="example/state/machine_database_test.go" +--8<-- "example/state/machine_database_test.go:16:46" +``` + +```mermaid +sequenceDiagram + participant R as Request + participant Store as Store + + activate R + R->>R: Validate(request) -> error + + R->>Store: Load state from database by request.ObjectId + activate Store + Store->>R: Ok(State) + deactivate Store + + R->>R: Create machine with state + R->>R: Apply command on a state + + R->>Store: Save state in database under request.ObjectId + activate Store + Store->>R: Ok() + deactivate Store + + deactivate R +``` + +Example implementation of such sequence diagram: + +```go +func Handle(rq Request, response Resopnse) { + ctx := rq.Context() + + // extract objectId and command from request + do some validation + id := rq.ObjectId + command := rq.Command + + // Load state from store + state, err := store.Find(ctx, id) + if err != nil { /*handle error*/ } + + machine := NewSimpleMachineWithState(Transition, state) + newState, err := machine.Apply(cmd, state) + if err != nil { /*handle error*/ } + + err := store.Save(ctx, newState) + if err != nil { /*handle error*/ } + + // serialize response + response.Write(newState) +} +``` + +## Error as state. Self-healing systems. +In request-response situation, handing errors is easy, but what if in some long-lived process something goes wrong? +How to handle errors in such situation? Without making what we learn about state machines useless or hard to use? + +One solution is to treat errors as state. +In such case, our state machines will never return error, but instead will return new state, that will represent error. + +When we introduce explicit command responsible for correcting RecoverableError, we can create self-healing systems. +Thanks to that, even in situation when errors are unknown, we can retroactivly introduce self-healing logic that correct states. + +Because there is always there is only one error state, it makes such state machines easy to reason about. + +```go +//go:generate mkunion -name State +type ( + // ... + RecoverableError struct { + ErrCode int + PrevState State + RetryCount int + } +) + +//go:generate mkunion -name Command +type ( + // ... + CorrectStateCMD struct {} +) +``` + +Now, we have to implement recoverable logic in our state machine. +We show example above how to do it in `Transition` function. + +Here is example implementation of such transition function: + +```go +func Transition(cmd Command, state State) (State, error) { +return MustMatchCommandR2( + cmd, + /* ... */ + func(cmd *CorrectStateCMD) (State, error) { + switch state := state.(type) { + case *RecoverableError: + state.RetryCount = state.RetryCount + 1 + + // here we can do some self-healing logic + if state.ErrCode == DuplicateServiceUnavailable { + newState, err := Transition(&MarkAsDuplicateCMD{}, state.PrevState) + if err != nil { + // we failed to correct error, so we return error state + return &RecoverableError{ + ErrCode: err, + PrevState: state.PrevState, + RetryCount: state.RetryCount, + }, nil + } + + // we manage to fix state, so we return new state + return newState, nil + } else { + // log information that we have new code, that we don't know how to handle + } + + // try to correct error in next iteration + return state, nil + } + } +} +``` + +Now, to correct states we have to select from database all states that are in error state. +It can be use in many ways, example below use a abstraction called `TaskQueue` that is responsible for running tasks in background. + +This abstraction guaranties that all records (historical and new ones) will be processed. +You can think about it, as a queue that is populated by records from database, that meet SQL query criteria. + +You can use CRON job and pull database. + +```go +//go:generate mms deployyml -type=TaskQueue -name=CorrectMSPErrors -autoscale=1,10 -memory=128Mi -cpu=100m -timeout=10s -schedule="0 0 * * *" +func main() + sql := "SELECT * FROM ObjectState WHERE RecoverableError.RetryCount < 3" + store := datalayer.DefaultStore() + queue := TaskQueueFrom("correct-msp-errors", sql, store) + queue.OnTask(func (ctx context.Context, task Task) error { + state := task.State() + cmd := &CorrectStateCMD{} + machine := NewSimpleMachineWithState(Transition, state) + newState, err := machine.Apply(cmd, state) + if err != nil { + return err + } + return task.Save(ctx, newState) + }) + err := queue.Run(ctx) + if err != nil { + log.Panic(err) + } +} +``` + + +## State machines and command queues and workflows +What if command would initiate state "to process" and save it in db +What if task queue would take such state and process it +Woudn't this be something like command queue? + +When to make a list of background processes that transition such states? + +### processors per state +It's like micromanage TaskQueue, where each state has it's own state, and it knows what command to apply to given state +This could be good starting point, when there is not a lot of good tooling + +### processor for state machine +With good tooling, transition of states can be declared in one place, +and deployment to task queue could be done automatically. + +Note, that only some of the transitions needs to happen in background, other can be done in request-response manner. + +### processor for state machine with workflow +State machine could be generalized to workflow. +We can think about it as set of generic Command and State (like a turing machine). + +States like Pending, Completed, Failed +Commands like Process, Retry, Cancel + +And workflow DSL with commands like: Invoke, Choose, Assign +Where function is some ID string, and functions needs to be either +pulled from registry, or called remotely (InvokeRemote). +some operations would require callback (InvokeAndAwait) + +Then background processor would be responsible for executing such workflow (using task queue) +Program would be responsible for defining workflow, and registering functions. + +Such programs could be also optimised for deployment, +if some function would be better to run on same machine that do RPC call +like function doing RPC call to database, and caching result in memory or in cache cluster dedicated to specific BFF + + + diff --git a/docs/examples/type_script.md b/docs/examples/type_script.md new file mode 100644 index 00000000..3a680462 --- /dev/null +++ b/docs/examples/type_script.md @@ -0,0 +1,11 @@ +--- +title: End-to-End types between Go and TypeScript +--- + +# End-to-End types between Go and TypeScript + +TODO description of generating TypeScript Definitions from using MkUnion + +```go title="example/my-app/server.go" +--8<-- "example/my-app/server.go:37:55" +``` \ No newline at end of file diff --git a/docs/getting_started.md b/docs/getting_started.md index 12e16afe..cea7545c 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -52,13 +52,13 @@ type User struct { Unfortunately Golang don't extend this feature to other parts of the language. -MkUnion defines `//go:tag` comment, following other idiomatic definitions `go:generate`, `go:embed` to allow to add metadata to types. -And use it heavily to offer way of adding new behaviour to go types. +MkUnion defines `//go:tag` comment, following other idiomatic definitions `go:generate`, `go:embed` to allow to add metadata to struct type. +And MkUnion use it heavily to offer way of adding new behaviour to go types. -#### `type ()` convention +#### `type (...)` convention Union type is defined as a set of types in a single type declaration. You can think of it as "one of" type. -To make it more readable, as convention I decided to use `type ()` declaration block, instead of individual `type` declaration. +To make it more readable, as convention I decided to use `type (...)` declaration block, instead of individual `type` declaration. ### Generate code In IDEs like Goland run `Option + Command + G` for fast code generation @@ -73,11 +73,11 @@ Alternatively you can run `mkunion` command directly mkunion -i example/vehicle.go ``` -In feature we plan to add `mkununion watch ./...` command that will watch for changes in your code and automatically generate union types for you. +In future I plan to add `mkununion watch ./...` command that will watch for changes in your code and automatically generate union types for you. This will allow you to remove `//go:generate` directive from your code, and have faster feedback loop. ### Match over union type -When you run `mkunion` command, it will generate file alongside your original file with `union_gen.go` suffix (example [vehicle_union_gen.go](..%2Fexample%2Fvehicle_union_gen.go)) +When you run `mkunion` command, it will generate file alongside your original file with `union_gen.go` suffix (example [vehicle_union_gen.go](../example/vehicle_union_gen.go)) You can use those function to do exhaustive matching on your union type. @@ -148,4 +148,4 @@ func ExampleVehicleToJSON() { You can notice that it has opinionated way of marshalling and unmarshalling your union type. It uses `$type` field to store type information, and then store actual data in separate field, with corresponding name. -You can read more about it in [Marshaling union in JSON](./example/json.md) section. \ No newline at end of file +You can read more about it in [Marshaling union in JSON](./examples/json.md) section. \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 1a3b2d33..61dd7fb6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,10 +9,10 @@ title: Introduction ## About -Strongly typed **union type** in golang. +Strongly typed **union type** in golang that supports generics* -* with full _pattern matching_ support -* with full _json marshalling_ support +* with exhaustive _pattern matching_ support +* with _json marshalling_ including generics * and as a bonus, can generate compatible typescript types for end-to-end type safety in your application ## Why @@ -23,7 +23,7 @@ Visitor pattern requires a lot of boiler plate code and hand crafting of the `Ac On top of that, any data marshalling like to/from JSON requires additional, hand crafted code, to make it work. -MkUnion solves all of those problems, by generating opinionated and strongly typed mindful code for you. +MkUnion solves all of those problems, by generating opinionated and strongly typed meaningful code for you. ## Example @@ -86,5 +86,5 @@ func ExampleFromJSON() { ## Next -- Read [getting started](docs/getting_started.md) to learn more. -- Or to understand better concepts jump and read [value proposition](docs/value_proposition.md) \ No newline at end of file +- Read [getting started](./getting_started.md) to learn more. +- Or to understand better concepts jump and read [value proposition](./value_proposition.md) \ No newline at end of file diff --git a/docs/roadmap.md b/docs/roadmap.md index 794a9042..f3f71663 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,15 +1,16 @@ # Roadmap - ## Learning and adoption -- [_] feature: `mkunion watch ./...` command that watches for changes in files and runs faster than go generate -- [_] docs: document simple state machine and how to use `mkunion` for it -- [_] docs: document other packages in `x/` directory -- [_] docs: document typescript types generation and end-to-end typs concepts (from backend to frontend) -- [_] feature: expose functions to extract `go:tag` metadata -- [_] docs: describe philosophy of "data as resource" and how it translates to some of library concepts +- [ ] **docs**: document simple state machine and how to use `mkunion` for it +- [ ] **feature**: `mkunion watch ./...` command that watches for changes in files and runs faster than `go generate ./...` that executes each go:generate separately +- [ ] **docs**: document other packages in `x/` directory +- [ ] **docs**: document typescript types generation and end-to-end typs concepts (from backend to frontend) +- [ ] **feature**: expose functions to extract `go:tag` metadata +- [ ] **docs**: describe philosophy of "data as resource" and how it translates to some of library concepts ## Long tern experiments and prototypes -- [_] experiment: generate other serialization formats (e.g. grpc) -- [_] prototype: http & gRPC client for end-to-end types. \ No newline at end of file +- [ ] **experiment**: generate other (de)serialization formats (e.g. grpc, sql, graphql) +- [ ] **prototype**: http & gRPC client for end-to-end types. +- [ ] **experiment**: allow to derive behaviour for types, like derive(Map), would generated union type with Map() method +- [ ] **experiment**: consider adding explicit discriminator type names like `example.Branch[int]` instead of `example.Branch`. This may complicate TypeScript codegen but it could increase end-to-end type safety. diff --git a/example/my-app/go.mod b/example/my-app/go.mod index 66f2672d..d0d2ea81 100644 --- a/example/my-app/go.mod +++ b/example/my-app/go.mod @@ -27,6 +27,7 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/fatih/structtag v1.2.0 // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/google/go-cmp v0.5.9 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/labstack/gommon v0.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect @@ -37,12 +38,12 @@ require ( github.com/stretchr/testify v1.8.4 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect - golang.org/x/crypto v0.14.0 // indirect + golang.org/x/crypto v0.18.0 // indirect golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 // indirect - golang.org/x/mod v0.12.0 // indirect - golang.org/x/net v0.17.0 // indirect - golang.org/x/sys v0.13.0 // indirect - golang.org/x/text v0.13.0 // indirect + golang.org/x/mod v0.14.0 // indirect + golang.org/x/net v0.20.0 // indirect + golang.org/x/sys v0.16.0 // indirect + golang.org/x/text v0.14.0 // indirect golang.org/x/time v0.3.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/example/my-app/server.go b/example/my-app/server.go index 2291359e..b915af00 100644 --- a/example/my-app/server.go +++ b/example/my-app/server.go @@ -37,7 +37,7 @@ import ( // this command make sure that all types that are imported will have generated typescript mapping //go:generate ../../cmd/mkunion/mkunion shape-export --language=typescript -o ./src/workflow -// this lines defines all types that should have typescript mapping +// this lines defines all types that should have typescript mapping generated by above command type ( Workflow = workflow.Workflow State = workflow.State @@ -63,12 +63,12 @@ type ( //go:tag mkunion:"ChatResult" type ( SystemResponse struct { - //ID string + //OrderID string Message string ToolCalls []openai.ToolCall } UserResponse struct { - //ID string + //OrderID string Message string } ChatResponses struct { @@ -154,10 +154,10 @@ func main() { oaic := openai.NewClient(os.Getenv("OPENAI_API_KEY")) - srv := NewService[workflow.Command, workflow.State]( + srv := NewService[workflow.Dependency, workflow.Command, workflow.State]( "process", statesRepo, - func(state workflow.State) *machine.Machine[workflow.Command, workflow.State] { + func(state workflow.State) *machine.Machine[workflow.Dependency, workflow.Command, workflow.State] { return workflow.NewMachine(di, state) }, func(cmd workflow.Command) (*predicate.WherePredicates, bool) { @@ -271,7 +271,7 @@ func main() { return nil, err } - result, err := shared.JSONMarshal[schemaless.FindingRecords[schemaless.Record[workflow.State]]](records) + result, err := shared.JSONMarshal[schemaless.PageResult[schemaless.Record[workflow.State]]](records) if err != nil { log.Errorf("failed to convert to json: %v", err) return nil, err @@ -342,7 +342,7 @@ func main() { return err } - resultJSON, err := shared.JSONMarshal[workflow.FunctionOutput](result) + resultJSON, err := shared.JSONMarshal[*workflow.FunctionOutput](result) if err != nil { log.Errorf("failed to convert to json: %v", err) return err @@ -380,7 +380,7 @@ func main() { return err } - result, err := shared.JSONMarshal[workflow.Workflow](record.Data) + result, err := shared.JSONMarshal[workflow.Flow](record.Data) if err != nil { if errors.Is(err, schemaless.ErrNotFound) { return c.JSONBlob(http.StatusNotFound, []byte(`{"error": "not found"}`)) @@ -551,7 +551,7 @@ func main() { // apply command work := workflow.NewMachine(di, state.Data) - err = work.Handle(cmd) + err = work.Handle(context.TODO(), cmd) if err != nil { log.Errorf("failed to handle command: %v", err) return nil, err @@ -576,7 +576,7 @@ func main() { proc := &taskqueue.FunctionProcessor[schemaless.Record[workflow.State]]{ F: func(task taskqueue.Task[schemaless.Record[workflow.State]]) { work := workflow.NewMachine(di, task.Data.Data) - err := work.Handle(&workflow.Run{}) + err := work.Handle(context.TODO(), &workflow.Run{}) if err != nil { log.Errorf("err: %s", err) return @@ -596,7 +596,7 @@ func main() { if next := workflow.ScheduleNext(newState, di); next != nil { work := workflow.NewMachine(di, nil) - err := work.Handle(next) + err := work.Handle(context.TODO(), next) if err != nil { log.Infof("err: %s", err) return @@ -725,14 +725,14 @@ func TypedJSONRequest[A, B any](handle func(x A) (B, error)) func(c echo.Context } } -func NewService[CMD any, State any]( +func NewService[Dep any, CMD any, State any]( recordType string, statesRepo *typedful.TypedRepoWithAggregator[State, any], - newMachine func(state State) *machine.Machine[CMD, State], + newMachine func(state State) *machine.Machine[Dep, CMD, State], extractWhere func(CMD) (*predicate.WherePredicates, bool), extractIDFromState func(State) (string, bool), -) *Service[CMD, State] { - return &Service[CMD, State]{ +) *Service[Dep, CMD, State] { + return &Service[Dep, CMD, State]{ repo: statesRepo, extractWhereFromCommandF: extractWhere, recordType: recordType, @@ -741,15 +741,15 @@ func NewService[CMD any, State any]( } } -type Service[CMD any, State any] struct { +type Service[Dep any, CMD any, State any] struct { repo *typedful.TypedRepoWithAggregator[State, any] extractWhereFromCommandF func(CMD) (*predicate.WherePredicates, bool) extractIDFromStateF func(State) (string, bool) recordType string - newMachine func(state State) *machine.Machine[CMD, State] + newMachine func(state State) *machine.Machine[Dep, CMD, State] } -func (service *Service[CMD, State]) CreateOrUpdate(cmd CMD) (res State, err error) { +func (service *Service[Dep, CMD, State]) CreateOrUpdate(cmd CMD) (res State, err error) { version := uint16(0) recordID := "" where, foundAndUpdate := service.extractWhereFromCommandF(cmd) @@ -773,7 +773,7 @@ func (service *Service[CMD, State]) CreateOrUpdate(cmd CMD) (res State, err erro } work := service.newMachine(res) - err = work.Handle(cmd) + err = work.Handle(context.TODO(), cmd) if err != nil { log.Errorf("failed to handle command: %v", err) return res, err diff --git a/example/my-app/src/App.tsx b/example/my-app/src/App.tsx index 20ad6b25..5b885c73 100644 --- a/example/my-app/src/App.tsx +++ b/example/my-app/src/App.tsx @@ -682,7 +682,7 @@ function App() { -