commit 92469fa3a230bb19c2fdf71e5ada09ee20277304 Author: fenix Date: Sun Mar 26 16:11:00 2023 +0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2ed58d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf +.idea/**/aws.xml +.idea/**/contentModel.xml +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml +.idea/**/gradle.xml +.idea/**/libraries +cmake-build-*/ +.idea/**/mongoSettings.xml +*.iws +out/ +.idea_modules/ +atlassian-ide-plugin.xml +.idea/replstate.xml +.idea/sonarlint/ +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties +.idea/httpRequests +.idea/caches/build_file_checksums.ser +*~ +.fuse_hidden* +.directory +.Trash-* +.nfs* +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +go.work +test.http +db diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..644cdf0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/golinter.xml b/.idea/golinter.xml new file mode 100644 index 0000000..fcdf3a5 --- /dev/null +++ b/.idea/golinter.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..3ce3588 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..bbaef83 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/webarchive.iml b/.idea/webarchive.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/webarchive.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/adapters/processors/headers.go b/adapters/processors/headers.go new file mode 100644 index 0000000..eecb950 --- /dev/null +++ b/adapters/processors/headers.go @@ -0,0 +1,53 @@ +package processors + +import ( + "bytes" + "context" + "fmt" + "net/http" + + "github.com/derfenix/webarchive/entity" +) + +func NewHeaders(client *http.Client) *Headers { + return &Headers{client: client} +} + +type Headers struct { + client *http.Client +} + +func (h *Headers) Process(ctx context.Context, url string) ([]entity.File, error) { + var ( + headersFile entity.File + err error + ) + + req, reqErr := http.NewRequestWithContext(ctx, http.MethodHead, url, nil) + if reqErr != nil { + return nil, fmt.Errorf("create request: %w", reqErr) + } + + resp, doErr := h.client.Do(req) + if doErr != nil { + return nil, fmt.Errorf("call url: %w", doErr) + } + + headersFile, err = h.newFile(resp.Header) + + if err != nil { + return nil, fmt.Errorf("new file from headers: %w", err) + } + + return []entity.File{headersFile}, nil +} + +func (h *Headers) newFile(headers http.Header) (entity.File, error) { + buf := bytes.NewBuffer(nil) + + if err := headers.Write(buf); err != nil { + return entity.File{}, fmt.Errorf("write headers: %w", err) + } + + return entity.NewFile("headers", buf.Bytes()), nil +} diff --git a/adapters/processors/pdf.go b/adapters/processors/pdf.go new file mode 100644 index 0000000..2098390 --- /dev/null +++ b/adapters/processors/pdf.go @@ -0,0 +1,52 @@ +package processors + +import ( + "context" + "fmt" + "time" + + "github.com/SebastiaanKlippert/go-wkhtmltopdf" + + "github.com/derfenix/webarchive/entity" +) + +func NewPDF() *PDF { + return &PDF{} +} + +type PDF struct{} + +func (P *PDF) Process(_ context.Context, url string) ([]entity.File, error) { + gen, err := wkhtmltopdf.NewPDFGenerator() + if err != nil { + return nil, fmt.Errorf("new pdf generator: %w", err) + } + + gen.Dpi.Set(300) + gen.PageSize.Set(wkhtmltopdf.PageSizeA4) + gen.Orientation.Set(wkhtmltopdf.OrientationPortrait) + gen.Grayscale.Set(false) + gen.Title.Set(url) + + page := wkhtmltopdf.NewPage(url) + page.JavascriptDelay.Set(200) + page.LoadMediaErrorHandling.Set("abort") + page.FooterRight.Set("[page]") + page.HeaderLeft.Set(url) + page.HeaderRight.Set(time.Now().Format(time.DateOnly)) + page.FooterFontSize.Set(10) + page.Zoom.Set(1) + page.ViewportSize.Set("1920x1080") + + gen.AddPage(page) + + // Create PDF document in internal buffer + err = gen.Create() + if err != nil { + return nil, fmt.Errorf("create pdf: %w", err) + } + + file := entity.NewFile("page.pdf", gen.Bytes()) + + return []entity.File{file}, nil +} diff --git a/adapters/processors/pdf_test.go b/adapters/processors/pdf_test.go new file mode 100644 index 0000000..d8971a4 --- /dev/null +++ b/adapters/processors/pdf_test.go @@ -0,0 +1,29 @@ +package processors + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestPDF_Process(t *testing.T) { + t.Parallel() + + if testing.Short() { + t.Skip("skip test with external resource") + } + + files, err := (&PDF{}).Process(context.Background(), "https://github.com/SebastiaanKlippert/go-wkhtmltopdf") + require.NoError(t, err) + require.Len(t, files, 1) + + f := files[0] + fmt.Println("ID ", f.ID) + fmt.Println("Name ", f.Name) + fmt.Println("MimeType ", f.MimeType) + fmt.Println("Size ", f.Size) + fmt.Println("Created ", f.Created.Format(time.RFC3339)) +} diff --git a/adapters/processors/processors.go b/adapters/processors/processors.go new file mode 100644 index 0000000..1b36155 --- /dev/null +++ b/adapters/processors/processors.go @@ -0,0 +1,93 @@ +package processors + +import ( + "context" + "fmt" + "net" + "net/http" + "net/http/cookiejar" + "time" + + "github.com/derfenix/webarchive/entity" +) + +type processor interface { + Process(ctx context.Context, url string) ([]entity.File, error) +} + +func NewProcessors() (*Processors, error) { + jar, err := cookiejar.New(&cookiejar.Options{ + PublicSuffixList: nil, + }) + if err != nil { + return nil, fmt.Errorf("create cookie jar: %w", err) + } + + httpClient := &http.Client{ + Transport: &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: time.Second * 10, + KeepAlive: time.Second * 10, + }).DialContext, + MaxIdleConns: 20, + MaxIdleConnsPerHost: 5, + MaxConnsPerHost: 10, + IdleConnTimeout: time.Second * 60, + ResponseHeaderTimeout: time.Second * 20, + MaxResponseHeaderBytes: 1024 * 1024 * 50, + WriteBufferSize: 256, + ReadBufferSize: 1024 * 64, + ForceAttemptHTTP2: true, + }, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) > 3 { + return fmt.Errorf("too many redirects") + } + + return nil + }, + Jar: jar, + Timeout: time.Second * 30, + } + + procs := Processors{ + processors: map[entity.Format]processor{ + entity.FormatHeaders: NewHeaders(httpClient), + entity.FormatPDF: NewPDF(), + }, + } + + return &procs, nil +} + +type Processors struct { + processors map[entity.Format]processor +} + +func (p *Processors) Process(ctx context.Context, format entity.Format, url string) entity.Result { + result := entity.Result{Format: format} + + proc, ok := p.processors[format] + if !ok { + result.Err = fmt.Errorf("no processor registered for format %v", format) + + return result + } + + files, err := proc.Process(ctx, url) + if err != nil { + result.Err = fmt.Errorf("process: %w", err) + + return result + } + + result.Files = files + + return result +} + +func (p *Processors) Override(format entity.Format, proc processor) error { + p.processors[format] = proc + + return nil +} diff --git a/adapters/processors/simplesample.pdf b/adapters/processors/simplesample.pdf new file mode 100644 index 0000000..7a94299 Binary files /dev/null and b/adapters/processors/simplesample.pdf differ diff --git a/adapters/repository/badger/db.go b/adapters/repository/badger/db.go new file mode 100644 index 0000000..c1eb5cf --- /dev/null +++ b/adapters/repository/badger/db.go @@ -0,0 +1,128 @@ +package badger + +import ( + "errors" + "fmt" + "os" + "path" + + "github.com/dgraph-io/badger/v4" + "github.com/dgraph-io/badger/v4/options" + "go.uber.org/zap" +) + +const ( + backupStartPath = "backup_start.db" + backupStopPath = "backup_stop.db" +) + +type BackupType uint8 + +const ( + BackupStart BackupType = iota + BackupStop +) + +var ErrDBClosed = fmt.Errorf("database is closed") + +type logger struct { + *zap.SugaredLogger +} + +func (l *logger) Warningf(s string, i ...interface{}) { + l.SugaredLogger.Warnf(s, i...) +} + +func NewBadger(dir string, log *zap.Logger) (*badger.DB, error) { + opts := badger.DefaultOptions(dir) + opts.Logger = &logger{SugaredLogger: log.Sugar()} + opts.Compression = options.ZSTD + opts.ZSTDCompressionLevel = 6 + + db, err := badger.Open(opts) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + + if err := Backup(db, BackupStart); err != nil { + log.Error("backup on start failed", zap.Error(err)) + } + + return db, nil +} + +func Backup(db *badger.DB, bt BackupType) error { + dir := db.Opts().Dir + var backupPath string + + switch bt { + case BackupStart: + backupPath = path.Join(dir, backupStartPath) + case BackupStop: + backupPath = path.Join(dir, backupStopPath) + } + + file, err := os.OpenFile(backupPath, os.O_CREATE|os.O_WRONLY, os.FileMode(0600)) + if err != nil { + return fmt.Errorf("open backup file %s: %w", backupPath, err) + } + defer func() { + _ = file.Close() + }() + + _, err = db.Backup(file, 0) + if err != nil { + return fmt.Errorf("backup: %w", err) + } + + return nil +} + +func Restore(db *badger.DB) error { + dir := db.Opts().Dir + + backupPathStart := path.Join(dir, backupStartPath) + backupPathStop := path.Join(dir, backupStopPath) + + startStat, err := os.Stat(backupPathStart) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("stat file %s: %w", backupPathStart, err) + } + + stopStat, err := os.Stat(backupPathStop) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("stat file %s: %w", backupPathStop, err) + } + + var backupFile string + + switch { + case stopStat != nil && startStat != nil: + if stopStat.ModTime().After(startStat.ModTime()) { + backupFile = backupPathStop + } else { + backupFile = backupPathStart + } + + case stopStat != nil: + backupFile = backupPathStart + + case startStat != nil: + backupFile = backupPathStop + } + + file, err := os.OpenFile(backupFile, os.O_RDONLY, os.FileMode(0600)) + if err != nil { + return fmt.Errorf("open backup file %s: %w", backupFile, err) + } + + defer func() { + _ = file.Close() + }() + + if err := db.Load(file, 20); err != nil { + return fmt.Errorf("load backup: %w", err) + } + + return nil +} diff --git a/adapters/repository/badger/file.go b/adapters/repository/badger/file.go new file mode 100644 index 0000000..6d9512b --- /dev/null +++ b/adapters/repository/badger/file.go @@ -0,0 +1,40 @@ +package badger + +import ( + "context" + "fmt" + + "github.com/dgraph-io/badger/v4" + + "github.com/derfenix/webarchive/entity" +) + +func NewFile(db *badger.DB) *File { + return &File{db: db, prefix: []byte("file:")} +} + +type File struct { + db *badger.DB + prefix []byte +} + +func (f *File) SaveTx(_ context.Context, txn *badger.Txn, file *entity.File) error { + if f.db.IsClosed() { + return ErrDBClosed + } + + marshaled, err := marshal(file) + if err != nil { + return fmt.Errorf("marshal data: %w", err) + } + + if err := txn.Set(f.key(file), marshaled); err != nil { + return fmt.Errorf("put data: %w", err) + } + + return nil +} + +func (f *File) key(file *entity.File) []byte { + return append(f.prefix, []byte(file.ID.String())...) +} diff --git a/adapters/repository/badger/marshal.go b/adapters/repository/badger/marshal.go new file mode 100644 index 0000000..2bbcfda --- /dev/null +++ b/adapters/repository/badger/marshal.go @@ -0,0 +1,13 @@ +package badger + +import ( + "github.com/vmihailenco/msgpack/v5" +) + +func marshal(v interface{}) ([]byte, error) { + return msgpack.Marshal(v) +} + +func unmarshal(b []byte, v interface{}) error { + return msgpack.Unmarshal(b, v) +} diff --git a/adapters/repository/badger/page.go b/adapters/repository/badger/page.go new file mode 100644 index 0000000..ddb0f19 --- /dev/null +++ b/adapters/repository/badger/page.go @@ -0,0 +1,142 @@ +package badger + +import ( + "context" + "fmt" + "sort" + + "github.com/dgraph-io/badger/v4" + "github.com/google/uuid" + + "github.com/derfenix/webarchive/entity" +) + +func NewPage(db *badger.DB, file *File) (*Page, error) { + return &Page{ + db: db, + prefix: []byte("page:"), + file: file, + }, nil +} + +type Page struct { + db *badger.DB + prefix []byte + file *File +} + +func (p *Page) Save(ctx context.Context, site *entity.Page) error { + if p.db.IsClosed() { + return ErrDBClosed + } + + marshaled, err := marshal(site) + if err != nil { + return fmt.Errorf("marshal data: %w", err) + } + + if err := p.db.Update(func(txn *badger.Txn) error { + if err := txn.Set(p.key(site), marshaled); err != nil { + return fmt.Errorf("put data: %w", err) + } + + for i, result := range site.Results.Results() { + for j, file := range result.Files { + if err := p.file.SaveTx(ctx, txn, &file); err != nil { + return fmt.Errorf("save file %d (%s) for result %d: %w", j, file.ID.String(), i, err) + } + } + } + + return nil + }); err != nil { + return fmt.Errorf("update db: %w", err) + } + + return nil +} + +func (p *Page) Get(_ context.Context, id uuid.UUID) (*entity.Page, error) { + site := entity.Page{ID: id} + + err := p.db.View(func(txn *badger.Txn) error { + data, err := txn.Get(p.key(&site)) + if err != nil { + return fmt.Errorf("get data: %w", err) + } + + err = data.Value(func(val []byte) error { + if err := unmarshal(val, &site); err != nil { + return fmt.Errorf("unmarshal data: %w", err) + } + + return nil + }) + if err != nil { + return fmt.Errorf("get value: %w", err) + } + + return nil + }) + if err != nil { + return nil, fmt.Errorf("view: %w", err) + } + + return &site, nil +} + +func (p *Page) ListAll(ctx context.Context) ([]*entity.Page, error) { + pages := make([]*entity.Page, 0, 100) + + err := p.db.View(func(txn *badger.Txn) error { + iterator := txn.NewIterator(badger.DefaultIteratorOptions) + + defer iterator.Close() + + for iterator.Seek(p.prefix); iterator.ValidForPrefix(p.prefix); iterator.Next() { + if err := ctx.Err(); err != nil { + return fmt.Errorf("context canceled: %w", err) + } + + var page entity.Page + + err := iterator.Item().Value(func(val []byte) error { + if err := unmarshal(val, &page); err != nil { + return fmt.Errorf("unmarshal: %w", err) + } + + return nil + }) + + if err != nil { + return fmt.Errorf("get item: %w", err) + } + + pages = append(pages, &entity.Page{ + ID: page.ID, + URL: page.URL, + Description: page.Description, + Created: page.Created, + Formats: page.Formats, + Version: page.Version, + Status: page.Status, + }) + } + + return nil + }) + + if err != nil { + return nil, fmt.Errorf("view: %w", err) + } + + sort.Slice(pages, func(i, j int) bool { + return pages[i].Created.After(pages[j].Created) + }) + + return pages, nil +} + +func (p *Page) key(site *entity.Page) []byte { + return append(p.prefix, []byte(site.ID.String())...) +} diff --git a/adapters/repository/badger/page_test.go b/adapters/repository/badger/page_test.go new file mode 100644 index 0000000..75eb2b3 --- /dev/null +++ b/adapters/repository/badger/page_test.go @@ -0,0 +1,60 @@ +package badger + +import ( + "context" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" + + "github.com/derfenix/webarchive/entity" +) + +func TestSite(t *testing.T) { + t.Parallel() + + if testing.Short() { + t.Skip("skip db test") + } + + ctx := context.Background() + + tempDir, err := os.MkdirTemp(os.TempDir(), "badger_test") + require.NoError(t, err) + + t.Cleanup(func() { + assert.NoError(t, os.RemoveAll(tempDir)) + }) + + log := zaptest.NewLogger(t) + + db, err := NewBadger(tempDir, log.Named("db")) + require.NoError(t, err) + + siteRepo, err := NewPage(db, nil) + require.NoError(t, err) + + t.Run("base path", func(t *testing.T) { + t.Parallel() + + site := entity.NewPage("https://google.com", "Save all google", entity.FormatPDF, entity.FormatSingleFile) + site.Created = site.Created.Truncate(time.Microsecond) + + err := siteRepo.Save(ctx, site) + require.NoError(t, err) + + storedSite, err := siteRepo.Get(ctx, site.ID) + require.NoError(t, err) + + assert.Equal(t, site, storedSite) + + all, err := siteRepo.ListAll(ctx) + require.NoError(t, err) + require.Len(t, all, 1) + + assert.Equal(t, site, all[0]) + }) +} diff --git a/api/gen.go b/api/gen.go new file mode 100644 index 0000000..11fb9b7 --- /dev/null +++ b/api/gen.go @@ -0,0 +1,3 @@ +package api + +//go:generate go run github.com/ogen-go/ogen/cmd/ogen@v0.60.1 --target ./openapi -package openapi --clean openapi.yaml diff --git a/api/openapi.yaml b/api/openapi.yaml new file mode 100644 index 0000000..24b0f2e --- /dev/null +++ b/api/openapi.yaml @@ -0,0 +1,174 @@ +openapi: 3.0.1 +info: + title: Sample API + description: API description in Markdown. + version: 1.0.0 +servers: + - url: 'https://api.example.com' +paths: + /pages: + get: + operationId: getPages + summary: Get all pages + responses: + 200: + description: All pages data + content: + application/json: + schema: + $ref: '#/components/schemas/pages' + default: + $ref: '#/components/responses/undefinedError' + post: + operationId: addPage + summary: Add new page + requestBody: + content: + application/json: + schema: + type: object + properties: + url: + type: string + description: + type: string + formats: + type: array + items: + $ref: '#/components/schemas/format' + required: + - url + responses: + 201: + description: Page added + content: + application/json: + schema: + $ref: '#/components/schemas/page' + default: + $ref: '#/components/responses/undefinedError' + + /pages/{id}: + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + get: + operationId: getPage + description: Get page details + responses: + 200: + description: Page data + content: + application/json: + schema: + $ref: '#/components/schemas/pageWithResults' + 404: + description: Page not found + default: + $ref: '#/components/responses/undefinedError' + +components: + responses: + undefinedError: + description: Undefined Error + content: + application/json: + schema: + $ref: '#/components/schemas/error' + schemas: + format: + type: string + enum: + - all + - pdf + - single_page + - headers + error: + type: object + properties: + message: + type: string + localized: + type: string + required: + - message + pages: + type: array + items: + $ref: '#/components/schemas/page' + page: + type: object + properties: + id: + type: string + format: uuid + url: + type: string + created: + type: string + format: date-time + formats: + type: array + items: + $ref: '#/components/schemas/format' + status: + $ref: '#/components/schemas/status' + required: + - id + - url + - formats + - status + - created + result: + type: object + properties: + format: + $ref: '#/components/schemas/format' + error: + type: string + files: + type: array + items: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + mimetype: + type: string + size: + type: integer + format: int64 + required: + - id + - name + - mimetype + - size + required: + - format + - files + pageWithResults: + allOf: + - $ref: '#/components/schemas/page' + - type: object + properties: + results: + type: array + items: + $ref: '#/components/schemas/result' + required: + - results + status: + type: string + enum: + - new + - processing + - done + - failed + - with_errors diff --git a/api/openapi/oas_cfg_gen.go b/api/openapi/oas_cfg_gen.go new file mode 100644 index 0000000..3bbddd8 --- /dev/null +++ b/api/openapi/oas_cfg_gen.go @@ -0,0 +1,277 @@ +// Code generated by ogen, DO NOT EDIT. + +package openapi + +import ( + "net/http" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/metric/instrument" + "go.opentelemetry.io/otel/trace" + + ht "github.com/ogen-go/ogen/http" + "github.com/ogen-go/ogen/middleware" + "github.com/ogen-go/ogen/ogenerrors" + "github.com/ogen-go/ogen/otelogen" +) + +var ( + // Allocate option closure once. + clientSpanKind = trace.WithSpanKind(trace.SpanKindClient) + // Allocate option closure once. + serverSpanKind = trace.WithSpanKind(trace.SpanKindServer) +) + +type ( + optionFunc[C any] func(*C) + otelOptionFunc func(*otelConfig) +) + +type otelConfig struct { + TracerProvider trace.TracerProvider + Tracer trace.Tracer + MeterProvider metric.MeterProvider + Meter metric.Meter +} + +func (cfg *otelConfig) initOTEL() { + if cfg.TracerProvider == nil { + cfg.TracerProvider = otel.GetTracerProvider() + } + if cfg.MeterProvider == nil { + cfg.MeterProvider = metric.NewNoopMeterProvider() + } + cfg.Tracer = cfg.TracerProvider.Tracer(otelogen.Name, + trace.WithInstrumentationVersion(otelogen.SemVersion()), + ) + cfg.Meter = cfg.MeterProvider.Meter(otelogen.Name) +} + +// ErrorHandler is error handler. +type ErrorHandler = ogenerrors.ErrorHandler + +type serverConfig struct { + otelConfig + NotFound http.HandlerFunc + MethodNotAllowed func(w http.ResponseWriter, r *http.Request, allowed string) + ErrorHandler ErrorHandler + Prefix string + Middleware Middleware + MaxMultipartMemory int64 +} + +// ServerOption is server config option. +type ServerOption interface { + applyServer(*serverConfig) +} + +var _ = []ServerOption{ + (optionFunc[serverConfig])(nil), + (otelOptionFunc)(nil), +} + +func (o optionFunc[C]) applyServer(c *C) { + o(c) +} + +func (o otelOptionFunc) applyServer(c *serverConfig) { + o(&c.otelConfig) +} + +func newServerConfig(opts ...ServerOption) serverConfig { + cfg := serverConfig{ + NotFound: http.NotFound, + MethodNotAllowed: func(w http.ResponseWriter, r *http.Request, allowed string) { + w.Header().Set("Allow", allowed) + w.WriteHeader(http.StatusMethodNotAllowed) + }, + ErrorHandler: ogenerrors.DefaultErrorHandler, + Middleware: nil, + MaxMultipartMemory: 32 << 20, // 32 MB + } + for _, opt := range opts { + opt.applyServer(&cfg) + } + cfg.initOTEL() + return cfg +} + +type baseServer struct { + cfg serverConfig + requests instrument.Int64Counter + errors instrument.Int64Counter + duration instrument.Int64Histogram +} + +func (s baseServer) notFound(w http.ResponseWriter, r *http.Request) { + s.cfg.NotFound(w, r) +} + +func (s baseServer) notAllowed(w http.ResponseWriter, r *http.Request, allowed string) { + s.cfg.MethodNotAllowed(w, r, allowed) +} + +func (cfg serverConfig) baseServer() (s baseServer, err error) { + s = baseServer{cfg: cfg} + if s.requests, err = s.cfg.Meter.Int64Counter(otelogen.ServerRequestCount); err != nil { + return s, err + } + if s.errors, err = s.cfg.Meter.Int64Counter(otelogen.ServerErrorsCount); err != nil { + return s, err + } + if s.duration, err = s.cfg.Meter.Int64Histogram(otelogen.ServerDuration); err != nil { + return s, err + } + return s, nil +} + +type clientConfig struct { + otelConfig + Client ht.Client +} + +// ClientOption is client config option. +type ClientOption interface { + applyClient(*clientConfig) +} + +var _ = []ClientOption{ + (optionFunc[clientConfig])(nil), + (otelOptionFunc)(nil), +} + +func (o optionFunc[C]) applyClient(c *C) { + o(c) +} + +func (o otelOptionFunc) applyClient(c *clientConfig) { + o(&c.otelConfig) +} + +func newClientConfig(opts ...ClientOption) clientConfig { + cfg := clientConfig{ + Client: http.DefaultClient, + } + for _, opt := range opts { + opt.applyClient(&cfg) + } + cfg.initOTEL() + return cfg +} + +type baseClient struct { + cfg clientConfig + requests instrument.Int64Counter + errors instrument.Int64Counter + duration instrument.Int64Histogram +} + +func (cfg clientConfig) baseClient() (c baseClient, err error) { + c = baseClient{cfg: cfg} + if c.requests, err = c.cfg.Meter.Int64Counter(otelogen.ClientRequestCount); err != nil { + return c, err + } + if c.errors, err = c.cfg.Meter.Int64Counter(otelogen.ClientErrorsCount); err != nil { + return c, err + } + if c.duration, err = c.cfg.Meter.Int64Histogram(otelogen.ClientDuration); err != nil { + return c, err + } + return c, nil +} + +// Option is config option. +type Option interface { + ServerOption + ClientOption +} + +// WithTracerProvider specifies a tracer provider to use for creating a tracer. +// +// If none is specified, the global provider is used. +func WithTracerProvider(provider trace.TracerProvider) Option { + return otelOptionFunc(func(cfg *otelConfig) { + if provider != nil { + cfg.TracerProvider = provider + } + }) +} + +// WithMeterProvider specifies a meter provider to use for creating a meter. +// +// If none is specified, the metric.NewNoopMeterProvider is used. +func WithMeterProvider(provider metric.MeterProvider) Option { + return otelOptionFunc(func(cfg *otelConfig) { + if provider != nil { + cfg.MeterProvider = provider + } + }) +} + +// WithClient specifies http client to use. +func WithClient(client ht.Client) ClientOption { + return optionFunc[clientConfig](func(cfg *clientConfig) { + if client != nil { + cfg.Client = client + } + }) +} + +// WithNotFound specifies Not Found handler to use. +func WithNotFound(notFound http.HandlerFunc) ServerOption { + return optionFunc[serverConfig](func(cfg *serverConfig) { + if notFound != nil { + cfg.NotFound = notFound + } + }) +} + +// WithMethodNotAllowed specifies Method Not Allowed handler to use. +func WithMethodNotAllowed(methodNotAllowed func(w http.ResponseWriter, r *http.Request, allowed string)) ServerOption { + return optionFunc[serverConfig](func(cfg *serverConfig) { + if methodNotAllowed != nil { + cfg.MethodNotAllowed = methodNotAllowed + } + }) +} + +// WithErrorHandler specifies error handler to use. +func WithErrorHandler(h ErrorHandler) ServerOption { + return optionFunc[serverConfig](func(cfg *serverConfig) { + if h != nil { + cfg.ErrorHandler = h + } + }) +} + +// WithPathPrefix specifies server path prefix. +func WithPathPrefix(prefix string) ServerOption { + return optionFunc[serverConfig](func(cfg *serverConfig) { + cfg.Prefix = prefix + }) +} + +// WithMiddleware specifies middlewares to use. +func WithMiddleware(m ...Middleware) ServerOption { + return optionFunc[serverConfig](func(cfg *serverConfig) { + switch len(m) { + case 0: + cfg.Middleware = nil + case 1: + cfg.Middleware = m[0] + default: + cfg.Middleware = middleware.ChainMiddlewares(m...) + } + }) +} + +// WithMaxMultipartMemory specifies limit of memory for storing file parts. +// File parts which can't be stored in memory will be stored on disk in temporary files. +func WithMaxMultipartMemory(max int64) ServerOption { + return optionFunc[serverConfig](func(cfg *serverConfig) { + if max > 0 { + cfg.MaxMultipartMemory = max + } + }) +} diff --git a/api/openapi/oas_client_gen.go b/api/openapi/oas_client_gen.go new file mode 100644 index 0000000..912bc8c --- /dev/null +++ b/api/openapi/oas_client_gen.go @@ -0,0 +1,319 @@ +// Code generated by ogen, DO NOT EDIT. + +package openapi + +import ( + "context" + "net/url" + "strings" + "time" + + "github.com/go-faster/errors" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + + "github.com/ogen-go/ogen/conv" + ht "github.com/ogen-go/ogen/http" + "github.com/ogen-go/ogen/otelogen" + "github.com/ogen-go/ogen/uri" +) + +// Client implements OAS client. +type Client struct { + serverURL *url.URL + baseClient +} +type errorHandler interface { + NewError(ctx context.Context, err error) *ErrorStatusCode +} + +var _ Handler = struct { + errorHandler + *Client +}{} + +func trimTrailingSlashes(u *url.URL) { + u.Path = strings.TrimRight(u.Path, "/") + u.RawPath = strings.TrimRight(u.RawPath, "/") +} + +// NewClient initializes new Client defined by OAS. +func NewClient(serverURL string, opts ...ClientOption) (*Client, error) { + u, err := url.Parse(serverURL) + if err != nil { + return nil, err + } + trimTrailingSlashes(u) + + c, err := newClientConfig(opts...).baseClient() + if err != nil { + return nil, err + } + return &Client{ + serverURL: u, + baseClient: c, + }, nil +} + +type serverURLKey struct{} + +// WithServerURL sets context key to override server URL. +func WithServerURL(ctx context.Context, u *url.URL) context.Context { + return context.WithValue(ctx, serverURLKey{}, u) +} + +func (c *Client) requestURL(ctx context.Context) *url.URL { + u, ok := ctx.Value(serverURLKey{}).(*url.URL) + if !ok { + return c.serverURL + } + return u +} + +// AddPage invokes addPage operation. +// +// Add new page. +// +// POST /pages +func (c *Client) AddPage(ctx context.Context, request OptAddPageReq) (*Page, error) { + res, err := c.sendAddPage(ctx, request) + _ = res + return res, err +} + +func (c *Client) sendAddPage(ctx context.Context, request OptAddPageReq) (res *Page, err error) { + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("addPage"), + } + // Validate request before sending. + if err := func() error { + if request.Set { + if err := func() error { + if err := request.Value.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + return res, errors.Wrap(err, "validate") + } + + // Run stopwatch. + startTime := time.Now() + defer func() { + elapsedDuration := time.Since(startTime) + c.duration.Record(ctx, elapsedDuration.Microseconds(), otelAttrs...) + }() + + // Increment request counter. + c.requests.Add(ctx, 1, otelAttrs...) + + // Start a span for this request. + ctx, span := c.cfg.Tracer.Start(ctx, "AddPage", + trace.WithAttributes(otelAttrs...), + clientSpanKind, + ) + // Track stage for error reporting. + var stage string + defer func() { + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, stage) + c.errors.Add(ctx, 1, otelAttrs...) + } + span.End() + }() + + stage = "BuildURL" + u := uri.Clone(c.requestURL(ctx)) + var pathParts [1]string + pathParts[0] = "/pages" + uri.AddPathParts(u, pathParts[:]...) + + stage = "EncodeRequest" + r, err := ht.NewRequest(ctx, "POST", u, nil) + if err != nil { + return res, errors.Wrap(err, "create request") + } + if err := encodeAddPageRequest(request, r); err != nil { + return res, errors.Wrap(err, "encode request") + } + + stage = "SendRequest" + resp, err := c.cfg.Client.Do(r) + if err != nil { + return res, errors.Wrap(err, "do request") + } + defer resp.Body.Close() + + stage = "DecodeResponse" + result, err := decodeAddPageResponse(resp) + if err != nil { + return res, errors.Wrap(err, "decode response") + } + + return result, nil +} + +// GetPage invokes getPage operation. +// +// Get page details. +// +// GET /pages/{id} +func (c *Client) GetPage(ctx context.Context, params GetPageParams) (GetPageRes, error) { + res, err := c.sendGetPage(ctx, params) + _ = res + return res, err +} + +func (c *Client) sendGetPage(ctx context.Context, params GetPageParams) (res GetPageRes, err error) { + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("getPage"), + } + + // Run stopwatch. + startTime := time.Now() + defer func() { + elapsedDuration := time.Since(startTime) + c.duration.Record(ctx, elapsedDuration.Microseconds(), otelAttrs...) + }() + + // Increment request counter. + c.requests.Add(ctx, 1, otelAttrs...) + + // Start a span for this request. + ctx, span := c.cfg.Tracer.Start(ctx, "GetPage", + trace.WithAttributes(otelAttrs...), + clientSpanKind, + ) + // Track stage for error reporting. + var stage string + defer func() { + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, stage) + c.errors.Add(ctx, 1, otelAttrs...) + } + span.End() + }() + + stage = "BuildURL" + u := uri.Clone(c.requestURL(ctx)) + var pathParts [2]string + pathParts[0] = "/pages/" + { + // Encode "id" parameter. + e := uri.NewPathEncoder(uri.PathEncoderConfig{ + Param: "id", + Style: uri.PathStyleSimple, + Explode: false, + }) + if err := func() error { + return e.EncodeValue(conv.UUIDToString(params.ID)) + }(); err != nil { + return res, errors.Wrap(err, "encode path") + } + encoded, err := e.Result() + if err != nil { + return res, errors.Wrap(err, "encode path") + } + pathParts[1] = encoded + } + uri.AddPathParts(u, pathParts[:]...) + + stage = "EncodeRequest" + r, err := ht.NewRequest(ctx, "GET", u, nil) + if err != nil { + return res, errors.Wrap(err, "create request") + } + + stage = "SendRequest" + resp, err := c.cfg.Client.Do(r) + if err != nil { + return res, errors.Wrap(err, "do request") + } + defer resp.Body.Close() + + stage = "DecodeResponse" + result, err := decodeGetPageResponse(resp) + if err != nil { + return res, errors.Wrap(err, "decode response") + } + + return result, nil +} + +// GetPages invokes getPages operation. +// +// Get all pages. +// +// GET /pages +func (c *Client) GetPages(ctx context.Context) (Pages, error) { + res, err := c.sendGetPages(ctx) + _ = res + return res, err +} + +func (c *Client) sendGetPages(ctx context.Context) (res Pages, err error) { + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("getPages"), + } + + // Run stopwatch. + startTime := time.Now() + defer func() { + elapsedDuration := time.Since(startTime) + c.duration.Record(ctx, elapsedDuration.Microseconds(), otelAttrs...) + }() + + // Increment request counter. + c.requests.Add(ctx, 1, otelAttrs...) + + // Start a span for this request. + ctx, span := c.cfg.Tracer.Start(ctx, "GetPages", + trace.WithAttributes(otelAttrs...), + clientSpanKind, + ) + // Track stage for error reporting. + var stage string + defer func() { + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, stage) + c.errors.Add(ctx, 1, otelAttrs...) + } + span.End() + }() + + stage = "BuildURL" + u := uri.Clone(c.requestURL(ctx)) + var pathParts [1]string + pathParts[0] = "/pages" + uri.AddPathParts(u, pathParts[:]...) + + stage = "EncodeRequest" + r, err := ht.NewRequest(ctx, "GET", u, nil) + if err != nil { + return res, errors.Wrap(err, "create request") + } + + stage = "SendRequest" + resp, err := c.cfg.Client.Do(r) + if err != nil { + return res, errors.Wrap(err, "do request") + } + defer resp.Body.Close() + + stage = "DecodeResponse" + result, err := decodeGetPagesResponse(resp) + if err != nil { + return res, errors.Wrap(err, "decode response") + } + + return result, nil +} diff --git a/api/openapi/oas_handlers_gen.go b/api/openapi/oas_handlers_gen.go new file mode 100644 index 0000000..5676049 --- /dev/null +++ b/api/openapi/oas_handlers_gen.go @@ -0,0 +1,331 @@ +// Code generated by ogen, DO NOT EDIT. + +package openapi + +import ( + "context" + "net/http" + "time" + + "github.com/go-faster/errors" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + semconv "go.opentelemetry.io/otel/semconv/v1.17.0" + "go.opentelemetry.io/otel/trace" + + ht "github.com/ogen-go/ogen/http" + "github.com/ogen-go/ogen/middleware" + "github.com/ogen-go/ogen/ogenerrors" + "github.com/ogen-go/ogen/otelogen" +) + +// handleAddPageRequest handles addPage operation. +// +// Add new page. +// +// POST /pages +func (s *Server) handleAddPageRequest(args [0]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) { + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("addPage"), + semconv.HTTPMethodKey.String("POST"), + semconv.HTTPRouteKey.String("/pages"), + } + + // Start a span for this request. + ctx, span := s.cfg.Tracer.Start(r.Context(), "AddPage", + trace.WithAttributes(otelAttrs...), + serverSpanKind, + ) + defer span.End() + + // Run stopwatch. + startTime := time.Now() + defer func() { + elapsedDuration := time.Since(startTime) + s.duration.Record(ctx, elapsedDuration.Microseconds(), otelAttrs...) + }() + + // Increment request counter. + s.requests.Add(ctx, 1, otelAttrs...) + + var ( + recordError = func(stage string, err error) { + span.RecordError(err) + span.SetStatus(codes.Error, stage) + s.errors.Add(ctx, 1, otelAttrs...) + } + err error + opErrContext = ogenerrors.OperationContext{ + Name: "AddPage", + ID: "addPage", + } + ) + request, close, err := s.decodeAddPageRequest(r) + if err != nil { + err = &ogenerrors.DecodeRequestError{ + OperationContext: opErrContext, + Err: err, + } + recordError("DecodeRequest", err) + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + defer func() { + if err := close(); err != nil { + recordError("CloseRequest", err) + } + }() + + var response *Page + if m := s.cfg.Middleware; m != nil { + mreq := middleware.Request{ + Context: ctx, + OperationName: "AddPage", + OperationID: "addPage", + Body: request, + Params: middleware.Parameters{}, + Raw: r, + } + + type ( + Request = OptAddPageReq + Params = struct{} + Response = *Page + ) + response, err = middleware.HookMiddleware[ + Request, + Params, + Response, + ]( + m, + mreq, + nil, + func(ctx context.Context, request Request, params Params) (response Response, err error) { + response, err = s.h.AddPage(ctx, request) + return response, err + }, + ) + } else { + response, err = s.h.AddPage(ctx, request) + } + if err != nil { + recordError("Internal", err) + if errRes, ok := errors.Into[*ErrorStatusCode](err); ok { + encodeErrorResponse(errRes, w, span) + return + } + if errors.Is(err, ht.ErrNotImplemented) { + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + encodeErrorResponse(s.h.NewError(ctx, err), w, span) + return + } + + if err := encodeAddPageResponse(response, w, span); err != nil { + recordError("EncodeResponse", err) + s.cfg.ErrorHandler(ctx, w, r, err) + return + } +} + +// handleGetPageRequest handles getPage operation. +// +// Get page details. +// +// GET /pages/{id} +func (s *Server) handleGetPageRequest(args [1]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) { + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("getPage"), + semconv.HTTPMethodKey.String("GET"), + semconv.HTTPRouteKey.String("/pages/{id}"), + } + + // Start a span for this request. + ctx, span := s.cfg.Tracer.Start(r.Context(), "GetPage", + trace.WithAttributes(otelAttrs...), + serverSpanKind, + ) + defer span.End() + + // Run stopwatch. + startTime := time.Now() + defer func() { + elapsedDuration := time.Since(startTime) + s.duration.Record(ctx, elapsedDuration.Microseconds(), otelAttrs...) + }() + + // Increment request counter. + s.requests.Add(ctx, 1, otelAttrs...) + + var ( + recordError = func(stage string, err error) { + span.RecordError(err) + span.SetStatus(codes.Error, stage) + s.errors.Add(ctx, 1, otelAttrs...) + } + err error + opErrContext = ogenerrors.OperationContext{ + Name: "GetPage", + ID: "getPage", + } + ) + params, err := decodeGetPageParams(args, argsEscaped, r) + if err != nil { + err = &ogenerrors.DecodeParamsError{ + OperationContext: opErrContext, + Err: err, + } + recordError("DecodeParams", err) + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + + var response GetPageRes + if m := s.cfg.Middleware; m != nil { + mreq := middleware.Request{ + Context: ctx, + OperationName: "GetPage", + OperationID: "getPage", + Body: nil, + Params: middleware.Parameters{ + { + Name: "id", + In: "path", + }: params.ID, + }, + Raw: r, + } + + type ( + Request = struct{} + Params = GetPageParams + Response = GetPageRes + ) + response, err = middleware.HookMiddleware[ + Request, + Params, + Response, + ]( + m, + mreq, + unpackGetPageParams, + func(ctx context.Context, request Request, params Params) (response Response, err error) { + response, err = s.h.GetPage(ctx, params) + return response, err + }, + ) + } else { + response, err = s.h.GetPage(ctx, params) + } + if err != nil { + recordError("Internal", err) + if errRes, ok := errors.Into[*ErrorStatusCode](err); ok { + encodeErrorResponse(errRes, w, span) + return + } + if errors.Is(err, ht.ErrNotImplemented) { + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + encodeErrorResponse(s.h.NewError(ctx, err), w, span) + return + } + + if err := encodeGetPageResponse(response, w, span); err != nil { + recordError("EncodeResponse", err) + s.cfg.ErrorHandler(ctx, w, r, err) + return + } +} + +// handleGetPagesRequest handles getPages operation. +// +// Get all pages. +// +// GET /pages +func (s *Server) handleGetPagesRequest(args [0]string, argsEscaped bool, w http.ResponseWriter, r *http.Request) { + otelAttrs := []attribute.KeyValue{ + otelogen.OperationID("getPages"), + semconv.HTTPMethodKey.String("GET"), + semconv.HTTPRouteKey.String("/pages"), + } + + // Start a span for this request. + ctx, span := s.cfg.Tracer.Start(r.Context(), "GetPages", + trace.WithAttributes(otelAttrs...), + serverSpanKind, + ) + defer span.End() + + // Run stopwatch. + startTime := time.Now() + defer func() { + elapsedDuration := time.Since(startTime) + s.duration.Record(ctx, elapsedDuration.Microseconds(), otelAttrs...) + }() + + // Increment request counter. + s.requests.Add(ctx, 1, otelAttrs...) + + var ( + recordError = func(stage string, err error) { + span.RecordError(err) + span.SetStatus(codes.Error, stage) + s.errors.Add(ctx, 1, otelAttrs...) + } + err error + ) + + var response Pages + if m := s.cfg.Middleware; m != nil { + mreq := middleware.Request{ + Context: ctx, + OperationName: "GetPages", + OperationID: "getPages", + Body: nil, + Params: middleware.Parameters{}, + Raw: r, + } + + type ( + Request = struct{} + Params = struct{} + Response = Pages + ) + response, err = middleware.HookMiddleware[ + Request, + Params, + Response, + ]( + m, + mreq, + nil, + func(ctx context.Context, request Request, params Params) (response Response, err error) { + response, err = s.h.GetPages(ctx) + return response, err + }, + ) + } else { + response, err = s.h.GetPages(ctx) + } + if err != nil { + recordError("Internal", err) + if errRes, ok := errors.Into[*ErrorStatusCode](err); ok { + encodeErrorResponse(errRes, w, span) + return + } + if errors.Is(err, ht.ErrNotImplemented) { + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + encodeErrorResponse(s.h.NewError(ctx, err), w, span) + return + } + + if err := encodeGetPagesResponse(response, w, span); err != nil { + recordError("EncodeResponse", err) + s.cfg.ErrorHandler(ctx, w, r, err) + return + } +} diff --git a/api/openapi/oas_interfaces_gen.go b/api/openapi/oas_interfaces_gen.go new file mode 100644 index 0000000..5bbe0d4 --- /dev/null +++ b/api/openapi/oas_interfaces_gen.go @@ -0,0 +1,6 @@ +// Code generated by ogen, DO NOT EDIT. +package openapi + +type GetPageRes interface { + getPageRes() +} diff --git a/api/openapi/oas_json_gen.go b/api/openapi/oas_json_gen.go new file mode 100644 index 0000000..8406603 --- /dev/null +++ b/api/openapi/oas_json_gen.go @@ -0,0 +1,1151 @@ +// Code generated by ogen, DO NOT EDIT. + +package openapi + +import ( + "math/bits" + "strconv" + + "github.com/go-faster/errors" + "github.com/go-faster/jx" + + "github.com/ogen-go/ogen/json" + "github.com/ogen-go/ogen/validate" +) + +// Encode implements json.Marshaler. +func (s *AddPageReq) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *AddPageReq) encodeFields(e *jx.Encoder) { + { + + e.FieldStart("url") + e.Str(s.URL) + } + { + if s.Description.Set { + e.FieldStart("description") + s.Description.Encode(e) + } + } + { + if s.Formats != nil { + e.FieldStart("formats") + e.ArrStart() + for _, elem := range s.Formats { + elem.Encode(e) + } + e.ArrEnd() + } + } +} + +var jsonFieldsNameOfAddPageReq = [3]string{ + 0: "url", + 1: "description", + 2: "formats", +} + +// Decode decodes AddPageReq from json. +func (s *AddPageReq) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode AddPageReq to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "url": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := d.Str() + s.URL = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"url\"") + } + case "description": + if err := func() error { + s.Description.Reset() + if err := s.Description.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"description\"") + } + case "formats": + if err := func() error { + s.Formats = make([]Format, 0) + if err := d.Arr(func(d *jx.Decoder) error { + var elem Format + if err := elem.Decode(d); err != nil { + return err + } + s.Formats = append(s.Formats, elem) + return nil + }); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"formats\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode AddPageReq") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00000001, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfAddPageReq) { + name = jsonFieldsNameOfAddPageReq[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *AddPageReq) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *AddPageReq) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *Error) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *Error) encodeFields(e *jx.Encoder) { + { + + e.FieldStart("message") + e.Str(s.Message) + } + { + if s.Localized.Set { + e.FieldStart("localized") + s.Localized.Encode(e) + } + } +} + +var jsonFieldsNameOfError = [2]string{ + 0: "message", + 1: "localized", +} + +// Decode decodes Error from json. +func (s *Error) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode Error to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "message": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := d.Str() + s.Message = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"message\"") + } + case "localized": + if err := func() error { + s.Localized.Reset() + if err := s.Localized.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"localized\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode Error") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00000001, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfError) { + name = jsonFieldsNameOfError[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *Error) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *Error) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes Format as json. +func (s Format) Encode(e *jx.Encoder) { + e.Str(string(s)) +} + +// Decode decodes Format from json. +func (s *Format) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode Format to nil") + } + v, err := d.StrBytes() + if err != nil { + return err + } + // Try to use constant string. + switch Format(v) { + case FormatAll: + *s = FormatAll + case FormatPdf: + *s = FormatPdf + case FormatSinglePage: + *s = FormatSinglePage + case FormatHeaders: + *s = FormatHeaders + default: + *s = Format(v) + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s Format) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *Format) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes AddPageReq as json. +func (o OptAddPageReq) Encode(e *jx.Encoder) { + if !o.Set { + return + } + o.Value.Encode(e) +} + +// Decode decodes AddPageReq from json. +func (o *OptAddPageReq) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptAddPageReq to nil") + } + o.Set = true + if err := o.Value.Decode(d); err != nil { + return err + } + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptAddPageReq) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptAddPageReq) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes string as json. +func (o OptString) Encode(e *jx.Encoder) { + if !o.Set { + return + } + e.Str(string(o.Value)) +} + +// Decode decodes string from json. +func (o *OptString) Decode(d *jx.Decoder) error { + if o == nil { + return errors.New("invalid: unable to decode OptString to nil") + } + o.Set = true + v, err := d.Str() + if err != nil { + return err + } + o.Value = string(v) + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s OptString) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *OptString) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *Page) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *Page) encodeFields(e *jx.Encoder) { + { + + e.FieldStart("id") + json.EncodeUUID(e, s.ID) + } + { + + e.FieldStart("url") + e.Str(s.URL) + } + { + + e.FieldStart("created") + json.EncodeDateTime(e, s.Created) + } + { + + e.FieldStart("formats") + e.ArrStart() + for _, elem := range s.Formats { + elem.Encode(e) + } + e.ArrEnd() + } + { + + e.FieldStart("status") + s.Status.Encode(e) + } +} + +var jsonFieldsNameOfPage = [5]string{ + 0: "id", + 1: "url", + 2: "created", + 3: "formats", + 4: "status", +} + +// Decode decodes Page from json. +func (s *Page) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode Page to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "id": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := json.DecodeUUID(d) + s.ID = v + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"id\"") + } + case "url": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + v, err := d.Str() + s.URL = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"url\"") + } + case "created": + requiredBitSet[0] |= 1 << 2 + if err := func() error { + v, err := json.DecodeDateTime(d) + s.Created = v + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"created\"") + } + case "formats": + requiredBitSet[0] |= 1 << 3 + if err := func() error { + s.Formats = make([]Format, 0) + if err := d.Arr(func(d *jx.Decoder) error { + var elem Format + if err := elem.Decode(d); err != nil { + return err + } + s.Formats = append(s.Formats, elem) + return nil + }); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"formats\"") + } + case "status": + requiredBitSet[0] |= 1 << 4 + if err := func() error { + if err := s.Status.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"status\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode Page") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00011111, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfPage) { + name = jsonFieldsNameOfPage[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *Page) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *Page) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *PageWithResults) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *PageWithResults) encodeFields(e *jx.Encoder) { + { + + e.FieldStart("id") + json.EncodeUUID(e, s.ID) + } + { + + e.FieldStart("url") + e.Str(s.URL) + } + { + + e.FieldStart("created") + json.EncodeDateTime(e, s.Created) + } + { + + e.FieldStart("formats") + e.ArrStart() + for _, elem := range s.Formats { + elem.Encode(e) + } + e.ArrEnd() + } + { + + e.FieldStart("status") + s.Status.Encode(e) + } + { + + e.FieldStart("results") + e.ArrStart() + for _, elem := range s.Results { + elem.Encode(e) + } + e.ArrEnd() + } +} + +var jsonFieldsNameOfPageWithResults = [6]string{ + 0: "id", + 1: "url", + 2: "created", + 3: "formats", + 4: "status", + 5: "results", +} + +// Decode decodes PageWithResults from json. +func (s *PageWithResults) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode PageWithResults to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "id": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := json.DecodeUUID(d) + s.ID = v + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"id\"") + } + case "url": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + v, err := d.Str() + s.URL = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"url\"") + } + case "created": + requiredBitSet[0] |= 1 << 2 + if err := func() error { + v, err := json.DecodeDateTime(d) + s.Created = v + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"created\"") + } + case "formats": + requiredBitSet[0] |= 1 << 3 + if err := func() error { + s.Formats = make([]Format, 0) + if err := d.Arr(func(d *jx.Decoder) error { + var elem Format + if err := elem.Decode(d); err != nil { + return err + } + s.Formats = append(s.Formats, elem) + return nil + }); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"formats\"") + } + case "status": + requiredBitSet[0] |= 1 << 4 + if err := func() error { + if err := s.Status.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"status\"") + } + case "results": + requiredBitSet[0] |= 1 << 5 + if err := func() error { + s.Results = make([]Result, 0) + if err := d.Arr(func(d *jx.Decoder) error { + var elem Result + if err := elem.Decode(d); err != nil { + return err + } + s.Results = append(s.Results, elem) + return nil + }); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"results\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode PageWithResults") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00111111, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfPageWithResults) { + name = jsonFieldsNameOfPageWithResults[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *PageWithResults) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *PageWithResults) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes Pages as json. +func (s Pages) Encode(e *jx.Encoder) { + unwrapped := []Page(s) + + e.ArrStart() + for _, elem := range unwrapped { + elem.Encode(e) + } + e.ArrEnd() +} + +// Decode decodes Pages from json. +func (s *Pages) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode Pages to nil") + } + var unwrapped []Page + if err := func() error { + unwrapped = make([]Page, 0) + if err := d.Arr(func(d *jx.Decoder) error { + var elem Page + if err := elem.Decode(d); err != nil { + return err + } + unwrapped = append(unwrapped, elem) + return nil + }); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "alias") + } + *s = Pages(unwrapped) + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s Pages) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *Pages) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *Result) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *Result) encodeFields(e *jx.Encoder) { + { + + e.FieldStart("format") + s.Format.Encode(e) + } + { + if s.Error.Set { + e.FieldStart("error") + s.Error.Encode(e) + } + } + { + + e.FieldStart("files") + e.ArrStart() + for _, elem := range s.Files { + elem.Encode(e) + } + e.ArrEnd() + } +} + +var jsonFieldsNameOfResult = [3]string{ + 0: "format", + 1: "error", + 2: "files", +} + +// Decode decodes Result from json. +func (s *Result) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode Result to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "format": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + if err := s.Format.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"format\"") + } + case "error": + if err := func() error { + s.Error.Reset() + if err := s.Error.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"error\"") + } + case "files": + requiredBitSet[0] |= 1 << 2 + if err := func() error { + s.Files = make([]ResultFilesItem, 0) + if err := d.Arr(func(d *jx.Decoder) error { + var elem ResultFilesItem + if err := elem.Decode(d); err != nil { + return err + } + s.Files = append(s.Files, elem) + return nil + }); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"files\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode Result") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00000101, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfResult) { + name = jsonFieldsNameOfResult[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *Result) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *Result) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode implements json.Marshaler. +func (s *ResultFilesItem) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *ResultFilesItem) encodeFields(e *jx.Encoder) { + { + + e.FieldStart("id") + json.EncodeUUID(e, s.ID) + } + { + + e.FieldStart("name") + e.Str(s.Name) + } + { + + e.FieldStart("mimetype") + e.Str(s.Mimetype) + } + { + + e.FieldStart("size") + e.Int64(s.Size) + } +} + +var jsonFieldsNameOfResultFilesItem = [4]string{ + 0: "id", + 1: "name", + 2: "mimetype", + 3: "size", +} + +// Decode decodes ResultFilesItem from json. +func (s *ResultFilesItem) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode ResultFilesItem to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "id": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := json.DecodeUUID(d) + s.ID = v + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"id\"") + } + case "name": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + v, err := d.Str() + s.Name = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"name\"") + } + case "mimetype": + requiredBitSet[0] |= 1 << 2 + if err := func() error { + v, err := d.Str() + s.Mimetype = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"mimetype\"") + } + case "size": + requiredBitSet[0] |= 1 << 3 + if err := func() error { + v, err := d.Int64() + s.Size = int64(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"size\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode ResultFilesItem") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00001111, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfResultFilesItem) { + name = jsonFieldsNameOfResultFilesItem[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *ResultFilesItem) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *ResultFilesItem) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + +// Encode encodes Status as json. +func (s Status) Encode(e *jx.Encoder) { + e.Str(string(s)) +} + +// Decode decodes Status from json. +func (s *Status) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode Status to nil") + } + v, err := d.StrBytes() + if err != nil { + return err + } + // Try to use constant string. + switch Status(v) { + case StatusNew: + *s = StatusNew + case StatusProcessing: + *s = StatusProcessing + case StatusDone: + *s = StatusDone + case StatusFailed: + *s = StatusFailed + case StatusWithErrors: + *s = StatusWithErrors + default: + *s = Status(v) + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s Status) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *Status) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} diff --git a/api/openapi/oas_middleware_gen.go b/api/openapi/oas_middleware_gen.go new file mode 100644 index 0000000..083fe0e --- /dev/null +++ b/api/openapi/oas_middleware_gen.go @@ -0,0 +1,10 @@ +// Code generated by ogen, DO NOT EDIT. + +package openapi + +import ( + "github.com/ogen-go/ogen/middleware" +) + +// Middleware is middleware type. +type Middleware = middleware.Middleware diff --git a/api/openapi/oas_parameters_gen.go b/api/openapi/oas_parameters_gen.go new file mode 100644 index 0000000..a0504da --- /dev/null +++ b/api/openapi/oas_parameters_gen.go @@ -0,0 +1,82 @@ +// Code generated by ogen, DO NOT EDIT. + +package openapi + +import ( + "net/http" + "net/url" + + "github.com/go-faster/errors" + "github.com/google/uuid" + + "github.com/ogen-go/ogen/conv" + "github.com/ogen-go/ogen/middleware" + "github.com/ogen-go/ogen/ogenerrors" + "github.com/ogen-go/ogen/uri" + "github.com/ogen-go/ogen/validate" +) + +// GetPageParams is parameters of getPage operation. +type GetPageParams struct { + ID uuid.UUID +} + +func unpackGetPageParams(packed middleware.Parameters) (params GetPageParams) { + { + key := middleware.ParameterKey{ + Name: "id", + In: "path", + } + params.ID = packed[key].(uuid.UUID) + } + return params +} + +func decodeGetPageParams(args [1]string, argsEscaped bool, r *http.Request) (params GetPageParams, _ error) { + // Decode path: id. + if err := func() error { + param := args[0] + if argsEscaped { + unescaped, err := url.PathUnescape(args[0]) + if err != nil { + return errors.Wrap(err, "unescape path") + } + param = unescaped + } + if len(param) > 0 { + d := uri.NewPathDecoder(uri.PathDecoderConfig{ + Param: "id", + Value: param, + Style: uri.PathStyleSimple, + Explode: false, + }) + + if err := func() error { + val, err := d.DecodeValue() + if err != nil { + return err + } + + c, err := conv.ToUUID(val) + if err != nil { + return err + } + + params.ID = c + return nil + }(); err != nil { + return err + } + } else { + return validate.ErrFieldRequired + } + return nil + }(); err != nil { + return params, &ogenerrors.DecodeParamError{ + Name: "id", + In: "path", + Err: err, + } + } + return params, nil +} diff --git a/api/openapi/oas_request_decoders_gen.go b/api/openapi/oas_request_decoders_gen.go new file mode 100644 index 0000000..bdd344b --- /dev/null +++ b/api/openapi/oas_request_decoders_gen.go @@ -0,0 +1,98 @@ +// Code generated by ogen, DO NOT EDIT. + +package openapi + +import ( + "io" + "mime" + "net/http" + + "github.com/go-faster/errors" + "github.com/go-faster/jx" + "go.uber.org/multierr" + + "github.com/ogen-go/ogen/ogenerrors" + "github.com/ogen-go/ogen/validate" +) + +func (s *Server) decodeAddPageRequest(r *http.Request) ( + req OptAddPageReq, + close func() error, + rerr error, +) { + var closers []func() error + close = func() error { + var merr error + // Close in reverse order, to match defer behavior. + for i := len(closers) - 1; i >= 0; i-- { + c := closers[i] + merr = multierr.Append(merr, c()) + } + return merr + } + defer func() { + if rerr != nil { + rerr = multierr.Append(rerr, close()) + } + }() + if _, ok := r.Header["Content-Type"]; !ok && r.ContentLength == 0 { + return req, close, nil + } + ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil { + return req, close, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + if r.ContentLength == 0 { + return req, close, nil + } + buf, err := io.ReadAll(r.Body) + if err != nil { + return req, close, err + } + + if len(buf) == 0 { + return req, close, nil + } + + d := jx.DecodeBytes(buf) + + var request OptAddPageReq + if err := func() error { + request.Reset() + if err := request.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return req, close, err + } + if err := func() error { + if request.Set { + if err := func() error { + if err := request.Value.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + return err + } + } + return nil + }(); err != nil { + return req, close, errors.Wrap(err, "validate") + } + return request, close, nil + default: + return req, close, validate.InvalidContentType(ct) + } +} diff --git a/api/openapi/oas_request_encoders_gen.go b/api/openapi/oas_request_encoders_gen.go new file mode 100644 index 0000000..f3f6f7c --- /dev/null +++ b/api/openapi/oas_request_encoders_gen.go @@ -0,0 +1,32 @@ +// Code generated by ogen, DO NOT EDIT. + +package openapi + +import ( + "bytes" + "net/http" + + "github.com/go-faster/jx" + + ht "github.com/ogen-go/ogen/http" +) + +func encodeAddPageRequest( + req OptAddPageReq, + r *http.Request, +) error { + const contentType = "application/json" + if !req.Set { + // Keep request with empty body if value is not set. + return nil + } + e := jx.GetEncoder() + { + if req.Set { + req.Encode(e) + } + } + encoded := e.Bytes() + ht.SetBody(r, bytes.NewReader(encoded), contentType) + return nil +} diff --git a/api/openapi/oas_response_decoders_gen.go b/api/openapi/oas_response_decoders_gen.go new file mode 100644 index 0000000..b50c296 --- /dev/null +++ b/api/openapi/oas_response_decoders_gen.go @@ -0,0 +1,267 @@ +// Code generated by ogen, DO NOT EDIT. + +package openapi + +import ( + "io" + "mime" + "net/http" + + "github.com/go-faster/errors" + "github.com/go-faster/jx" + + "github.com/ogen-go/ogen/ogenerrors" + "github.com/ogen-go/ogen/validate" +) + +func decodeAddPageResponse(resp *http.Response) (res *Page, err error) { + switch resp.StatusCode { + case 201: + // Code 201. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response Page + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + return &response, nil + default: + return res, validate.InvalidContentType(ct) + } + } + // Convenient error response. + defRes, err := func() (res *ErrorStatusCode, err error) { + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response Error + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + return &ErrorStatusCode{ + StatusCode: resp.StatusCode, + Response: response, + }, nil + default: + return res, validate.InvalidContentType(ct) + } + }() + if err != nil { + return res, errors.Wrap(err, "default") + } + return res, errors.Wrap(defRes, "error") +} + +func decodeGetPageResponse(resp *http.Response) (res GetPageRes, err error) { + switch resp.StatusCode { + case 200: + // Code 200. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response PageWithResults + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + return &response, nil + default: + return res, validate.InvalidContentType(ct) + } + case 404: + // Code 404. + return &GetPageNotFound{}, nil + } + // Convenient error response. + defRes, err := func() (res *ErrorStatusCode, err error) { + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response Error + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + return &ErrorStatusCode{ + StatusCode: resp.StatusCode, + Response: response, + }, nil + default: + return res, validate.InvalidContentType(ct) + } + }() + if err != nil { + return res, errors.Wrap(err, "default") + } + return res, errors.Wrap(defRes, "error") +} + +func decodeGetPagesResponse(resp *http.Response) (res Pages, err error) { + switch resp.StatusCode { + case 200: + // Code 200. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response Pages + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + return response, nil + default: + return res, validate.InvalidContentType(ct) + } + } + // Convenient error response. + defRes, err := func() (res *ErrorStatusCode, err error) { + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + buf, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + d := jx.DecodeBytes(buf) + + var response Error + if err := func() error { + if err := response.Decode(d); err != nil { + return err + } + if err := d.Skip(); err != io.EOF { + return errors.New("unexpected trailing data") + } + return nil + }(); err != nil { + err = &ogenerrors.DecodeBodyError{ + ContentType: ct, + Body: buf, + Err: err, + } + return res, err + } + return &ErrorStatusCode{ + StatusCode: resp.StatusCode, + Response: response, + }, nil + default: + return res, validate.InvalidContentType(ct) + } + }() + if err != nil { + return res, errors.Wrap(err, "default") + } + return res, errors.Wrap(defRes, "error") +} diff --git a/api/openapi/oas_response_encoders_gen.go b/api/openapi/oas_response_encoders_gen.go new file mode 100644 index 0000000..54265cd --- /dev/null +++ b/api/openapi/oas_response_encoders_gen.go @@ -0,0 +1,87 @@ +// Code generated by ogen, DO NOT EDIT. + +package openapi + +import ( + "net/http" + + "github.com/go-faster/errors" + "github.com/go-faster/jx" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +func encodeAddPageResponse(response *Page, w http.ResponseWriter, span trace.Span) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(201) + span.SetStatus(codes.Ok, http.StatusText(201)) + + e := jx.GetEncoder() + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + return nil +} + +func encodeGetPageResponse(response GetPageRes, w http.ResponseWriter, span trace.Span) error { + switch response := response.(type) { + case *PageWithResults: + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + span.SetStatus(codes.Ok, http.StatusText(200)) + + e := jx.GetEncoder() + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + return nil + + case *GetPageNotFound: + w.WriteHeader(404) + span.SetStatus(codes.Error, http.StatusText(404)) + + return nil + + default: + return errors.Errorf("unexpected response type: %T", response) + } +} + +func encodeGetPagesResponse(response Pages, w http.ResponseWriter, span trace.Span) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + span.SetStatus(codes.Ok, http.StatusText(200)) + + e := jx.GetEncoder() + response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + return nil +} + +func encodeErrorResponse(response *ErrorStatusCode, w http.ResponseWriter, span trace.Span) error { + w.Header().Set("Content-Type", "application/json") + code := response.StatusCode + if code == 0 { + // Set default status code. + code = http.StatusOK + } + w.WriteHeader(code) + st := http.StatusText(code) + if code >= http.StatusBadRequest { + span.SetStatus(codes.Error, st) + } else { + span.SetStatus(codes.Ok, st) + } + + e := jx.GetEncoder() + response.Response.Encode(e) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + return nil + +} diff --git a/api/openapi/oas_router_gen.go b/api/openapi/oas_router_gen.go new file mode 100644 index 0000000..04d5d8c --- /dev/null +++ b/api/openapi/oas_router_gen.go @@ -0,0 +1,220 @@ +// Code generated by ogen, DO NOT EDIT. + +package openapi + +import ( + "net/http" + "net/url" + "strings" + + "github.com/ogen-go/ogen/uri" +) + +// ServeHTTP serves http request as defined by OpenAPI v3 specification, +// calling handler that matches the path or returning not found error. +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + elem := r.URL.Path + elemIsEscaped := false + if rawPath := r.URL.RawPath; rawPath != "" { + if normalized, ok := uri.NormalizeEscapedPath(rawPath); ok { + elem = normalized + elemIsEscaped = strings.ContainsRune(elem, '%') + } + } + if prefix := s.cfg.Prefix; len(prefix) > 0 { + if strings.HasPrefix(elem, prefix) { + // Cut prefix from the path. + elem = strings.TrimPrefix(elem, prefix) + } else { + // Prefix doesn't match. + s.notFound(w, r) + return + } + } + if len(elem) == 0 { + s.notFound(w, r) + return + } + args := [1]string{} + + // Static code generated router with unwrapped path search. + switch { + default: + if len(elem) == 0 { + break + } + switch elem[0] { + case '/': // Prefix: "/pages" + if l := len("/pages"); len(elem) >= l && elem[0:l] == "/pages" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + switch r.Method { + case "GET": + s.handleGetPagesRequest([0]string{}, elemIsEscaped, w, r) + case "POST": + s.handleAddPageRequest([0]string{}, elemIsEscaped, w, r) + default: + s.notAllowed(w, r, "GET,POST") + } + + return + } + switch elem[0] { + case '/': // Prefix: "/" + if l := len("/"); len(elem) >= l && elem[0:l] == "/" { + elem = elem[l:] + } else { + break + } + + // Param: "id" + // Leaf parameter + args[0] = elem + elem = "" + + if len(elem) == 0 { + // Leaf node. + switch r.Method { + case "GET": + s.handleGetPageRequest([1]string{ + args[0], + }, elemIsEscaped, w, r) + default: + s.notAllowed(w, r, "GET") + } + + return + } + } + } + } + s.notFound(w, r) +} + +// Route is route object. +type Route struct { + name string + operationID string + pathPattern string + count int + args [1]string +} + +// Name returns ogen operation name. +// +// It is guaranteed to be unique and not empty. +func (r Route) Name() string { + return r.name +} + +// OperationID returns OpenAPI operationId. +func (r Route) OperationID() string { + return r.operationID +} + +// PathPattern returns OpenAPI path. +func (r Route) PathPattern() string { + return r.pathPattern +} + +// Args returns parsed arguments. +func (r Route) Args() []string { + return r.args[:r.count] +} + +// FindRoute finds Route for given method and path. +// +// Note: this method does not unescape path or handle reserved characters in path properly. Use FindPath instead. +func (s *Server) FindRoute(method, path string) (Route, bool) { + return s.FindPath(method, &url.URL{Path: path}) +} + +// FindPath finds Route for given method and URL. +func (s *Server) FindPath(method string, u *url.URL) (r Route, _ bool) { + var ( + elem = u.Path + args = r.args + ) + if rawPath := u.RawPath; rawPath != "" { + if normalized, ok := uri.NormalizeEscapedPath(rawPath); ok { + elem = normalized + } + defer func() { + for i, arg := range r.args[:r.count] { + if unescaped, err := url.PathUnescape(arg); err == nil { + r.args[i] = unescaped + } + } + }() + } + + // Static code generated router with unwrapped path search. + switch { + default: + if len(elem) == 0 { + break + } + switch elem[0] { + case '/': // Prefix: "/pages" + if l := len("/pages"); len(elem) >= l && elem[0:l] == "/pages" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + switch method { + case "GET": + r.name = "GetPages" + r.operationID = "getPages" + r.pathPattern = "/pages" + r.args = args + r.count = 0 + return r, true + case "POST": + r.name = "AddPage" + r.operationID = "addPage" + r.pathPattern = "/pages" + r.args = args + r.count = 0 + return r, true + default: + return + } + } + switch elem[0] { + case '/': // Prefix: "/" + if l := len("/"); len(elem) >= l && elem[0:l] == "/" { + elem = elem[l:] + } else { + break + } + + // Param: "id" + // Leaf parameter + args[0] = elem + elem = "" + + if len(elem) == 0 { + switch method { + case "GET": + // Leaf: GetPage + r.name = "GetPage" + r.operationID = "getPage" + r.pathPattern = "/pages/{id}" + r.args = args + r.count = 1 + return r, true + default: + return + } + } + } + } + } + return r, false +} diff --git a/api/openapi/oas_schemas_gen.go b/api/openapi/oas_schemas_gen.go new file mode 100644 index 0000000..2cda604 --- /dev/null +++ b/api/openapi/oas_schemas_gen.go @@ -0,0 +1,516 @@ +// Code generated by ogen, DO NOT EDIT. + +package openapi + +import ( + "fmt" + "time" + + "github.com/go-faster/errors" + "github.com/google/uuid" +) + +func (s *ErrorStatusCode) Error() string { + return fmt.Sprintf("code %d: %+v", s.StatusCode, s.Response) +} + +type AddPageReq struct { + URL string `json:"url"` + Description OptString `json:"description"` + Formats []Format `json:"formats"` +} + +// GetURL returns the value of URL. +func (s *AddPageReq) GetURL() string { + return s.URL +} + +// GetDescription returns the value of Description. +func (s *AddPageReq) GetDescription() OptString { + return s.Description +} + +// GetFormats returns the value of Formats. +func (s *AddPageReq) GetFormats() []Format { + return s.Formats +} + +// SetURL sets the value of URL. +func (s *AddPageReq) SetURL(val string) { + s.URL = val +} + +// SetDescription sets the value of Description. +func (s *AddPageReq) SetDescription(val OptString) { + s.Description = val +} + +// SetFormats sets the value of Formats. +func (s *AddPageReq) SetFormats(val []Format) { + s.Formats = val +} + +// Ref: #/components/schemas/error +type Error struct { + Message string `json:"message"` + Localized OptString `json:"localized"` +} + +// GetMessage returns the value of Message. +func (s *Error) GetMessage() string { + return s.Message +} + +// GetLocalized returns the value of Localized. +func (s *Error) GetLocalized() OptString { + return s.Localized +} + +// SetMessage sets the value of Message. +func (s *Error) SetMessage(val string) { + s.Message = val +} + +// SetLocalized sets the value of Localized. +func (s *Error) SetLocalized(val OptString) { + s.Localized = val +} + +// ErrorStatusCode wraps Error with StatusCode. +type ErrorStatusCode struct { + StatusCode int + Response Error +} + +// GetStatusCode returns the value of StatusCode. +func (s *ErrorStatusCode) GetStatusCode() int { + return s.StatusCode +} + +// GetResponse returns the value of Response. +func (s *ErrorStatusCode) GetResponse() Error { + return s.Response +} + +// SetStatusCode sets the value of StatusCode. +func (s *ErrorStatusCode) SetStatusCode(val int) { + s.StatusCode = val +} + +// SetResponse sets the value of Response. +func (s *ErrorStatusCode) SetResponse(val Error) { + s.Response = val +} + +// Ref: #/components/schemas/format +type Format string + +const ( + FormatAll Format = "all" + FormatPdf Format = "pdf" + FormatSinglePage Format = "single_page" + FormatHeaders Format = "headers" +) + +// MarshalText implements encoding.TextMarshaler. +func (s Format) MarshalText() ([]byte, error) { + switch s { + case FormatAll: + return []byte(s), nil + case FormatPdf: + return []byte(s), nil + case FormatSinglePage: + return []byte(s), nil + case FormatHeaders: + return []byte(s), nil + default: + return nil, errors.Errorf("invalid value: %q", s) + } +} + +// UnmarshalText implements encoding.TextUnmarshaler. +func (s *Format) UnmarshalText(data []byte) error { + switch Format(data) { + case FormatAll: + *s = FormatAll + return nil + case FormatPdf: + *s = FormatPdf + return nil + case FormatSinglePage: + *s = FormatSinglePage + return nil + case FormatHeaders: + *s = FormatHeaders + return nil + default: + return errors.Errorf("invalid value: %q", data) + } +} + +// GetPageNotFound is response for GetPage operation. +type GetPageNotFound struct{} + +func (*GetPageNotFound) getPageRes() {} + +// NewOptAddPageReq returns new OptAddPageReq with value set to v. +func NewOptAddPageReq(v AddPageReq) OptAddPageReq { + return OptAddPageReq{ + Value: v, + Set: true, + } +} + +// OptAddPageReq is optional AddPageReq. +type OptAddPageReq struct { + Value AddPageReq + Set bool +} + +// IsSet returns true if OptAddPageReq was set. +func (o OptAddPageReq) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptAddPageReq) Reset() { + var v AddPageReq + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptAddPageReq) SetTo(v AddPageReq) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptAddPageReq) Get() (v AddPageReq, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptAddPageReq) Or(d AddPageReq) AddPageReq { + if v, ok := o.Get(); ok { + return v + } + return d +} + +// NewOptString returns new OptString with value set to v. +func NewOptString(v string) OptString { + return OptString{ + Value: v, + Set: true, + } +} + +// OptString is optional string. +type OptString struct { + Value string + Set bool +} + +// IsSet returns true if OptString was set. +func (o OptString) IsSet() bool { return o.Set } + +// Reset unsets value. +func (o *OptString) Reset() { + var v string + o.Value = v + o.Set = false +} + +// SetTo sets value to v. +func (o *OptString) SetTo(v string) { + o.Set = true + o.Value = v +} + +// Get returns value and boolean that denotes whether value was set. +func (o OptString) Get() (v string, ok bool) { + if !o.Set { + return v, false + } + return o.Value, true +} + +// Or returns value if set, or given parameter if does not. +func (o OptString) Or(d string) string { + if v, ok := o.Get(); ok { + return v + } + return d +} + +// Ref: #/components/schemas/page +type Page struct { + ID uuid.UUID `json:"id"` + URL string `json:"url"` + Created time.Time `json:"created"` + Formats []Format `json:"formats"` + Status Status `json:"status"` +} + +// GetID returns the value of ID. +func (s *Page) GetID() uuid.UUID { + return s.ID +} + +// GetURL returns the value of URL. +func (s *Page) GetURL() string { + return s.URL +} + +// GetCreated returns the value of Created. +func (s *Page) GetCreated() time.Time { + return s.Created +} + +// GetFormats returns the value of Formats. +func (s *Page) GetFormats() []Format { + return s.Formats +} + +// GetStatus returns the value of Status. +func (s *Page) GetStatus() Status { + return s.Status +} + +// SetID sets the value of ID. +func (s *Page) SetID(val uuid.UUID) { + s.ID = val +} + +// SetURL sets the value of URL. +func (s *Page) SetURL(val string) { + s.URL = val +} + +// SetCreated sets the value of Created. +func (s *Page) SetCreated(val time.Time) { + s.Created = val +} + +// SetFormats sets the value of Formats. +func (s *Page) SetFormats(val []Format) { + s.Formats = val +} + +// SetStatus sets the value of Status. +func (s *Page) SetStatus(val Status) { + s.Status = val +} + +// Merged schema. +// Ref: #/components/schemas/pageWithResults +type PageWithResults struct { + ID uuid.UUID `json:"id"` + URL string `json:"url"` + Created time.Time `json:"created"` + Formats []Format `json:"formats"` + Status Status `json:"status"` + Results []Result `json:"results"` +} + +// GetID returns the value of ID. +func (s *PageWithResults) GetID() uuid.UUID { + return s.ID +} + +// GetURL returns the value of URL. +func (s *PageWithResults) GetURL() string { + return s.URL +} + +// GetCreated returns the value of Created. +func (s *PageWithResults) GetCreated() time.Time { + return s.Created +} + +// GetFormats returns the value of Formats. +func (s *PageWithResults) GetFormats() []Format { + return s.Formats +} + +// GetStatus returns the value of Status. +func (s *PageWithResults) GetStatus() Status { + return s.Status +} + +// GetResults returns the value of Results. +func (s *PageWithResults) GetResults() []Result { + return s.Results +} + +// SetID sets the value of ID. +func (s *PageWithResults) SetID(val uuid.UUID) { + s.ID = val +} + +// SetURL sets the value of URL. +func (s *PageWithResults) SetURL(val string) { + s.URL = val +} + +// SetCreated sets the value of Created. +func (s *PageWithResults) SetCreated(val time.Time) { + s.Created = val +} + +// SetFormats sets the value of Formats. +func (s *PageWithResults) SetFormats(val []Format) { + s.Formats = val +} + +// SetStatus sets the value of Status. +func (s *PageWithResults) SetStatus(val Status) { + s.Status = val +} + +// SetResults sets the value of Results. +func (s *PageWithResults) SetResults(val []Result) { + s.Results = val +} + +func (*PageWithResults) getPageRes() {} + +type Pages []Page + +// Ref: #/components/schemas/result +type Result struct { + Format Format `json:"format"` + Error OptString `json:"error"` + Files []ResultFilesItem `json:"files"` +} + +// GetFormat returns the value of Format. +func (s *Result) GetFormat() Format { + return s.Format +} + +// GetError returns the value of Error. +func (s *Result) GetError() OptString { + return s.Error +} + +// GetFiles returns the value of Files. +func (s *Result) GetFiles() []ResultFilesItem { + return s.Files +} + +// SetFormat sets the value of Format. +func (s *Result) SetFormat(val Format) { + s.Format = val +} + +// SetError sets the value of Error. +func (s *Result) SetError(val OptString) { + s.Error = val +} + +// SetFiles sets the value of Files. +func (s *Result) SetFiles(val []ResultFilesItem) { + s.Files = val +} + +type ResultFilesItem struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Mimetype string `json:"mimetype"` + Size int64 `json:"size"` +} + +// GetID returns the value of ID. +func (s *ResultFilesItem) GetID() uuid.UUID { + return s.ID +} + +// GetName returns the value of Name. +func (s *ResultFilesItem) GetName() string { + return s.Name +} + +// GetMimetype returns the value of Mimetype. +func (s *ResultFilesItem) GetMimetype() string { + return s.Mimetype +} + +// GetSize returns the value of Size. +func (s *ResultFilesItem) GetSize() int64 { + return s.Size +} + +// SetID sets the value of ID. +func (s *ResultFilesItem) SetID(val uuid.UUID) { + s.ID = val +} + +// SetName sets the value of Name. +func (s *ResultFilesItem) SetName(val string) { + s.Name = val +} + +// SetMimetype sets the value of Mimetype. +func (s *ResultFilesItem) SetMimetype(val string) { + s.Mimetype = val +} + +// SetSize sets the value of Size. +func (s *ResultFilesItem) SetSize(val int64) { + s.Size = val +} + +// Ref: #/components/schemas/status +type Status string + +const ( + StatusNew Status = "new" + StatusProcessing Status = "processing" + StatusDone Status = "done" + StatusFailed Status = "failed" + StatusWithErrors Status = "with_errors" +) + +// MarshalText implements encoding.TextMarshaler. +func (s Status) MarshalText() ([]byte, error) { + switch s { + case StatusNew: + return []byte(s), nil + case StatusProcessing: + return []byte(s), nil + case StatusDone: + return []byte(s), nil + case StatusFailed: + return []byte(s), nil + case StatusWithErrors: + return []byte(s), nil + default: + return nil, errors.Errorf("invalid value: %q", s) + } +} + +// UnmarshalText implements encoding.TextUnmarshaler. +func (s *Status) UnmarshalText(data []byte) error { + switch Status(data) { + case StatusNew: + *s = StatusNew + return nil + case StatusProcessing: + *s = StatusProcessing + return nil + case StatusDone: + *s = StatusDone + return nil + case StatusFailed: + *s = StatusFailed + return nil + case StatusWithErrors: + *s = StatusWithErrors + return nil + default: + return errors.Errorf("invalid value: %q", data) + } +} diff --git a/api/openapi/oas_server_gen.go b/api/openapi/oas_server_gen.go new file mode 100644 index 0000000..646ed6f --- /dev/null +++ b/api/openapi/oas_server_gen.go @@ -0,0 +1,52 @@ +// Code generated by ogen, DO NOT EDIT. + +package openapi + +import ( + "context" +) + +// Handler handles operations described by OpenAPI v3 specification. +type Handler interface { + // AddPage implements addPage operation. + // + // Add new page. + // + // POST /pages + AddPage(ctx context.Context, req OptAddPageReq) (*Page, error) + // GetPage implements getPage operation. + // + // Get page details. + // + // GET /pages/{id} + GetPage(ctx context.Context, params GetPageParams) (GetPageRes, error) + // GetPages implements getPages operation. + // + // Get all pages. + // + // GET /pages + GetPages(ctx context.Context) (Pages, error) + // NewError creates *ErrorStatusCode from error returned by handler. + // + // Used for common default response. + NewError(ctx context.Context, err error) *ErrorStatusCode +} + +// Server implements http server based on OpenAPI v3 specification and +// calls Handler to handle requests. +type Server struct { + h Handler + baseServer +} + +// NewServer creates new Server. +func NewServer(h Handler, opts ...ServerOption) (*Server, error) { + s, err := newServerConfig(opts...).baseServer() + if err != nil { + return nil, err + } + return &Server{ + h: h, + baseServer: s, + }, nil +} diff --git a/api/openapi/oas_unimplemented_gen.go b/api/openapi/oas_unimplemented_gen.go new file mode 100644 index 0000000..03c52e6 --- /dev/null +++ b/api/openapi/oas_unimplemented_gen.go @@ -0,0 +1,49 @@ +// Code generated by ogen, DO NOT EDIT. + +package openapi + +import ( + "context" + + ht "github.com/ogen-go/ogen/http" +) + +// UnimplementedHandler is no-op Handler which returns http.ErrNotImplemented. +type UnimplementedHandler struct{} + +var _ Handler = UnimplementedHandler{} + +// AddPage implements addPage operation. +// +// Add new page. +// +// POST /pages +func (UnimplementedHandler) AddPage(ctx context.Context, req OptAddPageReq) (r *Page, _ error) { + return r, ht.ErrNotImplemented +} + +// GetPage implements getPage operation. +// +// Get page details. +// +// GET /pages/{id} +func (UnimplementedHandler) GetPage(ctx context.Context, params GetPageParams) (r GetPageRes, _ error) { + return r, ht.ErrNotImplemented +} + +// GetPages implements getPages operation. +// +// Get all pages. +// +// GET /pages +func (UnimplementedHandler) GetPages(ctx context.Context) (r Pages, _ error) { + return r, ht.ErrNotImplemented +} + +// NewError creates *ErrorStatusCode from error returned by handler. +// +// Used for common default response. +func (UnimplementedHandler) NewError(ctx context.Context, err error) (r *ErrorStatusCode) { + r = new(ErrorStatusCode) + return r +} diff --git a/api/openapi/oas_validators_gen.go b/api/openapi/oas_validators_gen.go new file mode 100644 index 0000000..bbc15c1 --- /dev/null +++ b/api/openapi/oas_validators_gen.go @@ -0,0 +1,247 @@ +// Code generated by ogen, DO NOT EDIT. + +package openapi + +import ( + "fmt" + + "github.com/go-faster/errors" + + "github.com/ogen-go/ogen/validate" +) + +func (s *AddPageReq) Validate() error { + var failures []validate.FieldError + if err := func() error { + var failures []validate.FieldError + for i, elem := range s.Formats { + if err := func() error { + if err := elem.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: fmt.Sprintf("[%d]", i), + Error: err, + }) + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "formats", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} +func (s Format) Validate() error { + switch s { + case "all": + return nil + case "pdf": + return nil + case "single_page": + return nil + case "headers": + return nil + default: + return errors.Errorf("invalid value: %v", s) + } +} + +func (s *Page) Validate() error { + var failures []validate.FieldError + if err := func() error { + if s.Formats == nil { + return errors.New("nil is invalid value") + } + var failures []validate.FieldError + for i, elem := range s.Formats { + if err := func() error { + if err := elem.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: fmt.Sprintf("[%d]", i), + Error: err, + }) + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "formats", + Error: err, + }) + } + if err := func() error { + if err := s.Status.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "status", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} +func (s *PageWithResults) Validate() error { + var failures []validate.FieldError + if err := func() error { + if s.Formats == nil { + return errors.New("nil is invalid value") + } + var failures []validate.FieldError + for i, elem := range s.Formats { + if err := func() error { + if err := elem.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: fmt.Sprintf("[%d]", i), + Error: err, + }) + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "formats", + Error: err, + }) + } + if err := func() error { + if err := s.Status.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "status", + Error: err, + }) + } + if err := func() error { + if s.Results == nil { + return errors.New("nil is invalid value") + } + var failures []validate.FieldError + for i, elem := range s.Results { + if err := func() error { + if err := elem.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: fmt.Sprintf("[%d]", i), + Error: err, + }) + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "results", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} +func (s Pages) Validate() error { + if s == nil { + return errors.New("nil is invalid value") + } + var failures []validate.FieldError + for i, elem := range s { + if err := func() error { + if err := elem.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: fmt.Sprintf("[%d]", i), + Error: err, + }) + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} +func (s *Result) Validate() error { + var failures []validate.FieldError + if err := func() error { + if err := s.Format.Validate(); err != nil { + return err + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "format", + Error: err, + }) + } + if err := func() error { + if s.Files == nil { + return errors.New("nil is invalid value") + } + return nil + }(); err != nil { + failures = append(failures, validate.FieldError{ + Name: "files", + Error: err, + }) + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + return nil +} +func (s Status) Validate() error { + switch s { + case "new": + return nil + case "processing": + return nil + case "done": + return nil + case "failed": + return nil + case "with_errors": + return nil + default: + return errors.Errorf("invalid value: %v", s) + } +} diff --git a/application/application.go b/application/application.go new file mode 100644 index 0000000..27d51f4 --- /dev/null +++ b/application/application.go @@ -0,0 +1,182 @@ +package application + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "sync" + "time" + + "github.com/dgraph-io/badger/v4" + "github.com/ogen-go/ogen/middleware" + "go.uber.org/multierr" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + + "github.com/derfenix/webarchive/adapters/processors" + badgerRepo "github.com/derfenix/webarchive/adapters/repository/badger" + "github.com/derfenix/webarchive/api/openapi" + "github.com/derfenix/webarchive/entity" + "github.com/derfenix/webarchive/ports/rest" +) + +func NewApplication(cfg Config) (Application, error) { + log, err := newLogger(cfg.Logging) + if err != nil { + return Application{}, fmt.Errorf("new logger: %w", err) + } + + db, err := badgerRepo.NewBadger(cfg.DB.Path, log.Named("db")) + if err != nil { + return Application{}, fmt.Errorf("new badger: %w", err) + } + + fileRepo := badgerRepo.NewFile(db) + pageRepo, err := badgerRepo.NewPage(db, fileRepo) + if err != nil { + return Application{}, fmt.Errorf("new page repo: %w", err) + } + + processor, err := processors.NewProcessors() + if err != nil { + return Application{}, fmt.Errorf("new processors: %w", err) + } + + server, err := openapi.NewServer( + rest.NewService(pageRepo), + openapi.WithMiddleware( + func(r middleware.Request, next middleware.Next) (middleware.Response, error) { + start := time.Now() + + log := log.With( + zap.String("operation_id", r.OperationID), + zap.String("uri", r.Raw.RequestURI), + ) + + var response middleware.Response + var reqErr error + + response, reqErr = next(r) + + log.Debug("request completed", zap.Duration("duration", time.Since(start)), zap.Error(err)) + + return response, reqErr + }, + ), + ) + if err != nil { + return Application{}, fmt.Errorf("new rest server: %w", err) + } + + httpServer := http.Server{ + Addr: "0.0.0.0:5001", + Handler: server, + ReadTimeout: time.Second * 15, + ReadHeaderTimeout: time.Second * 5, + IdleTimeout: time.Second * 30, + MaxHeaderBytes: 1024 * 2, + } + + return Application{ + cfg: cfg, + log: log, + db: db, + processor: processor, + httpServer: &httpServer, + + pageRepo: pageRepo, + fileRepo: fileRepo, + }, nil +} + +type Application struct { + cfg Config + log *zap.Logger + db *badger.DB + processor entity.Processor + + httpServer *http.Server + + pageRepo *badgerRepo.Page + fileRepo *badgerRepo.File +} + +func (a *Application) Log() *zap.Logger { + return a.log +} + +func (a *Application) Start(ctx context.Context, wg *sync.WaitGroup) error { + wg.Add(2) + + a.httpServer.BaseContext = func(net.Listener) context.Context { + return ctx + } + + go func() { + defer wg.Done() + + <-ctx.Done() + + shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + if err := a.httpServer.Shutdown(shutdownCtx); err != nil { + a.log.Warn("http graceful shutdown failed", zap.Error(err)) + } + }() + + go func() { + defer wg.Done() + + a.log.Info("starting http server", zap.String("address", a.httpServer.Addr)) + + if err := a.httpServer.ListenAndServe(); err != nil { + if !errors.Is(err, http.ErrServerClosed) { + a.log.Error("http serve error", zap.Error(err)) + } + + a.log.Info("http server stopped") + } + }() + + return nil +} + +func (a *Application) Stop() error { + var errs error + + if err := a.db.Sync(); err != nil { + errs = multierr.Append(errs, fmt.Errorf("sync db: %w", err)) + } + + if err := badgerRepo.Backup(a.db, badgerRepo.BackupStop); err != nil { + errs = multierr.Append(errs, fmt.Errorf("backup on stop: %w", err)) + } + + if err := a.db.Close(); err != nil { + errs = multierr.Append(errs, fmt.Errorf("close db: %w", err)) + } + + return errs +} + +func newLogger(cfg Logging) (*zap.Logger, error) { + logCfg := zap.NewProductionConfig() + logCfg.EncoderConfig.EncodeTime = zapcore.RFC3339TimeEncoder + logCfg.EncoderConfig.EncodeDuration = zapcore.NanosDurationEncoder + logCfg.DisableCaller = true + + logCfg.Level = zap.NewAtomicLevelAt(zapcore.InfoLevel) + if cfg.Debug { + logCfg.Level = zap.NewAtomicLevelAt(zapcore.DebugLevel) + } + + log, err := logCfg.Build() + if err != nil { + return nil, fmt.Errorf("build logger: %w", err) + } + + return log, nil +} diff --git a/application/config.go b/application/config.go new file mode 100644 index 0000000..90bdf13 --- /dev/null +++ b/application/config.go @@ -0,0 +1,38 @@ +package application + +import ( + "context" + "fmt" + + "github.com/sethvargo/go-envconfig" +) + +const envPrefix = "WEBARCHIVE_" + +func NewConfig(ctx context.Context) (Config, error) { + cfg := Config{} + + lookuper := envconfig.MultiLookuper( + envconfig.PrefixLookuper(envPrefix, envconfig.OsLookuper()), + envconfig.OsLookuper(), + ) + + if err := envconfig.ProcessWith(ctx, &cfg, lookuper); err != nil { + return Config{}, fmt.Errorf("process env: %w", err) + } + + return cfg, nil +} + +type Config struct { + DB DB `env:",prefix=DB_"` + Logging Logging `env:",prefix=LOGGING_"` +} + +type DB struct { + Path string `env:"PATH,default=./db"` +} + +type Logging struct { + Debug bool `env:"DEBUG"` +} diff --git a/application/config_test.go b/application/config_test.go new file mode 100644 index 0000000..6aa4af8 --- /dev/null +++ b/application/config_test.go @@ -0,0 +1,42 @@ +package application + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewConfig(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + t.Run("no envs", func(t *testing.T) { + + config, err := NewConfig(ctx) + require.NoError(t, err) + + assert.Equal(t, "./db", config.DB.Path) + }) + + t.Run("env without prefix", func(t *testing.T) { + require.NoError(t, os.Setenv("DB_PATH", "./old_db")) + + config, err := NewConfig(ctx) + require.NoError(t, err) + + assert.Equal(t, "./old_db", config.DB.Path) + }) + + t.Run("prefix env override", func(t *testing.T) { + require.NoError(t, os.Setenv("WEBARCHIVE_DB_PATH", "./new_db")) + + config, err := NewConfig(ctx) + require.NoError(t, err) + + assert.Equal(t, "./new_db", config.DB.Path) + }) +} diff --git a/cmd/service/main.go b/cmd/service/main.go new file mode 100644 index 0000000..acecf8b --- /dev/null +++ b/cmd/service/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "sync" + + "go.uber.org/zap" + + "github.com/derfenix/webarchive/application" +) + +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill) + defer cancel() + + cfg, err := application.NewConfig(ctx) + if err != nil { + fmt.Printf("failed to init config: %s", err.Error()) + os.Exit(2) + } + + app, err := application.NewApplication(cfg) + if err != nil { + fmt.Printf("failed to init application: %s", err.Error()) + os.Exit(2) + } + + wg := sync.WaitGroup{} + + if err := app.Start(ctx, &wg); err != nil { + app.Log().Fatal("failed to start application", zap.Error(err)) + } + + wg.Wait() + + if err := app.Stop(); err != nil { + app.Log().Fatal("failed to graceful stop", zap.Error(err)) + } +} diff --git a/entity/file.go b/entity/file.go new file mode 100644 index 0000000..2b8613b --- /dev/null +++ b/entity/file.go @@ -0,0 +1,30 @@ +package entity + +import ( + "time" + + "github.com/gabriel-vasile/mimetype" + "github.com/google/uuid" +) + +func NewFile(name string, data []byte) File { + detected := mimetype.Detect(data) + + return File{ + ID: uuid.New(), + Name: name, + MimeType: detected.String(), + Size: int64(len(data)), + Data: data, + Created: time.Now(), + } +} + +type File struct { + ID uuid.UUID + Name string + MimeType string + Size int64 + Data []byte + Created time.Time +} diff --git a/entity/page.go b/entity/page.go new file mode 100644 index 0000000..fc86cad --- /dev/null +++ b/entity/page.go @@ -0,0 +1,99 @@ +package entity + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/google/uuid" +) + +type Processor interface { + Process(ctx context.Context, format Format, url string) Result +} + +type Format uint8 + +const ( + FormatHeaders Format = iota + FormatSingleFile + FormatPDF +) + +type Status uint8 + +const ( + StatusNew Status = iota + StatusProcessing + StatusDone + StatusFailed + StatusWithErrors +) + +func NewPage(url string, description string, formats ...Format) *Page { + return &Page{ + ID: uuid.New(), + URL: url, + Description: description, + Formats: formats, + Created: time.Now(), + Version: 1, + } +} + +type Page struct { + ID uuid.UUID + URL string + Description string + Created time.Time + Formats []Format + Results Results + Version uint16 + Status Status +} + +func (p *Page) SetProcessing() { + p.Status = StatusProcessing +} + +func (p *Page) Process(ctx context.Context, wg *sync.WaitGroup, processor Processor) { + defer wg.Done() + + innerWG := sync.WaitGroup{} + innerWG.Add(len(p.Formats)) + + for _, format := range p.Formats { + go func(format Format) { + defer innerWG.Done() + + defer func() { + if err := recover(); err != nil { + p.Results.Add(Result{Format: format, Err: fmt.Errorf("recovered from panic: %v", err)}) + } + }() + + result := processor.Process(ctx, format, p.URL) + p.Results.Add(result) + }(format) + } + + var hasResultWithOutErrors bool + for _, result := range p.Results.Results() { + if result.Err != nil { + p.Status = StatusWithErrors + } else { + hasResultWithOutErrors = true + } + } + + if !hasResultWithOutErrors { + p.Status = StatusFailed + } + + if p.Status == StatusProcessing { + p.Status = StatusDone + } + + innerWG.Wait() +} diff --git a/entity/result.go b/entity/result.go new file mode 100644 index 0000000..f2bb9f9 --- /dev/null +++ b/entity/result.go @@ -0,0 +1,39 @@ +package entity + +import ( + "sync" + + "github.com/vmihailenco/msgpack/v5" +) + +type Result struct { + Format Format + Err error + Files []File +} + +type Results struct { + mu sync.RWMutex + results []Result +} + +func (r *Results) MarshalMsgpack() ([]byte, error) { + return msgpack.Marshal(r.results) +} + +func (r *Results) UnmarshalMsgpack(b []byte) error { + return msgpack.Unmarshal(b, r.results) +} + +func (r *Results) Add(result Result) { + r.mu.Lock() + r.results = append(r.results, result) + r.mu.Unlock() +} + +func (r *Results) Results() []Result { + r.mu.RLock() + defer r.mu.RUnlock() + + return r.results +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2030333 --- /dev/null +++ b/go.mod @@ -0,0 +1,59 @@ +module github.com/derfenix/webarchive + +go 1.20 + +require ( + github.com/SebastiaanKlippert/go-wkhtmltopdf v1.9.0 + github.com/dgraph-io/badger/v4 v4.0.1 + github.com/gabriel-vasile/mimetype v1.4.2 + github.com/go-faster/errors v0.6.1 + github.com/go-faster/jx v1.0.0 + github.com/google/uuid v1.3.0 + github.com/ogen-go/ogen v0.60.1 + github.com/sethvargo/go-envconfig v0.9.0 + github.com/stretchr/testify v1.8.2 + github.com/vmihailenco/msgpack/v5 v5.3.5 + go.opentelemetry.io/otel v1.14.0 + go.opentelemetry.io/otel/metric v0.37.0 + go.opentelemetry.io/otel/trace v1.14.0 + go.uber.org/multierr v1.10.0 + go.uber.org/zap v1.24.0 +) + +require ( + github.com/benbjohnson/clock v1.3.0 // indirect + github.com/cespare/xxhash v1.1.0 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgraph-io/ristretto v0.1.1 // indirect + github.com/dlclark/regexp2 v1.8.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fatih/color v1.15.0 // indirect + github.com/ghodss/yaml v1.0.0 // indirect + github.com/go-faster/yamlx v0.4.1 // indirect + github.com/go-logr/logr v1.2.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/glog v1.1.1 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/google/flatbuffers v23.3.3+incompatible // indirect + github.com/klauspost/compress v1.16.3 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/segmentio/asm v1.2.0 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + go.opencensus.io v0.24.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + golang.org/x/exp v0.0.0-20230206171751-46f607a40771 // indirect + golang.org/x/net v0.8.0 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/sys v0.6.0 // indirect + golang.org/x/text v0.8.0 // indirect + google.golang.org/protobuf v1.30.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..dfbc794 --- /dev/null +++ b/go.sum @@ -0,0 +1,231 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/SebastiaanKlippert/go-wkhtmltopdf v1.9.0 h1:DNrExYwvyyI404SxdUCCANAj9TwnGjRfa3cYFMNY1AU= +github.com/SebastiaanKlippert/go-wkhtmltopdf v1.9.0/go.mod h1:SQq4xfIdvf6WYKSDxAJc+xOJdolt+/bc1jnQKMtPMvQ= +github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= +github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgraph-io/badger/v4 v4.0.1 h1:zwLYFc4sfxKdaRTvS6wlHsSuYWNUiWnYLU+TS+/nCDI= +github.com/dgraph-io/badger/v4 v4.0.1/go.mod h1:edFJfgVfwYjg+grodpS7Yj2vohQMK3VL6eCaR6EpRJU= +github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8= +github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dlclark/regexp2 v1.8.1 h1:6Lcdwya6GjPUNsBct8Lg/yRPwMhABj269AAzdGSiR+0= +github.com/dlclark/regexp2 v1.8.1/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= +github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-faster/errors v0.6.1 h1:nNIPOBkprlKzkThvS/0YaX8Zs9KewLCOSFQS5BU06FI= +github.com/go-faster/errors v0.6.1/go.mod h1:5MGV2/2T9yvlrbhe9pD9LO5Z/2zCSq2T8j+Jpi2LAyY= +github.com/go-faster/jx v1.0.0 h1:HE+ms2e6ZGkZ6u13t8u+onBinrPvIPI+0hWXGELm74g= +github.com/go-faster/jx v1.0.0/go.mod h1:zm8SlkwK+H0TYNKYtVJ/7cWFS7soJBQWhcPctKyYL/4= +github.com/go-faster/yamlx v0.4.1 h1:00RQkZopoLDF1SgBDJVHuN6epTOK7T0TkN427vbvEBk= +github.com/go-faster/yamlx v0.4.1/go.mod h1:QXr/i3Z00jRhskgyWkoGsEdseebd/ZbZEpGS6DJv8oo= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0= +github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/glog v1.1.1 h1:jxpi2eWoU84wbX9iIEyAeeoac3FLuifZpY9tcNUD9kw= +github.com/golang/glog v1.1.1/go.mod h1:zR+okUeTbrL6EL3xHUDxZuEtGv04p5shwip1+mL/rLQ= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/flatbuffers v23.3.3+incompatible h1:5PJI/WbJkaMTvpGxsHVKG/LurN/KnWXNyGpwSCDgen0= +github.com/google/flatbuffers v23.3.3+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.16.3 h1:XuJt9zzcnaz6a16/OU53ZjWp/v7/42WcR5t2a0PcNQY= +github.com/klauspost/compress v1.16.3/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/ogen-go/ogen v0.60.1 h1:yOt0i6NcH7jM3rBi9nnv5VsGUQRw4ACUMsiJojnqrAM= +github.com/ogen-go/ogen v0.60.1/go.mod h1:tcwLpHe4vyk9xtbTMe3yu3Qtcbz8VjrpBz9LzsdwWvQ= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sethvargo/go-envconfig v0.9.0 h1:Q6FQ6hVEeTECULvkJZakq3dZMeBQ3JUpcKMfPQbKMDE= +github.com/sethvargo/go-envconfig v0.9.0/go.mod h1:Iz1Gy1Sf3T64TQlJSvee81qDhf7YIlt8GMUX6yyNFs0= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU= +github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/otel v1.14.0 h1:/79Huy8wbf5DnIPhemGB+zEPVwnN6fuQybr/SRXa6hM= +go.opentelemetry.io/otel v1.14.0/go.mod h1:o4buv+dJzx8rohcUeRmWUZhqupFvzWis188WlggnNeU= +go.opentelemetry.io/otel/metric v0.37.0 h1:pHDQuLQOZwYD+Km0eb657A25NaRzy0a+eLyKfDXedEs= +go.opentelemetry.io/otel/metric v0.37.0/go.mod h1:DmdaHfGt54iV6UKxsV9slj2bBRJcKC1B1uvDLIioc1s= +go.opentelemetry.io/otel/trace v1.14.0 h1:wp2Mmvj41tDsyAJXiWDWpfNsOiIyd38fy85pyKcFq/M= +go.opentelemetry.io/otel/trace v1.14.0/go.mod h1:8avnQLK+CG77yNLUae4ea2JDQ6iT+gozhnZjy/rw9G8= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= +go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20230206171751-46f607a40771 h1:xP7rWLUr1e1n2xkK5YB4LI0hPEy3LJC6Wk+D4pGlOJg= +golang.org/x/exp v0.0.0-20230206171751-46f607a40771/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/ports/rest/converter.go b/ports/rest/converter.go new file mode 100644 index 0000000..e2252b6 --- /dev/null +++ b/ports/rest/converter.go @@ -0,0 +1,132 @@ +package rest + +import ( + "github.com/derfenix/webarchive/api/openapi" + "github.com/derfenix/webarchive/entity" +) + +func PageToRestWithResults(page *entity.Page) openapi.PageWithResults { + return openapi.PageWithResults{ + ID: page.ID, + URL: page.URL, + Created: page.Created, + Formats: func() []openapi.Format { + res := make([]openapi.Format, len(page.Formats)) + + for i, format := range page.Formats { + res[i] = FormatToRest(format) + } + + return res + }(), + Status: StatusToRest(page.Status), + Results: func() []openapi.Result { + results := make([]openapi.Result, len(page.Results.Results())) + + for i := range results { + result := &page.Results.Results()[i] + + results[i] = openapi.Result{ + Format: FormatToRest(result.Format), + Error: openapi.NewOptString(result.Err.Error()), + Files: func() []openapi.ResultFilesItem { + files := make([]openapi.ResultFilesItem, len(results[i].Files)) + + for j := range files { + file := &result.Files[j] + + files[i] = openapi.ResultFilesItem{ + ID: file.ID, + Name: file.Name, + Mimetype: file.MimeType, + Size: file.Size, + } + } + + return files + }(), + } + } + + return results + }(), + } +} + +func PageToRest(page *entity.Page) openapi.Page { + return openapi.Page{ + ID: page.ID, + URL: page.URL, + Created: page.Created, + Formats: func() []openapi.Format { + res := make([]openapi.Format, len(page.Formats)) + + for i, format := range page.Formats { + res[i] = FormatToRest(format) + } + + return res + }(), + Status: StatusToRest(page.Status), + } +} + +func StatusToRest(s entity.Status) openapi.Status { + switch s { + case entity.StatusNew: + return openapi.StatusNew + case entity.StatusProcessing: + return openapi.StatusProcessing + case entity.StatusDone: + return openapi.StatusDone + case entity.StatusFailed: + return openapi.StatusFailed + case entity.StatusWithErrors: + return openapi.StatusWithErrors + default: + return "" + } +} + +func FormatFromRest(format []openapi.Format) []entity.Format { + var formats []entity.Format + + switch { + case len(format) == 0 || (len(format) == 1 && format[0] == openapi.FormatAll): + formats = []entity.Format{ + entity.FormatHeaders, + entity.FormatPDF, + entity.FormatSingleFile, + } + + default: + formats = make([]entity.Format, len(format)) + for i, format := range format { + switch format { + case openapi.FormatPdf: + formats[i] = entity.FormatPDF + + case openapi.FormatHeaders: + formats[i] = entity.FormatHeaders + + case openapi.FormatSinglePage: + formats[i] = entity.FormatSingleFile + } + } + } + + return formats +} + +func FormatToRest(format entity.Format) openapi.Format { + switch format { + case entity.FormatPDF: + return openapi.FormatPdf + case entity.FormatSingleFile: + return openapi.FormatSinglePage + case entity.FormatHeaders: + return openapi.FormatHeaders + default: + return "" + } +} diff --git a/ports/rest/service.go b/ports/rest/service.go new file mode 100644 index 0000000..c45f518 --- /dev/null +++ b/ports/rest/service.go @@ -0,0 +1,75 @@ +package rest + +import ( + "context" + "fmt" + "net/http" + + "github.com/google/uuid" + + "github.com/derfenix/webarchive/api/openapi" + "github.com/derfenix/webarchive/entity" +) + +type Pages interface { + ListAll(ctx context.Context) ([]*entity.Page, error) + Save(ctx context.Context, site *entity.Page) error + Get(_ context.Context, id uuid.UUID) (*entity.Page, error) +} + +func NewService(sites Pages) *Service { + return &Service{pages: sites} +} + +type Service struct { + openapi.UnimplementedHandler + pages Pages +} + +func (s *Service) GetPage(ctx context.Context, params openapi.GetPageParams) (openapi.GetPageRes, error) { + page, err := s.pages.Get(ctx, params.ID) + if err != nil { + return &openapi.GetPageNotFound{}, nil + } + + restPage := PageToRestWithResults(page) + + return &restPage, nil +} + +func (s *Service) AddPage(ctx context.Context, req openapi.OptAddPageReq) (*openapi.Page, error) { + site := entity.NewPage(req.Value.URL, req.Value.Description.Value, FormatFromRest(req.Value.Formats)...) + + err := s.pages.Save(ctx, site) + if err != nil { + return nil, fmt.Errorf("save site: %w", err) + } + + res := PageToRest(site) + + return &res, nil +} + +func (s *Service) GetPages(ctx context.Context) (openapi.Pages, error) { + sites, err := s.pages.ListAll(ctx) + if err != nil { + return nil, fmt.Errorf("list all: %w", err) + } + + res := make(openapi.Pages, len(sites)) + for i := range res { + res[i] = PageToRest(sites[i]) + } + + return res, nil +} + +func (s *Service) NewError(_ context.Context, err error) *openapi.ErrorStatusCode { + return &openapi.ErrorStatusCode{ + StatusCode: http.StatusInternalServerError, + Response: openapi.Error{ + Message: err.Error(), + Localized: openapi.OptString{}, + }, + } +}