Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support XML API (#331) #1164

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions fakestorage/object.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
Expand Down Expand Up @@ -626,6 +627,72 @@ func (s *Server) xmlListObjects(r *http.Request) xmlResponse {
}
}

func (s *Server) xmlPutObject(r *http.Request) xmlResponse {
// https://cloud.google.com/storage/docs/xml-api/put-object-upload
vars := unescapeMuxVars(mux.Vars(r))
defer r.Body.Close()

metaData := make(map[string]string)
for key := range r.Header {
lowerKey := strings.ToLower(key)
if metaDataKey := strings.TrimPrefix(lowerKey, "x-goog-meta-"); metaDataKey != lowerKey {
metaData[metaDataKey] = r.Header.Get(key)
}
}

obj := StreamingObject{
ObjectAttrs: ObjectAttrs{
BucketName: vars["bucketName"],
Name: vars["objectName"],
ContentType: r.Header.Get(contentTypeHeader),
ContentEncoding: r.Header.Get(contentEncodingHeader),
Metadata: metaData,
},
}
if source := r.Header.Get("x-goog-copy-source"); source != "" {
escaped, err := url.PathUnescape(source)
if err != nil {
return xmlResponse{status: http.StatusBadRequest}
}

split := strings.SplitN(escaped, "/", 2)
if len(split) != 2 {
return xmlResponse{status: http.StatusBadRequest}
}

sourceObject, err := s.GetObjectStreaming(split[0], split[1])
if err != nil {
return xmlResponse{status: http.StatusNotFound}
}
obj.Content = sourceObject.Content
} else {
obj.Content = notImplementedSeeker{r.Body}
}

obj, err := s.createObject(obj, backend.NoConditions{})

if err != nil {
return xmlResponse{
status: http.StatusInternalServerError,
errorMessage: err.Error(),
}
}

obj.Close()
return xmlResponse{
status: http.StatusOK,
}
}

func (s *Server) xmlDeleteObject(r *http.Request) xmlResponse {
resp := s.deleteObject(r)
return xmlResponse{
status: resp.status,
errorMessage: resp.errorMessage,
header: resp.header,
}
}

