Skip to content

Commit

Permalink
Query parameters infra (#67)
Browse files Browse the repository at this point in the history
1) Enhanced REST server to parse URL query parameters and pass them to
translib APIs. Handles RESTCONF "depth" and a custom "deleteEmptyEntry"
query parameters. The deleteEmptyEntry parameter is valid only for
DELETE requests; takes a boolean value - "true" or "false". It will be
passed to translib via SetRequest.DeleteEmptyEntry property. App modules
and transofrmer changes to handle these parameters is pending.

2) Modified cli_client get() and head() python APIs to accept optional
depth parameter, which should be a +ve integer. By default no depth
value is passed to the server. Usage:
cl.get(path, depth=2)
cl.head(path, depth=1)

3) Modified cli_client.delete python API to accept an optional
deleteEmptyEntry parameter. Default value is False. When specified as
True, the DELETE request will include "deleteEmptyEntry=true" query
parameter. Usage:
cl.delete(path, deleteEmptyEntry=True)
  • Loading branch information
sachinholla authored Oct 29, 2020
1 parent f3b3f6f commit addbf3d
Show file tree
Hide file tree
Showing 4 changed files with 276 additions and 16 deletions.
30 changes: 20 additions & 10 deletions CLI/actioner/cli_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ class ApiClient(object):
__session = requests.Session()


def request(self, method, path, data=None, headers={}, query=None):
def request(self, method, path, data=None, headers={}, query=None, response_type=None):

url = '{0}{1}'.format(ApiClient.__api_root, path)

Expand All @@ -60,20 +60,24 @@ def request(self, method, path, data=None, headers={}, query=None):
params=query,
verify=False)

return Response(r)
return Response(r, response_type)

except requests.RequestException as e:
log_warning('cli_client request exception: ' + str(e))
#TODO have more specific error message based
msg = '%Error: Could not connect to Management REST Server'
return ApiClient.__new_error_response(msg)

def post(self, path, data):
return self.request("POST", path, data)
def post(self, path, data={}, response_type=None):
return self.request("POST", path, data, response_type=response_type)

def get(self, path, depth=None):
def get(self, path, depth=None, ignore404=True, response_type=None):
q = self.prepare_query(depth=depth)
return self.request("GET", path, query=q)
resp = self.request("GET", path, query=q, response_type=response_type)
if ignore404 and resp.status_code == 404:
resp.status_code = 200
resp.content = None
return resp

def head(self, path, depth=None):
q = self.prepare_query(depth=depth)
Expand All @@ -85,18 +89,21 @@ def put(self, path, data):
def patch(self, path, data):
return self.request("PATCH", path, data)

def delete(self, path, ignore404=True):
resp = self.request("DELETE", path, data=None)
def delete(self, path, ignore404=True, deleteEmptyEntry=False):
q = self.prepare_query(deleteEmptyEntry=deleteEmptyEntry)
resp = self.request("DELETE", path, data=None, query=q)
if ignore404 and resp.status_code == 404:
resp.status_code = 204
resp.content = None
return resp

@staticmethod
def prepare_query(depth=None):
def prepare_query(depth=None, deleteEmptyEntry=None):
query = {}
if depth != None and depth != "unbounded":
query["depth"] = depth
if deleteEmptyEntry is True:
query["deleteEmptyEntry"] = "true"
return query

@staticmethod
Expand All @@ -123,14 +130,17 @@ def __str__(self):


class Response(object):
def __init__(self, response):
def __init__(self, response, response_type=None):
self.response = response
self.response_type = response_type
self.status_code = response.status_code
self.content = response.content

try:
if response.content is None or len(response.content) == 0:
self.content = None
elif self.response_type and self.response_type.lower() == 'string':
self.content = str(response.content).decode('string_escape')
elif has_json_content(response):
self.content = json.loads(response.content, object_pairs_hook=OrderedDict)
except ValueError:
Expand Down
30 changes: 24 additions & 6 deletions rest/server/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ func Process(w http.ResponseWriter, r *http.Request) {
if err == nil {
err = args.parseClientVersion(r, rc)
}
if err == nil {
err = args.parseQueryParams(r)
}

if err != nil {
status, data, rtype = prepareErrorResponse(err, r)
Expand Down Expand Up @@ -102,6 +105,17 @@ write_resp:
}
}

// getRequestID returns the request ID for a http Request r.
// ID is looked up from the RequestContext associated with this request.
// Returns empty value if context is not initialized yet.
func getRequestID(r *http.Request) string {
cv := getContextValue(r, requestContextKey)
if cv != nil {
return cv.(*RequestContext).ID
}
return ""
}

