feat(dataset-groups): CRUD, latest-in-group, group SLD with dataset fallback, CreateRasterFromFile
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
185
internal/service/dataset_group.go
Normal file
185
internal/service/dataset_group.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gis/internal/domain"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// DatasetGroupRepository is the persistence behaviour DatasetGroupService needs.
|
||||
type DatasetGroupRepository interface {
|
||||
Create(ctx context.Context, g domain.DatasetGroup) (domain.DatasetGroup, error)
|
||||
GetByID(ctx context.Context, id uuid.UUID) (domain.DatasetGroup, error)
|
||||
GetByCode(ctx context.Context, code string) (domain.DatasetGroup, error)
|
||||
List(ctx context.Context, code *string, limit, offset int) ([]domain.DatasetGroup, error)
|
||||
Count(ctx context.Context, code *string) (int, error)
|
||||
Update(ctx context.Context, g domain.DatasetGroup) (domain.DatasetGroup, error)
|
||||
Delete(ctx context.Context, id uuid.UUID) error
|
||||
SetSLD(ctx context.Context, id uuid.UUID, key, filename, contentType string) (domain.DatasetGroup, error)
|
||||
}
|
||||
|
||||
// DatasetGroupInput carries the mutable fields of a group.
|
||||
type DatasetGroupInput struct {
|
||||
Code string
|
||||
Name string
|
||||
Description string
|
||||
}
|
||||
|
||||
// DatasetGroupPage is a page of groups.
|
||||
type DatasetGroupPage struct {
|
||||
Items []domain.DatasetGroup
|
||||
Page int
|
||||
PageSize int
|
||||
Total int
|
||||
}
|
||||
|
||||
// DatasetGroupService implements dataset-group business rules.
|
||||
type DatasetGroupService struct {
|
||||
repo DatasetGroupRepository
|
||||
datasets DatasetRepository
|
||||
store ObjectStore
|
||||
}
|
||||
|
||||
// NewDatasetGroupService wires the group repository, the dataset repository
|
||||
// (for the "latest" lookup) and the object store (for the group SLD).
|
||||
func NewDatasetGroupService(repo DatasetGroupRepository, datasets DatasetRepository, store ObjectStore) *DatasetGroupService {
|
||||
return &DatasetGroupService{repo: repo, datasets: datasets, store: store}
|
||||
}
|
||||
|
||||
// errInvalidGroupCode is the validation error returned for a non-slug code.
|
||||
func errInvalidGroupCode() error {
|
||||
return fmt.Errorf("%w: code must be a slug (lowercase latin letters, digits, and dashes)", domain.ErrValidation)
|
||||
}
|
||||
|
||||
// Create persists a new group after validating its code.
|
||||
func (s *DatasetGroupService) Create(ctx context.Context, in DatasetGroupInput) (domain.DatasetGroup, error) {
|
||||
if !domain.ValidSlug(in.Code) {
|
||||
return domain.DatasetGroup{}, errInvalidGroupCode()
|
||||
}
|
||||
return s.repo.Create(ctx, domain.DatasetGroup{Code: in.Code, Name: in.Name, Description: in.Description})
|
||||
}
|
||||
|
||||
// Get returns one group by id.
|
||||
func (s *DatasetGroupService) Get(ctx context.Context, id uuid.UUID) (domain.DatasetGroup, error) {
|
||||
return s.repo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// List returns a page of groups; page/pageSize are clamped like datasets.
|
||||
func (s *DatasetGroupService) List(ctx context.Context, code *string, page, pageSize int) (DatasetGroupPage, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = DefaultPageSize
|
||||
}
|
||||
if pageSize > MaxPageSize {
|
||||
pageSize = MaxPageSize
|
||||
}
|
||||
items, err := s.repo.List(ctx, code, pageSize, (page-1)*pageSize)
|
||||
if err != nil {
|
||||
return DatasetGroupPage{}, err
|
||||
}
|
||||
total, err := s.repo.Count(ctx, code)
|
||||
if err != nil {
|
||||
return DatasetGroupPage{}, err
|
||||
}
|
||||
return DatasetGroupPage{Items: items, Page: page, PageSize: pageSize, Total: total}, nil
|
||||
}
|
||||
|
||||
// Update replaces a group's editable fields.
|
||||
func (s *DatasetGroupService) Update(ctx context.Context, id uuid.UUID, in DatasetGroupInput) (domain.DatasetGroup, error) {
|
||||
if !domain.ValidSlug(in.Code) {
|
||||
return domain.DatasetGroup{}, errInvalidGroupCode()
|
||||
}
|
||||
return s.repo.Update(ctx, domain.DatasetGroup{ID: id, Code: in.Code, Name: in.Name, Description: in.Description})
|
||||
}
|
||||
|
||||
// Delete removes a group (409 via ErrConflict while datasets reference it) and
|
||||
// then best-effort removes its SLD object.
|
||||
func (s *DatasetGroupService) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
g, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.repo.Delete(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if g.SldStorageKey != nil {
|
||||
_ = s.store.Remove(ctx, *g.SldStorageKey) // best-effort: the row is gone
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Latest returns the freshest dataset of the group (greatest end_date, NULLs
|
||||
// last, then newest), optionally restricted by status and file type.
|
||||
func (s *DatasetGroupService) Latest(ctx context.Context, id uuid.UUID, status *string, fileType *domain.FileType) (domain.Dataset, error) {
|
||||
if _, err := s.repo.GetByID(ctx, id); err != nil {
|
||||
return domain.Dataset{}, err
|
||||
}
|
||||
return s.datasets.LatestInGroup(ctx, id, status, fileType)
|
||||
}
|
||||
|
||||
// UploadSLD validates and stores the group's shared SLD, replacing any previous one.
|
||||
func (s *DatasetGroupService) UploadSLD(ctx context.Context, id uuid.UUID, in UploadSLDInput) (domain.DatasetGroup, error) {
|
||||
g, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return domain.DatasetGroup{}, err
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(in.Filename))
|
||||
if !domain.ExtensionAllowedForSLD(ext) {
|
||||
return domain.DatasetGroup{}, fmt.Errorf("%w: sld file must have one of these extensions: %s",
|
||||
domain.ErrValidation, strings.Join(domain.AllowedSLDExtensions, ", "))
|
||||
}
|
||||
|
||||
head := make([]byte, 512)
|
||||
n, err := io.ReadFull(in.Reader, head)
|
||||
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
|
||||
return domain.DatasetGroup{}, fmt.Errorf("read sld upload: %w", err)
|
||||
}
|
||||
head = head[:n]
|
||||
if err := domain.ValidateFileContent(ext, head); err != nil {
|
||||
return domain.DatasetGroup{}, fmt.Errorf("%w: %s", domain.ErrValidation, err)
|
||||
}
|
||||
content := io.MultiReader(bytes.NewReader(head), in.Reader)
|
||||
|
||||
key := path.Join("groups", g.ID.String(), "sld", in.Filename)
|
||||
if err := s.store.Put(ctx, key, content, in.Size, in.ContentType); err != nil {
|
||||
return domain.DatasetGroup{}, err
|
||||
}
|
||||
updated, err := s.repo.SetSLD(ctx, id, key, in.Filename, in.ContentType)
|
||||
if err != nil {
|
||||
// Compensate: the row was not updated, so the object would be orphaned.
|
||||
_ = s.store.Remove(ctx, key)
|
||||
return domain.DatasetGroup{}, err
|
||||
}
|
||||
if g.SldStorageKey != nil && *g.SldStorageKey != key {
|
||||
_ = s.store.Remove(ctx, *g.SldStorageKey) // best-effort: drop the replaced file
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// DownloadSLD returns the group and a reader for its SLD; ErrSLDNotFound when
|
||||
// none has been uploaded. The caller must close the reader.
|
||||
func (s *DatasetGroupService) DownloadSLD(ctx context.Context, id uuid.UUID) (domain.DatasetGroup, io.ReadCloser, error) {
|
||||
g, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return domain.DatasetGroup{}, nil, err
|
||||
}
|
||||
if g.SldStorageKey == nil {
|
||||
return domain.DatasetGroup{}, nil, ErrSLDNotFound
|
||||
}
|
||||
obj, err := s.store.Get(ctx, *g.SldStorageKey)
|
||||
if err != nil {
|
||||
return domain.DatasetGroup{}, nil, err
|
||||
}
|
||||
return g, obj, nil
|
||||
}
|
||||
Reference in New Issue
Block a user