Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add UpdateSubscription endpoint #398

Merged
merged 10 commits into from
Mar 29, 2018
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

404 if subscription not found

* `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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

404 missing

$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