mirror of
https://github.com/fnproject/fn.git
synced 2022-10-28 21:29:17 +03:00
adds a test for the protocol dumping of a request to the containert stdin.
there are a number of vectors to test for a cloud event, but since we're going
to change that behavior soon it's probably a waste of time to go about doing
so. in any event, this was pretty broken. my understanding of the cloud event
spec is deepening and the json stuff overall seems a little weird.
* fixes content type issue around json checking (since a string is also a json
value, we can just decode it, even though it's wasteful it's more easily
correct)
* doesn't force all json values to be map[string]interface{} and lets them be
whoever they want to be. maybe their dads are still proud.
closes #966
205 lines
6.3 KiB
Go
205 lines
6.3 KiB
Go
package protocol
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
|
|
"go.opencensus.io/trace"
|
|
|
|
"github.com/fnproject/fn/api/models"
|
|
)
|
|
|
|
// CloudEvent is the official JSON representation of a CloudEvent: https://github.com/cloudevents/spec/blob/master/serialization.md
|
|
type CloudEvent struct {
|
|
CloudEventsVersion string `json:"cloudEventsVersion"`
|
|
EventID string `json:"eventID"`
|
|
Source string `json:"source"`
|
|
EventType string `json:"eventType"`
|
|
EventTypeVersion string `json:"eventTypeVersion"`
|
|
EventTime time.Time `json:"eventTime"`
|
|
SchemaURL string `json:"schemaURL"`
|
|
ContentType string `json:"contentType"`
|
|
Extensions map[string]interface{} `json:"extensions"`
|
|
Data interface{} `json:"data,omitempty"` // docs: the payload is encoded into a media format which is specified by the contentType attribute (e.g. application/json)
|
|
}
|
|
|
|
type cloudEventIn struct {
|
|
CloudEvent
|
|
|
|
// Deadline string `json:"deadline"`
|
|
// Protocol CallRequestHTTP `json:"protocol"`
|
|
}
|
|
|
|
// cloudEventOut the expected response from the function container
|
|
type cloudEventOut struct {
|
|
CloudEvent
|
|
|
|
// Protocol *CallResponseHTTP `json:"protocol,omitempty"`
|
|
}
|
|
|
|
// CloudEventProtocol converts stdin/stdout streams from HTTP into JSON format.
|
|
type CloudEventProtocol struct {
|
|
// These are the container input streams, not the input from the request or the output for the response
|
|
in io.Writer
|
|
out io.Reader
|
|
}
|
|
|
|
func (p *CloudEventProtocol) IsStreamable() bool {
|
|
return true
|
|
}
|
|
|
|
func (h *CloudEventProtocol) writeJSONToContainer(ci CallInfo) error {
|
|
buf := bufPool.Get().(*bytes.Buffer)
|
|
buf.Reset()
|
|
defer bufPool.Put(buf)
|
|
|
|
_, err := io.Copy(buf, ci.Input())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// TODO: handle binary
|
|
|
|
var in cloudEventIn
|
|
if ci.IsCloudEvent() {
|
|
// then it's already in the right format, let's parse it, then modify
|
|
err = json.Unmarshal(buf.Bytes(), &in)
|
|
if err != nil {
|
|
return fmt.Errorf("Invalid CloudEvent input. %v", err)
|
|
}
|
|
} else {
|
|
in = cloudEventIn{
|
|
CloudEvent: CloudEvent{
|
|
ContentType: ci.ContentType(),
|
|
EventID: ci.CallID(),
|
|
EventType: "http",
|
|
CloudEventsVersion: "0.1",
|
|
Source: ci.RequestURL(),
|
|
},
|
|
}
|
|
// NOTE: data is an optional field, we can leave it as nil
|
|
if buf.Len() > 0 {
|
|
// NOTE: if it's not contentType=application/json, then a string is a valid json value, so this will work.
|
|
err := json.NewDecoder(buf).Decode(&in.Data)
|
|
if err != nil {
|
|
return fmt.Errorf("Invalid json body with contentType 'application/json'. %v", err)
|
|
}
|
|
}
|
|
}
|
|
// todo: deal with the dual ID's, one from outside, one from inside
|
|
if in.Extensions == nil {
|
|
in.Extensions = map[string]interface{}{}
|
|
}
|
|
// note: protocol stuff should be set on first ingestion of the event in fn2.0, the http router for example, not here
|
|
in.Extensions["protocol"] = CallRequestHTTP{
|
|
Type: ci.ProtocolType(),
|
|
Method: ci.Method(),
|
|
RequestURL: ci.RequestURL(),
|
|
Headers: ci.Headers(),
|
|
}
|
|
in.Extensions["deadline"] = ci.Deadline().String()
|
|
|
|
return json.NewEncoder(h.in).Encode(in)
|
|
}
|
|
|
|
func (h *CloudEventProtocol) Dispatch(ctx context.Context, ci CallInfo, w io.Writer) error {
|
|
ctx, span := trace.StartSpan(ctx, "dispatch_cloudevent")
|
|
defer span.End()
|
|
|
|
_, span = trace.StartSpan(ctx, "dispatch_cloudevent_write_request")
|
|
err := h.writeJSONToContainer(ci)
|
|
span.End()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, span = trace.StartSpan(ctx, "dispatch_cloudevent_read_response")
|
|
var jout cloudEventOut
|
|
decoder := json.NewDecoder(h.out)
|
|
err = decoder.Decode(&jout)
|
|
span.End()
|
|
if err != nil {
|
|
return models.NewAPIError(http.StatusBadGateway, fmt.Errorf("invalid json response from function err: %v", err))
|
|
}
|
|
|
|
_, span = trace.StartSpan(ctx, "dispatch_cloudevent_write_response")
|
|
defer span.End()
|
|
|
|
rw, ok := w.(http.ResponseWriter)
|
|
if !ok {
|
|
// logs can just copy the full thing in there, headers and all.
|
|
err := json.NewEncoder(w).Encode(jout)
|
|
return isExcessData(err, decoder)
|
|
}
|
|
|
|
// this has to be done for pulling out:
|
|
// - status code
|
|
// - body
|
|
// - headers
|
|
pp := jout.Extensions["protocol"]
|
|
var p map[string]interface{}
|
|
if pp != nil {
|
|
p = pp.(map[string]interface{})
|
|
hh := p["headers"]
|
|
if hh != nil {
|
|
h, ok := hh.(map[string]interface{})
|
|
if !ok {
|
|
return fmt.Errorf("Invalid JSON for protocol headers, not a map")
|
|
}
|
|
for k, v := range h {
|
|
// fmt.Printf("HEADER: %v: %v\n", k, v)
|
|
// fmt.Printf("%v", reflect.TypeOf(v))
|
|
harray, ok := v.([]interface{})
|
|
if !ok {
|
|
return fmt.Errorf("Invalid JSON for protocol headers, not an array of strings for header value")
|
|
}
|
|
for _, vv := range harray {
|
|
rw.Header().Add(k, vv.(string)) // on top of any specified on the route
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// after other header setting, top level content_type takes precedence and is
|
|
// absolute (if set). it is expected that if users want to set multiple
|
|
// values they put it in the string, e.g. `"content-type:"application/json; charset=utf-8"`
|
|
// TODO this value should not exist since it's redundant in proto headers?
|
|
if jout.ContentType != "" {
|
|
rw.Header().Set("Content-Type", jout.ContentType)
|
|
}
|
|
|
|
// we must set all headers before writing the status, see http.ResponseWriter contract
|
|
if p != nil && p["status_code"] != nil {
|
|
sc, ok := p["status_code"].(float64)
|
|
if !ok {
|
|
return fmt.Errorf("Invalid status_code type in protocol extension, must be an integer: %v\n", p["status_code"])
|
|
}
|
|
rw.WriteHeader(int(sc))
|
|
}
|
|
|
|
if ci.IsCloudEvent() {
|
|
// then it's already in the right format so just return it as is
|
|
err = json.NewEncoder(rw).Encode(jout)
|
|
if err != nil {
|
|
return fmt.Errorf("Error marshalling CloudEvent response to json. %v\n", err)
|
|
}
|
|
} else {
|
|
if jout.ContentType == "application/json" {
|
|
d, err := json.Marshal(jout.Data)
|
|
if err != nil {
|
|
return fmt.Errorf("Error marshalling function response 'data' to json. %v\n", err)
|
|
}
|
|
_, err = rw.Write(d)
|
|
} else if jout.ContentType == "text/plain" {
|
|
_, err = io.WriteString(rw, jout.Data.(string))
|
|
} else {
|
|
return fmt.Errorf("Error: Unknown content type: %v\n", jout.ContentType)
|
|
}
|
|
}
|
|
return isExcessData(err, decoder)
|
|
}
|