auth

Paddy 2015-01-18 Parent:fa8ee6a4507c Child:565a9335e035

116:e000b1c24fc0 Go to Latest

auth/client.go

Make all tests that deal with the store interfaces go through the Context. This is mainly important so that pre- and post- save/retrieval/deletion/whatever transforms can be done without doing them in every single implementation of the store. Change the Endpoint URI property to be a string, not a *url.URL. This makes testing easier, JSON responses cleaner, and is all around just a better strategy. Just because we turn it into a URL every now and then doesn't mean that's how we need to store it. Add JSON tags to the Client type and Endpoint type. Create normalizeURI and normalizeURIString methods to... well, normalize the Endpoint URIs. This makes it so that we can compare them, and forgive some arbitrary user behaviour (like slashes, etc.) Add a NormalizedURI property to the Endpoint type. This is where we store the NormalizedURI, which is what we'll be using when we want to check if an endpoint is valid or not. For the sake of tests and predictability, however, we always want to redirect to the URI, not the NormalizedURI. Add checks to the Client creation API endpoint to give better errors. Now leaving out the Type won't be considered an invalid type, it will be considered a missing parameter. An empty name will be reported as a missing parameter, a name with too few characters will be reported as an insufficient name, and a name with too many characters will be reported as an overflow name. We gather as many of these errors as apply before returning. Check if an Endpoint URI is absolute before adding it as an endpoint, or return an invalid value error if it is not. Always return the errors array when creating a client. We could succeed in creating one or more things and still have errors. We should return anything that's created _as well as_ any errors encountered. Add unit testing for our CreateClientHandler. Fix our oauth2 tests so that if there's an error in the body, it's in the test logs. This should help debugging significantly. Fix our oauth2 tests so that the Profile only requires 1 iteration for its password hashing. This means each time we want to validate a session, it doesn't add a full second to our test runs. This is a big speed improvement for our tests. Add test helper methods for comparing API errors, API responses, and filling in server-generated information in a response that it's impossible to have an expectation around (e.g., IDs) so that we can use our comparison helpers to check if a response is as we expect it. Fix a typo in our Context helpers that was reporting no sessionStore being set _only_ when a sessionStore was set. So yes, the opposite of what we wanted. Oops. This was discovered by passing all our tests through the context. methods instead of operating on the stores themselves.

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