diff options
| author | Stefan Majewsky <majewsky@gmx.net> | 2018-01-28 22:47:35 +0100 |
|---|---|---|
| committer | Stefan Majewsky <majewsky@gmx.net> | 2018-01-28 22:47:35 +0100 |
| commit | 3834e49c90c39f4c95e3b9e7bb52b35204a75625 (patch) | |
| tree | 480a3e2867dc076c459ee2279e98eb546bc6ca10 | |
| parent | a9595114a691fd04ba043c1b1564741a107bbcdd (diff) | |
| download | go-schwift-3834e49c90c39f4c95e3b9e7bb52b35204a75625.tar.gz | |
sketch out how requests could work
| -rw-r--r-- | README.md | 30 | ||||
| -rw-r--r-- | account.go | 58 | ||||
| -rw-r--r-- | errors.go | 75 | ||||
| -rw-r--r-- | parse.go | 33 | ||||
| -rw-r--r-- | request.go | 161 |
5 files changed, 357 insertions, 0 deletions
@@ -61,3 +61,33 @@ handle(err) From this point, follow the [API documentation](https://godoc.org/github.com/majewsky/schwift) for what you can do with the `schwift.Account` object. + +## Why another Swift client library? + +The most popular Swift client library is [`ncw/swift`](https://github.com/ncw/swift). I have [used +it](https://github.com/docker/distribution/pull/2441) [extensively](https://github.com/sapcc/swift-http-import) and my +main gripe with it is that its API is designed around single tasks (like "get content of body as string") which are each +modeled as single functions. Since you cannot add arguments to an existing function without breaking backwards +compatibility, this means that if the existing functions do not cover your usecase, you have to add another function to +do basically the same thing. When you're trying to do something that's not one of the 10 most common things, you're +going to run into dead ends where the API does not allow you do specify that one URL parameter that you need. Like that +one day [when I filed five issues in a row because every function in the API that I tried turned out to be missing +something](https://github.com/ncw/swift/issues?utf8=%E2%9C%93&q=is%3Aissue+author%3Amajewsky+created%3A2017-11). + +This library uses Gophercloud for authentication (which solves one problem that ncw/swift has, namely that you cannot +use the Keystone token that ncw/swift fetches for talking to other OpenStack services), but besides the auth code, it +avoids pretty much all other parts of Gophercloud, because it too has fatal design flaws: + +- The API is modeled around individual requests and responses, which means that there will probably never be support for + advanced features like large objects unless you're willing to do all the footwork yourself. +- The built-in error handling paves over any useful error messages that the server might return. For example, when you + get a 404 response, `err.Error()` only says [`Resource not + found`](https://github.com/gophercloud/gophercloud/blob/4a3f5ae58624b68283375060dad06a214b05a32b/errors.go#L112). To + get the actual server error message, you have to use `err.(*gophercloud.ErrUnexpectedResponseCode).Body` which is + absolutely obvious. +- The implementation is quite unidiomatic. It all looks like a Java developer's first Go project. For example, to resume + the error handling example, [all of + this](https://github.com/gophercloud/gophercloud/blob/4a3f5ae58624b68283375060dad06a214b05a32b/errors.go#L65-L178) + should be deleted without replacement because `ErrUnexpectedResponseCode` does the same without paving over the server + error message. Most other types in that module should probably be deleted as well (there is no plausible reason for + requiring all error types to inherit from a `BaseError`; after all, this is Go, not Java). @@ -20,6 +20,7 @@ package schwift import ( "fmt" + "net/http" "regexp" "github.com/gophercloud/gophercloud" @@ -31,6 +32,8 @@ type Account struct { //URL parts baseURL string name string + //cache + metadata *AccountMetadata } //////////////////////////////////////////////////////////////////////////////// @@ -85,5 +88,60 @@ func (a *Account) Client() *gophercloud.ServiceClient { //////////////////////////////////////////////////////////////////////////////// // account metadata +//AccountMetadata contains the metadata for an account. The `Raw` attribute +//contains the raw set of headers returned from a HEAD or GET request on the +//account. The other attributes contain the parsed values of common headers. +type AccountMetadata struct { + Exists bool + BytesUsed uint64 //from X-Account-Bytes-Used + ContainerCount uint64 //from X-Account-Container-Count + ObjectCount uint64 //from X-Account-Object-Count + //TODO account quota + Raw http.Header +} + +//Metadata returns the metadata for this account. If the account does not exist, +func (a *Account) Metadata() (AccountMetadata, error) { + if a.metadata != nil { + return *a.metadata, nil + } + + resp, err := Request{ + Method: "HEAD", + ExpectStatusCodes: []int{200}, + }.Do(a.client) + if err != nil { + return AccountMetadata{}, err + } + + a.metadata, err = parseAccountMetadata(resp) + if err != nil { + return AccountMetadata{}, err + } + return *a.metadata, nil +} + +func parseAccountMetadata(resp *http.Response) (*AccountMetadata, error) { + bytesUsed, err := parseUnsignedIntHeader(resp, "X-Account-Bytes-Used") + if err != nil { + return nil, err + } + containerCount, err := parseUnsignedIntHeader(resp, "X-Account-Container-Count") + if err != nil { + return nil, err + } + objectCount, err := parseUnsignedIntHeader(resp, "X-Account-Object-Count") + if err != nil { + return nil, err + } + return &AccountMetadata{ + Exists: true, + BytesUsed: bytesUsed, + ContainerCount: containerCount, + ObjectCount: objectCount, + Raw: resp.Header, + }, nil +} + //////////////////////////////////////////////////////////////////////////////// // container listing diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..62c5792 --- /dev/null +++ b/errors.go @@ -0,0 +1,75 @@ +/****************************************************************************** +* +* Copyright 2018 Stefan Majewsky <majewsky@gmx.net> +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +* +******************************************************************************/ + +package schwift + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" +) + +var ( + //ErrNoContainerName is returned by Request.Do() if ObjectName is given, but + //ContainerName is empty. + ErrNoContainerName = errors.New("missing container name") + //ErrMalformedContainerName is returned by Request.Do() if ContainerName + //contains slashes. + ErrMalformedContainerName = errors.New("container name may not contain slashes") +) + +//UnexpectedStatusCodeError is generated when a request to Swift does not yield +//a response with the expected successful status code. +type UnexpectedStatusCodeError struct { + ExpectedStatusCodes []int + ActualResponse *http.Response + ResponseBody []byte +} + +//Error implements the builtin/error interface. +func (e UnexpectedStatusCodeError) Error() string { + codeStrs := make([]string, len(e.ExpectedStatusCodes)) + for idx, code := range e.ExpectedStatusCodes { + codeStrs[idx] = strconv.Itoa(code) + } + return fmt.Sprintf("expected %s response, got %d instead: %s", + strings.Join(codeStrs, "/"), + e.ActualResponse.StatusCode, + string(e.ResponseBody), + ) +} + +//Is checks if the given error is an UnexpectedStatusCodeError for that status +//code. For example: +// +// metadata, err := container.Metadata() +// if schwift.Is(err, http.StatusNotFound) { +// // ... create container ... +// } else if err != nil { +// // ... report error ... +// } else { +// // ... use metadata ... +// } +func Is(err error, code int) bool { + if e, ok := err.(UnexpectedStatusCodeError); ok { + return e.ActualResponse.StatusCode == code + } + return false +} diff --git a/parse.go b/parse.go new file mode 100644 index 0000000..cdb6e8e --- /dev/null +++ b/parse.go @@ -0,0 +1,33 @@ +/****************************************************************************** +* +* Copyright 2018 Stefan Majewsky <majewsky@gmx.net> +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +* +******************************************************************************/ + +package schwift + +import ( + "fmt" + "net/http" + "strconv" +) + +func parseUnsignedIntHeader(resp *http.Response, key string) (value uint64, err error) { + value, err = strconv.ParseUint(resp.Header.Get(key), 10, 64) + if err != nil { + err = fmt.Errorf("Bad header %s: %s", key, err.Error()) + } + return +} diff --git a/request.go b/request.go new file mode 100644 index 0000000..c6bef7b --- /dev/null +++ b/request.go @@ -0,0 +1,161 @@ +/****************************************************************************** +* +* Copyright 2018 Stefan Majewsky <majewsky@gmx.net> +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +* +******************************************************************************/ + +package schwift + +import ( + "io" + "io/ioutil" + "net/http" + urlmodule "net/url" + "strings" + + "github.com/gophercloud/gophercloud" +) + +var okCodes []int + +func init() { + //prepare input for gophercloud.RequestOpts.OkCodes such that gophercloud's + //error handling is fused + for code := 100; code < 600; code++ { + //as an exception, 401s are handled by Gophercloud because we want to use its + //internal token renewal logic + if code != 401 { + okCodes = append(okCodes, code) + } + } +} + +//Request contains the parameters that can be set in a request to the Swift API. +type Request struct { + Method string //"GET", "HEAD", "PUT", "POST" or "DELETE" + ContainerName string //empty for requests on accounts + ObjectName string //empty for requests on accounts/containers + AdditionalHeaders map[string]string + //ExpectStatusCodes can be left empty to disable this check, otherwise + //schwift.UnexpectedStatusCodeError may be returned. + ExpectStatusCodes []int +} + +//URL returns the full URL for this request. +func (r Request) URL(client *gophercloud.ServiceClient) (string, error) { + url, err := urlmodule.Parse(client.Endpoint) + if err != nil { + return "", err + } + if !strings.HasSuffix(url.Path, "/") { + url.Path += "/" + } + + if r.ContainerName == "" { + if r.ObjectName != "" { + return "", ErrNoContainerName + } + } else { + if strings.Contains(r.ContainerName, "/") { + return "", ErrMalformedContainerName + } + url.Path += r.ContainerName + "/" + r.ObjectName + } + + return url.String(), nil +} + +//Do executes this request on the given service client. +func (r Request) Do(client *gophercloud.ServiceClient) (*http.Response, error) { + return r.do(client, false) +} + +func (r Request) do(client *gophercloud.ServiceClient, afterReauth bool) (*http.Response, error) { + //build URL + url, err := r.URL(client) + if err != nil { + return nil, err + } + + //override gophercloud's error handling + opts := &gophercloud.RequestOpts{OkCodes: okCodes} + + //override gophercloud's default headers + opts.MoreHeaders = map[string]string{ + "Accept": "", + "Content-Type": "", + } + for key, value := range r.AdditionalHeaders { + opts.MoreHeaders[key] = value + } + + resp, err := client.ProviderClient.Request(r.Method, url, opts) + if err != nil { + return resp, err + } + + //return success if error code matches expectation + if len(r.ExpectStatusCodes) == 0 { + //check disabled -> return response unaltered + return resp, nil + } + for _, code := range r.ExpectStatusCodes { + if code == resp.StatusCode { + return resp, nil + } + } + + //since we override gophercloud's error handling, we need to handle token + //expiry ourselves + if resp.StatusCode == http.StatusUnauthorized && !afterReauth { + err := drainResponseBody(resp) + if err != nil { + return nil, err + } + err = client.Reauthenticate(resp.Request.Header.Get("X-Auth-Token")) + if err != nil { + return nil, err + } + //restart request with new token + return r.do(client, true) + } + + //other unexpected status code -> generate UnexpectedStatusCodeError + buf, err := collectResponseBody(resp) + if err != nil { + return nil, err + } + return nil, UnexpectedStatusCodeError{ + ExpectedStatusCodes: r.ExpectStatusCodes, + ActualResponse: resp, + ResponseBody: buf, + } +} + +func drainResponseBody(r *http.Response) error { + _, err := io.Copy(ioutil.Discard, r.Body) + if err != nil { + return err + } + return r.Body.Close() +} + +func collectResponseBody(r *http.Response) ([]byte, error) { + buf, err := ioutil.ReadAll(r.Body) + if err != nil { + return nil, err + } + return buf, r.Body.Close() +} |
