diff --git a/README.md b/README.md index e1ed6a5..b9dd04d 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ #### SMARTY DISCLAIMER: Subject to the terms of the associated license agreement, this software is freely available for your use. This software is FREE, AS IN PUPPIES, and is a gift. Enjoy your new responsibility. This means that while we may consider enhancement requests, we may or may not choose to entertain requests at our sole and absolute discretion. -Purpose ------------------------ +# Shuttle -How to Use: ------------------------ +## Purpose -Naming `shuttle` ------------------------ +Shuttle transforms HTTP requests into intention-revealing, user instructions to be processed by the application. After processing the given operation, shuttle then renders the results of that operation back to the underlying HTTP response. + +## How to Use: + +See the code in the `/sample` folder. \ No newline at end of file diff --git a/go.mod b/go.mod index 9a9c48a..63c2311 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/smarty/shuttle -go 1.20 +go 1.22 diff --git a/sample/app/processor.go b/sample/app/processor.go new file mode 100644 index 0000000..78944b4 --- /dev/null +++ b/sample/app/processor.go @@ -0,0 +1,42 @@ +package app + +import ( + "context" + "net/http" + + "github.com/smarty/shuttle" + "github.com/smarty/shuttle/sample/inputs" + "github.com/smarty/shuttle/sample/outputs" +) + +// Processor receives the InputModel, invokes application behavior, and returns the results to be rendered. +// Generally, the processor will receive some sort of application component which handles the real work +// of the application, but for this simple example, the domain work happens right here. +type Processor struct{} + +func NewProcessor() *Processor { + return &Processor{} +} + +func (this *Processor) Process(_ context.Context, v any) any { + switch input := v.(type) { + case *inputs.Addition: + return outputs.Addition{ + A: input.A, + B: input.B, + C: input.A + input.B, + } + case *inputs.Subtraction: + return outputs.Subtraction{ + A: input.A, + B: input.B, + C: input.A - input.B, + } + default: + return shuttle.SerializeResult{ + StatusCode: http.StatusInternalServerError, + ContentType: "text/plain; charset=utf-8", + Content: http.StatusText(http.StatusInternalServerError), + } + } +} diff --git a/sample/inputs/addition.go b/sample/inputs/addition.go new file mode 100644 index 0000000..ea7c85e --- /dev/null +++ b/sample/inputs/addition.go @@ -0,0 +1,79 @@ +package inputs + +import ( + "net/http" + "strconv" + + "github.com/smarty/shuttle" +) + +// Addition represents the data from the client's request. +type Addition struct { + A int + B int +} + +// NewAddition is the constructor and, by convention, sets the data to garbage values. +func NewAddition() *Addition { + return &Addition{ + A: -1, + B: -1, + } +} + +// Reset is called by shuttle to prepare the instance for use with the current request. +// This instance will be re-used over the lifetime of the application. +func (this *Addition) Reset() { + this.A = 0 + this.B = 0 +} + +// Bind is your only opportunity to get data from the request. +// Returning an error indicates that the request data is completely inscrutable. +// In such a case, processing will be short-circuited resulting in HTTP 400 Bad Request. +func (this *Addition) Bind(request *http.Request) error { + rawA := request.URL.Query().Get("a") + a, err := strconv.Atoi(rawA) + if err != nil { + return shuttle.InputError{ + Fields: []string{"query:a"}, + Name: "bind:integer", + Message: "failed to convert parameter to integer", + } + } + this.A = a + rawB := request.URL.Query().Get("b") + b, err := strconv.Atoi(rawB) + if err != nil { + return shuttle.InputError{ + Fields: []string{"query:b"}, + Name: "bind:integer", + Message: "failed to convert parameter to integer", + } + } + this.B = b + return nil +} + +// Validate is an opportunity to ensure that the values gathered during Bind are usable. +// The errors slice provided is pre-initialized and can be directly assigned to, beginning at index 0. +// The presence of any errors short-circuits processing and results in HTTP 422 Unprocessable Entity. +func (this *Addition) Validate(errors []error) (count int) { + if this.A <= 0 { + errors[count] = shuttle.InputError{ + Fields: []string{"query:a"}, + Name: "validate:positive", + Message: "parameter must be a positive integer", + } + count++ + } + if this.B <= 0 { + errors[count] = shuttle.InputError{ + Fields: []string{"query:b"}, + Name: "validate:positive", + Message: "parameter must be a positive integer", + } + count++ + } + return count +} diff --git a/sample/inputs/subtraction.go b/sample/inputs/subtraction.go new file mode 100644 index 0000000..8787e9a --- /dev/null +++ b/sample/inputs/subtraction.go @@ -0,0 +1,49 @@ +package inputs + +import "github.com/smarty/shuttle" + +type Subtraction struct { + shuttle.BaseInputModel + A int `json:"a"` + B int `json:"b"` +} + +func NewSubtraction() *Subtraction { + return &Subtraction{ + A: -1, + B: -1, + } +} + +func (this *Subtraction) Reset() { + this.A = 0 + this.B = 0 +} + +func (this *Subtraction) Validate(errors []error) (count int) { + if this.A <= 0 { + errors[count] = shuttle.InputError{ + Fields: []string{"query:a"}, + Name: "validate:positive", + Message: "parameter must be a positive integer", + } + count++ + } + if this.B <= 0 { + errors[count] = shuttle.InputError{ + Fields: []string{"query:b"}, + Name: "validate:positive", + Message: "parameter must be a positive integer", + } + count++ + } + if this.B > this.A { + errors[count] = shuttle.InputError{ + Fields: []string{"query:a", "query:b"}, + Name: "validate:a>b", + Message: "a must be greater than or equal to b", + } + count++ + } + return count +} diff --git a/sample/main.go b/sample/main.go new file mode 100644 index 0000000..f6e2cd9 --- /dev/null +++ b/sample/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "log" + "net/http" + + "github.com/smarty/shuttle" + "github.com/smarty/shuttle/sample/app" + "github.com/smarty/shuttle/sample/inputs" +) + +func main() { + // You can use any routing mechanism you'd like. For this sample, we'll be using net/http.ServeMux. + router := http.NewServeMux() + + // About as simple as a route definition gets: + router.Handle("/add", shuttle.NewHandler( + shuttle.Options.InputModel(func() shuttle.InputModel { return inputs.NewAddition() }), + shuttle.Options.Processor(func() shuttle.Processor { return app.NewProcessor() }), + )) + + // This route expects JSON in the request body: + router.Handle("/sub", shuttle.NewHandler( + shuttle.Options.InputModel(func() shuttle.InputModel { return inputs.NewSubtraction() }), + shuttle.Options.Processor(func() shuttle.Processor { return app.NewProcessor() }), + shuttle.Options.DeserializeJSON(true), + )) + + // Nothing interesting to see here... + address := "localhost:8080" + log.Printf("Listening on %s", address) + err := http.ListenAndServe(address, router) + if err != nil { + log.Fatal(err) + } +} diff --git a/sample/outputs/addition.go b/sample/outputs/addition.go new file mode 100644 index 0000000..ad2aac0 --- /dev/null +++ b/sample/outputs/addition.go @@ -0,0 +1,8 @@ +package outputs + +// Addition represents the http response, which by default will be serialized to JSON. +type Addition struct { + A int `json:"a"` + B int `json:"b"` + C int `json:"c"` +} diff --git a/sample/outputs/subtraction.go b/sample/outputs/subtraction.go new file mode 100644 index 0000000..2078615 --- /dev/null +++ b/sample/outputs/subtraction.go @@ -0,0 +1,7 @@ +package outputs + +type Subtraction struct { + A int `json:"a"` + B int `json:"b"` + C int `json:"c"` +}