// getRequestBody returns the validated request body
func getRequestBody(r *http.Request, rc *RequestContext) (*MediaType, []byte, error) {
if r.ContentLength == 0 {
Expand Down Expand Up @@ -241,10 +255,12 @@ func isOperationsRequest(r *http.Request) bool {

// translibArgs holds arguments for invoking translib APIs.
type translibArgs struct {
method string // API name
path string // Translib path
data []byte // payload
version translib.Version // client version
method string // API name
path string // Translib path
data []byte // payload
version translib.Version // client version
depth uint // RESTCONF depth, for Get API only
deleteEmpty bool // Delete empty entry during field delete
}

// parseMethod maps http method name to translib method.
Expand Down Expand Up @@ -288,6 +304,7 @@ func invokeTranslib(args *translibArgs, rc *RequestContext) (int, []byte, error)
case "GET", "HEAD":
req := translib.GetRequest{
Path: args.path,
Depth: args.depth,
ClientVersion: args.version,
}
resp, err1 := translib.Get(req)
Expand Down Expand Up @@ -330,8 +347,9 @@ func invokeTranslib(args *translibArgs, rc *RequestContext) (int, []byte, error)
case "DELETE":
status = 204
req := translib.SetRequest{
Path: args.path,
ClientVersion: args.version,
Path: args.path,
ClientVersion: args.version,
DeleteEmptyEntry: args.deleteEmpty,
}
_, err = translib.Delete(req)

Expand Down
116 changes: 116 additions & 0 deletions rest/server/query.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
////////////////////////////////////////////////////////////////////////////////
// //
// Copyright 2020 Broadcom. The term Broadcom refers to Broadcom Inc. and/or //
// its subsidiaries. //
// //
// 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 server

import (
"net/http"
"strconv"
"strings"

"github.com/golang/glog"
)

// parseQueryParams parses the http request's query parameters
// into a translibArgs args.
func (args *translibArgs) parseQueryParams(r *http.Request) error {
if strings.Contains(r.URL.Path, restconfDataPathPrefix) {
return args.parseRestconfQueryParams(r)
}

return nil
}

// parseRestconfQueryParams parses query parameters of a request 'r' to
// fill translibArgs object 'args'. Returns httpError with status 400
// if any parameter is unsupported or has invalid value.
func (args *translibArgs) parseRestconfQueryParams(r *http.Request) error {
var err error
qParams := r.URL.Query()

for name, vals := range qParams {
switch name {
case "depth":
args.depth, err = parseDepthParam(vals, r)
case "deleteEmptyEntry":
args.deleteEmpty, err = parseDeleteEmptyEntryParam(vals, r)
default:
err = newUnsupportedParamError(name, r)
}
if err != nil {
return err
}
}

return nil
}

func newUnsupportedParamError(name string, r *http.Request) error {
return httpError(http.StatusBadRequest, "query parameter '%s' not supported", name)
}

func newInvalidParamError(name string, r *http.Request) error {
return httpError(http.StatusBadRequest, "invalid '%s' query parameter", name)
}

// parseDepthParam parses query parameter value for "depth" parameter.
// See https://tools.ietf.org/html/rfc8040#section-4.8.2
func parseDepthParam(v []string, r *http.Request) (uint, error) {
if !restconfCapabilities.depth {
glog.V(1).Infof("[%s] 'depth' support disabled", getRequestID(r))
return 0, newUnsupportedParamError("depth", r)
}

if r.Method != "GET" && r.Method != "HEAD" {
glog.V(1).Infof("[%s] 'depth' not supported for %s", getRequestID(r), r.Method)
return 0, newUnsupportedParamError("depth", r)
}

if len(v) != 1 {
glog.V(1).Infof("[%s] Expecting only 1 depth param; found %d", getRequestID(r), len(v))
return 0, newInvalidParamError("depth", r)
}

if v[0] == "unbounded" {
return 0, nil
}

d, err := strconv.ParseUint(v[0], 10, 16)
if err != nil || d == 0 {
glog.V(1).Infof("[%s] Bad depth value '%s', err=%v", getRequestID(r), v[0], err)
return 0, newInvalidParamError("depth", r)
}

return uint(d), nil
}

// parseDeleteEmptyEntryParam parses the custom "deleteEmptyEntry" query parameter.
func parseDeleteEmptyEntryParam(v []string, r *http.Request) (bool, error) {
if r.Method != "DELETE" {
glog.V(1).Infof("[%s] deleteEmptyEntry not supported for %s", getRequestID(r), r.Method)
return false, newUnsupportedParamError("deleteEmptyEntry", r)
}

if len(v) != 1 {
glog.V(1).Infof("[%s] expecting only 1 deleteEmptyEntry; found %d", getRequestID(r), len(v))
return false, newInvalidParamError("deleteEmptyEntry", r)
}

return strings.EqualFold(v[0], "true"), nil
}
116 changes: 116 additions & 0 deletions rest/server/query_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
////////////////////////////////////////////////////////////////////////////////
// //
// Copyright 2020 Broadcom. The term Broadcom refers to Broadcom Inc. and/or //
// its subsidiaries. //
// //
// 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 server

import (
"net/http/httptest"
"testing"
)

func testQuery(method, queryStr string, exp *translibArgs) func(*testing.T) {
return func(t *testing.T) {
r := httptest.NewRequest(method, "/restconf/data/querytest?"+queryStr, nil)
_, r = GetContext(r)

p := translibArgs{}
err := p.parseQueryParams(r)

errCode := 0
if he, ok := err.(httpErrorType); ok {
errCode = he.status
}

if exp == nil && errCode == 400 {
return // success
}
if err != nil {
t.Fatalf("Failed to process query '%s'; err=%d/%v", r.URL.RawQuery, errCode, err)
}

// compare parsed translibArgs
if p.depth != exp.depth {
t.Errorf("'depth' mismatch; expecting %d, found %d", exp.depth, p.depth)
}
if p.deleteEmpty != exp.deleteEmpty {
t.Errorf("'deleteEmptyEntry' mismatch; expting %v, found %v", exp.deleteEmpty, p.deleteEmpty)
}
if t.Failed() {
t.Errorf("Testcase failed for query '%s'", r.URL.RawQuery)
}
}
}

func TestQuery(t *testing.T) {
t.Run("none", testQuery("GET", "", &translibArgs{}))
t.Run("unknown", testQuery("GET", "one=1", nil))
}

func TestQuery_depth(t *testing.T) {
rcCaps := restconfCapabilities
defer func() { restconfCapabilities = rcCaps }()

restconfCapabilities.depth = true

// run depth test cases for GET and HEAD
testDepth(t, "=unbounded", "depth=unbounded", &translibArgs{depth: 0})
testDepth(t, "=0", "depth=0", nil)
testDepth(t, "=1", "depth=1", &translibArgs{depth: 1})
testDepth(t, "=101", "depth=101", &translibArgs{depth: 101})
testDepth(t, "=65535", "depth=65535", &translibArgs{depth: 65535})
testDepth(t, "=65536", "depth=65536", nil)
testDepth(t, "=junk", "depth=junk", nil)
testDepth(t, "extra", "depth=1&extra=1", nil)
testDepth(t, "dup", "depth=1&depth=2", nil)

// check for other methods
t.Run("OPTIONS", testQuery("OPTIONS", "depth=1", nil))
t.Run("PUT", testQuery("PUT", "depth=1", nil))
t.Run("POST", testQuery("POST", "depth=1", nil))
t.Run("PATCH", testQuery("PATCH", "depth=1", nil))
t.Run("DELETE", testQuery("DELETE", "depth=1", nil))
}

func TestQuery_depth_disabled(t *testing.T) {
rcCaps := restconfCapabilities
defer func() { restconfCapabilities = rcCaps }()

restconfCapabilities.depth = false

testDepth(t, "100", "depth=100", nil)
}

func testDepth(t *testing.T, name, queryStr string, exp *translibArgs) {
t.Run("GET/"+name, testQuery("GET", queryStr, exp))
t.Run("HEAD/"+name, testQuery("HEAD", queryStr, exp))
}

func TestQuery_deleteEmptyEntry(t *testing.T) {
t.Run("=true", testQuery("DELETE", "deleteEmptyEntry=true", &translibArgs{deleteEmpty: true}))
t.Run("=True", testQuery("DELETE", "deleteEmptyEntry=True", &translibArgs{deleteEmpty: true}))
t.Run("=TRUE", testQuery("DELETE", "deleteEmptyEntry=TRUE", &translibArgs{deleteEmpty: true}))
t.Run("=false", testQuery("DELETE", "deleteEmptyEntry=false", &translibArgs{deleteEmpty: false}))
t.Run("=1", testQuery("DELETE", "deleteEmptyEntry=1", &translibArgs{deleteEmpty: false}))
t.Run("GET", testQuery("GET", "deleteEmptyEntry=true", nil))
t.Run("HEAD", testQuery("HEAD", "deleteEmptyEntry=true", nil))
t.Run("OPTIONS", testQuery("OPTIONS", "deleteEmptyEntry=true", nil))
t.Run("PUT", testQuery("PUT", "deleteEmptyEntry=true", nil))
t.Run("POST", testQuery("POST", "deleteEmptyEntry=true", nil))
t.Run("PATCH", testQuery("PATCH", "deleteEmptyEntry=true", nil))
}

0 comments on commit addbf3d

Please sign in to comment.