ducky/devices
ducky/devices/apiv1/handlers.go
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.
| paddy@15 | 1 package apiv1 |
| paddy@15 | 2 |
| paddy@15 | 3 import ( |
| paddy@17 | 4 "fmt" |
| paddy@15 | 5 "log" |
| paddy@15 | 6 "net/http" |
| paddy@15 | 7 |
| paddy@15 | 8 "code.secondbit.org/api.hg" |
| paddy@15 | 9 "code.secondbit.org/ducky/devices.hg" |
| paddy@18 | 10 "code.secondbit.org/trout.hg" |
| paddy@18 | 11 "code.secondbit.org/uuid.hg" |
| paddy@15 | 12 |
| paddy@15 | 13 "golang.org/x/net/context" |
| paddy@15 | 14 ) |
| paddy@15 | 15 |
| paddy@15 | 16 type createRequest struct { |
| paddy@20 | 17 Devices []Device `json:"devices"` |
| paddy@20 | 18 } |
| paddy@20 | 19 |
| paddy@20 | 20 type updateRequest struct { |
| paddy@20 | 21 DeviceChange DeviceChange `json:"device"` |
| paddy@15 | 22 } |
| paddy@15 | 23 |
| paddy@15 | 24 func handleCreateDevices(ctx context.Context, w http.ResponseWriter, r *http.Request) { |
| paddy@15 | 25 var req createRequest |
| paddy@16 | 26 |
| paddy@16 | 27 userID, err := api.AuthUser(r) |
| paddy@16 | 28 if err != nil { |
| paddy@18 | 29 api.Encode(w, r, http.StatusUnauthorized, Response{Errors: api.AccessDeniedError}) |
| paddy@16 | 30 return |
| paddy@16 | 31 } |
| paddy@16 | 32 |
| paddy@16 | 33 err = api.Decode(r, &req) |
| paddy@15 | 34 if err != nil { |
| paddy@15 | 35 api.Encode(w, r, http.StatusBadRequest, Response{Errors: api.InvalidFormatError}) |
| paddy@15 | 36 return |
| paddy@15 | 37 } |
| paddy@15 | 38 |
| paddy@20 | 39 devicesToCreate := devicesFromAPI(req.Devices) |
| paddy@16 | 40 passedScopes := api.GetScopes(r) |
| paddy@17 | 41 for pos, device := range devicesToCreate { |
| paddy@16 | 42 err := validateDeviceCreation(device, passedScopes, userID) |
| paddy@16 | 43 if err == nil { |
| paddy@16 | 44 continue |
| paddy@16 | 45 } |
| paddy@16 | 46 var requestErr api.RequestError |
| paddy@16 | 47 switch err { |
| paddy@16 | 48 case errUnauthorizedLastSeen: |
| paddy@16 | 49 requestErr.Slug = api.RequestErrAccessDenied |
| paddy@17 | 50 requestErr.Field = fmt.Sprintf("devices/%d/lastSeen", pos) |
| paddy@16 | 51 case errUnauthorizedCreated: |
| paddy@16 | 52 requestErr.Slug = api.RequestErrAccessDenied |
| paddy@17 | 53 requestErr.Field = fmt.Sprintf("devices/%d/created", pos) |
| paddy@16 | 54 case errUnauthorizedOwner: |
| paddy@16 | 55 requestErr.Slug = api.RequestErrAccessDenied |
| paddy@17 | 56 requestErr.Field = fmt.Sprintf("devices/%d/owner", pos) |
| paddy@16 | 57 case errInvalidDeviceType: |
| paddy@16 | 58 requestErr.Slug = api.RequestErrInvalidValue |
| paddy@17 | 59 requestErr.Field = fmt.Sprintf("devices/%d/type", pos) |
| paddy@16 | 60 case errDeviceNameTooShort: |
| paddy@17 | 61 if len(device.Name) == 0 { |
| paddy@17 | 62 requestErr.Slug = api.RequestErrMissing |
| paddy@17 | 63 } else { |
| paddy@17 | 64 requestErr.Slug = api.RequestErrInsufficient |
| paddy@17 | 65 } |
| paddy@17 | 66 requestErr.Field = fmt.Sprintf("devices/%d/name", pos) |
| paddy@16 | 67 case errDeviceNameTooLong: |
| paddy@16 | 68 requestErr.Slug = api.RequestErrOverflow |
| paddy@17 | 69 requestErr.Field = fmt.Sprintf("devices/%d/name", pos) |
| paddy@16 | 70 } |
| paddy@16 | 71 if requestErr.Slug != "" { |
| paddy@16 | 72 api.Encode(w, r, http.StatusBadRequest, Response{Errors: []api.RequestError{requestErr}}) |
| paddy@15 | 73 return |
| paddy@15 | 74 } |
| paddy@16 | 75 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError}) |
| paddy@15 | 76 } |
| paddy@15 | 77 createdDevices, err := devices.CreateMany(devicesToCreate, ctx) |
| paddy@15 | 78 if err != nil { |
| paddy@15 | 79 // BUG(paddy): we should filter out non-internal errors here and expose better error responses |
| paddy@15 | 80 log.Printf("Error creating devices: %+v\n", err) |
| paddy@15 | 81 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError}) |
| paddy@15 | 82 return |
| paddy@15 | 83 } |
| paddy@18 | 84 var resp Response |
| paddy@15 | 85 for _, device := range createdDevices { |
| paddy@16 | 86 resp.Devices = append(resp.Devices, apiDeviceFromCore(device, api.CheckScopes(passedScopes, ScopeViewPushToken.ID))) |
| paddy@15 | 87 } |
| paddy@15 | 88 api.Encode(w, r, http.StatusCreated, resp) |
| paddy@15 | 89 } |
| paddy@18 | 90 |
| paddy@18 | 91 func handleGetDevices(ctx context.Context, w http.ResponseWriter, r *http.Request) { |
| paddy@18 | 92 userID, err := api.AuthUser(r) |
| paddy@18 | 93 if err != nil { |
| paddy@18 | 94 api.Encode(w, r, http.StatusUnauthorized, Response{Errors: api.AccessDeniedError}) |
| paddy@18 | 95 return |
| paddy@18 | 96 } |
| paddy@18 | 97 |
| paddy@18 | 98 passedScopes := api.GetScopes(r) |
| paddy@18 | 99 if !api.CheckScopes(passedScopes, ScopeViewDevices.ID) { |
| paddy@18 | 100 api.Encode(w, r, http.StatusForbidden, Response{Errors: api.AccessDeniedError}) |
| paddy@18 | 101 return |
| paddy@18 | 102 } |
| paddy@18 | 103 |
| paddy@18 | 104 var retrievedDevices []devices.Device |
| paddy@18 | 105 requestedIDStrs := r.URL.Query()["id"] |
| paddy@18 | 106 requestedIDStrs = append(requestedIDStrs, trout.RequestVars(r)[http.CanonicalHeaderKey("id")]...) |
| paddy@18 | 107 |
| paddy@18 | 108 if len(requestedIDStrs) < 1 { |
| paddy@18 | 109 retrievedDevices, err = devices.ListByOwner(userID, ctx) |
| paddy@18 | 110 if err != nil { |
| paddy@18 | 111 log.Printf("Error listing devices: %+v\n", err) |
| paddy@18 | 112 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError}) |
| paddy@18 | 113 return |
| paddy@18 | 114 } |
| paddy@18 | 115 } else { |
| paddy@18 | 116 requestedIDs := make([]uuid.ID, 0, len(requestedIDStrs)) |
| paddy@18 | 117 var reqErrs []api.RequestError |
| paddy@18 | 118 for pos, idStr := range requestedIDStrs { |
| paddy@18 | 119 id, err := uuid.Parse(idStr) |
| paddy@18 | 120 if err != nil { |
| paddy@18 | 121 reqErrs = append(reqErrs, api.RequestError{Slug: api.RequestErrInvalidFormat, Param: fmt.Sprintf("id[%d]", pos)}) |
| paddy@18 | 122 continue |
| paddy@18 | 123 } |
| paddy@18 | 124 requestedIDs = append(requestedIDs, id) |
| paddy@18 | 125 } |
| paddy@18 | 126 if len(reqErrs) > 0 { |
| paddy@18 | 127 api.Encode(w, r, http.StatusBadRequest, Response{Errors: reqErrs}) |
| paddy@18 | 128 return |
| paddy@18 | 129 } |
| paddy@18 | 130 getDevices, err := devices.GetMany(requestedIDs, ctx) |
| paddy@18 | 131 if err != nil { |
| paddy@18 | 132 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError}) |
| paddy@18 | 133 return |
| paddy@18 | 134 } |
| paddy@18 | 135 for _, device := range getDevices { |
| paddy@18 | 136 if device.Owner.Equal(userID) { |
| paddy@18 | 137 retrievedDevices = append(retrievedDevices, device) |
| paddy@18 | 138 } |
| paddy@18 | 139 } |
| paddy@18 | 140 } |
| paddy@18 | 141 |
| paddy@18 | 142 var resp Response |
| paddy@18 | 143 for _, device := range retrievedDevices { |
| paddy@18 | 144 resp.Devices = append(resp.Devices, apiDeviceFromCore(device, api.CheckScopes(passedScopes, ScopeViewPushToken.ID))) |
| paddy@18 | 145 } |
| paddy@18 | 146 api.Encode(w, r, http.StatusOK, resp) |
| paddy@18 | 147 } |
| paddy@20 | 148 |
| paddy@20 | 149 func handleUpdateDevice(ctx context.Context, w http.ResponseWriter, r *http.Request) { |
| paddy@20 | 150 userID, err := api.AuthUser(r) |
| paddy@20 | 151 if err != nil { |
| paddy@20 | 152 api.Encode(w, r, http.StatusUnauthorized, Response{Errors: api.AccessDeniedError}) |
| paddy@20 | 153 return |
| paddy@20 | 154 } |
| paddy@20 | 155 |
| paddy@20 | 156 id, err := uuid.Parse(trout.RequestVars(r).Get("id")) |
| paddy@20 | 157 if err != nil { |
| paddy@20 | 158 api.Encode(w, r, http.StatusBadRequest, Response{Errors: []api.RequestError{{Slug: api.RequestErrInvalidFormat, Param: "id"}}}) |
| paddy@20 | 159 return |
| paddy@20 | 160 } |
| paddy@20 | 161 device, err := devices.Get(id, ctx) |
| paddy@20 | 162 if err != nil { |
| paddy@20 | 163 if err == devices.ErrDeviceNotFound { |
| paddy@20 | 164 api.Encode(w, r, http.StatusNotFound, Response{Errors: []api.RequestError{{Slug: api.RequestErrNotFound, Param: "id"}}}) |
| paddy@20 | 165 return |
| paddy@20 | 166 } |
| paddy@20 | 167 log.Printf("Error retrieving device %s: %+v\n", id, err) |
| paddy@20 | 168 api.Encode(w, r, http.StatusInternalServerError, api.ActOfGodError) |
| paddy@20 | 169 return |
| paddy@20 | 170 } |
| paddy@20 | 171 |
| paddy@20 | 172 var req updateRequest |
| paddy@20 | 173 err = api.Decode(r, &req) |
| paddy@20 | 174 if err != nil { |
| paddy@20 | 175 api.Encode(w, r, http.StatusBadRequest, Response{Errors: api.InvalidFormatError}) |
| paddy@20 | 176 return |
| paddy@20 | 177 } |
| paddy@20 | 178 |
| paddy@20 | 179 passedScopes := api.GetScopes(r) |
| paddy@20 | 180 err = validateDeviceUpdate(apiDeviceFromCore(device, true), req.DeviceChange, passedScopes, userID) |
| paddy@20 | 181 var requestErr api.RequestError |
| paddy@20 | 182 switch err { |
| paddy@20 | 183 case errUnauthorizedLastSeen: |
| paddy@20 | 184 requestErr.Slug = api.RequestErrAccessDenied |
| paddy@20 | 185 requestErr.Field = "device/lastSeen" |
| paddy@20 | 186 case errUnauthorizedCreated: |
| paddy@20 | 187 requestErr.Slug = api.RequestErrAccessDenied |
| paddy@20 | 188 requestErr.Field = "device/created" |
| paddy@20 | 189 case errUnauthorizedOwner: |
| paddy@20 | 190 requestErr.Slug = api.RequestErrAccessDenied |
| paddy@20 | 191 requestErr.Field = "device/owner" |
| paddy@20 | 192 case errInvalidDeviceType: |
| paddy@20 | 193 requestErr.Slug = api.RequestErrInvalidValue |
| paddy@20 | 194 requestErr.Field = "device/type" |
| paddy@20 | 195 case errDeviceNameTooShort: |
| paddy@20 | 196 if len(device.Name) == 0 { |
| paddy@20 | 197 requestErr.Slug = api.RequestErrMissing |
| paddy@20 | 198 } else { |
| paddy@20 | 199 requestErr.Slug = api.RequestErrInsufficient |
| paddy@20 | 200 } |
| paddy@20 | 201 requestErr.Field = "device/name" |
| paddy@20 | 202 case errDeviceNameTooLong: |
| paddy@20 | 203 requestErr.Slug = api.RequestErrOverflow |
| paddy@20 | 204 requestErr.Field = "device/name" |
| paddy@20 | 205 } |
| paddy@20 | 206 if requestErr.Slug != "" { |
| paddy@20 | 207 api.Encode(w, r, http.StatusBadRequest, Response{Errors: []api.RequestError{requestErr}}) |
| paddy@20 | 208 return |
| paddy@20 | 209 } |
| paddy@20 | 210 if err != nil { |
| paddy@20 | 211 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError}) |
| paddy@20 | 212 return |
| paddy@20 | 213 } |
| paddy@20 | 214 |
| paddy@20 | 215 change := changeFromAPI(req.DeviceChange) |
| paddy@20 | 216 updatedDevice, err := devices.Update(device, change, ctx) |
| paddy@20 | 217 if err != nil { |
| paddy@20 | 218 // BUG(paddy): we should filter out non-internal errors here and expose better error responses |
| paddy@20 | 219 log.Printf("Error updating device %s: %+v\n", id, err) |
| paddy@20 | 220 api.Encode(w, r, http.StatusInternalServerError, Response{Errors: api.ActOfGodError}) |
| paddy@20 | 221 return |
| paddy@20 | 222 } |
| paddy@20 | 223 resp := Response{Devices: []Device{apiDeviceFromCore(updatedDevice, api.CheckScopes(passedScopes, ScopeViewPushToken.ID))}} |
| paddy@20 | 224 api.Encode(w, r, http.StatusOK, resp) |
| paddy@20 | 225 } |