├── .github └── workflows │ └── go.yml ├── .gitignore ├── LICENSE ├── README.md ├── bind.go ├── bindfuncs.go ├── buildtime.go ├── cacheall.go ├── cacheall_test.go ├── codecs.go ├── codecs_test.go ├── context.go ├── docs ├── 00-the_thinking_behind_design.md ├── 01-define-handlers.md └── 01-register-handlers.md ├── endpoint.go ├── error.go ├── examples_test.go ├── go.mod ├── go.sum ├── log.go ├── perf ├── panic_test.go └── reflect_test.go ├── responsewriter.go ├── router.go ├── rpc.go ├── runtime.go ├── server.go └── test.sh /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | go: 16 | - 'stable' 17 | 18 | steps: 19 | - name: Checkout the repo 20 | uses: actions/checkout@v4 21 | 22 | - name: Set up Go 23 | uses: actions/setup-go@v5 24 | with: 25 | go-version: '${{ matrix.go }}' 26 | check-latest: true 27 | cache-dependency-path: ./go.sum 28 | 29 | - name: Build 30 | run: go build -v ./... 31 | 32 | - name: Test 33 | run: ./test.sh 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | cover.* 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Googol Lee 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-espresso 2 | 3 | [![Go Reference](https://pkg.go.dev/badge/github.com/googollee/go-espresso.svg)](https://pkg.go.dev/github.com/googollee/go-espresso) [![Go Report Card](https://goreportcard.com/badge/github.com/googollee/go-espresso)](https://goreportcard.com/report/github.com/googollee/go-espresso) [![CI](https://github.com/googollee/go-espresso/actions/workflows/go.yml/badge.svg)](https://github.com/googollee/go-espresso/actions/workflows/go.yml) 4 | 5 | An web/API framework. 6 | 7 | - For individual developers and small teams. 8 | - Code first. 9 | - Focus on code, instead of switching between schemas and code. 10 | - Type safe. 11 | - No casting from `any`. 12 | - Support IDE completion. 13 | - As small dependencies as possible. 14 | - `httprouter` 15 | - `exp/slog` for logging 16 | - This may go to std in the future. 17 | - testing 18 | - `go-cmp` 19 | 20 | Examples to show the usage: 21 | 22 | - [Example (SimpleWeb)] 23 | - [Example (RestAPI)] 24 | 25 | [Example (SimpleWeb)]: https://pkg.go.dev/github.com/googollee/go-espresso#example-Espresso 26 | [Example (RestAPI)]: https://pkg.go.dev/github.com/googollee/go-espresso#example-Espresso-Rpc 27 | 28 | Requirement: 29 | 30 | - Go >= 1.22 31 | - Require generics. 32 | - `errors.Is()` supports `interface{ Unwrap() []error }` 33 | - With `GODEBUG=httpmuxgo121=0` 34 | -------------------------------------------------------------------------------- /bind.go: -------------------------------------------------------------------------------- 1 | package espresso 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "strings" 7 | ) 8 | 9 | // BindSource describes the type of bind. 10 | type BindSource int 11 | 12 | const ( 13 | BindPathParam BindSource = iota 14 | BindFormParam 15 | BindQueryParam 16 | BindHeadParam 17 | ) 18 | 19 | func (b BindSource) String() string { 20 | switch b { 21 | case BindPathParam: 22 | return "path" 23 | case BindFormParam: 24 | return "form" 25 | case BindQueryParam: 26 | return "query" 27 | case BindHeadParam: 28 | return "head" 29 | } 30 | return fmt.Sprintf("unknown(%d)", int(b)) 31 | } 32 | 33 | func (b BindSource) Valid() bool { 34 | return !strings.HasPrefix(b.String(), "unknown") 35 | } 36 | 37 | type BindFunc func(any, string) error 38 | 39 | type BindParam struct { 40 | Key string 41 | From BindSource 42 | Type reflect.Type 43 | Func BindFunc 44 | } 45 | 46 | // BindError describes the error when binding a param. 47 | type BindError struct { 48 | Key string 49 | From BindSource 50 | Type reflect.Type 51 | Err error 52 | } 53 | 54 | func errorBind(bind BindParam, err error) BindError { 55 | return BindError{ 56 | Key: bind.Key, 57 | From: bind.From, 58 | Type: bind.Type, 59 | Err: err, 60 | } 61 | } 62 | 63 | func (b BindError) Error() string { 64 | return fmt.Sprintf("bind %s with name %q to type %s error: %v", b.From, b.Key, b.Type, b.Err) 65 | } 66 | 67 | func (b BindError) Unwrap() error { 68 | return b.Err 69 | } 70 | 71 | // BindErrors describes all errors when binding params. 72 | type BindErrors []BindError 73 | 74 | func (e BindErrors) Error() string { 75 | errStr := make([]string, 0, len(e)) 76 | for _, err := range e { 77 | errStr = append(errStr, err.Error()) 78 | } 79 | return strings.Join(errStr, ", ") 80 | } 81 | 82 | func (e BindErrors) Unwrap() []error { 83 | if len(e) == 0 { 84 | return nil 85 | } 86 | 87 | ret := make([]error, 0, len(e)) 88 | for _, err := range e { 89 | err := err 90 | ret = append(ret, err) 91 | } 92 | 93 | return ret 94 | } 95 | 96 | func newBindParam(key string, src BindSource, v any) (BindParam, error) { 97 | vt, fn := getBindFunc(v) 98 | if fn == nil { 99 | return BindParam{}, fmt.Errorf("not support to bind %s key %q to %T", src, key, v) 100 | } 101 | 102 | if !src.Valid() { 103 | return BindParam{}, fmt.Errorf("not support bind type %d", src) 104 | } 105 | 106 | return BindParam{ 107 | Key: key, 108 | From: src, 109 | Type: vt, 110 | Func: fn, 111 | }, nil 112 | } 113 | -------------------------------------------------------------------------------- /bindfuncs.go: -------------------------------------------------------------------------------- 1 | package espresso 2 | 3 | import ( 4 | "reflect" 5 | "strconv" 6 | ) 7 | 8 | func getBindFunc(v any) (reflect.Type, BindFunc) { 9 | switch v.(type) { 10 | case *string: 11 | return bindString[string]() 12 | case *int: 13 | return bindInt[int](int(reflect.TypeOf(int(0)).Size()) * 8) 14 | case *int8: 15 | return bindInt[int8](8) 16 | case *int16: 17 | return bindInt[int16](16) 18 | case *int32: 19 | return bindInt[int32](32) 20 | case *int64: 21 | return bindInt[int64](64) 22 | case *uint: 23 | return bindUint[uint](int(reflect.TypeOf(uint(0)).Size()) * 8) 24 | case *uint8: 25 | return bindUint[uint8](8) 26 | case *uint16: 27 | return bindUint[uint16](16) 28 | case *uint32: 29 | return bindUint[uint32](32) 30 | case *uint64: 31 | return bindUint[uint64](64) 32 | case *float32: 33 | return bindFloat[float32](32) 34 | case *float64: 35 | return bindFloat[float64](64) 36 | } 37 | 38 | return nil, nil 39 | } 40 | 41 | type integer interface { 42 | ~int | ~int8 | ~int16 | ~int32 | ~int64 43 | } 44 | 45 | func bindInt[T integer](bitSize int) (reflect.Type, BindFunc) { 46 | return reflect.TypeOf(T(0)), func(v any, param string) error { 47 | i, err := strconv.ParseInt(param, 10, bitSize) 48 | if err != nil { 49 | return err 50 | } 51 | p := v.(*T) 52 | *p = T(i) 53 | return nil 54 | } 55 | } 56 | 57 | type uinteger interface { 58 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 59 | } 60 | 61 | func bindUint[T uinteger](bitSize int) (reflect.Type, BindFunc) { 62 | return reflect.TypeOf(T(0)), func(v any, param string) error { 63 | i, err := strconv.ParseUint(param, 10, bitSize) 64 | if err != nil { 65 | return err 66 | } 67 | p := v.(*T) 68 | *p = T(i) 69 | return nil 70 | } 71 | } 72 | 73 | type float interface { 74 | ~float32 | ~float64 75 | } 76 | 77 | func bindFloat[T float](bitSize int) (reflect.Type, BindFunc) { 78 | return reflect.TypeOf(T(0)), func(v any, param string) error { 79 | i, err := strconv.ParseFloat(param, bitSize) 80 | if err != nil { 81 | return err 82 | } 83 | p := v.(*T) 84 | *p = T(i) 85 | return nil 86 | } 87 | } 88 | 89 | func bindString[T ~string]() (reflect.Type, BindFunc) { 90 | return reflect.TypeOf(T("")), func(v any, param string) error { 91 | p := v.(*T) 92 | *p = T(param) 93 | return nil 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /buildtime.go: -------------------------------------------------------------------------------- 1 | package espresso 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | ) 8 | 9 | var errBuilderEnd = errors.New("build end.") 10 | var errRegisterContextCall = errors.New("should call Context.Endpoint() in the beginning with End().") 11 | 12 | type buildtimeEndpoint struct { 13 | endpoint *Endpoint 14 | } 15 | 16 | func (b *buildtimeEndpoint) BindPath(key string, v any) EndpointBuilder { 17 | bind, err := newBindParam(key, BindPathParam, v) 18 | if err != nil { 19 | panic(errorBind(bind, err).Error()) 20 | } 21 | 22 | b.endpoint.PathParams[key] = bind 23 | 24 | return b 25 | } 26 | 27 | func (b *buildtimeEndpoint) End() BindErrors { 28 | panic(errBuilderEnd) 29 | } 30 | 31 | type buildtimeContext struct { 32 | context.Context 33 | endpoint *Endpoint 34 | } 35 | 36 | func newBuildtimeContext() *buildtimeContext { 37 | return &buildtimeContext{ 38 | Context: context.Background(), 39 | endpoint: newEndpoint(), 40 | } 41 | } 42 | 43 | func (c *buildtimeContext) Endpoint(method, path string, middlewares ...HandleFunc) EndpointBuilder { 44 | c.endpoint.Method = method 45 | c.endpoint.Path = path 46 | c.endpoint.ChainFuncs = middlewares 47 | 48 | return &buildtimeEndpoint{ 49 | endpoint: c.endpoint, 50 | } 51 | } 52 | 53 | func (c *buildtimeContext) Request() *http.Request { 54 | panic(errRegisterContextCall) 55 | } 56 | 57 | func (c *buildtimeContext) ResponseWriter() http.ResponseWriter { 58 | panic(errRegisterContextCall) 59 | } 60 | 61 | func (c *buildtimeContext) Error() error { 62 | panic(errRegisterContextCall) 63 | } 64 | 65 | func (c *buildtimeContext) Next() { 66 | panic(errRegisterContextCall) 67 | } 68 | 69 | func (c *buildtimeContext) WithParent(ctx context.Context) Context { 70 | panic(errRegisterContextCall) 71 | } 72 | 73 | func (c *buildtimeContext) WithResponseWriter(w http.ResponseWriter) Context { 74 | panic(errRegisterContextCall) 75 | } 76 | -------------------------------------------------------------------------------- /cacheall.go: -------------------------------------------------------------------------------- 1 | package espresso 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | ) 7 | 8 | func cacheAllError(ctx Context) error { 9 | wr := &responseWriter{ 10 | ResponseWriter: ctx.ResponseWriter(), 11 | } 12 | code := http.StatusInternalServerError 13 | defer func() { 14 | err := checkError(ctx, recover()) 15 | 16 | if wr.hasWritten || err == nil { 17 | return 18 | } 19 | 20 | if httpCoder, ok := err.(HTTPError); ok { 21 | code = httpCoder.HTTPCode() 22 | } 23 | wr.WriteHeader(code) 24 | 25 | codec := CodecsModule.Value(ctx) 26 | if codec == nil { 27 | fmt.Fprintf(wr, "%v", err) 28 | return 29 | } 30 | 31 | _ = codec.EncodeResponse(ctx, err) 32 | }() 33 | 34 | ctx = ctx.WithResponseWriter(wr) 35 | ctx.Next() 36 | 37 | return nil 38 | } 39 | 40 | func checkError(ctx Context, perr any) error { 41 | if perr != nil { 42 | return Error(http.StatusInternalServerError, fmt.Errorf("%v", perr)) 43 | } 44 | 45 | err := ctx.Err() 46 | if err == nil { 47 | return nil 48 | } 49 | 50 | if _, ok := err.(HTTPError); ok { 51 | return err 52 | } 53 | 54 | return Error(http.StatusInternalServerError, err) 55 | } 56 | -------------------------------------------------------------------------------- /cacheall_test.go: -------------------------------------------------------------------------------- 1 | package espresso_test 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "sync/atomic" 10 | "testing" 11 | 12 | "github.com/googollee/module" 13 | 14 | "github.com/googollee/go-espresso" 15 | ) 16 | 17 | func TestCacheAllMiddleware(t *testing.T) { 18 | tests := []struct { 19 | name string 20 | providers []module.Provider 21 | middlewares []espresso.HandleFunc 22 | wantCode int 23 | wantBody string 24 | }{ 25 | { 26 | name: "MiddlewareError", 27 | middlewares: []espresso.HandleFunc{func(ctx espresso.Context) error { 28 | return errors.New("error") 29 | }}, 30 | wantCode: http.StatusInternalServerError, 31 | wantBody: "error", 32 | }, 33 | { 34 | name: "MiddlewareHTTPError", 35 | middlewares: []espresso.HandleFunc{func(ctx espresso.Context) error { 36 | return espresso.Error(http.StatusGatewayTimeout, errors.New("gateway timeout")) 37 | }}, 38 | wantCode: http.StatusGatewayTimeout, 39 | wantBody: "gateway timeout", 40 | }, 41 | { 42 | name: "MiddlewarePanic", 43 | middlewares: []espresso.HandleFunc{func(ctx espresso.Context) error { 44 | panic("panic") 45 | }}, 46 | wantCode: http.StatusInternalServerError, 47 | wantBody: "panic", 48 | }, 49 | { 50 | name: "MiddlewareErrorWithCodec", 51 | providers: []module.Provider{espresso.ProvideCodecs}, 52 | middlewares: []espresso.HandleFunc{func(ctx espresso.Context) error { 53 | return errors.New("error") 54 | }}, 55 | wantCode: http.StatusInternalServerError, 56 | wantBody: "{\"message\":\"error\"}\n", 57 | }, 58 | { 59 | name: "MiddlewareHTTPErrorWithCodec", 60 | providers: []module.Provider{espresso.ProvideCodecs}, 61 | middlewares: []espresso.HandleFunc{func(ctx espresso.Context) error { 62 | return espresso.Error(http.StatusGatewayTimeout, errors.New("gateway timeout")) 63 | }}, 64 | wantCode: http.StatusGatewayTimeout, 65 | wantBody: "{\"message\":\"gateway timeout\"}\n", 66 | }, 67 | { 68 | name: "MiddlewarePanicWithCodec", 69 | providers: []module.Provider{espresso.ProvideCodecs}, 70 | middlewares: []espresso.HandleFunc{func(ctx espresso.Context) error { 71 | panic("panic") 72 | }}, 73 | wantCode: http.StatusInternalServerError, 74 | wantBody: "{\"message\":\"panic\"}\n", 75 | }, 76 | } 77 | 78 | for _, tc := range tests { 79 | t.Run(tc.name, func(t *testing.T) { 80 | espo := espresso.New() 81 | espo.Use(tc.middlewares...) 82 | espo.AddModule(tc.providers...) 83 | 84 | var called int32 85 | espo.HandleFunc(func(ctx espresso.Context) error { 86 | atomic.AddInt32(&called, 1) 87 | 88 | if err := ctx.Endpoint(http.MethodGet, "/").End(); err != nil { 89 | return err 90 | } 91 | 92 | fmt.Fprint(ctx.ResponseWriter(), "ok") 93 | 94 | return nil 95 | }) 96 | 97 | called = 0 98 | svr := httptest.NewServer(espo) 99 | defer svr.Close() 100 | 101 | resp, err := http.Get(svr.URL) 102 | if err != nil { 103 | t.Fatal(err) 104 | } 105 | defer resp.Body.Close() 106 | 107 | if got := atomic.LoadInt32(&called); got != 0 { 108 | t.Fatalf("handle func is called") 109 | } 110 | 111 | if got, want := resp.StatusCode, tc.wantCode; got != want { 112 | t.Fatalf("resp.Status = %d, want: %d", got, want) 113 | } 114 | 115 | respBody, err := io.ReadAll(resp.Body) 116 | if err != nil { 117 | t.Fatal(err) 118 | } 119 | 120 | if got, want := string(respBody), tc.wantBody; got != want { 121 | t.Errorf("resp.Body = %q, want: %q", got, want) 122 | } 123 | }) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /codecs.go: -------------------------------------------------------------------------------- 1 | package espresso 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "mime" 9 | 10 | "github.com/googollee/module" 11 | "gopkg.in/yaml.v3" 12 | ) 13 | 14 | var ( 15 | CodecsModule = module.New[*Codecs]() 16 | ProvideCodecs = CodecsModule.ProvideWithFunc(func(context.Context) (*Codecs, error) { 17 | return NewCodecs(JSON{}, YAML{}), nil 18 | }) 19 | ) 20 | 21 | type Codec interface { 22 | Mime() string 23 | Encode(ctx context.Context, w io.Writer, v any) error 24 | Decode(ctx context.Context, r io.Reader, v any) error 25 | } 26 | 27 | type Codecs struct { 28 | fallback Codec 29 | codecs map[string]Codec 30 | } 31 | 32 | func NewCodecs(codec ...Codec) *Codecs { 33 | ret := &Codecs{ 34 | fallback: codec[0], 35 | codecs: make(map[string]Codec), 36 | } 37 | 38 | for _, c := range codec { 39 | ret.codecs[c.Mime()] = c 40 | } 41 | 42 | return ret 43 | } 44 | 45 | func (c *Codecs) DecodeRequest(ctx Context, v any) error { 46 | codec := c.Request(ctx) 47 | if err := codec.Decode(ctx, ctx.Request().Body, v); err != nil { 48 | return fmt.Errorf("decode with codec(%s) error: %w", codec.Mime(), err) 49 | } 50 | return nil 51 | } 52 | 53 | func (c *Codecs) EncodeResponse(ctx Context, v any) error { 54 | codec := c.Response(ctx) 55 | if err := codec.Encode(ctx, ctx.ResponseWriter(), v); err != nil { 56 | return fmt.Errorf("encode with codec(%s) error: %w", codec.Mime(), err) 57 | } 58 | return nil 59 | } 60 | 61 | func (c *Codecs) Request(ctx Context) Codec { 62 | ret := c.getCodec(ctx, "Context-Type") 63 | if ret == nil { 64 | return c.fallback 65 | } 66 | 67 | return ret 68 | } 69 | 70 | func (c *Codecs) Response(ctx Context) Codec { 71 | if ret := c.getCodec(ctx, "Accept"); ret != nil { 72 | return ret 73 | } 74 | 75 | if ret := c.getCodec(ctx, "Context-Type"); ret != nil { 76 | return ret 77 | } 78 | 79 | return c.fallback 80 | } 81 | 82 | func (c *Codecs) getCodec(ctx Context, head string) Codec { 83 | req := ctx.Request() 84 | reqMime, _, err := mime.ParseMediaType(req.Header.Get(head)) 85 | if err != nil { 86 | return nil 87 | } 88 | 89 | ret, ok := c.codecs[reqMime] 90 | if !ok { 91 | return nil 92 | } 93 | 94 | return ret 95 | } 96 | 97 | type JSON struct{} 98 | 99 | func (JSON) Mime() string { 100 | return "application/json" 101 | } 102 | 103 | func (JSON) Decode(ctx context.Context, r io.Reader, v any) error { 104 | return json.NewDecoder(r).Decode(v) 105 | } 106 | 107 | func (JSON) Encode(ctx context.Context, w io.Writer, v any) error { 108 | return json.NewEncoder(w).Encode(v) 109 | } 110 | 111 | type YAML struct{} 112 | 113 | func (YAML) Mime() string { 114 | return "application/yaml" 115 | } 116 | 117 | func (YAML) Decode(ctx context.Context, r io.Reader, v any) error { 118 | return yaml.NewDecoder(r).Decode(v) 119 | } 120 | 121 | func (YAML) Encode(ctx context.Context, w io.Writer, v any) error { 122 | encoder := yaml.NewEncoder(w) 123 | defer encoder.Close() 124 | 125 | return encoder.Encode(v) 126 | } 127 | -------------------------------------------------------------------------------- /codecs_test.go: -------------------------------------------------------------------------------- 1 | package espresso 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | ) 9 | 10 | func TestCodecsJsonContentType(t *testing.T) { 11 | bg := context.Background() 12 | codecs := NewCodecs(JSON{}, YAML{}) 13 | 14 | req, err := http.NewRequest(http.MethodPost, "http://domain/path", nil) 15 | if err != nil { 16 | panic(err) 17 | } 18 | req.Header.Add("Content-Type", "application/json") 19 | 20 | resp := httptest.NewRecorder() 21 | 22 | ctx := &runtimeContext{ 23 | ctx: bg, 24 | request: req, 25 | response: resp, 26 | } 27 | 28 | reqCodec := codecs.Request(ctx) 29 | if got, want := reqCodec.Mime(), "application/json"; got != want { 30 | t.Errorf("reqCodec.Mime() = %q, want: %q", got, want) 31 | } 32 | respCodec := codecs.Response(ctx) 33 | if got, want := respCodec.Mime(), "application/json"; got != want { 34 | t.Errorf("reqCodec.Mime() = %q, want: %q", got, want) 35 | } 36 | } 37 | 38 | func TestCodecsEmptyContentType(t *testing.T) { 39 | bg := context.Background() 40 | codecs := NewCodecs(JSON{}, YAML{}) 41 | 42 | req, err := http.NewRequest(http.MethodPost, "http://domain/path", nil) 43 | if err != nil { 44 | panic(err) 45 | } 46 | 47 | resp := httptest.NewRecorder() 48 | 49 | ctx := &runtimeContext{ 50 | ctx: bg, 51 | request: req, 52 | response: resp, 53 | } 54 | 55 | reqCodec := codecs.Request(ctx) 56 | if got, want := reqCodec.Mime(), "application/json"; got != want { 57 | t.Errorf("reqCodec.Mime() = %q, want: %q", got, want) 58 | } 59 | respCodec := codecs.Response(ctx) 60 | if got, want := respCodec.Mime(), "application/json"; got != want { 61 | t.Errorf("reqCodec.Mime() = %q, want: %q", got, want) 62 | } 63 | } 64 | 65 | func TestCodecsDifferentContextTypeAccept(t *testing.T) { 66 | bg := context.Background() 67 | codecs := NewCodecs(JSON{}, YAML{}) 68 | 69 | req, err := http.NewRequest(http.MethodPost, "http://domain/path", nil) 70 | if err != nil { 71 | panic(err) 72 | } 73 | req.Header.Add("Content-Type", "application/json") 74 | req.Header.Add("Accept", "application/yaml") 75 | 76 | resp := httptest.NewRecorder() 77 | 78 | ctx := &runtimeContext{ 79 | ctx: bg, 80 | request: req, 81 | response: resp, 82 | } 83 | 84 | reqCodec := codecs.Request(ctx) 85 | if got, want := reqCodec.Mime(), "application/json"; got != want { 86 | t.Errorf("reqCodec.Mime() = %q, want: %q", got, want) 87 | } 88 | respCodec := codecs.Response(ctx) 89 | if got, want := respCodec.Mime(), "application/yaml"; got != want { 90 | t.Errorf("reqCodec.Mime() = %q, want: %q", got, want) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /context.go: -------------------------------------------------------------------------------- 1 | package espresso 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | ) 7 | 8 | type HandleFunc func(Context) error 9 | 10 | type ContextExtender interface { 11 | WithParent(ctx context.Context) Context 12 | WithResponseWriter(w http.ResponseWriter) Context 13 | } 14 | 15 | type Context interface { 16 | context.Context 17 | ContextExtender 18 | Endpoint(method string, path string, middlewares ...HandleFunc) EndpointBuilder 19 | 20 | Next() 21 | 22 | Request() *http.Request 23 | ResponseWriter() http.ResponseWriter 24 | } 25 | 26 | type MiddlewareProvider interface { 27 | Middlewares() []HandleFunc 28 | } 29 | -------------------------------------------------------------------------------- /docs/00-the_thinking_behind_design.md: -------------------------------------------------------------------------------- 1 | # The Thinking Behind Design 2 | 3 | I have a lot of personal side projects, which need backend services. When I built them, I found Go API frameworks are not good enough for personal projects. Most of frameworks belong to two kinds: 4 | 5 | 1. gRPC based. 6 | - API definitions and implementation in different files. 7 | - Require a extra step to generate scaffold files. 8 | - New concepts and grammar. 9 | 2. Std `net/http` based. 10 | - Inbound/outbond uses `any`, not type-safe. 11 | - Require A lot of code to parse/convert requests/responses. 12 | - No telemetry integration. 13 | 14 | I hope to create a new framework for personal developers and small groups, to simplify the developing workflow but also keep best practices like type-safe, easy testing, and fast developing. 15 | 16 | The result is `go-espresso`. This project follows guidelines below, to achieve purposes mentioned above. 17 | 18 | - The endpoint definition and implementation are in the same place, 19 | - No code generation, 20 | - Provide generics helpers to reduce scaffold codes and keep type-safe. 21 | - Integrate telemetry through middlewares. 22 | - Follow Go guidelines, as much as possible. 23 | - No reflecting when handling real requests. 24 | 25 | Please also check other documents about design and usage. 26 | -------------------------------------------------------------------------------- /docs/01-define-handlers.md: -------------------------------------------------------------------------------- 1 | # Define Handlers 2 | 3 | The signature of a `espresso` handler is: 4 | 5 | ```go 6 | type Handler func(ctx espresso.Context) error 7 | ``` 8 | 9 | It could be implemented by either functions or struct methods: 10 | 11 | ```go 12 | func FuncHanlder(ctx espresso.Context) error { 13 | // ... 14 | } 15 | 16 | type Service struct{ 17 | // ... 18 | } 19 | 20 | func (s *Service) MethodHandlerWithPointer(ctx espresso.Context) error { 21 | // ... 22 | } 23 | 24 | func (s Service) MethodHandlerWithValue(ctx espresso.Context) error { 25 | // ... 26 | } 27 | ``` 28 | 29 | To be simple, examples below are based on function handlers. 30 | 31 | Use the `Context` instance in a handler to define endpoint information, like below: 32 | 33 | ```go 34 | func Handler(ctx espresso.Context) error { 35 | var param int 36 | if err := ctx.Endpoint(http.MethodGet, "/endpoint/with/:param_in_path"). 37 | BindPath("param_in_path", ¶m). 38 | End(); err != nil { 39 | return err 40 | } 41 | // ... 42 | } 43 | ``` 44 | 45 | `Context.Endpoint()` registers an endpoint as `GET /endpoint/with/:param_in_path`, with binding values: 46 | 47 | - binds the parameter `:param_in_path` in a path to an integer `param` variable. 48 | - Valid paths are like `/endpoint/with/1` or `/endpoint/with/1000`, but not `/endpoint/with/non_number`. 49 | - Parse `:param_in_path` part in the path of a request to a `int` value and assign to the `param` variable. 50 | 51 | When registering this handler, `espresso` passes a special `Context` to collect bindings with `Context.Endpoint()`, and panic in `End()`. Please put this code block at the top of a handler, to avoid calling real logic code below when registering. 52 | 53 | When handling a request, `espresso` passes another `Context` to this handler, parse values from strings in the request and assign results to bind variables. If there are parsing errors, all errors return by `End()`. 54 | -------------------------------------------------------------------------------- /docs/01-register-handlers.md: -------------------------------------------------------------------------------- 1 | # Register Handlers 2 | 3 | It's easy to register a function handler: 4 | 5 | ```go 6 | func FuncHandler(ctx espresso.Context) error { 7 | // ... 8 | } 9 | 10 | svr := espresso.New() 11 | 12 | svr.HandleFunc(FuncHandler) 13 | ``` 14 | 15 | It's also easy to register all method handlers in a struct: 16 | 17 | ```go 18 | type Service struct{ 19 | // ... 20 | } 21 | 22 | func (s *Service) Handle1(ctx espresso.Context) error { /* ... */ } 23 | func (s *Service) Handle2(ctx espresso.Context) error { /* ... */ } 24 | func (s *Service) Handle3(ctx espresso.Context) error { /* ... */ } 25 | func (s *Service) Handle4(ctx espresso.Context) error { /* ... */ } 26 | 27 | service := &Service{} 28 | 29 | svr := espresso.New() 30 | svr.HandleAll(service) 31 | ``` 32 | 33 | `HandleAll` goes through all methods of the given value by reflecting, and register any methods matching with the signature `func(espresso.Context) error` of the `espresso` handler. When handling requests, it calls methods directly. No reflecting during handling real requests. 34 | -------------------------------------------------------------------------------- /endpoint.go: -------------------------------------------------------------------------------- 1 | package espresso 2 | 3 | import "reflect" 4 | 5 | type EndpointBuilder interface { 6 | BindPath(key string, v any) EndpointBuilder 7 | End() BindErrors 8 | } 9 | 10 | type Endpoint struct { 11 | Method string 12 | Path string 13 | PathParams map[string]BindParam 14 | QueryParams map[string]BindParam 15 | FormParams map[string]BindParam 16 | HeadParams map[string]BindParam 17 | RequestType reflect.Type 18 | ResponseType reflect.Type 19 | ChainFuncs []HandleFunc 20 | } 21 | 22 | func newEndpoint() *Endpoint { 23 | return &Endpoint{ 24 | PathParams: make(map[string]BindParam), 25 | QueryParams: make(map[string]BindParam), 26 | FormParams: make(map[string]BindParam), 27 | HeadParams: make(map[string]BindParam), 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /error.go: -------------------------------------------------------------------------------- 1 | package espresso 2 | 3 | type HTTPError interface { 4 | HTTPCode() int 5 | } 6 | 7 | func Error(code int, err error) error { 8 | return &httpError{ 9 | Message: err.Error(), 10 | 11 | err: err, 12 | code: code, 13 | } 14 | } 15 | 16 | type httpError struct { 17 | Message string `json:"message"` 18 | 19 | code int 20 | err error 21 | } 22 | 23 | func (e httpError) Error() string { 24 | return e.err.Error() 25 | } 26 | 27 | func (e httpError) HTTPCode() int { 28 | return e.code 29 | } 30 | 31 | func (e httpError) Unwrap() error { 32 | return e.err 33 | } 34 | -------------------------------------------------------------------------------- /examples_test.go: -------------------------------------------------------------------------------- 1 | package espresso_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "log/slog" 9 | "net/http" 10 | "net/http/httptest" 11 | "os" 12 | 13 | "github.com/googollee/go-espresso" 14 | ) 15 | 16 | func ExampleEspresso() { 17 | type Book struct { 18 | ID int `json:"id"` 19 | Title string `json:"title"` 20 | } 21 | type Books map[int]Book 22 | 23 | books := make(Books) 24 | books[1] = Book{ 25 | ID: 1, 26 | Title: "The Espresso Book", 27 | } 28 | books[2] = Book{ 29 | ID: 2, 30 | Title: "The Second Book", 31 | } 32 | 33 | espo := espresso.New() 34 | // Log to stdout for Output 35 | espo.AddModule(espresso.LogModule.ProvideWithFunc(func(ctx context.Context) (*slog.Logger, error) { 36 | removeTime := func(groups []string, a slog.Attr) slog.Attr { 37 | // Remove time from the output for predictable test output. 38 | if a.Key == slog.TimeKey { 39 | return slog.Attr{} 40 | } 41 | return a 42 | } 43 | 44 | opt := slog.HandlerOptions{ 45 | ReplaceAttr: removeTime, 46 | } 47 | return slog.New(slog.NewTextHandler(os.Stdout, &opt)), nil 48 | })) 49 | 50 | espo.AddModule(espresso.ProvideCodecs) 51 | 52 | router := espo.WithPrefix("/http") 53 | router.HandleFunc(func(ctx espresso.Context) error { 54 | var id int 55 | if err := ctx.Endpoint(http.MethodGet, "/book/{id}"). 56 | BindPath("id", &id). 57 | End(); err != nil { 58 | return err 59 | } 60 | 61 | book, ok := books[id] 62 | if !ok { 63 | return espresso.Error(http.StatusNotFound, fmt.Errorf("not found")) 64 | } 65 | 66 | if err := espresso.CodecsModule.Value(ctx).EncodeResponse(ctx, &book); err != nil { 67 | return err 68 | } 69 | 70 | return nil 71 | }) 72 | router.HandleFunc(func(ctx espresso.Context) error { 73 | if err := ctx.Endpoint(http.MethodPost, "/book"). 74 | End(); err != nil { 75 | return err 76 | } 77 | 78 | codecs := espresso.CodecsModule.Value(ctx) 79 | 80 | var book Book 81 | if err := codecs.DecodeRequest(ctx, &book); err != nil { 82 | return espresso.Error(http.StatusBadRequest, err) 83 | } 84 | 85 | book.ID = len(books) 86 | books[book.ID] = book 87 | 88 | if err := codecs.EncodeResponse(ctx, &book); err != nil { 89 | return err 90 | } 91 | 92 | return nil 93 | }) 94 | 95 | svr := httptest.NewServer(espo) 96 | defer svr.Close() 97 | 98 | func() { 99 | var book Book 100 | resp, err := http.Get(svr.URL + "/http/book/1") 101 | if err != nil { 102 | panic(err) 103 | } 104 | defer resp.Body.Close() 105 | 106 | if resp.StatusCode != http.StatusOK { 107 | panic(resp.Status) 108 | } 109 | 110 | if err := json.NewDecoder(resp.Body).Decode(&book); err != nil { 111 | panic(err) 112 | } 113 | 114 | fmt.Println("Book 1 title:", book.Title) 115 | }() 116 | 117 | func() { 118 | arg := Book{Title: "The New Book"} 119 | 120 | var buf bytes.Buffer 121 | if err := json.NewEncoder(&buf).Encode(&arg); err != nil { 122 | panic(err) 123 | } 124 | 125 | resp, err := http.Post(svr.URL+"/http/book", "application/json", &buf) 126 | if err != nil { 127 | panic(err) 128 | } 129 | defer resp.Body.Close() 130 | 131 | if resp.StatusCode != http.StatusOK { 132 | panic(resp.Status) 133 | } 134 | 135 | var ret Book 136 | if err := json.NewDecoder(resp.Body).Decode(&ret); err != nil { 137 | panic(err) 138 | } 139 | 140 | fmt.Println("The New Book id:", ret.ID) 141 | }() 142 | 143 | // Output: 144 | // level=INFO msg="receive http" method=GET path=/http/book/1 145 | // level=INFO msg="finish http" method=GET path=/http/book/1 146 | // Book 1 title: The Espresso Book 147 | // level=INFO msg="receive http" method=POST path=/http/book 148 | // level=INFO msg="finish http" method=POST path=/http/book 149 | // The New Book id: 2 150 | } 151 | 152 | func ExampleEspresso_rpc() { 153 | type Book struct { 154 | ID int `json:"id"` 155 | Title string `json:"title"` 156 | } 157 | type Books map[int]Book 158 | 159 | books := make(Books) 160 | books[1] = Book{ 161 | ID: 1, 162 | Title: "The Espresso Book", 163 | } 164 | books[2] = Book{ 165 | ID: 2, 166 | Title: "The Second Book", 167 | } 168 | 169 | espo := espresso.New() 170 | // Log to stdout for Output 171 | espo.AddModule(espresso.LogModule.ProvideWithFunc(func(ctx context.Context) (*slog.Logger, error) { 172 | removeTime := func(groups []string, a slog.Attr) slog.Attr { 173 | // Remove time from the output for predictable test output. 174 | if a.Key == slog.TimeKey { 175 | return slog.Attr{} 176 | } 177 | return a 178 | } 179 | 180 | opt := slog.HandlerOptions{ 181 | ReplaceAttr: removeTime, 182 | } 183 | return slog.New(slog.NewTextHandler(os.Stdout, &opt)), nil 184 | })) 185 | espo.AddModule(espresso.ProvideCodecs) 186 | 187 | router := espo.WithPrefix("/rpc") 188 | router.HandleFunc(espresso.RPCRetrive(func(ctx espresso.Context) (*Book, error) { 189 | var id int 190 | if err := ctx.Endpoint(http.MethodGet, "/book/{id}"). 191 | BindPath("id", &id). 192 | End(); err != nil { 193 | return nil, err 194 | } 195 | 196 | book, ok := books[id] 197 | if !ok { 198 | return nil, espresso.Error(http.StatusNotFound, fmt.Errorf("not found")) 199 | } 200 | 201 | return &book, nil 202 | })) 203 | router.HandleFunc(espresso.RPC(func(ctx espresso.Context, book *Book) (*Book, error) { 204 | if err := ctx.Endpoint(http.MethodPost, "/book"). 205 | End(); err != nil { 206 | return nil, err 207 | } 208 | 209 | book.ID = len(books) 210 | books[book.ID] = *book 211 | 212 | return book, nil 213 | })) 214 | 215 | svr := httptest.NewServer(espo) 216 | defer svr.Close() 217 | 218 | func() { 219 | var book Book 220 | resp, err := http.Get(svr.URL + "/rpc/book/1") 221 | if err != nil { 222 | panic(err) 223 | } 224 | defer resp.Body.Close() 225 | 226 | if resp.StatusCode != http.StatusOK { 227 | panic(resp.Status) 228 | } 229 | 230 | if err := json.NewDecoder(resp.Body).Decode(&book); err != nil { 231 | panic(err) 232 | } 233 | 234 | fmt.Println("Book 1 title:", book.Title) 235 | }() 236 | 237 | func() { 238 | arg := Book{Title: "The New Book"} 239 | 240 | var buf bytes.Buffer 241 | if err := json.NewEncoder(&buf).Encode(&arg); err != nil { 242 | panic(err) 243 | } 244 | 245 | resp, err := http.Post(svr.URL+"/rpc/book", "application/json", &buf) 246 | if err != nil { 247 | panic(err) 248 | } 249 | defer resp.Body.Close() 250 | 251 | if resp.StatusCode != http.StatusOK { 252 | panic(resp.Status) 253 | } 254 | 255 | var ret Book 256 | if err := json.NewDecoder(resp.Body).Decode(&ret); err != nil { 257 | panic(err) 258 | } 259 | 260 | fmt.Println("The New Book id:", ret.ID) 261 | }() 262 | 263 | // Output: 264 | // level=INFO msg="receive http" method=GET path=/rpc/book/1 265 | // level=INFO msg="finish http" method=GET path=/rpc/book/1 266 | // Book 1 title: The Espresso Book 267 | // level=INFO msg="receive http" method=POST path=/rpc/book 268 | // level=INFO msg="finish http" method=POST path=/rpc/book 269 | // The New Book id: 2 270 | } 271 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/googollee/go-espresso 2 | 3 | go 1.22.5 4 | 5 | require gopkg.in/yaml.v3 v3.0.1 6 | 7 | require ( 8 | github.com/googollee/module v0.1.3 // indirect 9 | github.com/kr/pretty v0.3.1 // indirect 10 | github.com/rogpeppe/go-internal v1.10.0 // indirect 11 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 12 | ) 13 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 2 | github.com/googollee/module v0.1.3 h1:AqHw8NoRphSAfJiEGeIIRjz1G1JM71vz11glUDGTbio= 3 | github.com/googollee/module v0.1.3/go.mod h1:cNpph6Kvg/09jlnNn/C0JmPAHQb/5+UNH7RznShbKaY= 4 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 5 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 6 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 7 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 8 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 9 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 10 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 11 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 12 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 13 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 14 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 15 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 16 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 17 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 18 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 19 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 20 | -------------------------------------------------------------------------------- /log.go: -------------------------------------------------------------------------------- 1 | package espresso 2 | 3 | import ( 4 | "github.com/googollee/module/log" 5 | ) 6 | 7 | var ( 8 | LogModule = log.Module 9 | LogText = log.TextLogger 10 | LogJSON = log.JSONLogger 11 | 12 | DEBUG = log.DEBUG 13 | WARN = log.WARN 14 | INFO = log.INFO 15 | ERROR = log.ERROR 16 | ) 17 | 18 | func logHandling(ctx Context) error { 19 | method := ctx.Request().Method 20 | path := ctx.Request().URL.String() 21 | 22 | slog := LogModule.Value(ctx) 23 | if slog != nil { 24 | ctx = ctx.WithParent(log.With(ctx, "method", method, "path", path)) 25 | } 26 | 27 | INFO(ctx, "receive http") 28 | defer INFO(ctx, "finish http") 29 | 30 | ctx.Next() 31 | 32 | return nil 33 | } 34 | -------------------------------------------------------------------------------- /perf/panic_test.go: -------------------------------------------------------------------------------- 1 | package perf 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func BenchmarkPanic(b *testing.B) { 9 | pf := func() { 10 | panic(1) 11 | fmt.Println() 12 | } 13 | f := func() { 14 | defer func() { 15 | _ = recover() 16 | }() 17 | 18 | pf() 19 | } 20 | 21 | b.ResetTimer() 22 | for i := 0; i < b.N; i++ { 23 | f() 24 | } 25 | } 26 | 27 | func BenchmarkEarlyReturn(b *testing.B) { 28 | pf := func() { 29 | return 30 | fmt.Println() 31 | } 32 | f := func() { 33 | defer func() { 34 | _ = recover() 35 | }() 36 | 37 | pf() 38 | } 39 | 40 | b.ResetTimer() 41 | for i := 0; i < b.N; i++ { 42 | f() 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /perf/reflect_test.go: -------------------------------------------------------------------------------- 1 | package perf 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | type SomeStruct struct { 10 | Str string 11 | I int 12 | F float32 13 | } 14 | 15 | func BenchmarkTypeOf(b *testing.B) { 16 | got := reflect.TypeOf(&SomeStruct{}).String() 17 | if got, want := got, "*perf.SomeStruct"; got != want { 18 | b.Fatalf("got: %v, want: %v", got, want) 19 | } 20 | 21 | b.ResetTimer() 22 | for i := 0; i < b.N; i++ { 23 | _ = reflect.TypeOf(&SomeStruct{}).String() 24 | } 25 | } 26 | 27 | func BenchmarkSprintfT(b *testing.B) { 28 | got := fmt.Sprintf("%T", &SomeStruct{}) 29 | if got, want := got, "*perf.SomeStruct"; got != want { 30 | b.Fatalf("got: %v, want: %v", got, want) 31 | } 32 | 33 | b.ResetTimer() 34 | for i := 0; i < b.N; i++ { 35 | _ = fmt.Sprintf("%T", &SomeStruct{}) 36 | } 37 | } 38 | 39 | func setInt(v any) { 40 | *v.(*int) = 10 41 | } 42 | 43 | func BenchmarkReflectNew(b *testing.B) { 44 | typ := reflect.TypeOf(int(1)) 45 | 46 | b.ResetTimer() 47 | for i := 0; i < b.N; i++ { 48 | v := reflect.New(typ).Interface() 49 | setInt(v) 50 | } 51 | } 52 | func BenchmarkCompilerNew(b *testing.B) { 53 | b.ResetTimer() 54 | for i := 0; i < b.N; i++ { 55 | var v int 56 | setInt(&v) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /responsewriter.go: -------------------------------------------------------------------------------- 1 | package espresso 2 | 3 | import "net/http" 4 | 5 | type responseWriter struct { 6 | http.ResponseWriter 7 | hasWritten bool 8 | } 9 | 10 | func (w *responseWriter) Write(p []byte) (int, error) { 11 | w.hasWritten = true 12 | return w.ResponseWriter.Write(p) 13 | } 14 | 15 | func (w *responseWriter) WriteHeader(code int) { 16 | w.hasWritten = true 17 | w.ResponseWriter.WriteHeader(code) 18 | } 19 | -------------------------------------------------------------------------------- /router.go: -------------------------------------------------------------------------------- 1 | package espresso 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "slices" 7 | "strings" 8 | ) 9 | 10 | type Router interface { 11 | Use(middlewares ...HandleFunc) 12 | WithPrefix(path string) Router 13 | HandleFunc(handleFunc HandleFunc) 14 | } 15 | 16 | type router struct { 17 | prefix string 18 | middlewares []HandleFunc 19 | mux *http.ServeMux 20 | } 21 | 22 | func (g *router) WithPrefix(path string) Router { 23 | return &router{ 24 | prefix: strings.TrimRight(g.prefix, "/") + "/" + strings.Trim(path, "/"), 25 | middlewares: g.middlewares[0:len(g.middlewares)], 26 | mux: g.mux, 27 | } 28 | } 29 | 30 | func (g *router) Use(middleware ...HandleFunc) { 31 | g.middlewares = append(g.middlewares, middleware...) 32 | } 33 | 34 | func (g *router) HandleFunc(fn HandleFunc) { 35 | g.handleFunc(fn) 36 | } 37 | 38 | func (g *router) handleFunc(fn HandleFunc) { 39 | ctx := newBuildtimeContext() 40 | 41 | defer func() { 42 | v := recover() 43 | if v != errBuilderEnd { 44 | if v == nil { 45 | v = fmt.Errorf("should call ctx.Endpoint().End()") 46 | } 47 | panic(v) 48 | } 49 | 50 | g.register(ctx, fn) 51 | }() 52 | 53 | _ = fn(ctx) 54 | } 55 | 56 | func (g *router) register(ctx *buildtimeContext, fn HandleFunc) { 57 | path := strings.TrimRight(g.prefix, "/") + "/" + strings.TrimLeft(ctx.endpoint.Path, "/") 58 | chains := slices.Clone(g.middlewares) 59 | chains = append(chains, ctx.endpoint.ChainFuncs...) 60 | chains = append(chains, fn) 61 | 62 | endpoint := *ctx.endpoint 63 | endpoint.Path = path 64 | endpoint.ChainFuncs = chains 65 | 66 | pattern := ctx.endpoint.Method + " " + path 67 | g.mux.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) { 68 | ctx := &runtimeContext{ 69 | ctx: r.Context(), 70 | endpoint: &endpoint, 71 | request: r, 72 | response: w, 73 | } 74 | 75 | ctx.Next() 76 | }) 77 | } 78 | -------------------------------------------------------------------------------- /rpc.go: -------------------------------------------------------------------------------- 1 | package espresso 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "reflect" 8 | ) 9 | 10 | func RPC[Request, Response any](fn func(Context, Request) (Response, error)) HandleFunc { 11 | return func(ctx Context) error { 12 | var req Request 13 | if bctx, ok := ctx.(*buildtimeContext); ok { 14 | bctx.endpoint.RequestType = reflect.TypeOf(&req).Elem() 15 | var resp Response 16 | bctx.endpoint.ResponseType = reflect.TypeOf(&resp).Elem() 17 | 18 | _, err := fn(bctx, req) 19 | return err 20 | } 21 | 22 | codec := CodecsModule.Value(ctx) 23 | if codec == nil { 24 | return Error(http.StatusInternalServerError, errors.New("no codec in the context")) 25 | } 26 | 27 | if err := codec.DecodeRequest(ctx, &req); err != nil { 28 | return Error(http.StatusBadRequest, fmt.Errorf("can't decode request: %w", err)) 29 | } 30 | 31 | resp, err := fn(ctx, req) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | if err := codec.EncodeResponse(ctx, &resp); err != nil { 37 | return Error(http.StatusInternalServerError, fmt.Errorf("can't encode response: %w", err)) 38 | } 39 | 40 | return nil 41 | } 42 | } 43 | 44 | func RPCRetrive[Response any](fn func(Context) (Response, error)) HandleFunc { 45 | return func(ctx Context) error { 46 | if bctx, ok := ctx.(*buildtimeContext); ok { 47 | var resp Response 48 | bctx.endpoint.ResponseType = reflect.TypeOf(&resp).Elem() 49 | 50 | _, err := fn(bctx) 51 | return err 52 | } 53 | 54 | codec := CodecsModule.Value(ctx) 55 | if codec == nil { 56 | return Error(http.StatusInternalServerError, errors.New("no codec in the context")) 57 | } 58 | 59 | resp, err := fn(ctx) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | if err := codec.EncodeResponse(ctx, &resp); err != nil { 65 | return Error(http.StatusInternalServerError, fmt.Errorf("can't encode response: %w", err)) 66 | } 67 | 68 | return nil 69 | } 70 | } 71 | 72 | func RPCConsume[Request any](fn func(Context, Request) error) HandleFunc { 73 | return func(ctx Context) error { 74 | var req Request 75 | if bctx, ok := ctx.(*buildtimeContext); ok { 76 | bctx.endpoint.RequestType = reflect.TypeOf(&req).Elem() 77 | 78 | err := fn(bctx, req) 79 | return err 80 | } 81 | 82 | codec := CodecsModule.Value(ctx) 83 | if codec == nil { 84 | return Error(http.StatusInternalServerError, errors.New("no codec in the context")) 85 | } 86 | 87 | if err := codec.DecodeRequest(ctx, &req); err != nil { 88 | return Error(http.StatusBadRequest, fmt.Errorf("can't decode request: %w", err)) 89 | } 90 | 91 | err := fn(ctx, req) 92 | if err != nil { 93 | return err 94 | } 95 | 96 | return nil 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /runtime.go: -------------------------------------------------------------------------------- 1 | package espresso 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | type runtimeEndpoint struct { 10 | request *http.Request 11 | endpoint *Endpoint 12 | err BindErrors 13 | } 14 | 15 | func (e *runtimeEndpoint) BindPath(key string, v any) EndpointBuilder { 16 | binder, ok := e.endpoint.PathParams[key] 17 | if !ok { 18 | return e 19 | } 20 | 21 | strV := e.request.PathValue(key) 22 | if err := binder.Func(v, strV); err != nil { 23 | e.err = append(e.err, errorBind(binder, err)) 24 | } 25 | 26 | return e 27 | } 28 | 29 | func (e *runtimeEndpoint) End() BindErrors { 30 | return e.err 31 | } 32 | 33 | type runtimeContext struct { 34 | ctx context.Context 35 | endpoint *Endpoint 36 | request *http.Request 37 | response http.ResponseWriter 38 | 39 | err error 40 | chainIndex int 41 | } 42 | 43 | func (c *runtimeContext) Endpoint(method, path string, mid ...HandleFunc) EndpointBuilder { 44 | return &runtimeEndpoint{ 45 | request: c.request, 46 | endpoint: c.endpoint, 47 | } 48 | } 49 | 50 | func (c *runtimeContext) Value(key any) any { 51 | return c.ctx.Value(key) 52 | } 53 | 54 | func (c *runtimeContext) Deadline() (time.Time, bool) { 55 | return c.ctx.Deadline() 56 | } 57 | 58 | func (c *runtimeContext) Done() <-chan struct{} { 59 | return c.ctx.Done() 60 | } 61 | 62 | func (c *runtimeContext) Err() error { 63 | if c.err != nil { 64 | return c.err 65 | } 66 | return c.ctx.Err() 67 | } 68 | 69 | func (c *runtimeContext) WithParent(ctx context.Context) Context { 70 | return &runtimeContext{ 71 | ctx: ctx, 72 | endpoint: c.endpoint, 73 | request: c.request, 74 | response: c.response, 75 | err: c.err, 76 | chainIndex: c.chainIndex, 77 | } 78 | } 79 | 80 | func (c *runtimeContext) WithResponseWriter(w http.ResponseWriter) Context { 81 | return &runtimeContext{ 82 | ctx: c.ctx, 83 | endpoint: c.endpoint, 84 | request: c.request, 85 | response: w, 86 | err: c.err, 87 | chainIndex: c.chainIndex, 88 | } 89 | } 90 | 91 | func (c *runtimeContext) Request() *http.Request { 92 | return c.request 93 | } 94 | 95 | func (c *runtimeContext) ResponseWriter() http.ResponseWriter { 96 | return c.response 97 | } 98 | 99 | func (c *runtimeContext) Next() { 100 | index := c.chainIndex 101 | c.chainIndex++ 102 | if err := c.endpoint.ChainFuncs[index](c); err != nil { 103 | c.err = err 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package espresso 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/googollee/module" 7 | ) 8 | 9 | type Espresso struct { 10 | repo *module.Repo 11 | mux *http.ServeMux 12 | router Router 13 | } 14 | 15 | func New() *Espresso { 16 | ret := &Espresso{ 17 | repo: module.NewRepo(), 18 | mux: http.NewServeMux(), 19 | } 20 | ret.router = &router{ 21 | mux: ret.mux, 22 | } 23 | 24 | ret.Use(logHandling, cacheAllError) 25 | 26 | return ret 27 | } 28 | 29 | func (s *Espresso) AddModule(provider ...module.Provider) { 30 | for _, p := range provider { 31 | s.repo.Add(p) 32 | } 33 | } 34 | 35 | func (s *Espresso) Use(middlewares ...HandleFunc) { 36 | s.router.Use(middlewares...) 37 | } 38 | 39 | func (s *Espresso) HandleFunc(handleFunc HandleFunc) { 40 | s.router.HandleFunc(handleFunc) 41 | } 42 | 43 | func (s *Espresso) WithPrefix(path string) Router { 44 | return s.router.WithPrefix(path) 45 | } 46 | 47 | func (s *Espresso) ServeHTTP(w http.ResponseWriter, r *http.Request) { 48 | ctx, err := s.repo.InjectTo(r.Context()) 49 | if err != nil { 50 | panic(err) 51 | } 52 | 53 | r = r.WithContext(ctx) 54 | s.mux.ServeHTTP(w, r) 55 | } 56 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | GODEBUG=httpmuxgo121=0 go test -v -race -cover ./... 4 | --------------------------------------------------------------------------------