Space API (#290)

This commit is contained in:
Dominic Wong
2021-12-09 10:56:42 +00:00
committed by GitHub
parent bed010d0a9
commit c61badab4b
13 changed files with 2126 additions and 109 deletions

View File

@@ -1,5 +1,7 @@
GOPATH:=$(shell go env GOPATH)
MODIFY=Mproto/imports/api.proto=github.com/micro/micro/v3/proto/api
.PHONY: init
init:
go get -u github.com/golang/protobuf/proto
@@ -13,7 +15,7 @@ api:
.PHONY: proto
proto:
protoc --proto_path=. --micro_out=. --go_out=:. proto/space.proto
protoc --proto_path=. --micro_out=${MODIFY}:. --go_out=${MODIFY}:. proto/space.proto
.PHONY: build
build:

View File

@@ -3,5 +3,7 @@ Infinite cloud storage
# Space Service
Space for simple object storage. Put anything in the cloud
forever. Backed by S3 compatible storage API.
forever. Objects can be public (readable by all via a public URL) or private.
Powered by S3 compatible storage API.

View File

@@ -1,13 +1,90 @@
{
"vote": [
"create": [
{
"title": "Vote for the API",
"title": "Create an object",
"run_check": false,
"request": {
"message": "Launch it!"
"object": "<file bytes>",
"name": "images/file.jpg",
"visibility": "public"
},
"response": {
"url": "https://example.com/foo/bar/file.jpg"
}
}
],
"update": [
{
"title": "Update an object",
"run_check": false,
"request": {
"object": "<file bytes>",
"name": "images/file.jpg",
"visibility": "public"
},
"response": {
"url": "https://example.com/foo/bar/images/file.jpg"
}
}
],
"delete": [
{
"title": "Delete an object",
"run_check": false,
"request": {
"name": "images/file.jpg"
},
"response": {
}
}
],
"list": [
{
"title": "List objects with prefix",
"run_check": false,
"request": {
"prefix": "images/"
},
"response": {
"objects": [
{
"name": "images/file.jpg",
"modified": 1638549232,
"url": "https://example.com/foo/bar/images/file.jpg"
},
{
"name": "images/file2.jpg",
"modified": 1638547232,
"url": "https://example.com/foo/bar/images/file2.jpg"
}
]
}
}
],
"head": [
{
"title": "Head an object",
"run_check": false,
"request": {
"name": "images/file.jpg"
},
"response": {
"name": "images/file.jpg",
"modified": 1638549232,
"created": 1638546232,
"url": "https://example.com/foo/bar/images/file.jpg",
"visibility": "public"
}
}
],
"read": [
{
"title": "Read an object",
"run_check": false,
"request": {
"name": "images/file.jpg"
},
"response": {
"message": "Thanks for the vote!"
}
}
]

View File

@@ -1,50 +1,306 @@
package handler
import (
"bytes"
"context"
"sync"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/micro/micro/v3/proto/api"
"github.com/micro/micro/v3/service"
"github.com/micro/micro/v3/service/config"
"github.com/micro/micro/v3/service/errors"
log "github.com/micro/micro/v3/service/logger"
"github.com/micro/services/pkg/tenant"
"github.com/micro/micro/v3/service/store"
pb "github.com/micro/services/space/proto"
"github.com/minio/minio-go/v7/pkg/s3utils"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
awscreds "github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
sthree "github.com/aws/aws-sdk-go/service/s3"
"github.com/aws/aws-sdk-go/service/s3/s3iface"
)
type Space struct{}
const (
mdACL = "X-Amz-Acl"
mdACLPublic = "public-read"
mdCreated = "Micro-Created"
mdVisibility = "Micro-Visibility"
var (
mtx sync.RWMutex
voteKey = "votes/"
visibilityPrivate = "private"
visibilityPublic = "public"
)
type Vote struct {
Id string `json:"id"`
Message string `json:"message"`
VotedAt time.Time `json:"voted_at"`
type Space struct {
conf conf
client s3iface.S3API
}
func (n *Space) Vote(ctx context.Context, req *pb.VoteRequest, rsp *pb.VoteResponse) error {
mtx.Lock()
defer mtx.Unlock()
type conf struct {
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
Endpoint string `json:"endpoint"`
SpaceName string `json:"space_name"`
SSL bool `json:"ssl"`
Region string `json:"region"`
BaseURL string `json:"base_url"`
}
id, ok := tenant.FromContext(ctx)
if !ok {
id = "micro"
func NewSpace(srv *service.Service) *Space {
var c conf
val, err := config.Get("micro.space")
if err != nil {
log.Fatalf("Failed to load config %s", err)
}
if err := val.Scan(&c); err != nil {
log.Fatalf("Failed to load config %s", err)
}
rec := store.NewRecord(voteKey + id, &Vote{
Id: id,
Message: req.Message,
VotedAt: time.Now(),
sess := session.Must(session.NewSession(&aws.Config{
Endpoint: &c.Endpoint,
Region: &c.Region,
Credentials: awscreds.NewStaticCredentials(c.AccessKey, c.SecretKey, ""),
}))
client := sthree.New(sess)
// make sure this thing exists
if _, err := client.CreateBucket(&sthree.CreateBucketInput{
Bucket: aws.String(c.SpaceName),
}); err != nil &&
(!strings.Contains(err.Error(), "already exists") && !strings.Contains(err.Error(), "not empty")) {
log.Fatalf("Error making bucket %s", err)
}
return &Space{
conf: c,
client: client,
}
}
func (s Space) Create(ctx context.Context, request *pb.CreateRequest, response *pb.CreateResponse) error {
var err error
response.Url, err = s.upsert(ctx, request.Object, request.Name, request.Visibility, "space.Create", true)
return err
}
func (s Space) upsert(ctx context.Context, object []byte, name, visibility, method string, create bool) (string, error) {
tnt, ok := tenant.FromContext(ctx)
if !ok {
return "", errors.Unauthorized(method, "Unauthorized")
}
if len(name) == 0 {
return "", errors.BadRequest(method, "Missing name param")
}
objectName := fmt.Sprintf("%s/%s", tnt, name)
if err := s3utils.CheckValidObjectName(objectName); err != nil {
return "", errors.BadRequest(method, "Invalid name")
}
exists := false
hoo, err := s.client.HeadObject(&sthree.HeadObjectInput{
Bucket: aws.String(s.conf.SpaceName),
Key: aws.String(objectName),
})
if err != nil {
aerr, ok := err.(awserr.Error)
if !ok || aerr.Code() != "NotFound" {
return "", errors.InternalServerError(method, "Error creating object")
}
} else {
exists = true
}
// we don't need to check the error
store.Write(rec)
if create && exists {
return "", errors.BadRequest(method, "Object already exists")
}
rsp.Message = "Thanks for the vote!"
createTime := aws.String(fmt.Sprintf("%d", time.Now().Unix()))
if exists {
createTime = hoo.Metadata[mdCreated]
}
if len(visibility) == 0 {
visibility = visibilityPrivate
}
putInput := &sthree.PutObjectInput{
Body: bytes.NewReader(object),
Key: aws.String(objectName),
Bucket: aws.String(s.conf.SpaceName),
Metadata: map[string]*string{
mdVisibility: aws.String(visibility),
mdCreated: createTime,
},
}
// TODO flesh out options - might want to do content-type for better serving of object
if visibility == visibilityPublic {
putInput.ACL = aws.String(mdACLPublic)
}
if _, err := s.client.PutObject(putInput); err != nil {
log.Errorf("Error creating object %s", err)
return "", errors.InternalServerError(method, "Error creating object")
}
// TODO fix the url
return fmt.Sprintf("%s/%s", s.conf.BaseURL, objectName), nil
}
func (s Space) Update(ctx context.Context, request *pb.UpdateRequest, response *pb.UpdateResponse) error {
var err error
response.Url, err = s.upsert(ctx, request.Object, request.Name, request.Visibility, "space.Update", false)
return err
}
func (s Space) Delete(ctx context.Context, request *pb.DeleteRequest, response *pb.DeleteResponse) error {
method := "space.Delete"
tnt, ok := tenant.FromContext(ctx)
if !ok {
return errors.Unauthorized(method, "Unauthorized")
}
if len(request.Name) == 0 {
return errors.BadRequest(method, "Missing name param")
}
objectName := fmt.Sprintf("%s/%s", tnt, request.Name)
if _, err := s.client.DeleteObject(&sthree.DeleteObjectInput{
Bucket: aws.String(s.conf.SpaceName),
Key: aws.String(objectName),
}); err != nil {
log.Errorf("Error deleting object %s", err)
return errors.InternalServerError(method, "Error deleting object")
}
return nil
}
func (s Space) List(ctx context.Context, request *pb.ListRequest, response *pb.ListResponse) error {
method := "space.List"
tnt, ok := tenant.FromContext(ctx)
if !ok {
return errors.Unauthorized(method, "Unauthorized")
}
objectName := fmt.Sprintf("%s/%s", tnt, request.Prefix)
rsp, err := s.client.ListObjects(&sthree.ListObjectsInput{
Bucket: aws.String(s.conf.SpaceName),
Prefix: aws.String(objectName),
})
if err != nil {
log.Errorf("Error listing objects %s", err)
return errors.InternalServerError(method, "Error listing objects")
}
response.Objects = []*pb.ListObject{}
for _, oi := range rsp.Contents {
response.Objects = append(response.Objects, &pb.ListObject{
Name: strings.TrimPrefix(*oi.Key, tnt+"/"),
Modified: oi.LastModified.Unix(),
Url: fmt.Sprintf("%s/%s", s.conf.BaseURL, *oi.Key),
})
}
return nil
}
func (s Space) Head(ctx context.Context, request *pb.HeadRequest, response *pb.HeadResponse) error {
method := "space.Head"
tnt, ok := tenant.FromContext(ctx)
if !ok {
return errors.Unauthorized(method, "Unauthorized")
}
if len(request.Name) == 0 {
return errors.BadRequest(method, "Missing name param")
}
objectName := fmt.Sprintf("%s/%s", tnt, request.Name)
goo, err := s.client.HeadObject(&sthree.HeadObjectInput{
Bucket: aws.String(s.conf.SpaceName),
Key: aws.String(objectName),
})
if err != nil {
aerr, ok := err.(awserr.Error)
if ok && aerr.Code() == "NotFound" {
return errors.BadRequest(method, "Object not found")
}
log.Errorf("Error s3 %s", err)
return errors.InternalServerError(method, "Error reading object")
}
vis := visibilityPrivate
if md, ok := goo.Metadata[mdVisibility]; ok && len(*md) > 0 {
vis = *md
}
var created int64
if md, ok := goo.Metadata[mdCreated]; ok && len(*md) > 0 {
created, err = strconv.ParseInt(*md, 10, 64)
if err != nil {
log.Errorf("Error %s", err)
}
}
response.Object = &pb.HeadObject{
Name: request.Name,
Modified: goo.LastModified.Unix(),
Created: created,
Visibility: vis,
Url: fmt.Sprintf("%s/%s", s.conf.BaseURL, objectName),
}
return nil
}
func (s *Space) Read(ctx context.Context, req *api.Request, rsp *api.Response) error {
method := "space.Read"
tnt, ok := tenant.FromContext(ctx)
if !ok {
return errors.Unauthorized(method, "Unauthorized")
}
var input map[string]string
if err := json.Unmarshal([]byte(req.Body), &input); err != nil {
log.Errorf("Error unmarshalling %s", err)
return errors.BadRequest(method, "Request in unexpected format")
}
name := input["name"]
if len(name) == 0 {
return errors.BadRequest(method, "Missing name param")
}
objectName := fmt.Sprintf("%s/%s", tnt, name)
_, err := s.client.HeadObject(&sthree.HeadObjectInput{
Bucket: aws.String(s.conf.SpaceName),
Key: aws.String(objectName),
})
if err != nil {
aerr, ok := err.(awserr.Error)
if ok && aerr.Code() == "NotFound" {
return errors.BadRequest(method, "Object not found")
}
log.Errorf("Error s3 %s", err)
return errors.InternalServerError(method, "Error reading object")
}
gooreq, _ := s.client.GetObjectRequest(&sthree.GetObjectInput{
Bucket: aws.String(s.conf.SpaceName),
Key: aws.String(objectName),
})
urlStr, err := gooreq.Presign(5 * time.Second)
if err != nil {
aerr, ok := err.(awserr.Error)
if ok && aerr.Code() == "NoSuchKey" {
return errors.BadRequest(method, "Object not found")
}
log.Errorf("Error presigning url %s", err)
return errors.InternalServerError(method, "Error reading object")
}
rsp.Header = map[string]*api.Pair{
"Location": {
Key: "Location",
Values: []string{urlStr},
},
}
rsp.StatusCode = 302
return nil
}

534
space/handler/space_test.go Normal file
View File

@@ -0,0 +1,534 @@
package handler
import (
"context"
"fmt"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
sthree "github.com/aws/aws-sdk-go/service/s3"
"github.com/aws/aws-sdk-go/service/s3/s3iface"
"github.com/micro/micro/v3/service/auth"
"github.com/micro/micro/v3/service/errors"
pb "github.com/micro/services/space/proto"
. "github.com/onsi/gomega"
)
type mockS3Client struct {
s3iface.S3API
head func(input *sthree.HeadObjectInput) (*sthree.HeadObjectOutput, error)
put func(input *sthree.PutObjectInput) (*sthree.PutObjectOutput, error)
delete func(input *sthree.DeleteObjectInput) (*sthree.DeleteObjectOutput, error)
list func(input *sthree.ListObjectsInput) (*sthree.ListObjectsOutput, error)
get func(input *sthree.GetObjectInput) (*sthree.GetObjectOutput, error)
}
func (m mockS3Client) HeadObject(input *sthree.HeadObjectInput) (*sthree.HeadObjectOutput, error) {
if m.head != nil {
return m.head(input)
}
return &sthree.HeadObjectOutput{}, nil
}
func (m mockS3Client) PutObject(input *sthree.PutObjectInput) (*sthree.PutObjectOutput, error) {
if m.put != nil {
return m.put(input)
}
return &sthree.PutObjectOutput{}, nil
}
func (m mockS3Client) DeleteObject(input *sthree.DeleteObjectInput) (*sthree.DeleteObjectOutput, error) {
if m.delete != nil {
return m.delete(input)
}
return &sthree.DeleteObjectOutput{}, nil
}
func (m mockS3Client) ListObjects(input *sthree.ListObjectsInput) (*sthree.ListObjectsOutput, error) {
if m.list != nil {
return m.list(input)
}
return &sthree.ListObjectsOutput{}, nil
}
func (m mockS3Client) GetObject(input *sthree.GetObjectInput) (*sthree.GetObjectOutput, error) {
if m.get != nil {
return m.get(input)
}
return &sthree.GetObjectOutput{}, nil
}
type mockError struct {
code string
message string
err string
}
func (m mockError) Error() string {
return m.err
}
func (m mockError) Code() string {
return m.code
}
func (m mockError) Message() string {
return m.message
}
func (m mockError) OrigErr() error {
return fmt.Errorf(m.err)
}
func TestCreate(t *testing.T) {
g := NewWithT(t)
tcs := []struct {
name string
objName string
visibility string
err error
url string
head func(input *sthree.HeadObjectInput) (*sthree.HeadObjectOutput, error)
put func(input *sthree.PutObjectInput) (*sthree.PutObjectOutput, error)
}{
{
name: "Simple case",
objName: "foo.jpg",
url: "https://my-space.ams3.example.com/micro/123/foo.jpg",
head: func(input *sthree.HeadObjectInput) (*sthree.HeadObjectOutput, error) {
return nil, mockError{code: "NotFound"}
},
put: func(input *sthree.PutObjectInput) (*sthree.PutObjectOutput, error) {
g.Expect(*input.Bucket).To(Equal("my-space"))
g.Expect(input.ACL).To(BeNil())
g.Expect(*input.Metadata[mdVisibility]).To(Equal(visibilityPrivate))
g.Expect(input.Metadata[mdCreated]).To(Not(BeNil()))
return &sthree.PutObjectOutput{}, nil
},
},
{
name: "Public object",
objName: "bar/baz/foo.jpg",
visibility: "public",
url: "https://my-space.ams3.example.com/micro/123/bar/baz/foo.jpg",
head: func(input *sthree.HeadObjectInput) (*sthree.HeadObjectOutput, error) {
return nil, mockError{code: "NotFound"}
},
put: func(input *sthree.PutObjectInput) (*sthree.PutObjectOutput, error) {
g.Expect(*input.Bucket).To(Equal("my-space"))
g.Expect(*input.ACL).To(Equal(mdACLPublic))
g.Expect(*input.Metadata[mdVisibility]).To(Equal(visibilityPublic))
g.Expect(input.Metadata[mdCreated]).To(Not(BeNil()))
return &sthree.PutObjectOutput{}, nil
},
},
{
name: "Missing name",
objName: "",
err: errors.BadRequest("space.Create", "Missing name param"),
},
{
name: "Already exists",
objName: "foo.jpg",
err: errors.BadRequest("space.Create", "Object already exists"),
head: func(input *sthree.HeadObjectInput) (*sthree.HeadObjectOutput, error) {
g.Expect(*input.Bucket).To(Equal("my-space"))
g.Expect(*input.Key).To(Equal("micro/123/foo.jpg"))
return &sthree.HeadObjectOutput{}, nil
},
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
handler := Space{
conf: conf{
AccessKey: "access",
SecretKey: "secret",
Endpoint: "example.com",
SpaceName: "my-space",
SSL: true,
Region: "ams3",
BaseURL: "https://my-space.ams3.example.com",
},
client: &mockS3Client{head: tc.head, put: tc.put},
}
ctx := context.Background()
ctx = auth.ContextWithAccount(ctx, &auth.Account{
ID: "123",
Type: "user",
Issuer: "micro",
Metadata: map[string]string{},
Scopes: []string{"space"},
Name: "john@example.com",
})
rsp := pb.CreateResponse{}
err := handler.Create(ctx, &pb.CreateRequest{
Object: []byte("foobar"),
Name: tc.objName,
Visibility: tc.visibility,
}, &rsp)
if tc.err != nil {
g.Expect(err).To(Equal(tc.err))
} else {
g.Expect(err).To(BeNil())
g.Expect(rsp.Url).To(Equal(tc.url))
}
})
}
}
func TestUpdate(t *testing.T) {
g := NewWithT(t)
tcs := []struct {
name string
objName string
visibility string
err error
url string
head func(input *sthree.HeadObjectInput) (*sthree.HeadObjectOutput, error)
put func(input *sthree.PutObjectInput) (*sthree.PutObjectOutput, error)
}{
{
name: "Does not exist",
objName: "foo.jpg",
url: "https://my-space.ams3.example.com/micro/123/foo.jpg",
head: func(input *sthree.HeadObjectInput) (*sthree.HeadObjectOutput, error) {
return nil, mockError{code: "NotFound"}
},
put: func(input *sthree.PutObjectInput) (*sthree.PutObjectOutput, error) {
g.Expect(*input.Bucket).To(Equal("my-space"))
g.Expect(input.ACL).To(BeNil())
g.Expect(*input.Metadata[mdVisibility]).To(Equal(visibilityPrivate))
g.Expect(input.Metadata[mdCreated]).To(Not(BeNil()))
return &sthree.PutObjectOutput{}, nil
},
},
{
name: "Does not exist. Public object",
objName: "bar/baz/foo.jpg",
visibility: "public",
url: "https://my-space.ams3.example.com/micro/123/bar/baz/foo.jpg",
head: func(input *sthree.HeadObjectInput) (*sthree.HeadObjectOutput, error) {
return nil, mockError{code: "NotFound"}
},
put: func(input *sthree.PutObjectInput) (*sthree.PutObjectOutput, error) {
g.Expect(*input.Bucket).To(Equal("my-space"))
g.Expect(*input.ACL).To(Equal(mdACLPublic))
g.Expect(*input.Metadata[mdVisibility]).To(Equal(visibilityPublic))
g.Expect(input.Metadata[mdCreated]).To(Not(BeNil()))
return &sthree.PutObjectOutput{}, nil
},
},
{
name: "Missing name",
objName: "",
err: errors.BadRequest("space.Update", "Missing name param"),
},
{
name: "Already exists",
objName: "foo.jpg",
head: func(input *sthree.HeadObjectInput) (*sthree.HeadObjectOutput, error) {
g.Expect(*input.Bucket).To(Equal("my-space"))
g.Expect(*input.Key).To(Equal("micro/123/foo.jpg"))
return &sthree.HeadObjectOutput{
Metadata: map[string]*string{
mdCreated: aws.String("1638541918"),
mdVisibility: aws.String(visibilityPrivate),
},
}, nil
},
put: func(input *sthree.PutObjectInput) (*sthree.PutObjectOutput, error) {
g.Expect(*input.Bucket).To(Equal("my-space"))
g.Expect(input.ACL).To(BeNil())
g.Expect(*input.Metadata[mdVisibility]).To(Equal(visibilityPrivate))
// created shouuld be copied from the previous
g.Expect(*input.Metadata[mdCreated]).To(Equal("1638541918"))
return &sthree.PutObjectOutput{}, nil
},
url: "https://my-space.ams3.example.com/micro/123/foo.jpg",
},
{
name: "Already exists public",
objName: "foo.jpg",
head: func(input *sthree.HeadObjectInput) (*sthree.HeadObjectOutput, error) {
g.Expect(*input.Bucket).To(Equal("my-space"))
g.Expect(*input.Key).To(Equal("micro/123/foo.jpg"))
return &sthree.HeadObjectOutput{
Metadata: map[string]*string{
mdCreated: aws.String("1638541918"),
mdVisibility: aws.String(visibilityPrivate),
},
}, nil
},
put: func(input *sthree.PutObjectInput) (*sthree.PutObjectOutput, error) {
g.Expect(*input.Bucket).To(Equal("my-space"))
g.Expect(*input.ACL).To(Equal(mdACLPublic))
g.Expect(*input.Metadata[mdVisibility]).To(Equal(visibilityPublic))
// created shouuld be copied from the previous
g.Expect(*input.Metadata[mdCreated]).To(Equal("1638541918"))
return &sthree.PutObjectOutput{}, nil
},
url: "https://my-space.ams3.example.com/micro/123/foo.jpg",
visibility: "public",
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
handler := Space{
conf: conf{
AccessKey: "access",
SecretKey: "secret",
Endpoint: "example.com",
SpaceName: "my-space",
SSL: true,
Region: "ams3",
BaseURL: "https://my-space.ams3.example.com",
},
client: &mockS3Client{head: tc.head, put: tc.put},
}
ctx := context.Background()
ctx = auth.ContextWithAccount(ctx, &auth.Account{
ID: "123",
Type: "user",
Issuer: "micro",
Metadata: map[string]string{},
Scopes: []string{"space"},
Name: "john@example.com",
})
rsp := pb.UpdateResponse{}
err := handler.Update(ctx, &pb.UpdateRequest{
Object: []byte("foobar"),
Name: tc.objName,
Visibility: tc.visibility,
}, &rsp)
if tc.err != nil {
g.Expect(err).To(Equal(tc.err))
} else {
g.Expect(err).To(BeNil())
g.Expect(rsp.Url).To(Equal(tc.url))
}
})
}
}
func TestDelete(t *testing.T) {
g := NewWithT(t)
tcs := []struct {
name string
objName string
err error
delete func(input *sthree.DeleteObjectInput) (*sthree.DeleteObjectOutput, error)
}{
{
name: "Simple case",
objName: "foo.jpg",
},
{
name: "Missing name",
objName: "",
err: errors.BadRequest("space.Delete", "Missing name param"),
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
handler := Space{
conf: conf{
AccessKey: "access",
SecretKey: "secret",
Endpoint: "example.com",
SpaceName: "my-space",
SSL: true,
Region: "ams3",
BaseURL: "https://my-space.ams3.example.com",
},
client: &mockS3Client{
delete: func(input *sthree.DeleteObjectInput) (*sthree.DeleteObjectOutput, error) {
g.Expect(input.Bucket).To(Equal(aws.String("my-space")))
g.Expect(input.Key).To(Equal(aws.String("micro/123/" + tc.objName)))
return &sthree.DeleteObjectOutput{}, nil
}},
}
ctx := context.Background()
ctx = auth.ContextWithAccount(ctx, &auth.Account{
ID: "123",
Type: "user",
Issuer: "micro",
Metadata: map[string]string{},
Scopes: []string{"space"},
Name: "john@example.com",
})
rsp := pb.DeleteResponse{}
err := handler.Delete(ctx, &pb.DeleteRequest{
Name: tc.objName,
}, &rsp)
if tc.err != nil {
g.Expect(err).To(Equal(tc.err))
} else {
g.Expect(err).To(BeNil())
}
})
}
}
func TestList(t *testing.T) {
g := NewWithT(t)
tcs := []struct {
name string
prefix string
err error
list func(input *sthree.ListObjectsInput) (*sthree.ListObjectsInput, error)
}{
{
name: "Simple case",
prefix: "foo",
},
{
name: "Empty prefix",
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
handler := Space{
conf: conf{
AccessKey: "access",
SecretKey: "secret",
Endpoint: "example.com",
SpaceName: "my-space",
SSL: true,
Region: "ams3",
BaseURL: "https://my-space.ams3.example.com",
},
client: &mockS3Client{
list: func(input *sthree.ListObjectsInput) (*sthree.ListObjectsOutput, error) {
g.Expect(input.Bucket).To(Equal(aws.String("my-space")))
g.Expect(input.Prefix).To(Equal(aws.String("micro/123/" + tc.prefix)))
return &sthree.ListObjectsOutput{}, nil
}},
}
ctx := context.Background()
ctx = auth.ContextWithAccount(ctx, &auth.Account{
ID: "123",
Type: "user",
Issuer: "micro",
Metadata: map[string]string{},
Scopes: []string{"space"},
Name: "john@example.com",
})
rsp := pb.ListResponse{}
err := handler.List(ctx, &pb.ListRequest{
Prefix: tc.prefix,
}, &rsp)
if tc.err != nil {
g.Expect(err).To(Equal(tc.err))
} else {
g.Expect(err).To(BeNil())
}
})
}
}
func TestHead(t *testing.T) {
g := NewWithT(t)
tcs := []struct {
name string
objectName string
url string
visibility string
modified int64
created int64
err error
head func(input *sthree.HeadObjectInput) (*sthree.HeadObjectOutput, error)
}{
{
name: "Simple case",
objectName: "foo.jpg",
visibility: "public",
url: "https://my-space.ams3.example.com/micro/123/foo.jpg",
created: 1638547905,
modified: 1638547906,
head: func(input *sthree.HeadObjectInput) (*sthree.HeadObjectOutput, error) {
g.Expect(*input.Bucket).To(Equal("my-space"))
g.Expect(*input.Key).To(Equal("micro/123/foo.jpg"))
return &sthree.HeadObjectOutput{
LastModified: aws.Time(time.Unix(1638547906, 0)),
Metadata: map[string]*string{
mdCreated: aws.String("1638547905"),
mdVisibility: aws.String(visibilityPublic),
},
}, nil
},
},
{
name: "Empty prefix",
err: errors.BadRequest("space.Head", "Missing name param"),
},
{
name: "Not found",
objectName: "foo.jpg",
err: errors.BadRequest("space.Head", "Object not found"),
head: func(input *sthree.HeadObjectInput) (*sthree.HeadObjectOutput, error) {
return nil, mockError{code: "NotFound"}
},
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
handler := Space{
conf: conf{
AccessKey: "access",
SecretKey: "secret",
Endpoint: "example.com",
SpaceName: "my-space",
SSL: true,
Region: "ams3",
BaseURL: "https://my-space.ams3.example.com",
},
client: &mockS3Client{
head: tc.head,
},
}
ctx := context.Background()
ctx = auth.ContextWithAccount(ctx, &auth.Account{
ID: "123",
Type: "user",
Issuer: "micro",
Metadata: map[string]string{},
Scopes: []string{"space"},
Name: "john@example.com",
})
rsp := pb.HeadResponse{}
err := handler.Head(ctx, &pb.HeadRequest{
Name: tc.objectName,
}, &rsp)
if tc.err != nil {
g.Expect(err).To(Equal(tc.err))
} else {
g.Expect(err).To(BeNil())
g.Expect(rsp.Object.Name).To(Equal(tc.objectName))
g.Expect(rsp.Object.Url).To(Equal("https://my-space.ams3.example.com/micro/123/" + tc.objectName))
g.Expect(rsp.Object.Visibility).To(Equal(tc.visibility))
g.Expect(rsp.Object.Created).To(Equal(tc.created))
g.Expect(rsp.Object.Modified).To(Equal(tc.modified))
}
})
}
}

View File

@@ -1,10 +1,10 @@
package main
import (
"github.com/micro/services/space/handler"
pb "github.com/micro/services/space/proto"
"github.com/micro/micro/v3/service"
"github.com/micro/micro/v3/service/api"
"github.com/micro/micro/v3/service/logger"
"github.com/micro/services/space/handler"
)
func main() {
@@ -15,7 +15,19 @@ func main() {
)
// Register handler
pb.RegisterSpaceHandler(srv.Server(), new(handler.Space))
//pb.RegisterSpaceHandler(srv.Server(), handler.NewSpace(srv))
srv.Server().Handle(
srv.Server().NewHandler(
handler.NewSpace(srv),
api.WithEndpoint(
&api.Endpoint{
Name: "Space.Read",
Handler: "api",
Method: []string{"POST", "GET"},
Path: []string{"/space/read"},
}),
))
// Run service
if err := srv.Run(); err != nil {

File diff suppressed because it is too large Load Diff

View File

@@ -42,7 +42,12 @@ func NewSpaceEndpoints() []*api.Endpoint {
// Client API for Space service
type SpaceService interface {
Vote(ctx context.Context, in *VoteRequest, opts ...client.CallOption) (*VoteResponse, error)
Create(ctx context.Context, in *CreateRequest, opts ...client.CallOption) (*CreateResponse, error)
Update(ctx context.Context, in *UpdateRequest, opts ...client.CallOption) (*UpdateResponse, error)
Delete(ctx context.Context, in *DeleteRequest, opts ...client.CallOption) (*DeleteResponse, error)
List(ctx context.Context, in *ListRequest, opts ...client.CallOption) (*ListResponse, error)
Head(ctx context.Context, in *HeadRequest, opts ...client.CallOption) (*HeadResponse, error)
Read(ctx context.Context, in *ReadRequest, opts ...client.CallOption) (*ReadResponse, error)
}
type spaceService struct {
@@ -57,9 +62,59 @@ func NewSpaceService(name string, c client.Client) SpaceService {
}
}
func (c *spaceService) Vote(ctx context.Context, in *VoteRequest, opts ...client.CallOption) (*VoteResponse, error) {
req := c.c.NewRequest(c.name, "Space.Vote", in)
out := new(VoteResponse)
func (c *spaceService) Create(ctx context.Context, in *CreateRequest, opts ...client.CallOption) (*CreateResponse, error) {
req := c.c.NewRequest(c.name, "Space.Create", in)
out := new(CreateResponse)
err := c.c.Call(ctx, req, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *spaceService) Update(ctx context.Context, in *UpdateRequest, opts ...client.CallOption) (*UpdateResponse, error) {
req := c.c.NewRequest(c.name, "Space.Update", in)
out := new(UpdateResponse)
err := c.c.Call(ctx, req, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *spaceService) Delete(ctx context.Context, in *DeleteRequest, opts ...client.CallOption) (*DeleteResponse, error) {
req := c.c.NewRequest(c.name, "Space.Delete", in)
out := new(DeleteResponse)
err := c.c.Call(ctx, req, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *spaceService) List(ctx context.Context, in *ListRequest, opts ...client.CallOption) (*ListResponse, error) {
req := c.c.NewRequest(c.name, "Space.List", in)
out := new(ListResponse)
err := c.c.Call(ctx, req, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *spaceService) Head(ctx context.Context, in *HeadRequest, opts ...client.CallOption) (*HeadResponse, error) {
req := c.c.NewRequest(c.name, "Space.Head", in)
out := new(HeadResponse)
err := c.c.Call(ctx, req, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *spaceService) Read(ctx context.Context, in *ReadRequest, opts ...client.CallOption) (*ReadResponse, error) {
req := c.c.NewRequest(c.name, "Space.Read", in)
out := new(ReadResponse)
err := c.c.Call(ctx, req, out, opts...)
if err != nil {
return nil, err
@@ -70,12 +125,22 @@ func (c *spaceService) Vote(ctx context.Context, in *VoteRequest, opts ...client
// Server API for Space service
type SpaceHandler interface {
Vote(context.Context, *VoteRequest, *VoteResponse) error
Create(context.Context, *CreateRequest, *CreateResponse) error
Update(context.Context, *UpdateRequest, *UpdateResponse) error
Delete(context.Context, *DeleteRequest, *DeleteResponse) error
List(context.Context, *ListRequest, *ListResponse) error
Head(context.Context, *HeadRequest, *HeadResponse) error
Read(context.Context, *ReadRequest, *ReadResponse) error
}
func RegisterSpaceHandler(s server.Server, hdlr SpaceHandler, opts ...server.HandlerOption) error {
type space interface {
Vote(ctx context.Context, in *VoteRequest, out *VoteResponse) error
Create(ctx context.Context, in *CreateRequest, out *CreateResponse) error
Update(ctx context.Context, in *UpdateRequest, out *UpdateResponse) error
Delete(ctx context.Context, in *DeleteRequest, out *DeleteResponse) error
List(ctx context.Context, in *ListRequest, out *ListResponse) error
Head(ctx context.Context, in *HeadRequest, out *HeadResponse) error
Read(ctx context.Context, in *ReadRequest, out *ReadResponse) error
}
type Space struct {
space
@@ -88,6 +153,26 @@ type spaceHandler struct {
SpaceHandler
}
func (h *spaceHandler) Vote(ctx context.Context, in *VoteRequest, out *VoteResponse) error {
return h.SpaceHandler.Vote(ctx, in, out)
func (h *spaceHandler) Create(ctx context.Context, in *CreateRequest, out *CreateResponse) error {
return h.SpaceHandler.Create(ctx, in, out)
}
func (h *spaceHandler) Update(ctx context.Context, in *UpdateRequest, out *UpdateResponse) error {
return h.SpaceHandler.Update(ctx, in, out)
}
func (h *spaceHandler) Delete(ctx context.Context, in *DeleteRequest, out *DeleteResponse) error {
return h.SpaceHandler.Delete(ctx, in, out)
}
func (h *spaceHandler) List(ctx context.Context, in *ListRequest, out *ListResponse) error {
return h.SpaceHandler.List(ctx, in, out)
}
func (h *spaceHandler) Head(ctx context.Context, in *HeadRequest, out *HeadResponse) error {
return h.SpaceHandler.Head(ctx, in, out)
}
func (h *spaceHandler) Read(ctx context.Context, in *ReadRequest, out *ReadResponse) error {
return h.SpaceHandler.Read(ctx, in, out)
}

View File

@@ -5,16 +5,100 @@ package space;
option go_package = "./proto;space";
service Space {
rpc Vote(VoteRequest) returns (VoteResponse) {}
rpc Create(CreateRequest) returns (CreateResponse) {}
rpc Update(UpdateRequest) returns (UpdateResponse) {}
rpc Delete(DeleteRequest) returns (DeleteResponse) {}
rpc List(ListRequest) returns (ListResponse) {}
rpc Head(HeadRequest) returns (HeadResponse) {}
rpc Read(ReadRequest) returns (ReadResponse) {}
}
// Vote to have the Space api launched faster!
message VoteRequest {
// optional message
string message = 1;
// Create an object. Returns error if object with this name already exists. If you want to update an existing object use the `Update` endpoint
// You need to send the request as a multipart/form-data rather than the usual application/json
// with each parameter as a form field.
message CreateRequest {
// The contents of the object
bytes object = 1;
// The name of the object. Use forward slash delimiter to implement a nested directory-like structure e.g. images/foo.jpg
string name = 2;
// Who can see this object? "public" or "private", defaults to "private"
string visibility = 3;
}
message VoteResponse {
// response message
string message = 2;
message CreateResponse {
// A public URL to access the object if visibility is "public"
string url = 1;
}
// Update an object. If an object with this name does not exist, creates a new one.
// You need to send the request as a multipart/form-data rather than the usual application/json
// with each parameter as a form field.
message UpdateRequest {
// The contents of the object
bytes object = 1;
// The name of the object. Use forward slash delimiter to implement a nested directory-like structure e.g. images/foo.jpg
string name = 2;
// Who can see this object? "public" or "private", defaults to "private"
string visibility = 3;
}
message UpdateResponse {
// A public URL to access the object if visibility is "public"
string url = 1;
}
// Delete an object
message DeleteRequest {
// The name of the object. Use forward slash delimiter to implement a nested directory-like structure e.g. images/foo.jpg
string name = 1;
}
message DeleteResponse {}
// List the objects in the space
message ListRequest {
// optional prefix for the name e.g. to return all the objects in the images directory pass images/
string prefix = 1;
}
message ListResponse {
repeated ListObject objects = 1;
}
message ListObject {
string name = 1;
// when was this last modified
int64 modified = 2;
string url = 3;
}
// Retrieve meta information about an object
message HeadRequest {
string name = 1;
}
message HeadResponse {
HeadObject object = 1;
}
message HeadObject {
string name = 1;
// when was this last modified
int64 modified = 2;
// when was this created
int64 created = 3;
// is this public or private
string visibility = 4;
// URL to access the object if it is public
string url = 5;
}
// Read/download the object
message ReadRequest {
string name = 1;
}
// Returns the raw object
message ReadResponse {
}

View File

@@ -1,6 +1,6 @@
{
"name": "space",
"icon": "🖴",
"category": "coming soon",
"display_name": "Space (Coming Soon)"
"category": "storage",
"display_name": "Space"
}