func (s *Server) getObject(w http.ResponseWriter, r *http.Request) {
if alt := r.URL.Query().Get("alt"); alt == "media" || r.Method == http.MethodHead {
s.downloadObject(w, r)
Expand Down Expand Up @@ -867,6 +934,7 @@ func (s *Server) downloadObject(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Goog-Generation", strconv.FormatInt(obj.Generation, 10))
w.Header().Set("X-Goog-Hash", fmt.Sprintf("crc32c=%s,md5=%s", obj.Crc32c, obj.Md5Hash))
w.Header().Set("Last-Modified", obj.Updated.Format(http.TimeFormat))
w.Header().Set("ETag", obj.Etag)
for name, value := range obj.Metadata {
w.Header().Set("X-Goog-Meta-"+name, value)
}
Expand Down
9 changes: 3 additions & 6 deletions fakestorage/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,9 @@ func (s *Server) buildMuxer() {
for _, r := range xmlApiRouters {
r.Path("/").Methods(http.MethodGet).HandlerFunc(xmlToHTTPHandler(s.xmlListObjects))
r.Path("").Methods(http.MethodGet).HandlerFunc(xmlToHTTPHandler(s.xmlListObjects))
r.Path("/{objectName:.+}").Methods(http.MethodPut).HandlerFunc(xmlToHTTPHandler(s.xmlPutObject))
r.Path("/{objectName:.+}").Methods(http.MethodGet, http.MethodHead).HandlerFunc(s.downloadObject)
r.Path("/{objectName:.+}").Methods(http.MethodDelete).HandlerFunc(xmlToHTTPHandler(s.xmlDeleteObject))
}

bucketHost := fmt.Sprintf("{bucketName}.%s", s.publicHost)
Expand All @@ -296,12 +299,6 @@ func (s *Server) buildMuxer() {
handler.Host(s.publicHost).Path("/{bucketName}").MatcherFunc(matchFormData).Methods(http.MethodPost, http.MethodPut).HandlerFunc(xmlToHTTPHandler(s.insertFormObject))
handler.Host(bucketHost).MatcherFunc(matchFormData).Methods(http.MethodPost, http.MethodPut).HandlerFunc(xmlToHTTPHandler(s.insertFormObject))

// Signed URLs (upload and download)
handler.MatcherFunc(s.publicHostMatcher).Path("/{bucketName}/{objectName:.+}").Methods(http.MethodPost, http.MethodPut).HandlerFunc(jsonToHTTPHandler(s.insertObject))
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can't have ever worked as insertObject expects query parameters encoding the object name, etc...

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

insertObject does not necessarily read the object name from query parameters:

default:
// Support Signed URL Uploads
if r.URL.Query().Get("X-Goog-Algorithm") != "" {
switch r.Method {
case http.MethodPost:
return s.resumableUpload(bucketName, r)
case http.MethodPut:
return s.signedUpload(bucketName, r)
}
}
return jsonResponse{errorMessage: "invalid uploadType", status: http.StatusBadRequest}

I need to take a deeper look at this, but curious if you're basing on comment on the docs? There are many undocumented things in the GCS API that we run into when someone tries to integrate with some different SDK, and I've ironically done a poor job in documenting those in fake-gcs-server 🙈

Copy link
Author

@tustvold tustvold May 12, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, insertObject has a peculiar special case that uses X-Goog-Algorithm to detect a signed URL and then falls back to implementing something that looks like the XML API, because that is what signed URLs are. With this PR that can probably be removed 🤔

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, absolutely. insertObject definitely looks like a little monster doing too many things. Thank you very much! I'll go over the PR in details later today.

handler.MatcherFunc(s.publicHostMatcher).Path("/{bucketName}/{objectName:.+}").Methods(http.MethodGet, http.MethodHead).HandlerFunc(s.getObject)
handler.Host(bucketHost).Path("/{objectName:.+}").Methods(http.MethodPost, http.MethodPut).HandlerFunc(jsonToHTTPHandler(s.insertObject))
handler.Host("{bucketName:.+}").Path("/{objectName:.+}").Methods(http.MethodPost, http.MethodPut).HandlerFunc(jsonToHTTPHandler(s.insertObject))

s.handler = handler
}

Expand Down
2 changes: 2 additions & 0 deletions fakestorage/upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import (

const contentTypeHeader = "Content-Type"

const contentEncodingHeader = "Content-Encoding"

const (
uploadTypeMedia = "media"
uploadTypeMultipart = "multipart"
Expand Down
79 changes: 58 additions & 21 deletions fakestorage/upload_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
"context"
"crypto/tls"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"mime/multipart"
Expand Down Expand Up @@ -570,9 +569,6 @@ func TestServerClientSignedUpload(t *testing.T) {

func TestServerClientSignedUploadBucketCNAME(t *testing.T) {
url := "https://mybucket.mydomain.com:4443/files/txt/text-02.txt?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=fake-gcs&X-Goog-Expires=3600&X-Goog-SignedHeaders=host&X-Goog-Signature=fake-gc"
expectedName := "files/txt/text-02.txt"
expectedContentType := "text/plain"
expectedHash := "bHupxaFBQh4cA8uYB8l8dA=="
opts := Options{
InitialObjects: []Object{
{ObjectAttrs: ObjectAttrs{BucketName: "mybucket.mydomain.com", Name: "files/txt/text-01.txt"}, Content: []byte("something")},
Expand All @@ -596,23 +592,6 @@ func TestServerClientSignedUploadBucketCNAME(t *testing.T) {
if resp.StatusCode != http.StatusOK {
t.Errorf("wrong status returned\nwant %d\ngot %d", http.StatusOK, resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was incorrect, the XML APIs should not return a body and certainly shouldn't return JSON

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 Oh are signed uploads powered by the XML API? Interesting!

if err != nil {
t.Fatal(err)
}
var obj Object
if err := json.Unmarshal(data, &obj); err != nil {
t.Fatal(err)
}
if obj.Name != expectedName {
t.Errorf("wrong filename\nwant %q\ngot %q", expectedName, obj.Name)
}
if obj.ContentType != expectedContentType {
t.Errorf("wrong content type\nwant %q\ngot %q", expectedContentType, obj.ContentType)
}
if obj.Md5Hash != expectedHash {
t.Errorf("wrong md5 hash\nwant %q\ngot %q", expectedHash, obj.Md5Hash)
}
}

func TestServerClientUploadWithPredefinedAclPublicRead(t *testing.T) {
Expand Down Expand Up @@ -700,6 +679,64 @@ func TestServerClientSimpleUploadNoName(t *testing.T) {
}
}

func TestServerXMLPut(t *testing.T) {
server, err := NewServerWithOptions(Options{
PublicHost: "test",
})
if err != nil {
t.Fatal(err)
}
defer server.Stop()
server.CreateBucketWithOpts(CreateBucketOpts{Name: "bucket1"})
server.CreateBucketWithOpts(CreateBucketOpts{Name: "bucket2"})

const data = "some nice content"
req, err := http.NewRequest("PUT", server.URL()+"/bucket1/path", strings.NewReader(data))
req.Host = "test"
if err != nil {
t.Fatal(err)
}
client := http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("got %d expected %d", resp.StatusCode, http.StatusOK)
}

req, err = http.NewRequest("PUT", server.URL()+"/bucket2/path", nil)
req.Host = "test"
req.Header.Set("x-goog-copy-source", "bucket1/path")

resp, err = client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("got %d expected %d", resp.StatusCode, http.StatusOK)
}

req, err = http.NewRequest("PUT", server.URL()+"/bucket2/path2", nil)
req.Host = "test"
req.Header.Set("x-goog-copy-source", "bucket1/nonexistent")

resp, err = client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("got %d expected %d", resp.StatusCode, http.StatusNotFound)
}
}

func TestServerInvalidUploadType(t *testing.T) {
server := NewServer(nil)
defer server.Stop()
Expand Down