1
This commit is contained in:
10
internal/object_storage/client.go
Normal file
10
internal/object_storage/client.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package object_storage
|
||||
|
||||
func New(c Config, t ClientType) (Client, error) {
|
||||
switch t {
|
||||
case ClientTypeS3:
|
||||
return newS3Storage(c)
|
||||
default:
|
||||
return nil, ErrUnknownClient
|
||||
}
|
||||
}
|
25
internal/object_storage/errors.go
Normal file
25
internal/object_storage/errors.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package object_storage
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrInvalidConfig = errors.New("invalid config")
|
||||
ErrUnknownClient = errors.New("unknown client")
|
||||
ErrInvalidInstance = errors.New("invalid instance")
|
||||
ErrToManyOptions = errors.New("too many options")
|
||||
|
||||
ErrObjectNotFound = errors.New("object not found")
|
||||
ErrObjectAlreadyExists = errors.New("object already exists")
|
||||
|
||||
ErrPutNewObject = errors.New("failed put new object")
|
||||
ErrGetObject = errors.New("failed get object")
|
||||
ErrDeleteObject = errors.New("failed delete object")
|
||||
ErrUpdateObject = errors.New("failed update object")
|
||||
ErrGetPresignedLink = errors.New("failed get presigned link")
|
||||
ErrCheckExists = errors.New("check exists")
|
||||
)
|
||||
|
||||
const (
|
||||
s3ErrorPatternNoSuchKey = "NoSuchKey"
|
||||
s3ErrorPatternNotFound = "NotFound"
|
||||
)
|
255
internal/object_storage/s3_storage.go
Normal file
255
internal/object_storage/s3_storage.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package object_storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/config"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
"github.com/aws/smithy-go"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type S3StorageConfig struct {
|
||||
Bucket string
|
||||
DefaultLinkTTL time.Duration
|
||||
}
|
||||
|
||||
type s3Storage struct {
|
||||
client *s3.Client
|
||||
presignedClient *s3.PresignClient
|
||||
config S3StorageConfig
|
||||
}
|
||||
|
||||
func newS3Storage(c Config) (*s3Storage, error) {
|
||||
cfg, ok := c.(S3StorageConfig)
|
||||
if !ok {
|
||||
return nil, ErrInvalidConfig
|
||||
}
|
||||
|
||||
storageConfig, err := config.LoadDefaultConfig(context.TODO())
|
||||
if err != nil {
|
||||
return nil, ErrInvalidConfig
|
||||
}
|
||||
|
||||
s := &s3Storage{
|
||||
client: s3.NewFromConfig(storageConfig),
|
||||
config: cfg,
|
||||
}
|
||||
|
||||
s.presignedClient = s3.NewPresignClient(s.client)
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *s3Storage) PutNewObject(ctx context.Context, objectId string, category Category, r io.Reader, opts ...PutOptions) error {
|
||||
if err := s.checkInstance(); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrGetPresignedLink, err)
|
||||
}
|
||||
|
||||
if len(opts) > 1 {
|
||||
return fmt.Errorf("%w: %v", ErrPutNewObject, ErrToManyOptions)
|
||||
}
|
||||
|
||||
fullKey := strings.Join([]string{string(category), objectId}, keySeparator)
|
||||
|
||||
if len(opts) != 0 && opts[0].LocalPrefix != nil && len(opts[0].LocalPrefix) > 0 {
|
||||
fullKey = strings.Join([]string{string(category), strings.Join(opts[0].LocalPrefix, keySeparator), objectId}, keySeparator)
|
||||
}
|
||||
|
||||
if _, err := s.exists(ctx, fullKey); err != nil {
|
||||
if !errors.Is(err, ErrObjectNotFound) {
|
||||
return fmt.Errorf("%w: %v", ErrPutNewObject, err)
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("%w: %v", ErrPutNewObject, ErrObjectAlreadyExists)
|
||||
}
|
||||
|
||||
putOptions := &s3.PutObjectInput{
|
||||
Bucket: aws.String(s.config.Bucket),
|
||||
Key: aws.String(fullKey),
|
||||
Body: r,
|
||||
ChecksumAlgorithm: types.ChecksumAlgorithmSha256,
|
||||
}
|
||||
|
||||
if len(opts) != 0 {
|
||||
putOptions.ContentType = aws.String(opts[0].ContentType)
|
||||
}
|
||||
|
||||
if _, err := s.client.PutObject(ctx, putOptions); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrPutNewObject, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *s3Storage) GetObject(ctx context.Context, objectId string, category Category, opts ...GetOptions) (io.ReadCloser, error) {
|
||||
if err := s.checkInstance(); err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrGetPresignedLink, err)
|
||||
}
|
||||
|
||||
if len(opts) > 1 {
|
||||
return nil, fmt.Errorf("%w: %v", ErrGetObject, ErrToManyOptions)
|
||||
}
|
||||
|
||||
fullKey := strings.Join([]string{string(category), objectId}, keySeparator)
|
||||
|
||||
if len(opts) != 0 && opts[0].LocalPrefix != nil && len(opts[0].LocalPrefix) > 0 {
|
||||
fullKey = strings.Join([]string{string(category), strings.Join(opts[0].LocalPrefix, keySeparator), objectId}, keySeparator)
|
||||
}
|
||||
|
||||
out, err := s.client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(s.config.Bucket),
|
||||
Key: aws.String(fullKey),
|
||||
ChecksumMode: types.ChecksumModeEnabled,
|
||||
})
|
||||
if err != nil {
|
||||
var apiErr smithy.APIError
|
||||
if errors.As(err, &apiErr) && apiErr.ErrorCode() == s3ErrorPatternNoSuchKey {
|
||||
return nil, fmt.Errorf("%w: %v", ErrGetObject, ErrObjectNotFound)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("%w: %v", ErrGetObject, err)
|
||||
}
|
||||
|
||||
return out.Body, nil
|
||||
}
|
||||
|
||||
func (s *s3Storage) DeleteObject(ctx context.Context, objectId string, category Category, opts ...DeleteOptions) error {
|
||||
if err := s.checkInstance(); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrGetPresignedLink, err)
|
||||
}
|
||||
|
||||
if len(opts) > 1 {
|
||||
return fmt.Errorf("%w: %v", ErrDeleteObject, ErrToManyOptions)
|
||||
}
|
||||
|
||||
fullKey := strings.Join([]string{string(category), objectId}, keySeparator)
|
||||
|
||||
if len(opts) != 0 && opts[0].LocalPrefix != nil && len(opts[0].LocalPrefix) > 0 {
|
||||
fullKey = strings.Join([]string{string(category), strings.Join(opts[0].LocalPrefix, keySeparator), objectId}, keySeparator)
|
||||
}
|
||||
|
||||
head, err := s.exists(ctx, fullKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err = s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(s.config.Bucket),
|
||||
Key: aws.String(fullKey),
|
||||
IfMatch: head.ETag,
|
||||
}); err != nil {
|
||||
var apiErr smithy.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
return fmt.Errorf("%w: %v", ErrDeleteObject, ErrObjectNotFound)
|
||||
}
|
||||
|
||||
return fmt.Errorf("%w: %v", ErrDeleteObject, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *s3Storage) UpdateObject(ctx context.Context, objectId string, category Category, r io.Reader, opts ...UpdateOptions) error {
|
||||
if err := s.checkInstance(); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrGetPresignedLink, err)
|
||||
}
|
||||
|
||||
if len(opts) > 1 {
|
||||
return fmt.Errorf("%w: %v", ErrUpdateObject, ErrToManyOptions)
|
||||
}
|
||||
|
||||
fullKey := strings.Join([]string{string(category), objectId}, keySeparator)
|
||||
|
||||
if len(opts) != 0 && opts[0].LocalPrefix != nil && len(opts[0].LocalPrefix) > 0 {
|
||||
fullKey = strings.Join([]string{string(category), strings.Join(opts[0].LocalPrefix, keySeparator), objectId}, keySeparator)
|
||||
}
|
||||
|
||||
head, err := s.exists(ctx, fullKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
input := &s3.PutObjectInput{
|
||||
Bucket: aws.String(s.config.Bucket),
|
||||
Key: aws.String(fullKey),
|
||||
Body: r,
|
||||
IfMatch: head.ETag,
|
||||
}
|
||||
|
||||
if len(opts) != 0 && opts[0].ContentType != "" {
|
||||
input.ContentType = aws.String(opts[0].ContentType)
|
||||
}
|
||||
|
||||
if _, err = s.client.PutObject(ctx, input); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrUpdateObject, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *s3Storage) GetPresignedLink(ctx context.Context, objectId string, category Category, opts ...LinkOptions) (string, error) {
|
||||
if err := s.checkInstance(); err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrGetPresignedLink, err)
|
||||
}
|
||||
|
||||
if len(opts) > 1 {
|
||||
return "", fmt.Errorf("%w: %v", ErrGetPresignedLink, ErrToManyOptions)
|
||||
}
|
||||
|
||||
fullKey := strings.Join([]string{string(category), objectId}, keySeparator)
|
||||
|
||||
if len(opts) != 0 && opts[0].LocalPrefix != nil && len(opts[0].LocalPrefix) > 0 {
|
||||
fullKey = strings.Join([]string{string(category), strings.Join(opts[0].LocalPrefix, keySeparator), objectId}, keySeparator)
|
||||
}
|
||||
|
||||
if _, err := s.exists(ctx, fullKey); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
duration := s.config.DefaultLinkTTL
|
||||
|
||||
if len(opts) != 0 && opts[0].TTL != nil {
|
||||
duration = *opts[0].TTL
|
||||
}
|
||||
|
||||
presigned, err := s.presignedClient.PresignGetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(s.config.Bucket),
|
||||
Key: aws.String(fullKey),
|
||||
}, s3.WithPresignExpires(duration))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrGetPresignedLink, err)
|
||||
}
|
||||
|
||||
return presigned.URL, nil
|
||||
}
|
||||
|
||||
func (s *s3Storage) checkInstance() error {
|
||||
if s == nil || s.client == nil {
|
||||
return ErrInvalidInstance
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *s3Storage) exists(ctx context.Context, fullKey string) (*s3.HeadObjectOutput, error) {
|
||||
head, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
|
||||
Bucket: aws.String(s.config.Bucket),
|
||||
Key: aws.String(fullKey),
|
||||
})
|
||||
if err != nil {
|
||||
var apiErr smithy.APIError
|
||||
if errors.As(err, &apiErr) && apiErr.ErrorCode() == s3ErrorPatternNotFound {
|
||||
return nil, ErrObjectNotFound
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("%w: %v", ErrCheckExists, err)
|
||||
}
|
||||
|
||||
return head, nil
|
||||
}
|
94
internal/object_storage/types.go
Normal file
94
internal/object_storage/types.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package object_storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client interface {
|
||||
// PutNewObject создаёт новый объект в хранилище
|
||||
//
|
||||
// Если такой объект уже существует,
|
||||
// то будет возвращена ошибка ErrObjectAlreadyExists.
|
||||
PutNewObject(ctx context.Context, objectId string, category Category, r io.Reader, opts ...PutOptions) error
|
||||
|
||||
// GetObject возвращает интерфейс ReadCloser получаемого файла
|
||||
//
|
||||
// Обязательным условие использования является вызов метода Close()
|
||||
// на стороне вызывающего кода.
|
||||
//
|
||||
// Если такого объекта не существует,
|
||||
// то будет возвращена ошибка ErrObjectNotFound.
|
||||
GetObject(ctx context.Context, objectId string, category Category, opts ...GetOptions) (io.ReadCloser, error)
|
||||
|
||||
// DeleteObject удаляет объект
|
||||
//
|
||||
// Если такого объекта не существует,
|
||||
// то будет возвращена ошибка ErrObjectNotFound.
|
||||
DeleteObject(ctx context.Context, objectId string, category Category, opts ...DeleteOptions) error
|
||||
|
||||
// UpdateObject обновляет содержимое файла по переданному
|
||||
// названию и типу
|
||||
//
|
||||
// Если такого объекта не существует,
|
||||
// то будет возвращена ошибка ErrObjectNotFound.
|
||||
UpdateObject(ctx context.Context, objectId string, category Category, r io.Reader, opts ...UpdateOptions) error
|
||||
|
||||
// GetPresignedLink возвращает временную публичную ссылку для скачивания
|
||||
// на запрашиваемый объект с переданным TTL.
|
||||
//
|
||||
// Если такого объекта не существует,
|
||||
// то будет возвращена ошибка ErrObjectNotFound.
|
||||
//
|
||||
// Для S3 хранилища: если не указан ttl в LinkOptions, то будет использоваться дефолтный
|
||||
// переданный в конфиге.
|
||||
GetPresignedLink(ctx context.Context, objectId string, category Category, opts ...LinkOptions) (string, error)
|
||||
|
||||
// TODO: по необходимости можно расширять другими методами
|
||||
}
|
||||
|
||||
type (
|
||||
// LocalPrefix - префикс хранимого файла, относительно категории.
|
||||
// Например "logos/**2012/02/15**/logo.png"
|
||||
|
||||
PutOptions struct {
|
||||
ContentType string
|
||||
LocalPrefix []string
|
||||
}
|
||||
|
||||
GetOptions struct {
|
||||
LocalPrefix []string
|
||||
}
|
||||
|
||||
UpdateOptions struct {
|
||||
ContentType string
|
||||
LocalPrefix []string
|
||||
}
|
||||
|
||||
DeleteOptions struct {
|
||||
LocalPrefix []string
|
||||
}
|
||||
|
||||
LinkOptions struct {
|
||||
LocalPrefix []string
|
||||
TTL *time.Duration
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
Category string
|
||||
Config any
|
||||
ClientType uint
|
||||
)
|
||||
|
||||
const (
|
||||
ClientTypeS3 ClientType = iota
|
||||
)
|
||||
|
||||
const (
|
||||
LogoCategory Category = "logos"
|
||||
DocumentCategory Category = "documents"
|
||||
|
||||
keySeparator = "/"
|
||||
)
|
Reference in New Issue
Block a user