Add updating devices to apiv1.
We needed a way to be able to update devices after they were created. This is
supported in the devices package, we just needed to expose it using apiv1
endpoints.
In doing so, it became apparent that allowing users to change the Owner of their
Devices wasn't properly thought through, and pending a reason to use it, I'm
just removing it. The biggest issue came when trying to return usable error
messages; we couldn't distinguish between "you don't own the device you're
trying to update" and "you're not allowed to change the owner of the device". I
also couldn't figure out _who should be able to_ change the owner of the device,
which is generally an indication that I'm building a feature before I have a use
case for it.
To support this change, the apiv1.DeviceChange type needed its Owner property
removed.
I also needed to add deviceFromAPI and devicesFromAPI helpers to return
devices.Device types from apiv1.Device types.
There's now a new validateDeviceUpdate helper that checks to ensure that a
device update request is valid and the user has the appropriate permissions.
The createRequest type now accepts a slice of Devices, not a slice of
DeviceChanges, because we want to pass the Owner in.
A new updateRequest type is created, which accepts a DeviceChange to apply.
A new handleUpdateDevice handler is created, which is assigned to the endpoint
for PATCH requests against a device ID. It checks that the user is logged in,
the Device they're trying to update exists, and that it's a valid update. If all
of that is true, the device is updated and the updated device is returned.
Finally, we had to add two new scopes to support new functionality:
ScopeUpdateOtherUserDevices allows a user to update other user's devices, and
ScopeUpdateLastSeen allows a user to update the LastSeen property of a device.
Pending some better error messages, this should be a full implementation of
updating a device, which leaves only the deletion endpoint to deal with.
8 "code.secondbit.org/api.hg"
9 "code.secondbit.org/ducky/devices.hg"
10 "code.secondbit.org/trout.hg"
11 "code.secondbit.org/uuid.hg"
13 "golang.org/x/net/context"
16 type createRequest struct {
17 Devices []Device `json:"devices"`
20 type updateRequest struct {
21 DeviceChange DeviceChange `json:"device"`
24 func handleCreateDevices(ctx context.Context, w http.ResponseWriter, r *http.Request) {
27 userID, err := api.AuthUser(r)
29 api.Encode(w, r, http.StatusUnauthorized, Response{Errors: api.AccessDeniedError})
33 err = api.Decode(r, &req)
35 api.Encode(w, r, http.StatusBadRequest, Response{Errors: api.InvalidFormatError})
39 devicesToCreate := devicesFromAPI(req.Devices)
40 passedScopes := api.GetScopes(r)
41 for pos, device := range devicesToCreate {
42 err := validateDeviceCreation(device, passedScopes, userID)
46 var requestErr api.RequestError
48 case errUnauthorizedLastSeen:
49 requestErr.Slug = api.RequestErrAccessDenied
50 requestErr.Field = fmt.Sprintf("devices/%d/lastSeen", pos)
51 case errUnauthorizedCreated:
52 requestErr.Slug = api.RequestErrAccessDenied
53 requestErr.Field = fmt.Sprintf("devices/%d/created", pos)
54 case errUnauthorizedOwner:
55 requestErr.Slug = api.RequestErrAccessDenied
56 requestErr.Field = fmt.Sprintf("devices/%d/owner", pos)
57 case errInvalidDeviceType:
58 requestErr.Slug = api.RequestErrInvalidValue
59 requestErr.Field = fmt.Sprintf("devices/%d/type", pos)
60 case errDeviceNameTooShort:
61 if len(device.Name) == 0 {
62 requestErr.Slug = api.RequestErrMissing
64 requestErr.Slug = api.RequestErrInsufficient
66 requestErr.Field = fmt.Sprintf("devices/%d/name", pos)
67 case errDeviceNameTooLong:
68 requestErr.Slug = api.RequestErrOverflow
69 requestErr.Field = fmt.Sprintf("devices/%d/name", pos)
71 if requestErr.Slug != "" {
72 api.Encode(w, r, http.StatusBadRequest, Response{Errors: []api.RequestError{requestErr}})
75 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError})
77 createdDevices, err := devices.CreateMany(devicesToCreate, ctx)
79 // BUG(paddy): we should filter out non-internal errors here and expose better error responses
80 log.Printf("Error creating devices: %+v\n", err)
81 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError})
85 for _, device := range createdDevices {
86 resp.Devices = append(resp.Devices, apiDeviceFromCore(device, api.CheckScopes(passedScopes, ScopeViewPushToken.ID)))
88 api.Encode(w, r, http.StatusCreated, resp)
91 func handleGetDevices(ctx context.Context, w http.ResponseWriter, r *http.Request) {
92 userID, err := api.AuthUser(r)
94 api.Encode(w, r, http.StatusUnauthorized, Response{Errors: api.AccessDeniedError})
98 passedScopes := api.GetScopes(r)
99 if !api.CheckScopes(passedScopes, ScopeViewDevices.ID) {
100 api.Encode(w, r, http.StatusForbidden, Response{Errors: api.AccessDeniedError})
104 var retrievedDevices []devices.Device
105 requestedIDStrs := r.URL.Query()["id"]
106 requestedIDStrs = append(requestedIDStrs, trout.RequestVars(r)[http.CanonicalHeaderKey("id")]...)
108 if len(requestedIDStrs) < 1 {
109 retrievedDevices, err = devices.ListByOwner(userID, ctx)
111 log.Printf("Error listing devices: %+v\n", err)
112 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError})
116 requestedIDs := make([]uuid.ID, 0, len(requestedIDStrs))
117 var reqErrs []api.RequestError
118 for pos, idStr := range requestedIDStrs {
119 id, err := uuid.Parse(idStr)
121 reqErrs = append(reqErrs, api.RequestError{Slug: api.RequestErrInvalidFormat, Param: fmt.Sprintf("id[%d]", pos)})
124 requestedIDs = append(requestedIDs, id)
126 if len(reqErrs) > 0 {
127 api.Encode(w, r, http.StatusBadRequest, Response{Errors: reqErrs})
130 getDevices, err := devices.GetMany(requestedIDs, ctx)
132 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError})
135 for _, device := range getDevices {
136 if device.Owner.Equal(userID) {
137 retrievedDevices = append(retrievedDevices, device)
143 for _, device := range retrievedDevices {
144 resp.Devices = append(resp.Devices, apiDeviceFromCore(device, api.CheckScopes(passedScopes, ScopeViewPushToken.ID)))
146 api.Encode(w, r, http.StatusOK, resp)
149 func handleUpdateDevice(ctx context.Context, w http.ResponseWriter, r *http.Request) {
150 userID, err := api.AuthUser(r)
152 api.Encode(w, r, http.StatusUnauthorized, Response{Errors: api.AccessDeniedError})
156 id, err := uuid.Parse(trout.RequestVars(r).Get("id"))
158 api.Encode(w, r, http.StatusBadRequest, Response{Errors: []api.RequestError{{Slug: api.RequestErrInvalidFormat, Param: "id"}}})
161 device, err := devices.Get(id, ctx)
163 if err == devices.ErrDeviceNotFound {
164 api.Encode(w, r, http.StatusNotFound, Response{Errors: []api.RequestError{{Slug: api.RequestErrNotFound, Param: "id"}}})
167 log.Printf("Error retrieving device %s: %+v\n", id, err)
168 api.Encode(w, r, http.StatusInternalServerError, api.ActOfGodError)
172 var req updateRequest
173 err = api.Decode(r, &req)
175 api.Encode(w, r, http.StatusBadRequest, Response{Errors: api.InvalidFormatError})
179 passedScopes := api.GetScopes(r)
180 err = validateDeviceUpdate(apiDeviceFromCore(device, true), req.DeviceChange, passedScopes, userID)
181 var requestErr api.RequestError
183 case errUnauthorizedLastSeen:
184 requestErr.Slug = api.RequestErrAccessDenied
185 requestErr.Field = "device/lastSeen"
186 case errUnauthorizedCreated:
187 requestErr.Slug = api.RequestErrAccessDenied
188 requestErr.Field = "device/created"
189 case errUnauthorizedOwner:
190 requestErr.Slug = api.RequestErrAccessDenied
191 requestErr.Field = "device/owner"
192 case errInvalidDeviceType:
193 requestErr.Slug = api.RequestErrInvalidValue
194 requestErr.Field = "device/type"
195 case errDeviceNameTooShort:
196 if len(device.Name) == 0 {
197 requestErr.Slug = api.RequestErrMissing
199 requestErr.Slug = api.RequestErrInsufficient
201 requestErr.Field = "device/name"
202 case errDeviceNameTooLong:
203 requestErr.Slug = api.RequestErrOverflow
204 requestErr.Field = "device/name"
206 if requestErr.Slug != "" {
207 api.Encode(w, r, http.StatusBadRequest, Response{Errors: []api.RequestError{requestErr}})
211 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError})
215 change := changeFromAPI(req.DeviceChange)
216 updatedDevice, err := devices.Update(device, change, ctx)
218 // BUG(paddy): we should filter out non-internal errors here and expose better error responses
219 log.Printf("Error updating device %s: %+v\n", id, err)
220 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError})
223 resp := Response{Devices: []Device{apiDeviceFromCore(updatedDevice, api.CheckScopes(passedScopes, ScopeViewPushToken.ID))}}
224 api.Encode(w, r, http.StatusOK, resp)