aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorStefan Majewsky <majewsky@gmx.net>2018-01-28 22:47:35 +0100
committerStefan Majewsky <majewsky@gmx.net>2018-01-28 22:47:35 +0100
commit3834e49c90c39f4c95e3b9e7bb52b35204a75625 (patch)
tree480a3e2867dc076c459ee2279e98eb546bc6ca10
parenta9595114a691fd04ba043c1b1564741a107bbcdd (diff)
downloadgo-schwift-3834e49c90c39f4c95e3b9e7bb52b35204a75625.tar.gz
sketch out how requests could work
-rw-r--r--README.md30
-rw-r--r--account.go58
-rw-r--r--errors.go75
-rw-r--r--parse.go33
-rw-r--r--request.go161
5 files changed, 357 insertions, 0 deletions
diff --git a/README.md b/README.md
index 823537c..dc82a13 100644
--- a/README.md
+++ b/README.md
@@ -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).
diff --git a/account.go b/account.go
index 6672a62..d89299f 100644
--- a/account.go
+++ b/account.go
@@ -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()
+}