diff --git a/README.md b/README.md index 0315bf2..190ee14 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,9 @@ * [ ] R2 - Partially supported - [x] Head - [x] Get - - [ ] Put - - [ ] Delete + - [x] Put (load all bytes to memory) + - [ ] Put (stream) + - [x] Delete - [x] List - [ ] Options for R2 methods * [ ] environment variables (WIP) diff --git a/examples/r2-image-server/README.md b/examples/r2-image-server/README.md index 8702731..78bd7d8 100644 --- a/examples/r2-image-server/README.md +++ b/examples/r2-image-server/README.md @@ -1,14 +1,26 @@ # r2-image-server -* An example server which returns image from Cloudflare R2. -* This server is implemented in Go and compiled with tinygo. +* An example server of R2. +* This server can store / load / delete images in R2. -## Example +## Usage -* https://r2-image-server.syumai.workers.dev/syumai.png +### Endpoints + +* **GET `/images/{key}`** + - Get an image object at the `key` and returns it. +* **POST `/images/{key}`** + - Create an image object at the `key` and uploads image. + - Request body must be binary and request header must have `Content-Type`. +* **DELETE `/images/{key}`** + - Delete an image object at the `key`. ## Development +* See the following documents for details on how to use R2. + - https://developers.cloudflare.com/r2/runtime-apis + - https://pkg.go.dev/github.com/syumai/workers + ### Requirements This project requires these tools to be installed globally. diff --git a/examples/r2-image-server/main.go b/examples/r2-image-server/main.go index 0313a67..a6df295 100644 --- a/examples/r2-image-server/main.go +++ b/examples/r2-image-server/main.go @@ -5,6 +5,7 @@ import ( "io" "log" "net/http" + "os" "strings" "github.com/syumai/workers" @@ -16,26 +17,61 @@ const bucketName = "BUCKET" func handleErr(w http.ResponseWriter, msg string, err error) { log.Println(err) w.WriteHeader(http.StatusInternalServerError) + w.Header().Set("Content-Type", "text/plain") w.Write([]byte(msg)) } -// This example is based on implementation in syumai/workers-playground -// * https://github.com/syumai/workers-playground/blob/e32881648ccc055e3690a0d9c750a834261c333e/r2-image-viewer/src/index.ts#L30 -func handler(w http.ResponseWriter, req *http.Request) { +type server struct { + bucket workers.R2Bucket +} + +func newServer() (*server, error) { + // delete image object from R2 bucket, err := workers.NewR2Bucket(bucketName) if err != nil { - handleErr(w, "failed to get R2Bucket\n", err) + return nil, err + } + return &server{bucket: bucket}, nil +} + +func (s *server) post(w http.ResponseWriter, req *http.Request, key string) { + objects, err := s.bucket.List() + if err != nil { + handleErr(w, "failed to list R2Objects\n", err) return } - imgPath := strings.TrimPrefix(req.URL.Path, "/") - imgObj, err := bucket.Get(imgPath) + for _, obj := range objects.Objects { + if obj.Key == key { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, "key %s already exists\n", key) + return + } + } + _, err = s.bucket.Put(key, req.Body, &workers.R2PutOptions{ + HTTPMetadata: workers.R2HTTPMetadata{ + ContentType: req.Header.Get("Content-Type"), + }, + CustomMetadata: map[string]string{"custom-key": "custom-value"}, + }) + if err != nil { + handleErr(w, "failed to put R2Object\n", err) + return + } + w.WriteHeader(http.StatusCreated) + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte("successfully uploaded image")) +} + +func (s *server) get(w http.ResponseWriter, req *http.Request, key string) { + // get image object from R2 + imgObj, err := s.bucket.Get(key) if err != nil { handleErr(w, "failed to get R2Object\n", err) return } if imgObj == nil { w.WriteHeader(http.StatusNotFound) - w.Write([]byte(fmt.Sprintf("image not found: %s", imgPath))) + w.Write([]byte(fmt.Sprintf("image not found: %s", key))) return } w.Header().Set("Cache-Control", "public, max-age=14400") @@ -48,6 +84,40 @@ func handler(w http.ResponseWriter, req *http.Request) { io.Copy(w, imgObj.Body) } -func main() { - workers.Serve(http.HandlerFunc(handler)) +func (s *server) delete(w http.ResponseWriter, req *http.Request, key string) { + // delete image object from R2 + if err := s.bucket.Delete(key); err != nil { + handleErr(w, "failed to delete R2Object\n", err) + return + } + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte("successfully deleted image")) +} + +func (s *server) routeHandler(w http.ResponseWriter, req *http.Request) { + key := strings.TrimPrefix(req.URL.Path, "/") + switch req.Method { + case "GET": + s.get(w, req, key) + return + case "DELETE": + s.delete(w, req, key) + return + case "POST": + s.post(w, req, key) + return + default: + w.WriteHeader(http.StatusNotFound) + w.Write([]byte("url not found\n")) + return + } +} + +func main() { + s, err := newServer() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to start server: %v", err) + os.Exit(1) + } + workers.Serve(http.HandlerFunc(s.routeHandler)) } diff --git a/jsutil.go b/jsutil.go index e84b891..6affd51 100644 --- a/jsutil.go +++ b/jsutil.go @@ -2,7 +2,6 @@ package workers import ( "fmt" - "strconv" "syscall/js" "time" ) @@ -17,9 +16,7 @@ var ( uint8ArrayClass = global.Get("Uint8Array") errorClass = global.Get("Error") readableStreamClass = global.Get("ReadableStream") - stringClass = global.Get("String") dateClass = global.Get("Date") - numberClass = global.Get("Number") ) func newObject() js.Value { @@ -96,16 +93,11 @@ func maybeDate(v js.Value) (time.Time, error) { // dateToTime converts JavaScript side's Data object into time.Time. func dateToTime(v js.Value) (time.Time, error) { - milliStr := stringClass.Invoke(v.Call("getTime")).String() - milli, err := strconv.ParseInt(milliStr, 10, 64) - if err != nil { - return time.Time{}, fmt.Errorf("failed to convert Date to time.Time: %w", err) - } - return time.UnixMilli(milli), nil + milli := v.Call("getTime").Float() + return time.UnixMilli(int64(milli)), nil } // timeToDate converts Go side's time.Time into Date object. func timeToDate(t time.Time) js.Value { - milliStr := strconv.FormatInt(t.UnixMilli(), 10) - return dateClass.New(numberClass.Call(milliStr)) + return dateClass.New(t.UnixMilli()) } diff --git a/r2bucket.go b/r2bucket.go index d379c8c..1fbc6b5 100644 --- a/r2bucket.go +++ b/r2bucket.go @@ -99,11 +99,24 @@ func (opts *R2PutOptions) toJS() js.Value { } // Put returns the result of `put` call to R2Bucket. +// * This method copies all bytes into memory for implementation restriction. // * Body field of *R2Object is always nil for Put call. // * if a network error happens, returns error. func (r *r2Bucket) Put(key string, value io.ReadCloser, opts *R2PutOptions) (*R2Object, error) { + /* TODO: implement this in FixedLengthStream: https://developers.cloudflare.com/workers/runtime-apis/streams/transformstream/#fixedlengthstream body := convertReaderToReadableStream(value) - p := r.instance.Call("put", key, body, opts.toJS()) + streams := fixedLengthStreamClass.New(contentLength) + rs := streams.Get("readable") + body.Call("pipeTo", streams.Get("writable")) + */ + b, err := io.ReadAll(value) + if err != nil { + return nil, err + } + defer value.Close() + ua := newUint8Array(len(b)) + js.CopyBytesToJS(ua, b) + p := r.instance.Call("put", key, ua.Get("buffer"), opts.toJS()) v, err := awaitPromise(p) if err != nil { return nil, err diff --git a/r2objects.go b/r2objects.go index df14f8a..e812429 100644 --- a/r2objects.go +++ b/r2objects.go @@ -28,15 +28,15 @@ func toR2Objects(v js.Value) (*R2Objects, error) { } objects[i] = obj } - prefixesVal := objectsVal.Get("delimitedPrefixes") + prefixesVal := v.Get("delimitedPrefixes") prefixes := make([]string, prefixesVal.Length()) for i := 0; i < len(prefixes); i++ { prefixes[i] = prefixesVal.Index(i).String() } return &R2Objects{ Objects: objects, - Truncated: objectsVal.Get("truncated").Bool(), - Cursor: maybeString(objectsVal.Get("cursor")), + Truncated: v.Get("truncated").Bool(), + Cursor: maybeString(v.Get("cursor")), DelimitedPrefixes: prefixes, }, nil }