Skip to content
This repository has been archived by the owner on Sep 15, 2021. It is now read-only.

Commit

Permalink
interoptestservice: support GET and POST HTTP requests
Browse files Browse the repository at this point in the history
Support for requests over HTTP with:
* GET to /result/:id
which will retrieve a test result. As shown in the template
route, ":id" must be replaced with the actual id which is
a 64-bit signed integer

* POST to /result and /run
which will
For /result submit a result
For /run, run the test asynchronously

Fixes #81
  • Loading branch information
odeke-em committed Dec 16, 2018
1 parent c577fff commit 7ff9403
Show file tree
Hide file tree
Showing 2 changed files with 303 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,16 @@ package interoptestservice

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"math/rand"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"

Expand Down Expand Up @@ -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": <ERROR_MESSAGE>}
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)
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}

0 comments on commit 7ff9403

Please sign in to comment.