auth
auth/client.go
Turn AddEndpoint into AddEndpoints. Because one is a special case of many, it makes sense to be able to add multiple endpoints in a single call to the database. So we've converted the AddEndpoint method into an AddEndpoints method and updated our tests appropriately. We also filled in the errors when creating a client through the API, and moved things around to optimize for the maximum number of errors returned in a single call.
| paddy@6 | 1 package auth |
| paddy@0 | 2 |
| paddy@0 | 3 import ( |
| paddy@108 | 4 "crypto/rand" |
| paddy@108 | 5 "encoding/hex" |
| paddy@85 | 6 "encoding/json" |
| paddy@31 | 7 "errors" |
| paddy@108 | 8 "github.com/gorilla/mux" |
| paddy@85 | 9 "net/http" |
| paddy@41 | 10 "net/url" |
| paddy@115 | 11 "strconv" |
| paddy@41 | 12 "time" |
| paddy@31 | 13 |
| paddy@107 | 14 "code.secondbit.org/uuid.hg" |
| paddy@0 | 15 ) |
| paddy@0 | 16 |
| paddy@31 | 17 var ( |
| paddy@57 | 18 // ErrNoClientStore is returned when a Context tries to act on a clientStore without setting one first. |
| paddy@57 | 19 ErrNoClientStore = errors.New("no clientStore was specified for the Context") |
| paddy@57 | 20 // ErrClientNotFound is returned when a Client is requested but not found in a clientStore. |
| paddy@57 | 21 ErrClientNotFound = errors.New("client not found in clientStore") |
| paddy@57 | 22 // ErrClientAlreadyExists is returned when a Client is added to a clientStore, but another Client with |
| paddy@57 | 23 // the same ID already exists in the clientStore. |
| paddy@57 | 24 ErrClientAlreadyExists = errors.New("client already exists in clientStore") |
| paddy@41 | 25 |
| paddy@57 | 26 // ErrEmptyChange is returned when a Change has all its properties set to nil. |
| paddy@57 | 27 ErrEmptyChange = errors.New("change must have at least one property set") |
| paddy@57 | 28 // ErrClientNameTooShort is returned when a Client's Name property is too short. |
| paddy@57 | 29 ErrClientNameTooShort = errors.New("client name must be at least 2 characters") |
| paddy@57 | 30 // ErrClientNameTooLong is returned when a Client's Name property is too long. |
| paddy@57 | 31 ErrClientNameTooLong = errors.New("client name must be at most 32 characters") |
| paddy@57 | 32 // ErrClientLogoTooLong is returned when a Client's Logo property is too long. |
| paddy@57 | 33 ErrClientLogoTooLong = errors.New("client logo must be at most 1024 characters") |
| paddy@57 | 34 // ErrClientLogoNotURL is returned when a Client's Logo property is not a valid absolute URL. |
| paddy@57 | 35 ErrClientLogoNotURL = errors.New("client logo must be a valid absolute URL") |
| paddy@57 | 36 // ErrClientWebsiteTooLong is returned when a Client's Website property is too long. |
| paddy@49 | 37 ErrClientWebsiteTooLong = errors.New("client website must be at most 1024 characters") |
| paddy@57 | 38 // ErrClientWebsiteNotURL is returned when a Client's Website property is not a valid absolute URL. |
| paddy@57 | 39 ErrClientWebsiteNotURL = errors.New("client website must be a valid absolute URL") |
| paddy@31 | 40 ) |
| paddy@31 | 41 |
| paddy@115 | 42 const ( |
| paddy@115 | 43 clientTypePublic = "public" |
| paddy@115 | 44 clientTypeConfidential = "confidential" |
| paddy@115 | 45 ) |
| paddy@115 | 46 |
| paddy@25 | 47 // Client represents a client that grants access |
| paddy@25 | 48 // to the auth server, exchanging grants for tokens, |
| paddy@25 | 49 // and tokens for access. |
| paddy@0 | 50 type Client struct { |
| paddy@41 | 51 ID uuid.ID |
| paddy@41 | 52 Secret string |
| paddy@41 | 53 OwnerID uuid.ID |
| paddy@41 | 54 Name string |
| paddy@41 | 55 Logo string |
| paddy@41 | 56 Website string |
| paddy@41 | 57 Type string |
| paddy@0 | 58 } |
| paddy@0 | 59 |
| paddy@57 | 60 // ApplyChange applies the properties of the passed |
| paddy@57 | 61 // ClientChange to the Client object it is called on. |
| paddy@39 | 62 func (c *Client) ApplyChange(change ClientChange) { |
| paddy@39 | 63 if change.Secret != nil { |
| paddy@39 | 64 c.Secret = *change.Secret |
| paddy@39 | 65 } |
| paddy@39 | 66 if change.OwnerID != nil { |
| paddy@39 | 67 c.OwnerID = change.OwnerID |
| paddy@39 | 68 } |
| paddy@39 | 69 if change.Name != nil { |
| paddy@39 | 70 c.Name = *change.Name |
| paddy@39 | 71 } |
| paddy@39 | 72 if change.Logo != nil { |
| paddy@39 | 73 c.Logo = *change.Logo |
| paddy@39 | 74 } |
| paddy@39 | 75 if change.Website != nil { |
| paddy@39 | 76 c.Website = *change.Website |
| paddy@39 | 77 } |
| paddy@39 | 78 } |
| paddy@39 | 79 |
| paddy@57 | 80 // ClientChange represents a bundle of options for |
| paddy@57 | 81 // updating a Client's mutable data. |
| paddy@31 | 82 type ClientChange struct { |
| paddy@41 | 83 Secret *string |
| paddy@41 | 84 OwnerID uuid.ID |
| paddy@41 | 85 Name *string |
| paddy@41 | 86 Logo *string |
| paddy@41 | 87 Website *string |
| paddy@31 | 88 } |
| paddy@31 | 89 |
| paddy@57 | 90 // Validate checks the ClientChange it is called on |
| paddy@57 | 91 // and asserts its internal validity, or lack thereof. |
| paddy@39 | 92 func (c ClientChange) Validate() error { |
| paddy@42 | 93 if c.Secret == nil && c.OwnerID == nil && c.Name == nil && c.Logo == nil && c.Website == nil { |
| paddy@42 | 94 return ErrEmptyChange |
| paddy@42 | 95 } |
| paddy@41 | 96 if c.Name != nil && len(*c.Name) < 2 { |
| paddy@41 | 97 return ErrClientNameTooShort |
| paddy@41 | 98 } |
| paddy@41 | 99 if c.Name != nil && len(*c.Name) > 32 { |
| paddy@41 | 100 return ErrClientNameTooLong |
| paddy@41 | 101 } |
| paddy@42 | 102 if c.Logo != nil && *c.Logo != "" { |
| paddy@42 | 103 if len(*c.Logo) > 1024 { |
| paddy@42 | 104 return ErrClientLogoTooLong |
| paddy@42 | 105 } |
| paddy@42 | 106 u, err := url.Parse(*c.Logo) |
| paddy@42 | 107 if err != nil || !u.IsAbs() { |
| paddy@42 | 108 return ErrClientLogoNotURL |
| paddy@42 | 109 } |
| paddy@41 | 110 } |
| paddy@42 | 111 if c.Website != nil && *c.Website != "" { |
| paddy@42 | 112 if len(*c.Website) > 140 { |
| paddy@42 | 113 return ErrClientWebsiteTooLong |
| paddy@42 | 114 } |
| paddy@42 | 115 u, err := url.Parse(*c.Website) |
| paddy@42 | 116 if err != nil || !u.IsAbs() { |
| paddy@42 | 117 return ErrClientWebsiteNotURL |
| paddy@42 | 118 } |
| paddy@41 | 119 } |
| paddy@39 | 120 return nil |
| paddy@39 | 121 } |
| paddy@39 | 122 |
| paddy@85 | 123 func verifyClient(w http.ResponseWriter, r *http.Request, allowPublic bool, context Context) (uuid.ID, bool) { |
| paddy@85 | 124 enc := json.NewEncoder(w) |
| paddy@85 | 125 clientIDStr, clientSecret, fromAuthHeader := r.BasicAuth() |
| paddy@85 | 126 if !fromAuthHeader { |
| paddy@85 | 127 if !allowPublic { |
| paddy@85 | 128 w.WriteHeader(http.StatusBadRequest) |
| paddy@85 | 129 renderJSONError(enc, "unauthorized_client") |
| paddy@85 | 130 return nil, false |
| paddy@85 | 131 } |
| paddy@85 | 132 clientIDStr = r.PostFormValue("client_id") |
| paddy@85 | 133 } |
| paddy@85 | 134 clientID, err := uuid.Parse(clientIDStr) |
| paddy@85 | 135 if err != nil { |
| paddy@85 | 136 w.WriteHeader(http.StatusUnauthorized) |
| paddy@85 | 137 if fromAuthHeader { |
| paddy@85 | 138 w.Header().Set("WWW-Authenticate", "Basic") |
| paddy@85 | 139 } |
| paddy@85 | 140 renderJSONError(enc, "invalid_client") |
| paddy@85 | 141 return nil, false |
| paddy@85 | 142 } |
| paddy@85 | 143 client, err := context.GetClient(clientID) |
| paddy@85 | 144 if err == ErrClientNotFound { |
| paddy@85 | 145 w.WriteHeader(http.StatusUnauthorized) |
| paddy@85 | 146 if fromAuthHeader { |
| paddy@85 | 147 w.Header().Set("WWW-Authenticate", "Basic") |
| paddy@85 | 148 } |
| paddy@85 | 149 renderJSONError(enc, "invalid_client") |
| paddy@85 | 150 return nil, false |
| paddy@85 | 151 } else if err != nil { |
| paddy@85 | 152 w.WriteHeader(http.StatusInternalServerError) |
| paddy@85 | 153 renderJSONError(enc, "server_error") |
| paddy@85 | 154 return nil, false |
| paddy@85 | 155 } |
| paddy@113 | 156 if client.Secret != clientSecret { // it's important that any client deemed "public" is not issued a client secret. |
| paddy@85 | 157 w.WriteHeader(http.StatusUnauthorized) |
| paddy@85 | 158 if fromAuthHeader { |
| paddy@85 | 159 w.Header().Set("WWW-Authenticate", "Basic") |
| paddy@85 | 160 } |
| paddy@85 | 161 renderJSONError(enc, "invalid_client") |
| paddy@85 | 162 return nil, false |
| paddy@85 | 163 } |
| paddy@85 | 164 return clientID, true |
| paddy@85 | 165 } |
| paddy@85 | 166 |
| paddy@57 | 167 // Endpoint represents a single URI that a Client |
| paddy@57 | 168 // controls. Users will be redirected to these URIs |
| paddy@57 | 169 // following successful authorization grants and |
| paddy@57 | 170 // exchanges for access tokens. |
| paddy@41 | 171 type Endpoint struct { |
| paddy@41 | 172 ID uuid.ID |
| paddy@41 | 173 ClientID uuid.ID |
| paddy@41 | 174 URI url.URL |
| paddy@41 | 175 Added time.Time |
| paddy@41 | 176 } |
| paddy@41 | 177 |
| paddy@41 | 178 type sortedEndpoints []Endpoint |
| paddy@41 | 179 |
| paddy@41 | 180 func (s sortedEndpoints) Len() int { |
| paddy@41 | 181 return len(s) |
| paddy@41 | 182 } |
| paddy@41 | 183 |
| paddy@41 | 184 func (s sortedEndpoints) Less(i, j int) bool { |
| paddy@41 | 185 return s[i].Added.Before(s[j].Added) |
| paddy@41 | 186 } |
| paddy@41 | 187 |
| paddy@41 | 188 func (s sortedEndpoints) Swap(i, j int) { |
| paddy@41 | 189 s[i], s[j] = s[j], s[i] |
| paddy@41 | 190 } |
| paddy@41 | 191 |
| paddy@57 | 192 type clientStore interface { |
| paddy@57 | 193 getClient(id uuid.ID) (Client, error) |
| paddy@57 | 194 saveClient(client Client) error |
| paddy@57 | 195 updateClient(id uuid.ID, change ClientChange) error |
| paddy@57 | 196 deleteClient(id uuid.ID) error |
| paddy@57 | 197 listClientsByOwner(ownerID uuid.ID, num, offset int) ([]Client, error) |
| paddy@41 | 198 |
| paddy@115 | 199 addEndpoints(client uuid.ID, endpoint []Endpoint) error |
| paddy@57 | 200 removeEndpoint(client, endpoint uuid.ID) error |
| paddy@58 | 201 checkEndpoint(client uuid.ID, endpoint string) (bool, error) |
| paddy@57 | 202 listEndpoints(client uuid.ID, num, offset int) ([]Endpoint, error) |
| paddy@57 | 203 countEndpoints(client uuid.ID) (int64, error) |
| paddy@0 | 204 } |
| paddy@31 | 205 |
| paddy@57 | 206 func (m *memstore) getClient(id uuid.ID) (Client, error) { |
| paddy@31 | 207 m.clientLock.RLock() |
| paddy@31 | 208 defer m.clientLock.RUnlock() |
| paddy@31 | 209 c, ok := m.clients[id.String()] |
| paddy@31 | 210 if !ok { |
| paddy@31 | 211 return Client{}, ErrClientNotFound |
| paddy@31 | 212 } |
| paddy@31 | 213 return c, nil |
| paddy@31 | 214 } |
| paddy@31 | 215 |
| paddy@57 | 216 func (m *memstore) saveClient(client Client) error { |
| paddy@31 | 217 m.clientLock.Lock() |
| paddy@31 | 218 defer m.clientLock.Unlock() |
| paddy@31 | 219 if _, ok := m.clients[client.ID.String()]; ok { |
| paddy@31 | 220 return ErrClientAlreadyExists |
| paddy@31 | 221 } |
| paddy@31 | 222 m.clients[client.ID.String()] = client |
| paddy@31 | 223 m.profileClientLookup[client.OwnerID.String()] = append(m.profileClientLookup[client.OwnerID.String()], client.ID) |
| paddy@31 | 224 return nil |
| paddy@31 | 225 } |
| paddy@31 | 226 |
| paddy@57 | 227 func (m *memstore) updateClient(id uuid.ID, change ClientChange) error { |
| paddy@39 | 228 m.clientLock.Lock() |
| paddy@39 | 229 defer m.clientLock.Unlock() |
| paddy@39 | 230 c, ok := m.clients[id.String()] |
| paddy@39 | 231 if !ok { |
| paddy@39 | 232 return ErrClientNotFound |
| paddy@39 | 233 } |
| paddy@39 | 234 c.ApplyChange(change) |
| paddy@39 | 235 m.clients[id.String()] = c |
| paddy@31 | 236 return nil |
| paddy@31 | 237 } |
| paddy@31 | 238 |
| paddy@57 | 239 func (m *memstore) deleteClient(id uuid.ID) error { |
| paddy@57 | 240 client, err := m.getClient(id) |
| paddy@31 | 241 if err != nil { |
| paddy@31 | 242 return err |
| paddy@31 | 243 } |
| paddy@31 | 244 m.clientLock.Lock() |
| paddy@31 | 245 defer m.clientLock.Unlock() |
| paddy@31 | 246 delete(m.clients, id.String()) |
| paddy@31 | 247 pos := -1 |
| paddy@31 | 248 for p, item := range m.profileClientLookup[client.OwnerID.String()] { |
| paddy@31 | 249 if item.Equal(id) { |
| paddy@31 | 250 pos = p |
| paddy@31 | 251 break |
| paddy@31 | 252 } |
| paddy@31 | 253 } |
| paddy@31 | 254 if pos >= 0 { |
| paddy@31 | 255 m.profileClientLookup[client.OwnerID.String()] = append(m.profileClientLookup[client.OwnerID.String()][:pos], m.profileClientLookup[client.OwnerID.String()][pos+1:]...) |
| paddy@31 | 256 } |
| paddy@31 | 257 return nil |
| paddy@31 | 258 } |
| paddy@31 | 259 |
| paddy@57 | 260 func (m *memstore) listClientsByOwner(ownerID uuid.ID, num, offset int) ([]Client, error) { |
| paddy@33 | 261 ids := m.lookupClientsByProfileID(ownerID.String()) |
| paddy@31 | 262 if len(ids) > num+offset { |
| paddy@31 | 263 ids = ids[offset : num+offset] |
| paddy@31 | 264 } else if len(ids) > offset { |
| paddy@31 | 265 ids = ids[offset:] |
| paddy@31 | 266 } else { |
| paddy@31 | 267 return []Client{}, nil |
| paddy@31 | 268 } |
| paddy@31 | 269 clients := []Client{} |
| paddy@31 | 270 for _, id := range ids { |
| paddy@57 | 271 client, err := m.getClient(id) |
| paddy@31 | 272 if err != nil { |
| paddy@31 | 273 return []Client{}, err |
| paddy@31 | 274 } |
| paddy@31 | 275 clients = append(clients, client) |
| paddy@31 | 276 } |
| paddy@31 | 277 return clients, nil |
| paddy@31 | 278 } |
| paddy@41 | 279 |
| paddy@115 | 280 func (m *memstore) addEndpoints(client uuid.ID, endpoints []Endpoint) error { |
| paddy@41 | 281 m.endpointLock.Lock() |
| paddy@41 | 282 defer m.endpointLock.Unlock() |
| paddy@115 | 283 m.endpoints[client.String()] = append(m.endpoints[client.String()], endpoints...) |
| paddy@41 | 284 return nil |
| paddy@41 | 285 } |
| paddy@41 | 286 |
| paddy@57 | 287 func (m *memstore) removeEndpoint(client, endpoint uuid.ID) error { |
| paddy@41 | 288 m.endpointLock.Lock() |
| paddy@41 | 289 defer m.endpointLock.Unlock() |
| paddy@41 | 290 pos := -1 |
| paddy@41 | 291 for p, item := range m.endpoints[client.String()] { |
| paddy@41 | 292 if item.ID.Equal(endpoint) { |
| paddy@41 | 293 pos = p |
| paddy@41 | 294 break |
| paddy@41 | 295 } |
| paddy@41 | 296 } |
| paddy@41 | 297 if pos >= 0 { |
| paddy@41 | 298 m.endpoints[client.String()] = append(m.endpoints[client.String()][:pos], m.endpoints[client.String()][pos+1:]...) |
| paddy@41 | 299 } |
| paddy@41 | 300 return nil |
| paddy@41 | 301 } |
| paddy@41 | 302 |
| paddy@58 | 303 func (m *memstore) checkEndpoint(client uuid.ID, endpoint string) (bool, error) { |
| paddy@41 | 304 m.endpointLock.RLock() |
| paddy@41 | 305 defer m.endpointLock.RUnlock() |
| paddy@41 | 306 for _, candidate := range m.endpoints[client.String()] { |
| paddy@58 | 307 if endpoint == candidate.URI.String() { |
| paddy@41 | 308 return true, nil |
| paddy@41 | 309 } |
| paddy@41 | 310 } |
| paddy@41 | 311 return false, nil |
| paddy@41 | 312 } |
| paddy@41 | 313 |
| paddy@57 | 314 func (m *memstore) listEndpoints(client uuid.ID, num, offset int) ([]Endpoint, error) { |
| paddy@41 | 315 m.endpointLock.RLock() |
| paddy@41 | 316 defer m.endpointLock.RUnlock() |
| paddy@41 | 317 return m.endpoints[client.String()], nil |
| paddy@41 | 318 } |
| paddy@54 | 319 |
| paddy@57 | 320 func (m *memstore) countEndpoints(client uuid.ID) (int64, error) { |
| paddy@54 | 321 m.endpointLock.RLock() |
| paddy@54 | 322 defer m.endpointLock.RUnlock() |
| paddy@54 | 323 return int64(len(m.endpoints[client.String()])), nil |
| paddy@54 | 324 } |
| paddy@108 | 325 |
| paddy@108 | 326 type newClientReq struct { |
| paddy@108 | 327 Name string `json:"name"` |
| paddy@108 | 328 Logo string `json:"logo"` |
| paddy@108 | 329 Website string `json:"website"` |
| paddy@108 | 330 Type string `json:"type"` |
| paddy@108 | 331 Endpoints []string `json:"endpoints"` |
| paddy@108 | 332 } |
| paddy@108 | 333 |
| paddy@108 | 334 func RegisterClientHandlers(r *mux.Router, context Context) { |
| paddy@108 | 335 r.Handle("/clients", wrap(context, CreateClientHandler)).Methods("POST") |
| paddy@108 | 336 } |
| paddy@108 | 337 |
| paddy@108 | 338 func CreateClientHandler(w http.ResponseWriter, r *http.Request, c Context) { |
| paddy@115 | 339 errors := []requestError{} |
| paddy@108 | 340 username, password, ok := r.BasicAuth() |
| paddy@108 | 341 if !ok { |
| paddy@115 | 342 errors = append(errors, requestError{Slug: requestErrAccessDenied}) |
| paddy@115 | 343 encode(w, r, http.StatusUnauthorized, response{Errors: errors}) |
| paddy@108 | 344 return |
| paddy@108 | 345 } |
| paddy@108 | 346 profile, err := authenticate(username, password, c) |
| paddy@108 | 347 if err != nil { |
| paddy@115 | 348 errors = append(errors, requestError{Slug: requestErrAccessDenied}) |
| paddy@115 | 349 encode(w, r, http.StatusUnauthorized, response{Errors: errors}) |
| paddy@108 | 350 return |
| paddy@108 | 351 } |
| paddy@108 | 352 var req newClientReq |
| paddy@108 | 353 decoder := json.NewDecoder(r.Body) |
| paddy@108 | 354 err = decoder.Decode(&req) |
| paddy@108 | 355 if err != nil { |
| paddy@108 | 356 encode(w, r, http.StatusBadRequest, invalidFormatResponse) |
| paddy@108 | 357 return |
| paddy@108 | 358 } |
| paddy@115 | 359 if req.Type != clientTypePublic && req.Type != clientTypeConfidential { |
| paddy@115 | 360 errors = append(errors, requestError{Slug: requestErrInvalidValue, Field: "/type"}) |
| paddy@115 | 361 encode(w, r, http.StatusBadRequest, response{Errors: errors}) |
| paddy@108 | 362 return |
| paddy@108 | 363 } |
| paddy@108 | 364 client := Client{ |
| paddy@108 | 365 ID: uuid.NewID(), |
| paddy@108 | 366 OwnerID: profile.ID, |
| paddy@108 | 367 Name: req.Name, |
| paddy@108 | 368 Logo: req.Logo, |
| paddy@108 | 369 Website: req.Website, |
| paddy@108 | 370 Type: req.Type, |
| paddy@108 | 371 } |
| paddy@115 | 372 if client.Type == clientTypePublic { |
| paddy@115 | 373 secret := make([]byte, 32) |
| paddy@115 | 374 _, err = rand.Read(secret) |
| paddy@115 | 375 if err != nil { |
| paddy@115 | 376 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@115 | 377 return |
| paddy@115 | 378 } |
| paddy@115 | 379 client.Secret = hex.EncodeToString(secret) |
| paddy@115 | 380 } |
| paddy@108 | 381 err = c.SaveClient(client) |
| paddy@108 | 382 if err != nil { |
| paddy@115 | 383 if err == ErrClientAlreadyExists { |
| paddy@115 | 384 errors = append(errors, requestError{Slug: requestErrConflict, Field: "/id"}) |
| paddy@115 | 385 encode(w, r, http.StatusBadRequest, response{Errors: errors}) |
| paddy@115 | 386 return |
| paddy@115 | 387 } |
| paddy@115 | 388 encode(w, r, http.StatusInternalServerError, actOfGodResponse) |
| paddy@108 | 389 return |
| paddy@108 | 390 } |
| paddy@108 | 391 endpoints := []Endpoint{} |
| paddy@115 | 392 for pos, u := range req.Endpoints { |
| paddy@108 | 393 uri, err := url.Parse(u) |
| paddy@108 | 394 if err != nil { |
| paddy@115 | 395 errors = append(errors, requestError{Slug: requestErrInvalidFormat, Field: "/endpoints/" + strconv.Itoa(pos)}) |
| paddy@108 | 396 continue |
| paddy@108 | 397 } |
| paddy@108 | 398 endpoint := Endpoint{ |
| paddy@108 | 399 ID: uuid.NewID(), |
| paddy@108 | 400 ClientID: client.ID, |
| paddy@108 | 401 URI: *uri, |
| paddy@108 | 402 Added: time.Now(), |
| paddy@108 | 403 } |
| paddy@108 | 404 endpoints = append(endpoints, endpoint) |
| paddy@108 | 405 } |
| paddy@115 | 406 err = c.AddEndpoints(client.ID, endpoints) |
| paddy@115 | 407 if err != nil { |
| paddy@115 | 408 errors = append(errors, requestError{Slug: requestErrActOfGod}) |
| paddy@115 | 409 encode(w, r, http.StatusInternalServerError, response{Errors: errors, Clients: []Client{client}}) |
| paddy@115 | 410 return |
| paddy@115 | 411 } |
| paddy@108 | 412 resp := response{ |
| paddy@108 | 413 Clients: []Client{client}, |
| paddy@108 | 414 Endpoints: endpoints, |
| paddy@108 | 415 } |
| paddy@108 | 416 encode(w, r, http.StatusCreated, resp) |
| paddy@108 | 417 } |