├── gateway_example_test.go ├── gateway_test.go ├── gateway.go └── Readme.md /gateway_example_test.go: -------------------------------------------------------------------------------- 1 | package gateway_test 2 | 3 | import ( 4 | "github.com/apex/go-apex" 5 | "github.com/tj/go-gateway" 6 | ) 7 | 8 | type Math struct{} 9 | 10 | type AddInput struct { 11 | A int `json:"a"` 12 | B int `json:"b"` 13 | } 14 | 15 | func (m *Math) Add(in *AddInput) (int, error) { 16 | return in.A + in.B, nil 17 | } 18 | 19 | func (m *Math) Sub(in *AddInput) (int, error) { 20 | return in.A - in.B, nil 21 | } 22 | 23 | func Example() { 24 | apex.Handle(gateway.New(&Math{})) 25 | } 26 | -------------------------------------------------------------------------------- /gateway_test.go: -------------------------------------------------------------------------------- 1 | package gateway 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func event(method, body string) json.RawMessage { 12 | return json.RawMessage(`{ 13 | "body": ` + body + `, 14 | "params": { 15 | "path": { 16 | "method": "` + method + `" 17 | }, 18 | "querystring": {}, 19 | "header": { 20 | "Accept": "*/*", 21 | "CloudFront-Forwarded-Proto": "https", 22 | "CloudFront-Is-Desktop-Viewer": "true", 23 | "CloudFront-Is-Mobile-Viewer": "false", 24 | "CloudFront-Is-SmartTV-Viewer": "false", 25 | "CloudFront-Is-Tablet-Viewer": "false", 26 | "CloudFront-Viewer-Country": "CA", 27 | "Content-Type": "application/json", 28 | "Host": "whxkpa6fwf.execute-api.us-west-2.amazonaws.com", 29 | "User-Agent": "curl/7.43.0", 30 | "Via": "1.1 fc8d4c3a573bbd496e96047052c4d3f1.cloudfront.net (CloudFront)", 31 | "X-Amz-Cf-Id": "RW7zWvoOaoxsxWM_OPEadaqJf_rTQg5Pkfu4SMAruaULcqYH0K9MUA==", 32 | "X-Forwarded-For": "70.66.179.182, 54.182.214.52", 33 | "X-Forwarded-Port": "443", 34 | "X-Forwarded-Proto": "https" 35 | } 36 | }, 37 | "context": { 38 | "account-id": "", 39 | "api-id": "whxkpa6fwf", 40 | "api-key": "", 41 | "authorizer-principal-id": "", 42 | "caller": "", 43 | "cognito-authentication-provider": "", 44 | "cognito-authentication-type": "", 45 | "cognito-identity-id": "", 46 | "cognito-identity-pool-id": "", 47 | "http-method": "POST", 48 | "stage": "prod", 49 | "source-ip": "70.66.179.182", 50 | "user": "", 51 | "user-agent": "curl/7.43.0", 52 | "user-arn": "", 53 | "request-id": "55066e03-19f7-11e6-8e97-231379f58d27", 54 | "resource-id": "cppmxl", 55 | "resource-path": "/public/{method}" 56 | } 57 | }`) 58 | } 59 | 60 | type Math struct{} 61 | 62 | type AddInput struct { 63 | A int `json:"a"` 64 | B int `json:"b"` 65 | } 66 | 67 | func (m *Math) AddSomeNumbers(in *AddInput) (interface{}, error) { 68 | return in.A + in.B, nil 69 | } 70 | 71 | func (m *Math) Add(in *AddInput) (interface{}, error) { 72 | return in.A + in.B, nil 73 | } 74 | 75 | func (m *Math) Sub(in *AddInput) (int, error) { 76 | return in.A - in.B, nil 77 | } 78 | 79 | func (m *Math) NoInput() (int, error) { 80 | return 5, nil 81 | } 82 | 83 | // func (m *Math) NoInputNoOutput() error { 84 | // return nil 85 | // } 86 | 87 | func (m *Math) Error(in *AddInput) (int, error) { 88 | return 0, errors.New("boom") 89 | } 90 | 91 | func (m *Math) notExported(a, b int) error { 92 | return nil 93 | } 94 | 95 | func TestNewConfig(t *testing.T) { 96 | g := NewConfig(&Config{ 97 | Service: &Math{}, 98 | Verbose: true, 99 | }) 100 | 101 | m := g.Methods() 102 | assert.Len(t, m, 5, "incorrect number of methods") 103 | } 104 | 105 | func TestGateway_Lookup(t *testing.T) { 106 | g := NewConfig(&Config{ 107 | Service: &Math{}, 108 | }) 109 | 110 | { 111 | method := g.Lookup("add_some_numbers") 112 | assert.NotNil(t, method, "lookup by snake case failed") 113 | assert.Equal(t, "AddSomeNumbers", method.Name) 114 | } 115 | 116 | { 117 | method := g.Lookup("AddSomeNumbers") 118 | assert.NotNil(t, method, "lookup by snake case failed") 119 | assert.Equal(t, "AddSomeNumbers", method.Name) 120 | } 121 | 122 | { 123 | method := g.Lookup("whoop") 124 | assert.Nil(t, method, "should be missing") 125 | } 126 | } 127 | 128 | func TestGateway_Handle_noInput(t *testing.T) { 129 | g := New(&Math{}) 130 | e := event("no_input", `{}`) 131 | v, err := g.Handle(e, nil) 132 | assert.NoError(t, err) 133 | assert.Equal(t, &Response{200, 5}, v) 134 | } 135 | 136 | func TestGateway_Handle_lowercaseReturnInterface(t *testing.T) { 137 | g := New(&Math{}) 138 | e := event("add", `{ "a": 5, "b": 10 }`) 139 | v, err := g.Handle(e, nil) 140 | assert.NoError(t, err) 141 | assert.Equal(t, &Response{200, 15}, v) 142 | } 143 | 144 | func TestGateway_Handle_lowercaseReturn(t *testing.T) { 145 | g := New(&Math{}) 146 | e := event("sub", `{ "a": 10, "b": 5 }`) 147 | v, err := g.Handle(e, nil) 148 | assert.NoError(t, err) 149 | assert.Equal(t, &Response{200, 5}, v) 150 | } 151 | 152 | func TestGateway_Handle_notFound(t *testing.T) { 153 | g := New(&Math{}) 154 | e := event("nothing", `{ "a": 10, "b": 5 }`) 155 | v, err := g.Handle(e, nil) 156 | assert.NoError(t, err) 157 | assert.Equal(t, 404, v.(*Response).Status) 158 | assert.Equal(t, "Not Found", v.(*Response).Body) 159 | } 160 | 161 | func TestGateway_Handle_malformedRequest(t *testing.T) { 162 | g := New(&Math{}) 163 | e := event("nothing", `{ "a": 10, `) 164 | v, err := g.Handle(e, nil) 165 | assert.NoError(t, err) 166 | assert.Equal(t, 400, v.(*Response).Status) 167 | assert.Equal(t, "Malformed Request", v.(*Response).Body) 168 | } 169 | 170 | func TestGateway_Handle_malformedRequestBody(t *testing.T) { 171 | g := New(&Math{}) 172 | e := event("add", `5`) 173 | v, err := g.Handle(e, nil) 174 | assert.NoError(t, err) 175 | assert.Equal(t, 400, v.(*Response).Status) 176 | assert.Equal(t, "Malformed Request Body", v.(*Response).Body) 177 | } 178 | 179 | func TestGateway_Handle_errors(t *testing.T) { 180 | g := New(&Math{}) 181 | e := event("error", `{ "a": 5, "b": 5 }`) 182 | v, err := g.Handle(e, nil) 183 | assert.NoError(t, err) 184 | assert.Equal(t, 500, v.(*Response).Status) 185 | assert.Equal(t, "Internal Server Error", v.(*Response).Body) 186 | } 187 | -------------------------------------------------------------------------------- /gateway.go: -------------------------------------------------------------------------------- 1 | // Package gateway provides an RPC-style interface to a "service" (struct with methods) 2 | // via API Gateway for HTTP access. 3 | package gateway 4 | 5 | import ( 6 | "encoding/json" 7 | "log" 8 | "net/http" 9 | "reflect" 10 | 11 | "github.com/apex/go-apex" 12 | "github.com/zhgo/nameconv" 13 | ) 14 | 15 | // error interface type. 16 | var errType = reflect.TypeOf((*error)(nil)).Elem() 17 | 18 | // Responder is an interface allowing you to customize the HTTP response. 19 | type Responder interface { 20 | Status() int 21 | Body() interface{} 22 | } 23 | 24 | // Context metadata. 25 | type Context struct { 26 | AccountID string `json:"account_id"` 27 | APIID string `json:"api_id"` 28 | APIKey string `json:"api_key"` 29 | AuthorizerPrincipalID string `json:"authorizer_principal_id"` 30 | Caller string `json:"caller"` 31 | CognitoAuthenticationProvider string `json:"cognito_authentication_provider"` 32 | CognitoAuthenticationType string `json:"cognito_authentication_type"` 33 | CognitoIdentityID string `json:"cognito_identity_id"` 34 | CognitoIdentityPoolID string `json:"cognito_identity_pool_id"` 35 | HTTPMethod string `json:"http_method"` 36 | RequestID string `json:"request_id"` 37 | ResourceID string `json:"resource_id"` 38 | ResourcePath string `json:"resource_path"` 39 | SourceIP string `json:"source_ip"` 40 | Stage string `json:"stage"` 41 | User string `json:"user"` 42 | UserAgent string `json:"user_agent"` 43 | UserArn string `json:"user_arn"` 44 | } 45 | 46 | // Header fields. 47 | type Header map[string]string 48 | 49 | // Request from API Gateway requests. 50 | type Request struct { 51 | Body json.RawMessage `json:"body"` // Body of the request 52 | Params struct { 53 | Path struct { 54 | Method string `json:"method"` // Method is the RPC method name 55 | } `json:"path"` 56 | Header Header `json:"header"` 57 | } `json:"params"` 58 | Context *Context `json:"context"` 59 | } 60 | 61 | // Response for API Gateway requests. 62 | type Response struct { 63 | Status int `json:"status"` 64 | Body interface{} `json:"body"` 65 | } 66 | 67 | // Gateway wraps your service to expose its methods. 68 | type Gateway struct { 69 | *Config 70 | methods map[string]*reflect.Method 71 | } 72 | 73 | // Config for the gateway service. 74 | type Config struct { 75 | Service interface{} // Service instance 76 | Verbose bool // Verbose logging 77 | } 78 | 79 | // New returns a new gateway with `service`. 80 | func New(service interface{}) *Gateway { 81 | return NewConfig(&Config{ 82 | Service: service, 83 | }) 84 | } 85 | 86 | // NewConfig returns a new gateway with `config`. 87 | func NewConfig(config *Config) *Gateway { 88 | g := &Gateway{ 89 | Config: config, 90 | methods: make(map[string]*reflect.Method), 91 | } 92 | 93 | g.init() 94 | return g 95 | } 96 | 97 | // log when Verbose is enabled. 98 | func (g *Gateway) log(s string, v ...interface{}) { 99 | if g.Verbose { 100 | log.Printf("gateway: "+s, v...) 101 | } 102 | } 103 | 104 | // init registers the service methods. 105 | func (g *Gateway) init() { 106 | service := reflect.TypeOf(g.Service) 107 | for i := 0; i < service.NumMethod(); i++ { 108 | method := service.Method(i) 109 | 110 | // Method must be exported. 111 | if method.PkgPath != "" { 112 | g.log("%q unexported", method.Name) 113 | continue 114 | } 115 | 116 | g.methods[method.Name] = &method 117 | } 118 | } 119 | 120 | // Methods returns the method names registered. 121 | func (g *Gateway) Methods() (v []*reflect.Method) { 122 | for _, m := range g.methods { 123 | v = append(v, m) 124 | } 125 | return 126 | } 127 | 128 | // Lookup method by `name`. 129 | func (g *Gateway) Lookup(name string) *reflect.Method { 130 | cname := nameconv.UnderscoreToCamelcase(name, true) 131 | return g.methods[cname] 132 | } 133 | 134 | // Handle Lambda event. 135 | func (g *Gateway) Handle(event json.RawMessage, ctx *apex.Context) (interface{}, error) { 136 | var req Request 137 | 138 | if err := json.Unmarshal(event, &req); err != nil { 139 | return &Response{http.StatusBadRequest, "Malformed Request"}, nil 140 | } 141 | 142 | // lookup method 143 | name := req.Params.Path.Method 144 | method := g.Lookup(name) 145 | if method == nil { 146 | return &Response{http.StatusNotFound, "Not Found"}, nil 147 | } 148 | 149 | mtype := method.Type 150 | 151 | var args = []reflect.Value{reflect.ValueOf(g.Service)} 152 | 153 | // input 154 | if mtype.NumIn() > 1 { 155 | in := reflect.New(mtype.In(1).Elem()) 156 | args = append(args, in) 157 | if err := json.Unmarshal(req.Body, in.Interface()); err != nil { 158 | return &Response{http.StatusBadRequest, "Malformed Request Body"}, nil 159 | } 160 | } 161 | 162 | // invoke 163 | out := method.Func.Call(args) 164 | 165 | // no output 166 | if len(out) == 0 { 167 | return &Response{200, nil}, nil 168 | } 169 | 170 | // one output: (error) 171 | if len(out) == 1 { 172 | err := out[0].Interface().(error) 173 | if r, ok := err.(Responder); ok { 174 | return &Response{r.Status(), r.Body()}, nil 175 | } 176 | 177 | return &Response{http.StatusInternalServerError, "Internal Server Error"}, nil 178 | } 179 | 180 | // two outputs: (interface{}, error) 181 | if err, ok := out[1].Interface().(error); ok && err != nil { 182 | if r, ok := err.(Responder); ok { 183 | return &Response{r.Status(), r.Body()}, nil 184 | } 185 | 186 | return &Response{http.StatusInternalServerError, "Internal Server Error"}, nil 187 | } 188 | 189 | // two outputs: (interface{}, error) 190 | if r, ok := out[0].Interface().(Responder); ok { 191 | return &Response{r.Status(), r.Body()}, nil 192 | } 193 | 194 | return &Response{200, out[0].Interface()}, nil 195 | } 196 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | 2 | # Gateway 3 | 4 | Package gateway provides an RPC-style interface to a "service" (struct with methods) via API Gateway for HTTP access. 5 | 6 | ## About 7 | 8 | Why would you go with RPC style for API Gateway? While it's a great tool for avoiding backend server maintenance, API Gateway provides a very convoluted and unintuitive interface for creating APIs. Defining an API is not hard, they really took something simple and made it more difficult. 9 | 10 | Many of API Gateway's features are unnecessary unless you're re-mapping a legacy API, so it can be much simpler to (ab)use API Gateway's scaling capabilities while effectively ignoring its other features. 11 | 12 | With this package you just define a struct full of methods, and public methods will be exposed via HTTP. This is similar to Dropbox's V2 API and [go-hpc](https://github.com/tj/go-hpc). 13 | 14 | ## Setup 15 | 16 | Create an API Gateway route of `POST /{method}`, pointing to your Lambda function, then use the mapping template below to relay the request. Note that the parameter name of "{method}" is important. 17 | 18 | Then create your Lambda function. This package implements the [apex](https://github.com/apex/go-apex).Handler, so an implementation may look something like this: 19 | 20 | ```go 21 | package main 22 | 23 | import ( 24 | "github.com/tj/go-gateway" 25 | "github.com/apex/go-apex" 26 | ) 27 | 28 | type Math struct{} 29 | 30 | type AddInput struct { 31 | A int `json:"a"` 32 | B int `json:"b"` 33 | } 34 | 35 | func (m *Math) Add(in *AddInput) (int, error) { 36 | return in.A + in.B, nil 37 | } 38 | 39 | func (m *Math) Sub(in *AddInput) (int, error) { 40 | return in.A - in.B, nil 41 | } 42 | 43 | func main() { 44 | apex.Handle(gateway.New(&Math{})) 45 | } 46 | ``` 47 | 48 | Deploy the API and you'll be able to invoke `/Add` or `/Sub` with the request body `{ "a": 5, "b": 10 }`. Note that snake-case is also supported, so `/add` or `/sub` work here as well. If you'd like to separate by resource, simply deploy functions to `/pets/{method}`, `/books/{method}` and so on. 49 | 50 | ## Mapping Template 51 | 52 | Use the following mapping template to relay the request information to your Lambda function. 53 | 54 | ```json 55 | #set($allParams = $input.params()) 56 | { 57 | "body" : $input.json('$'), 58 | "params" : { 59 | #foreach($type in $allParams.keySet()) 60 | #set($params = $allParams.get($type)) 61 | "$type" : { 62 | #foreach($paramName in $params.keySet()) 63 | "$paramName" : "$util.escapeJavaScript($params.get($paramName))" 64 | #if($foreach.hasNext),#end 65 | #end 66 | } 67 | #if($foreach.hasNext),#end 68 | #end 69 | }, 70 | "context" : { 71 | "account-id" : "$context.identity.accountId", 72 | "api-id" : "$context.apiId", 73 | "api-key" : "$context.identity.apiKey", 74 | "authorizer-principal-id" : "$context.authorizer.principalId", 75 | "caller" : "$context.identity.caller", 76 | "cognito-authentication-provider" : "$context.identity.cognitoAuthenticationProvider", 77 | "cognito-authentication-type" : "$context.identity.cognitoAuthenticationType", 78 | "cognito-identity-id" : "$context.identity.cognitoIdentityId", 79 | "cognito-identity-pool-id" : "$context.identity.cognitoIdentityPoolId", 80 | "http-method" : "$context.httpMethod", 81 | "stage" : "$context.stage", 82 | "source-ip" : "$context.identity.sourceIp", 83 | "user" : "$context.identity.user", 84 | "user-agent" : "$context.identity.userAgent", 85 | "user-arn" : "$context.identity.userArn", 86 | "request-id" : "$context.requestId", 87 | "resource-id" : "$context.resourceId", 88 | "resource-path" : "$context.resourcePath" 89 | } 90 | } 91 | ``` 92 | 93 | ## Reference request 94 | 95 | The request received by go-gateway looks something like the following: 96 | 97 | ```json 98 | { 99 | "body": { 100 | "a": 5, 101 | "b": 5 102 | }, 103 | "params": { 104 | "path": { 105 | "method": "Add" 106 | }, 107 | "querystring": {}, 108 | "header": { 109 | "Accept": "*/*", 110 | "CloudFront-Forwarded-Proto": "https", 111 | "CloudFront-Is-Desktop-Viewer": "true", 112 | "CloudFront-Is-Mobile-Viewer": "false", 113 | "CloudFront-Is-SmartTV-Viewer": "false", 114 | "CloudFront-Is-Tablet-Viewer": "false", 115 | "CloudFront-Viewer-Country": "CA", 116 | "Content-Type": "application/json", 117 | "Host": "whatever.execute-api.us-west-2.amazonaws.com", 118 | "User-Agent": "curl/7.43.0", 119 | "Via": "1.1 fc8d4c3a573bbd496e96047052c4d3f1.cloudfront.net (CloudFront)", 120 | "X-Amz-Cf-Id": "RW7zWvoOaoxsxWM_OPEadaqJf_rTQg5Pkfu4SMAruaULcqYH0K9MUA==", 121 | "X-Forwarded-For": "70.66.179.182, 54.182.214.52", 122 | "X-Forwarded-Port": "443", 123 | "X-Forwarded-Proto": "https" 124 | } 125 | }, 126 | "context": { 127 | "account-id": "", 128 | "api-id": "whatever", 129 | "api-key": "", 130 | "authorizer-principal-id": "", 131 | "caller": "", 132 | "cognito-authentication-provider": "", 133 | "cognito-authentication-type": "", 134 | "cognito-identity-id": "", 135 | "cognito-identity-pool-id": "", 136 | "http-method": "POST", 137 | "stage": "prod", 138 | "source-ip": "70.66.179.182", 139 | "user": "", 140 | "user-agent": "curl/7.43.0", 141 | "user-arn": "", 142 | "request-id": "55066e03-19f7-11e6-8e97-231379f58d27", 143 | "resource-id": "cppmxl", 144 | "resource-path": "/public/{method}" 145 | } 146 | } 147 | ``` 148 | 149 | ## Badges 150 | 151 | [![Build Status](https://semaphoreci.com/api/v1/tj/go-gateway/branches/master/badge.svg)](https://semaphoreci.com/tj/go-gateway) 152 | [![GoDoc](https://godoc.org/github.com/tj/go-gateway?status.svg)](https://godoc.org/github.com/tj/go-gateway) 153 | ![](https://img.shields.io/badge/license-MIT-blue.svg) 154 | ![](https://img.shields.io/badge/status-stable-green.svg) 155 | [![](http://apex.sh/images/badge.svg)](https://apex.sh/ping/) 156 | 157 | --- 158 | 159 | > [tjholowaychuk.com](http://tjholowaychuk.com)  ·  160 | > GitHub [@tj](https://github.com/tj)  ·  161 | > Twitter [@tjholowaychuk](https://twitter.com/tjholowaychuk) 162 | --------------------------------------------------------------------------------