Skip to content

Commit

Permalink
Add UpdateSubscription endpoint (#398)
Browse files Browse the repository at this point in the history
* Add UpdateSubscription endpoint

* Add missing mock

* Add tests

* Update docs and OpenAPI

* Add httpapi tests

* Fix how we check for valid updates

* Update README and change method to function

* Change for 404 if subscription not found
  • Loading branch information
alexdebrie authored and mthenw committed Mar 29, 2018
1 parent 0da4cd7 commit acfe7bd
Show file tree
Hide file tree
Showing 9 changed files with 510 additions and 2 deletions.
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,46 @@ JSON object:

---

#### Update Subscription

**Endpoint**

`PUT <Configuration API URL>/v1/spaces/<space>/subscriptions/<subscription ID>`

**Request**

_Note that `event`, `functionId`, `path`, and `method` may not be updated in an UpdateSubscription call._

* `event` - `string` - event name
* `functionId` - `string` - ID of function to receive events
* `path` - `string` - optional, URL path under which events (HTTP requests) are accepted, default: `/`
* `method` - `string` - required for `http` event, HTTP method that accepts requests
* `cors` - `object` - optional, in case of `http` event, By default CORS is disabled. When set to empty object CORS configuration will use default values for all fields below. Available fields:
* `origins` - `array` of `string` - list of allowed origins. An origin may contain a wildcard (\*) to replace 0 or more characters (i.e.: http://\*.domain.com), default: `*`
* `methods` - `array` of `string` - list of allowed methods, default: `HEAD`, `GET`, `POST`
* `headers` - `array` of `string` - list of allowed headers, default: `Origin`, `Accept`, `Content-Type`
* `allowCredentials` - `bool` - default: false

**Response**

Status code:

* `200 Created` on success
* `400 Bad Request` on validation error
* `404 Not Found` if subscription doesn't exist

JSON object:

* `space` - `string` - space name
* `subscriptionId` - `string` - subscription ID
* `event` - `string` - event name
* `functionId` - function ID
* `method` - `string` - optional, in case of `http` event, HTTP method that accepts requests
* `path` - `string` - optional, in case of `http` event, path that accepts requests, starts with `/`
* `cors` - `object` - optional, in case of `http` event, CORS configuration

---

#### Delete Subscription

**Endpoint**
Expand Down
39 changes: 39 additions & 0 deletions docs/openapi/openapi-config-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,28 @@ paths:
$ref: '#/components/responses/NotFoundError'
500:
$ref: '#/components/responses/Error'
put:
summary: "Update subscription"
tags:
- "subscription"
operationId: "UpdateSubscription"
parameters:
- $ref: "#/components/parameters/Space"
requestBody:
$ref: "#/components/requestBodies/UpdateSubscription"
responses:
200:
description: "subscription updated"
content:
application/json:
schema:
$ref: "#/components/schemas/Subscription"
400:
$ref: '#/components/responses/ValidationError'
404:
$ref: '#/components/responses/NotFoundError'
500:
$ref: '#/components/responses/Error'
delete:
summary: "Delete subscription"
tags:
Expand Down Expand Up @@ -468,6 +490,23 @@ components:
$ref: '#/components/schemas/Method'
cors:
$ref: '#/components/schemas/CORS'
UpdateSubscription:
description: "subscription update request body"
content:
application/json:
schema:
type: object
properties:
functionId:
$ref: '#/components/schemas/FunctionID'
event:
$ref: '#/components/schemas/Event'
path:
$ref: '#/components/schemas/Path'
method:
$ref: '#/components/schemas/Method'
cors:
$ref: '#/components/schemas/CORS'
responses:
Error:
description: "internal server error"
Expand Down
40 changes: 40 additions & 0 deletions httpapi/httpapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ func (h HTTPAPI) RegisterRoutes(router *httprouter.Router) {
router.GET("/v1/spaces/:space/subscriptions", h.listSubscriptions)
router.GET("/v1/spaces/:space/subscriptions/*id", h.getSubscription)
router.POST("/v1/spaces/:space/subscriptions", h.createSubscription)
router.PUT("/v1/spaces/:space/subscriptions/*id", h.updateSubscription)
router.DELETE("/v1/spaces/:space/subscriptions/*id", h.deleteSubscription)
}

Expand Down Expand Up @@ -253,6 +254,45 @@ func (h HTTPAPI) createSubscription(w http.ResponseWriter, r *http.Request, para
metricConfigRequests.WithLabelValues(s.Space, "subscription", "create").Inc()
}

func (h HTTPAPI) updateSubscription(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
w.Header().Set("Content-Type", "application/json")
encoder := json.NewEncoder(w)

s := &subscription.Subscription{}
dec := json.NewDecoder(r.Body)
err := dec.Decode(s)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
validationErr := subscription.ErrSubscriptionValidation{Message: err.Error()}
encoder.Encode(&Response{Errors: []Error{{Message: validationErr.Error()}}})
return
}

s.Space = params.ByName("space")
s.ID = extractSubscriptionID(r.URL.RawPath)
output, err := h.Subscriptions.UpdateSubscription(s.ID, s)
if err != nil {
if _, ok := err.(*subscription.ErrInvalidSubscriptionUpdate); ok {
w.WriteHeader(http.StatusBadRequest)
} else if _, ok := err.(*subscription.ErrSubscriptionNotFound); ok {
w.WriteHeader(http.StatusNotFound)
} else if _, ok := err.(*function.ErrFunctionNotFound); ok {
w.WriteHeader(http.StatusBadRequest)
} else if _, ok := err.(*subscription.ErrSubscriptionValidation); ok {
w.WriteHeader(http.StatusBadRequest)
} else {
w.WriteHeader(http.StatusInternalServerError)
}

encoder.Encode(&Response{Errors: []Error{{Message: err.Error()}}})
} else {
w.WriteHeader(http.StatusOK)
encoder.Encode(output)
}

metricConfigRequests.WithLabelValues(s.Space, "subscription", "update").Inc()
}

func (h HTTPAPI) deleteSubscription(w http.ResponseWriter, r *http.Request, params httprouter.Params) {
w.Header().Set("Content-Type", "application/json")
encoder := json.NewEncoder(w)
Expand Down
220 changes: 220 additions & 0 deletions httpapi/httpapi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/golang/mock/gomock"
"github.com/julienschmidt/httprouter"
"github.com/serverless/event-gateway/function"
"github.com/serverless/event-gateway/subscription"
"github.com/serverless/event-gateway/httpapi"
"github.com/serverless/event-gateway/mock"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -305,6 +306,225 @@ func TestDeleteFunction_OK(t *testing.T) {
assert.Equal(t, http.StatusNoContent, resp.Code)
}

func TestUpdateSubscription_OK(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
router, _, subscriptions := setup(ctrl)

returned := &subscription.Subscription{
Space: "default",
ID: subscription.ID("http,GET,%2F"),
Event: "http",
FunctionID: "func",
Method: "GET",
Path: "/",
CORS: &subscription.CORS{
Origins: []string{"*"},
Methods: []string{"HEAD", "GET", "POST"},
Headers: []string{"Origin", "Accept", "Content-Type"},
AllowCredentials: false,
},
}
subscriptions.EXPECT().UpdateSubscription(subscription.ID("http,GET,%2F"), returned).Return(returned, nil)

resp := httptest.NewRecorder()
payload := bytes.NewReader([]byte(`
{"space":"default","subscriptionId":"http,GET,%2F","event":"http","functionId":"func","method":"GET","path":"/","cors":{"origins":["*"],"methods":["HEAD","GET","POST"],"headers":["Origin","Accept","Content-Type"],"allowCredentials":false}}
`))
req, _ := http.NewRequest(http.MethodPut, "/v1/spaces/default/subscriptions/http,GET,%2F", payload)
router.ServeHTTP(resp, req)

sub := &subscription.Subscription{}
json.Unmarshal(resp.Body.Bytes(), sub)
assert.Equal(t, http.StatusOK, resp.Code)
assert.Equal(t, "default", sub.Space)
assert.Equal(t, subscription.ID("http,GET,%2F"), sub.ID)
}

func TestUpdateSubscription_InvalidJSON(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
router, _, _:= setup(ctrl)

resp := httptest.NewRecorder()
payload := bytes.NewReader([]byte(`{"name":"te`))
req, _ := http.NewRequest(http.MethodPut, "/v1/spaces/default/subscriptions/http,GET,%2F", payload)
router.ServeHTTP(resp, req)

sub := &subscription.Subscription{}
json.Unmarshal(resp.Body.Bytes(), sub)
assert.Equal(t, http.StatusBadRequest, resp.Code)
}

func TestUpdateSubscription_InvalidSubscriptionUpdate(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
router, _, subscriptions := setup(ctrl)

input := &subscription.Subscription{
Space: "default",
ID: subscription.ID("http,GET,%2F"),
Event: "http",
FunctionID: "func2",
Method: "GET",
Path: "/",
CORS: &subscription.CORS{
Origins: []string{"*"},
Methods: []string{"HEAD", "GET", "POST"},
Headers: []string{"Origin", "Accept", "Content-Type"},
AllowCredentials: false,
},
}
subscriptions.EXPECT().UpdateSubscription(subscription.ID("http,GET,%2F"), input).Return(nil, &subscription.ErrInvalidSubscriptionUpdate{Field: "FunctionID"})

resp := httptest.NewRecorder()
payload := bytes.NewReader([]byte(`
{"space":"default","subscriptionId":"http,GET,%2F","event":"http","functionId":"func2","method":"GET","path":"/","cors":{"origins":["*"],"methods":["HEAD","GET","POST"],"headers":["Origin","Accept","Content-Type"],"allowCredentials":false}}
`))
req, _ := http.NewRequest(http.MethodPut, "/v1/spaces/default/subscriptions/http,GET,%2F", payload)
router.ServeHTTP(resp, req)

httpresp := &httpapi.Response{}
json.Unmarshal(resp.Body.Bytes(), httpresp)
assert.Equal(t, http.StatusBadRequest, resp.Code)
assert.Equal(t, `Invalid update. 'FunctionID' of existing subscription cannot be updated.`, httpresp.Errors[0].Message)
}

func TestUpdateSubscription_SubscriptionNotFound(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
router, _, subscriptions := setup(ctrl)

input := &subscription.Subscription{
Space: "default",
ID: subscription.ID("http,GET,%2F"),
Event: "http",
FunctionID: "func",
Method: "GET",
Path: "/",
CORS: &subscription.CORS{
Origins: []string{"*"},
Methods: []string{"HEAD", "GET", "POST"},
Headers: []string{"Origin", "Accept", "Content-Type"},
AllowCredentials: false,
},
}
subscriptions.EXPECT().UpdateSubscription(subscription.ID("http,GET,%2F"), input).Return(nil, &subscription.ErrSubscriptionNotFound{ID: subscription.ID("http,GET,%2F")})

resp := httptest.NewRecorder()
payload := bytes.NewReader([]byte(`
{"space":"default","subscriptionId":"http,GET,%2F","event":"http","functionId":"func","method":"GET","path":"/","cors":{"origins":["*"],"methods":["HEAD","GET","POST"],"headers":["Origin","Accept","Content-Type"],"allowCredentials":false}}
`))
req, _ := http.NewRequest(http.MethodPut, "/v1/spaces/default/subscriptions/http,GET,%2F", payload)
router.ServeHTTP(resp, req)

httpresp := &httpapi.Response{}
json.Unmarshal(resp.Body.Bytes(), httpresp)
assert.Equal(t, http.StatusNotFound, resp.Code)
assert.Equal(t, `Subscription "http,GET,%2F" not found.`, httpresp.Errors[0].Message)
}

func TestUpdateSubscription_FunctionNotFound(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
router, _, subscriptions := setup(ctrl)

input := &subscription.Subscription{
Space: "default",
ID: subscription.ID("http,GET,%2F"),
Event: "http",
FunctionID: "func",
Method: "GET",
Path: "/",
CORS: &subscription.CORS{
Origins: []string{"*"},
Methods: []string{"HEAD", "GET", "POST"},
Headers: []string{"Origin", "Accept", "Content-Type"},
AllowCredentials: false,
},
}
subscriptions.EXPECT().UpdateSubscription(subscription.ID("http,GET,%2F"), input).Return(nil, &function.ErrFunctionNotFound{ID: function.ID("func")})

resp := httptest.NewRecorder()
payload := bytes.NewReader([]byte(`
{"space":"default","subscriptionId":"http,GET,%2F","event":"http","functionId":"func","method":"GET","path":"/","cors":{"origins":["*"],"methods":["HEAD","GET","POST"],"headers":["Origin","Accept","Content-Type"],"allowCredentials":false}}
`))
req, _ := http.NewRequest(http.MethodPut, "/v1/spaces/default/subscriptions/http,GET,%2F", payload)
router.ServeHTTP(resp, req)

httpresp := &httpapi.Response{}
json.Unmarshal(resp.Body.Bytes(), httpresp)
assert.Equal(t, http.StatusBadRequest, resp.Code)
assert.Equal(t, `Function "func" not found.`, httpresp.Errors[0].Message)
}

func TestUpdateSubscription_SubscriptionValidationError(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
router, _, subscriptions := setup(ctrl)

input := &subscription.Subscription{
Space: "default",
ID: subscription.ID("http,GET,%2F"),
FunctionID: "func",
Method: "GET",
Path: "/",
CORS: &subscription.CORS{
Origins: []string{"*"},
Methods: []string{"HEAD", "GET", "POST"},
Headers: []string{"Origin", "Accept", "Content-Type"},
AllowCredentials: false,
},
}
subscriptions.EXPECT().UpdateSubscription(subscription.ID("http,GET,%2F"), input).Return(nil, &subscription.ErrSubscriptionValidation{Message: "" })

resp := httptest.NewRecorder()
payload := bytes.NewReader([]byte(`
{"space":"default","subscriptionId":"http,GET,%2F","functionId":"func","method":"GET","path":"/","cors":{"origins":["*"],"methods":["HEAD","GET","POST"],"headers":["Origin","Accept","Content-Type"],"allowCredentials":false}}
`))
req, _ := http.NewRequest(http.MethodPut, "/v1/spaces/default/subscriptions/http,GET,%2F", payload)
router.ServeHTTP(resp, req)

httpresp := &httpapi.Response{}
json.Unmarshal(resp.Body.Bytes(), httpresp)
assert.Equal(t, http.StatusBadRequest, resp.Code)
assert.Contains(t, httpresp.Errors[0].Message, "Subscription doesn't validate. Validation error")
}

func TestUpdateSubscription_InternalError(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
router, _, subscriptions := setup(ctrl)

input := &subscription.Subscription{
Space: "default",
ID: subscription.ID("http,GET,%2F"),
Event: "http",
FunctionID: "func",
Method: "GET",
Path: "/",
CORS: &subscription.CORS{
Origins: []string{"*"},
Methods: []string{"HEAD", "GET", "POST"},
Headers: []string{"Origin", "Accept", "Content-Type"},
AllowCredentials: false,
},
}
subscriptions.EXPECT().UpdateSubscription(subscription.ID("http,GET,%2F"), input).Return(nil, errors.New("processing failed"))

resp := httptest.NewRecorder()
payload := bytes.NewReader([]byte(`
{"space":"default","subscriptionId":"http,GET,%2F","event":"http","functionId":"func","method":"GET","path":"/","cors":{"origins":["*"],"methods":["HEAD","GET","POST"],"headers":["Origin","Accept","Content-Type"],"allowCredentials":false}}
`))
req, _ := http.NewRequest(http.MethodPut, "/v1/spaces/default/subscriptions/http,GET,%2F", payload)
router.ServeHTTP(resp, req)

httpresp := &httpapi.Response{}
json.Unmarshal(resp.Body.Bytes(), httpresp)
assert.Equal(t, http.StatusInternalServerError, resp.Code)
assert.Equal(t, "processing failed", httpresp.Errors[0].Message)
}

func setup(ctrl *gomock.Controller) (
*httprouter.Router,
*mock.MockFunctionService,
Expand Down
Loading

0 comments on commit acfe7bd

Please sign in to comment.