mirror of
https://github.com/syumai/workers.git
synced 2025-03-11 01:39:11 +00:00
Merge pull request #6 from syumai/fix-put-delete-impl
fix implementation of put and delete of R2
This commit is contained in:
commit
1cf0183b17
@ -10,8 +10,9 @@
|
|||||||
* [ ] R2 - Partially supported
|
* [ ] R2 - Partially supported
|
||||||
- [x] Head
|
- [x] Head
|
||||||
- [x] Get
|
- [x] Get
|
||||||
- [ ] Put
|
- [x] Put (load all bytes to memory)
|
||||||
- [ ] Delete
|
- [ ] Put (stream)
|
||||||
|
- [x] Delete
|
||||||
- [x] List
|
- [x] List
|
||||||
- [ ] Options for R2 methods
|
- [ ] Options for R2 methods
|
||||||
* [ ] environment variables (WIP)
|
* [ ] environment variables (WIP)
|
||||||
|
@ -1,14 +1,26 @@
|
|||||||
# r2-image-server
|
# r2-image-server
|
||||||
|
|
||||||
* An example server which returns image from Cloudflare R2.
|
* An example server of R2.
|
||||||
* This server is implemented in Go and compiled with tinygo.
|
* 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
|
## 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
|
### Requirements
|
||||||
|
|
||||||
This project requires these tools to be installed globally.
|
This project requires these tools to be installed globally.
|
||||||
|
@ -5,6 +5,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/syumai/workers"
|
"github.com/syumai/workers"
|
||||||
@ -16,26 +17,61 @@ const bucketName = "BUCKET"
|
|||||||
func handleErr(w http.ResponseWriter, msg string, err error) {
|
func handleErr(w http.ResponseWriter, msg string, err error) {
|
||||||
log.Println(err)
|
log.Println(err)
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
w.Write([]byte(msg))
|
w.Write([]byte(msg))
|
||||||
}
|
}
|
||||||
|
|
||||||
// This example is based on implementation in syumai/workers-playground
|
type server struct {
|
||||||
// * https://github.com/syumai/workers-playground/blob/e32881648ccc055e3690a0d9c750a834261c333e/r2-image-viewer/src/index.ts#L30
|
bucket workers.R2Bucket
|
||||||
func handler(w http.ResponseWriter, req *http.Request) {
|
}
|
||||||
|
|
||||||
|
func newServer() (*server, error) {
|
||||||
|
// delete image object from R2
|
||||||
bucket, err := workers.NewR2Bucket(bucketName)
|
bucket, err := workers.NewR2Bucket(bucketName)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
imgPath := strings.TrimPrefix(req.URL.Path, "/")
|
for _, obj := range objects.Objects {
|
||||||
imgObj, err := bucket.Get(imgPath)
|
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 {
|
if err != nil {
|
||||||
handleErr(w, "failed to get R2Object\n", err)
|
handleErr(w, "failed to get R2Object\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if imgObj == nil {
|
if imgObj == nil {
|
||||||
w.WriteHeader(http.StatusNotFound)
|
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
|
return
|
||||||
}
|
}
|
||||||
w.Header().Set("Cache-Control", "public, max-age=14400")
|
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)
|
io.Copy(w, imgObj.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func (s *server) delete(w http.ResponseWriter, req *http.Request, key string) {
|
||||||
workers.Serve(http.HandlerFunc(handler))
|
// 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))
|
||||||
}
|
}
|
||||||
|
14
jsutil.go
14
jsutil.go
@ -2,7 +2,6 @@ package workers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
|
||||||
"syscall/js"
|
"syscall/js"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@ -17,9 +16,7 @@ var (
|
|||||||
uint8ArrayClass = global.Get("Uint8Array")
|
uint8ArrayClass = global.Get("Uint8Array")
|
||||||
errorClass = global.Get("Error")
|
errorClass = global.Get("Error")
|
||||||
readableStreamClass = global.Get("ReadableStream")
|
readableStreamClass = global.Get("ReadableStream")
|
||||||
stringClass = global.Get("String")
|
|
||||||
dateClass = global.Get("Date")
|
dateClass = global.Get("Date")
|
||||||
numberClass = global.Get("Number")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func newObject() js.Value {
|
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.
|
// dateToTime converts JavaScript side's Data object into time.Time.
|
||||||
func dateToTime(v js.Value) (time.Time, error) {
|
func dateToTime(v js.Value) (time.Time, error) {
|
||||||
milliStr := stringClass.Invoke(v.Call("getTime")).String()
|
milli := v.Call("getTime").Float()
|
||||||
milli, err := strconv.ParseInt(milliStr, 10, 64)
|
return time.UnixMilli(int64(milli)), nil
|
||||||
if err != nil {
|
|
||||||
return time.Time{}, fmt.Errorf("failed to convert Date to time.Time: %w", err)
|
|
||||||
}
|
|
||||||
return time.UnixMilli(milli), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// timeToDate converts Go side's time.Time into Date object.
|
// timeToDate converts Go side's time.Time into Date object.
|
||||||
func timeToDate(t time.Time) js.Value {
|
func timeToDate(t time.Time) js.Value {
|
||||||
milliStr := strconv.FormatInt(t.UnixMilli(), 10)
|
return dateClass.New(t.UnixMilli())
|
||||||
return dateClass.New(numberClass.Call(milliStr))
|
|
||||||
}
|
}
|
||||||
|
15
r2bucket.go
15
r2bucket.go
@ -99,11 +99,24 @@ func (opts *R2PutOptions) toJS() js.Value {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Put returns the result of `put` call to R2Bucket.
|
// 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.
|
// * Body field of *R2Object is always nil for Put call.
|
||||||
// * if a network error happens, returns error.
|
// * if a network error happens, returns error.
|
||||||
func (r *r2Bucket) Put(key string, value io.ReadCloser, opts *R2PutOptions) (*R2Object, 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)
|
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)
|
v, err := awaitPromise(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -28,15 +28,15 @@ func toR2Objects(v js.Value) (*R2Objects, error) {
|
|||||||
}
|
}
|
||||||
objects[i] = obj
|
objects[i] = obj
|
||||||
}
|
}
|
||||||
prefixesVal := objectsVal.Get("delimitedPrefixes")
|
prefixesVal := v.Get("delimitedPrefixes")
|
||||||
prefixes := make([]string, prefixesVal.Length())
|
prefixes := make([]string, prefixesVal.Length())
|
||||||
for i := 0; i < len(prefixes); i++ {
|
for i := 0; i < len(prefixes); i++ {
|
||||||
prefixes[i] = prefixesVal.Index(i).String()
|
prefixes[i] = prefixesVal.Index(i).String()
|
||||||
}
|
}
|
||||||
return &R2Objects{
|
return &R2Objects{
|
||||||
Objects: objects,
|
Objects: objects,
|
||||||
Truncated: objectsVal.Get("truncated").Bool(),
|
Truncated: v.Get("truncated").Bool(),
|
||||||
Cursor: maybeString(objectsVal.Get("cursor")),
|
Cursor: maybeString(v.Get("cursor")),
|
||||||
DelimitedPrefixes: prefixes,
|
DelimitedPrefixes: prefixes,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user