Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/gh 251 custom response headers #252

Merged
merged 9 commits into from
Jun 24, 2024
68 changes: 66 additions & 2 deletions docs/res-service-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,31 +64,80 @@ The content of the payload depends on the subject type.


## Response
When a request is received by a service, it should send a response as a JSON object. The object MUST have one of the following members, dependent upon whether the response is a successful *result*, a *resource*, or an *error*:
When a request is received by a service, it should send a response as a JSON object. The object MUST have one of the members, *result*, *resource*, or *error*, depending upon whether the request is successful, is a resource response, or is an error. In addition, the response MAY contain a *meta* member.

**result**
Raw data from a successful request.
Is REQUIRED on success if **resource** is not set.
SHOULD be ignored if **error** or **resource** is set.
The value is determined by the request subject.

**resource**
A successful request resulting in a reference to a resource.
MUST be omitted if the request type is not `call` or `auth`.
Is REQUIRED on success if **result** is not set.
SHOULD be ignored if **error** is set.
The value MUST be a valid [resource reference](res-protocol.md#resource-references).

**error**
Error encountered during the request.
Is REQUIRED on error.
MUST be omitted on success.
The value MUST be an [error object](#error-object).

**meta**
Metadata about the response. May be omitted.
The value MUST be a [meta object](#meta-object).


## Meta object

In addition to the *result*, *resource*, or *error* member of a response, the response may contain a *meta* member which allows the service to specify things like HTTP status and headers set in the HTTP response of a client's HTTP or WebSocket connection. If multiple responses contains overlapping metadata that affects the same connection, the priority of the metadata SHOULD be as follow, listed with the highest priority first:
* [call request](#call-request)
* [access request](#access-request)
* [auth request](#auth-request)

The value is an object with the following members:

**status**
HTTP status code, overriding default HTTP response status code. MAY be omitted.
SHOULD be ignored if **isHttp** is not set to `true` on the request.
SHOULD be ignored if [status codes](#status-codes) has no definition for the value.
MUST be a one of the defined [status codes]
MUST be a number.

**header**
HTTP headers to set on the HTTP response. MAY be omitted.
SHOULD be ignored if **isHttp** is not set to `true` on the request.
MUST be a key/value object, where the key is the canonical format of the MIME header, and the value is an array of strings associated with the key.
If the header key is `"Set-Cookie"`, the value will be addeded to any existing values, otherwise it will replace any existing value.

### Status codes
The status code is a subset of the HTTP status codes. Behavior is only defined for redirection (3XX), client error (4XX), and server error (5XX).
The gateway MUST respond to the HTTP or WebSocket connection using the given status code, if behavior is defined for it. Otherwise it SHOULD ignore the code and make a fallback to default behavior.

**3XX**
SHOULD result in an immediate response to the client, without subsequent service requests.
SHOULD have the `"Location"` header set if the **resource** field is not set on the response.
SHOULD result in no content being sent to the client making the request.

**4XX**
SHOULD result in an immediate response to the client, without subsequent service requests.
If **error** is set on the response, that error value should be sent in the client response.
If no **error** is set on the response, the gateway SHOULD respond to the client with an error matching the code.

**5XX**
SHOULD result in an immediate response to the client, without subsequent service requests.
If **error** is set on the response, that error value should be sent in the client response.
If no **error** is set on the response, the gateway SHOULD respond to the client with an error matching the code.

## Error object

On error, the error member contains a value that is an object with the following members:

**code**
A dot-separated string identifying the error.
Custom errors SHOULD begin with the service name.
Custom errors SHOULD NOT begin with `system.`.
MUST be a string.

**message**
Expand Down Expand Up @@ -152,6 +201,11 @@ Query part of the [resource ID](res-protocol.md#resource-ids) without the questi
MUST be omitted if the resource ID has no query.
MUST be a string.

**isHttp**
Flag telling if the response's [meta object](#meta-object) may contain *status* and *header* members.
MAY be omitted if the value is otherwise `false`.
MUST be a boolean.

### Result

**get**
Expand Down Expand Up @@ -235,6 +289,11 @@ MUST be a string.
Method parameters as defined by the service or by the appropriate [pre-defined call method](#pre-defined-call-methods).
MAY be omitted.

**isHttp**
Flag telling if the response's [meta object](#meta-object) may contain *status* and *header* members.
MAY be omitted if the value is otherwise `false`.
MUST be a boolean.

### Result

The result is defined by the service, or by the appropriate [pre-defined call method](#pre-defined-call-methods). The result may be null.
Expand Down Expand Up @@ -297,6 +356,11 @@ The unmodified Request-URI of the Request-Line (RFC 2616, Section 5.1) as sent b
May be omitted.
MUST be a string.

**isHttp**
Flag telling if the response's [meta object](#meta-object) may contain *status* and *header* members.
MAY be omitted if the value is otherwise `false`.
MUST be a boolean.

### Result

The result is defined by the service, and may be null.
Expand Down
3 changes: 3 additions & 0 deletions server/apiEncoding.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,9 @@ func PathToRIDAction(path, query, prefix string) (string, string) {
// The prefix is the part of the path that should be prepended
// to the resource ID path, and it should both start and end with /. Eg. "/api/".
func RIDToPath(rid, prefix string) string {
if rid == "" {
return ""
}
return prefix + strings.Replace(url.PathEscape(rid), ".", "/", -1)
}

Expand Down
184 changes: 157 additions & 27 deletions server/apiHandler.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,13 @@ func (s *Service) apiHandler(w http.ResponseWriter, r *http.Request) {
return
}

s.temporaryConn(w, r, func(c *wsConn, cb func([]byte, error, bool)) {
c.GetSubscription(rid, func(sub *Subscription, err error) {
if err != nil {
cb(nil, err, false)
return
s.temporaryConn(w, r, func(c *wsConn, cb func([]byte, string, error, *codec.Meta)) {
c.GetHTTPSubscription(rid, func(sub *Subscription, meta *codec.Meta, err error) {
var b []byte
if err == nil && !meta.IsDirectResponseStatus() {
b, err = s.enc.EncodeGET(sub)
}
b, err := s.enc.EncodeGET(sub)
cb(b, err, false)
cb(b, "", err, meta)
})
})
return
Expand Down Expand Up @@ -165,59 +164,96 @@ func (s *Service) handleCall(w http.ResponseWriter, r *http.Request, rid string,
}
}

s.temporaryConn(w, r, func(c *wsConn, cb func([]byte, error, bool)) {
c.CallHTTPResource(rid, s.cfg.APIPath, action, params, func(r json.RawMessage, href string, err error) {
if err != nil {
cb(nil, err, false)
} else if href != "" {
w.Header().Set("Location", href)
w.WriteHeader(http.StatusOK)
cb(nil, nil, true)
} else {
b, err := s.enc.EncodePOST(r)
cb(b, err, false)
s.temporaryConn(w, r, func(c *wsConn, cb func([]byte, string, error, *codec.Meta)) {
c.CallHTTPResource(rid, action, params, func(r json.RawMessage, refRID string, err error, meta *codec.Meta) {
var b []byte
if err == nil && refRID == "" && !meta.IsDirectResponseStatus() {
b, err = s.enc.EncodePOST(r)
}
cb(b, RIDToPath(refRID, s.cfg.APIPath), err, meta)
})
})
}

func (s *Service) temporaryConn(w http.ResponseWriter, r *http.Request, cb func(*wsConn, func([]byte, error, bool))) {
// temporaryConn creates a temporary connection which is provides through a
// callback. Once the callback calls its write callback, the temporaryConn will
// return.
// The write callback takes 4 arguments, with priority in order: meta, err, href, out
// * out - If not nil, it will be the output with a status OK response
// * href - If not empty, it will be used as Location for a Found response
// * err - If not empty, it will be encoded into an error for an error response based on the error code
// * meta - If not empty, may change the behavior of all the others.
func (s *Service) temporaryConn(w http.ResponseWriter, r *http.Request, cb func(*wsConn, func(out []byte, href string, err error, meta *codec.Meta))) {
c := s.newWSConn(r, versionLatest)
if c == nil {
httpError(w, reserr.ErrServiceUnavailable, s.enc)
return
}

done := make(chan struct{})
rs := func(out []byte, err error, headerWritten bool) {
var authMeta *codec.Meta
rs := func(out []byte, href string, err error, meta *codec.Meta) {
defer c.dispose()
defer close(done)

// Merge auth meta into the callbacks meta
meta = authMeta.Merge(meta)

// Validate the status of the meta object.
if !meta.IsValidStatus() {
s.Errorf("Invalid meta status: %d", *meta.Status)
meta.Status = nil
}

// Handle meta override
if meta.IsDirectResponseStatus() {
httpStatusResponse(w, s.enc, *meta.Status, meta.Header, href, err)
return
}

// Merge any meta headers into the response header.
codec.MergeHeader(w.Header(), meta.GetHeader())

// Handle error
if err != nil {
// Convert system.methodNotFound to system.methodNotAllowed for PUT/DELETE/PATCH
if rerr, ok := err.(*reserr.Error); ok {
if rerr.Code == reserr.CodeMethodNotFound && (r.Method == "PUT" || r.Method == "DELETE" || r.Method == "PATCH") {
httpError(w, reserr.ErrMethodNotAllowed, s.enc)
return
err = reserr.ErrMethodNotAllowed
}
}
httpError(w, err, s.enc)
return
}

// Handle href
if href != "" {
w.Header().Set("Location", href)
w.WriteHeader(http.StatusOK)
return
}

// Output content
if len(out) > 0 {
w.Header().Set("Content-Type", s.enc.ContentType())
w.Write(out)
return
}

if !headerWritten {
w.WriteHeader(http.StatusNoContent)
}
// No content
w.WriteHeader(http.StatusNoContent)
}
c.Enqueue(func() {
if s.cfg.HeaderAuth != nil {
c.AuthResourceNoResult(s.cfg.headerAuthRID, s.cfg.headerAuthAction, nil, func(err error) {
c.AuthResourceNoResult(s.cfg.headerAuthRID, s.cfg.headerAuthAction, nil, func(refRID string, err error, m *codec.Meta) {
if m.IsDirectResponseStatus() {
httpStatusResponse(w, s.enc, *m.Status, m.Header, RIDToPath(refRID, s.cfg.APIPath), err)
c.dispose()
close(done)
return
}

authMeta = m
cb(c, rs)
})
} else {
Expand All @@ -227,7 +263,34 @@ func (s *Service) temporaryConn(w http.ResponseWriter, r *http.Request, cb func(
<-done
}

func httpError(w http.ResponseWriter, err error, enc APIEncoder) {
func httpStatusResponse(w http.ResponseWriter, enc APIEncoder, status int, header http.Header, href string, err error) {
// Redirect
if status >= 300 && status < 400 {
if href != "" {
if _, ok := header["Location"]; !ok {
w.Header().Set("Location", href)
}
}
codec.MergeHeader(w.Header(), header)
w.WriteHeader(status)
return
}

// 4xx and 5xx errors
var rerr *reserr.Error
if err == nil {
rerr = statusError(status)
} else {
rerr = reserr.RESError(err)
}

codec.MergeHeader(w.Header(), header)
w.Header().Set("Content-Type", enc.ContentType())
w.WriteHeader(status)
w.Write(enc.EncodeError(rerr))
}

func errorStatus(err error) (*reserr.Error, int) {
rerr := reserr.RESError(err)

var code int
Expand All @@ -254,7 +317,74 @@ func httpError(w http.ResponseWriter, err error, enc APIEncoder) {
code = http.StatusBadRequest
}

return rerr, code
}

func httpError(w http.ResponseWriter, err error, enc APIEncoder) {
rerr, code := errorStatus(err)
w.Header().Set("Content-Type", enc.ContentType())
w.WriteHeader(code)
w.Write(enc.EncodeError(rerr))
}

// statusError returns a res error based on a HTTP status.
func statusError(status int) *reserr.Error {
if status >= 400 {
if status < 500 {
switch status {
// Access denied
case http.StatusUnauthorized:
fallthrough
case http.StatusPaymentRequired:
fallthrough
case http.StatusProxyAuthRequired:
return reserr.ErrAccessDenied

// Forbidden
case http.StatusForbidden:
fallthrough
case http.StatusUnavailableForLegalReasons:
return reserr.ErrForbidden

// Not found
case http.StatusGone:
fallthrough
case http.StatusNotFound:
return reserr.ErrNotFound

// Method not allowed
case http.StatusMethodNotAllowed:
return reserr.ErrMethodNotAllowed

// Timeout
case http.StatusRequestTimeout:
return reserr.ErrTimeout

// Bad request (default)
default:
return reserr.ErrBadRequest
}
}
if status < 600 {
switch status {
// Not implemented
case http.StatusNotImplemented:
return reserr.ErrNotImplemented

// Service unavailable
case http.StatusServiceUnavailable:
return reserr.ErrServiceUnavailable

// Timeout
case http.StatusGatewayTimeout:
return reserr.ErrTimeout

// Internal error (default)
default:
return reserr.ErrInternalError
}
}
}

return reserr.ErrInternalError
}
Loading