diff --git a/interoptest/src/testcoordinator/interoptestservice/interop_test_service.go b/interoptest/src/testcoordinator/interoptestservice/interop_test_service.go index d899d3c..86050c6 100644 --- a/interoptest/src/testcoordinator/interoptestservice/interop_test_service.go +++ b/interoptest/src/testcoordinator/interoptestservice/interop_test_service.go @@ -16,10 +16,16 @@ package interoptestservice import ( "context" + "encoding/json" "errors" "fmt" + "io" + "io/ioutil" "math/rand" "net" + "net/http" + "strconv" + "strings" "sync" "time" @@ -177,3 +183,139 @@ func (s *ServiceImpl) Run(ctx context.Context, req *interop.InteropRunRequest) ( func verifySpans(map[*commonpb.Node][]*tracepb.Span) { // TODO: implement this } + +var _ http.Handler = (*ServiceImpl)(nil) + +// ServeHTTP allows ServiceImpl to handle HTTP requests. +func (s *ServiceImpl) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + // For options, unconditionally respond with a 200 and send CORS headers. + // Without properly responding to OPTIONS and without CORS headers, browsers + // won't be able to use this handler. + w.Header().Add("Access-Control-Allow-Origin", "*") + w.Header().Add("Access-Control-Allow-Methods", "*") + w.Header().Add("Access-Control-Allow-Headers", "*") + w.WriteHeader(200) + return + } + + // Handle routing. + switch r.Method { + case "GET": + s.handleHTTPGET(w, r) + return + + case "POST": + s.handleHTTPPOST(w, r) + return + + default: + http.Error(w, "Unhandled HTTP Method: "+r.Method+" only accepting POST and GET", http.StatusMethodNotAllowed) + return + } +} + +func deserializeJSON(blob []byte, save interface{}) error { + return json.Unmarshal(blob, save) +} + +func serializeJSON(src interface{}) ([]byte, error) { + return json.Marshal(src) +} + +// readTillEOFAndDeserializeJSON reads the entire body out of rc and then closes it. +// If it encounters an error, it will return it immediately. +// After successfully reading the body, it then JSON unmarshals to save. +func readTillEOFAndDeserializeJSON(rc io.ReadCloser, save interface{}) error { + // We are always receiving an interop.InteropResultRequest + blob, err := ioutil.ReadAll(rc) + _ = rc.Close() + if err != nil { + return err + } + return deserializeJSON(blob, save) +} + +const resultPathPrefix = "/result/" + +func (s *ServiceImpl) handleHTTPGET(w http.ResponseWriter, r *http.Request) { + // Expecting a request path of: "/result/:id" + var path string + if r.URL != nil { + path = r.URL.Path + } + + if len(path) <= len(resultPathPrefix) { + http.Error(w, "Expected path of the form: /result/:id", http.StatusBadRequest) + return + } + + strId := strings.TrimPrefix(path, resultPathPrefix) + if strId == "" || strId == "/" { + http.Error(w, "Expected path of the form: /result/:id", http.StatusBadRequest) + return + } + + id, err := strconv.ParseInt(strId, 10, 64) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // TODO: actually look up the available tests by their IDs + req := &interop.InteropResultRequest{Id: id} + res, err := s.Result(r.Context(), req) + if err != nil { + // TODO: perhaps multiplex on NotFound and other sentinel errors. + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + blob, _ := serializeJSON(res) + w.Header().Set("Content-Type", "application/json") + w.Write(blob) +} + +func (s *ServiceImpl) handleHTTPPOST(w http.ResponseWriter, r *http.Request) { + var path string + if r.URL != nil { + path = r.URL.Path + } + + ctx := r.Context() + var res interface{} + var err error + + switch path { + case "/run", "/run/": + inrreq := new(interop.InteropRunRequest) + if err := readTillEOFAndDeserializeJSON(r.Body, inrreq); err != nil { + http.Error(w, "Failed to JSON unmarshal interop.InteropRunRequest: "+err.Error(), http.StatusBadRequest) + return + } + res, err = s.Run(ctx, inrreq) + + case "/result", "/result/": + inrreq := new(interop.InteropResultRequest) + if err := readTillEOFAndDeserializeJSON(r.Body, inrreq); err != nil { + http.Error(w, "Failed to JSON unmarshal interop.InteropResultRequest: "+err.Error(), http.StatusBadRequest) + return + } + res, err = s.Result(ctx, inrreq) + + default: + http.Error(w, "Unmatched route: "+path+"\nOnly accepting /result and /run", http.StatusNotFound) + return + } + + if err != nil { + // TODO: Perhap return a structured error e.g. {"error": } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Otherwise all clear to return the response. + blob, _ := serializeJSON(res) + w.Header().Set("Content-Type", "application/json") + w.Write(blob) +} diff --git a/interoptest/src/testcoordinator/interoptestservice/interop_test_service_test.go b/interoptest/src/testcoordinator/interoptestservice/interop_test_service_test.go new file mode 100644 index 0000000..1196106 --- /dev/null +++ b/interoptest/src/testcoordinator/interoptestservice/interop_test_service_test.go @@ -0,0 +1,161 @@ +// Copyright 2018, OpenCensus Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package interoptestservice_test + +import ( + "bufio" + "net/http" + "net/http/httptest" + "net/http/httputil" + "strings" + "testing" + + "github.com/census-ecosystem/opencensus-experiments/interoptest/src/testcoordinator/interoptestservice" +) + +func TestRequestsOverHTTP(t *testing.T) { + h := new(interoptestservice.ServiceImpl) + tests := []struct { + reqWire string // The request's wire data + wantResWire string // The response's wire format + }{ + + // OPTIONS request + { + reqWire: `OPTIONS / HTTP/1.1 +Host: * + +`, + wantResWire: "HTTP/1.1 200 OK\r\n" + + "Connection: close\r\n" + + "Access-Control-Allow-Headers: *\r\n" + + "Access-Control-Allow-Methods: *\r\n" + + "Access-Control-Allow-Origin: *\r\n\r\n", + }, + + // GET: bad path + { + reqWire: `GET / HTTP/1.1 +Host: foo +Content-Length: 0 + +`, + wantResWire: "HTTP/1.1 400 Bad Request\r\n" + + "Connection: close\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "X-Content-Type-Options: nosniff\r\n\r\n" + + "Expected path of the form: /result/:id\n", + }, + + // GET: good path no id + { + reqWire: `GET /result HTTP/1.1 + +`, + wantResWire: "HTTP/1.1 400 Bad Request\r\n" + + "Connection: close\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "X-Content-Type-Options: nosniff\r\n\r\n" + + "Expected path of the form: /result/:id\n", + }, + + // GET: good path with proper id + { + reqWire: `GET /result/1 HTTP/1.1 + +`, + wantResWire: "HTTP/1.1 200 OK\r\n" + + "Connection: close\r\n" + + "Content-Type: application/json\r\n\r\n" + + `{"id":1,"status":{}}`, + }, + // POST: no body + { + reqWire: `POST /result HTTP/1.1 + +`, + wantResWire: "HTTP/1.1 400 Bad Request\r\n" + + "Connection: close\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "X-Content-Type-Options: nosniff\r\n\r\n" + + "Failed to JSON unmarshal interop.InteropResultRequest: unexpected end of JSON input\n", + }, + + // POST: body with content length to accepted route + { + reqWire: `POST /result HTTP/1.1 +Content-Length: 9 +Content-Type: application/json + +{"id":10} +`, + wantResWire: "HTTP/1.1 200 OK\r\n" + + "Connection: close\r\n" + + "Content-Type: application/json\r\n\r\n" + + `{"id":10,"status":{}}`, + }, + + // POST: body with no content length + { + // Using a string concatenation here because for "streamed"/"chunked" + // requests, we have to ensure that the last 2 bytes before EOF are + // strictly "\r\n" lest a "malformed chunked encoding" error. + reqWire: "POST /result HTTP/1.1\r\n" + + "Host: golang.org\r\n" + + "Content-Type: application/json\r\n" + + "Transfer-Encoding: chunked\r\n" + + "Accept-Encoding: gzip\r\n\r\n" + + "b\r\n" + + "{\"id\":8888}\r\n" + + "0\r\n\r\n", + wantResWire: "HTTP/1.1 200 OK\r\n" + + "Connection: close\r\n" + + "Content-Type: application/json\r\n\r\n" + + `{"id":8888,"status":{}}`, + }, + + // POST: body with content length to non-existent route + { + reqWire: `POST /results HTTP/1.1 +Content-Length: 9 +Content-Type: application/json + +{"id":10} +`, + wantResWire: "HTTP/1.1 404 Not Found\r\n" + + "Connection: close\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "X-Content-Type-Options: nosniff\r\n\r\n" + + "Unmatched route: /results\n" + + "Only accepting /result and /run\n", + }, + } + + for i, tt := range tests { + req, err := http.ReadRequest(bufio.NewReader(strings.NewReader(tt.reqWire))) + if err != nil { + t.Errorf("#%d unexpected error parsing request: %v", i, err) + continue + } + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + gotResBlob, _ := httputil.DumpResponse(rec.Result(), true) + gotRes := string(gotResBlob) + if gotRes != tt.wantResWire { + t.Errorf("#%d non-matching responses\nGot:\n%q\nWant:\n%q", i, gotRes, tt.wantResWire) + } + } +}