init
This commit is contained in:
commit
d02ff08aa7
46
.air.toml
Normal file
46
.air.toml
Normal file
@ -0,0 +1,46 @@
|
||||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
tmp_dir = "/tmp"
|
||||
|
||||
[build]
|
||||
args_bin = []
|
||||
bin = "/tmp/minireader"
|
||||
cmd = "go build -o /tmp/minireader ./cmd/minireader"
|
||||
delay = 1000
|
||||
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = ""
|
||||
include_dir = []
|
||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
||||
include_file = []
|
||||
kill_delay = "0s"
|
||||
log = "/tmp/build-errors.log"
|
||||
poll = false
|
||||
poll_interval = 0
|
||||
post_cmd = []
|
||||
pre_cmd = []
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = false
|
||||
stop_on_error = false
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = false
|
||||
keep_scroll = true
|
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
.idea
|
||||
|
||||
.env
|
||||
tmp/*
|
348
.golangci.yml
Normal file
348
.golangci.yml
Normal file
@ -0,0 +1,348 @@
|
||||
# Options for analysis running.
|
||||
run:
|
||||
# Timeout for analysis, e.g. 30s, 5m.
|
||||
# Default: 1m
|
||||
timeout: 10m
|
||||
# Exit code when at least one issue was found.
|
||||
# Default: 1
|
||||
issues-exit-code: 1
|
||||
# Include test files or not.
|
||||
# Default: true
|
||||
tests: true
|
||||
# List of build tags, all linters use it.
|
||||
# Default: [].
|
||||
build-tags: [ ]
|
||||
# Which dirs to skip: issues from them won't be reported.
|
||||
# Can use regexp here: `generated.*`, regexp is applied on full path,
|
||||
# including the path prefix if one is set.
|
||||
# Default value is empty list,
|
||||
# but default dirs are skipped independently of this option's value (see skip-dirs-use-default).
|
||||
# "/" will be replaced by current OS file path separator to properly work on Windows.
|
||||
skip-dirs:
|
||||
- pkg/
|
||||
- migrations/
|
||||
# If set we pass it to "go list -mod={option}". From "go help modules":
|
||||
# If invoked with -mod=readonly, the go command is disallowed from the implicit
|
||||
# automatic updating of go.mod described above. Instead, it fails when any changes
|
||||
# to go.mod are needed. This setting is most useful to check that go.mod does
|
||||
# not need updates, such as in a continuous integration and testing system.
|
||||
# If invoked with -mod=vendor, the go command assumes that the vendor
|
||||
# directory holds the correct copies of dependencies and ignores
|
||||
# the dependency descriptions in go.mod.
|
||||
#
|
||||
# Allowed values: readonly|vendor|mod
|
||||
# By default, it isn't set.
|
||||
modules-download-mode: readonly
|
||||
# Allow multiple parallel golangci-lint instances running.
|
||||
allow-parallel-runners: true
|
||||
|
||||
output:
|
||||
sort-results: true
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- asciicheck # Simple linter to check that your code does not contain non-ASCII identifiers
|
||||
- containedctx # containedctx is a linter that detects struct contained context.Context field
|
||||
- cyclop # checks function and package cyclomatic complexity
|
||||
- dupl # Tool for code clone detection
|
||||
- errname # Checks that sentinel errors are prefixed with the `Err` and error types are suffixed with the `Error`
|
||||
- errorlint # errorlint is a linter for that can be used to find code that will cause problems with the error wrapping scheme introduced in Go 1.13
|
||||
- exhaustive # check exhaustiveness of enum switch statements
|
||||
- exhaustruct # Checks if all structure fields are initialized
|
||||
- funlen # Tool for detection of long functions
|
||||
- gocognit # Computes and checks the cognitive complexity of functions
|
||||
- goconst # Finds repeated strings that could be replaced by a constant
|
||||
- gocritic # Provides diagnostics that check for bugs, performance and style issues
|
||||
- lll # Reports long lines
|
||||
- makezero # Finds slice declarations with non-zero initial length
|
||||
- nilerr # Finds the code that returns nil even if it checks that the error is not nil
|
||||
- prealloc # Finds slice declarations that could potentially be pre-allocated
|
||||
- tagliatelle # Checks the struct tags
|
||||
# TODO: consider following linters
|
||||
|
||||
# - fieldalignment
|
||||
# - wrapcheck
|
||||
|
||||
linters-settings:
|
||||
cyclop:
|
||||
# The maximal code complexity to report.
|
||||
# Default: 10
|
||||
max-complexity: 10
|
||||
# The maximal average package complexity.
|
||||
# If it's higher than 0.0 (float) the check is enabled
|
||||
# Default: 0.0
|
||||
package-average: 0.0
|
||||
# Should ignore tests.
|
||||
# Default: false
|
||||
skip-tests: true
|
||||
dupl:
|
||||
# Tokens count to trigger issue.
|
||||
# Default: 150
|
||||
threshold: 150
|
||||
errorlint:
|
||||
# Check whether fmt.Errorf uses the %w verb for formatting errors.
|
||||
# See the https://github.com/polyfloyd/go-errorlint for caveats.
|
||||
# Default: true
|
||||
errorf: true
|
||||
# Permit more than 1 %w verb, valid per Go 1.20 (Requires errorf:true)
|
||||
# Default: true
|
||||
errorf-multi: true
|
||||
# Check for plain type assertions and type switches.
|
||||
# Default: true
|
||||
asserts: true
|
||||
# Check for plain error comparisons.
|
||||
# Default: true
|
||||
comparison: true
|
||||
exhaustruct:
|
||||
# List of regular expressions to exclude struct packages and names from check.
|
||||
# Default: []
|
||||
exclude:
|
||||
# public libs
|
||||
- "^github.com/segmentio/kafka-go.Message$"
|
||||
exhaustive:
|
||||
# Program elements to check for exhaustiveness.
|
||||
# Default: [ switch ]
|
||||
check:
|
||||
- switch
|
||||
# - map
|
||||
# Check switch statements in generated files also.
|
||||
# Default: false
|
||||
check-generated: false
|
||||
# Presence of "default" case in switch statements satisfies exhaustiveness,
|
||||
# even if all enum members are not listed.
|
||||
# Default: false
|
||||
default-signifies-exhaustive: false
|
||||
# Enum members matching the supplied regex do not have to be listed in
|
||||
# switch statements to satisfy exhaustiveness.
|
||||
# Default: ""
|
||||
ignore-enum-members: "Example.+"
|
||||
# Enum types matching the supplied regex do not have to be listed in
|
||||
# switch statements to satisfy exhaustiveness.
|
||||
# Default: ""
|
||||
ignore-enum-types: "Example.+"
|
||||
# Consider enums only in package scopes, not in inner scopes.
|
||||
# Default: false
|
||||
package-scope-only: false
|
||||
# Only run exhaustive check on switches with "//exhaustive:enforce" comment.
|
||||
# Default: false
|
||||
explicit-exhaustive-switch: false
|
||||
# Only run exhaustive check on map literals with "//exhaustive:enforce" comment.
|
||||
# Default: false
|
||||
explicit-exhaustive-map: false
|
||||
funlen:
|
||||
# Checks the number of lines in a function.
|
||||
# If lower than 0, disable the check.
|
||||
# Default: 60
|
||||
lines: 60
|
||||
# Checks the number of statements in a function.
|
||||
# If lower than 0, disable the check.
|
||||
# Default: 40
|
||||
statements: 40
|
||||
gocognit:
|
||||
# Minimal code complexity to report.
|
||||
# Default: 30
|
||||
min-complexity: 30
|
||||
goconst:
|
||||
# Minimal length of string constant.
|
||||
# Default: 3
|
||||
min-len: 3
|
||||
# Minimum occurrences of constant string count to trigger issue.
|
||||
# Default: 3
|
||||
min-occurrences: 3
|
||||
# Ignore test files.
|
||||
# Default: false
|
||||
ignore-tests: true
|
||||
# Look for existing constants matching the values.
|
||||
# Default: true
|
||||
match-constant: true
|
||||
# Search also for duplicated numbers.
|
||||
# Default: false
|
||||
numbers: false
|
||||
# Ignore when constant is not used as function argument.
|
||||
# Default: true
|
||||
ignore-calls: true
|
||||
gocritic:
|
||||
# Which checks should be enabled; can't be combined with 'disabled-checks'.
|
||||
# See https://go-critic.github.io/overview#checks-overview.
|
||||
# To check which checks are enabled run `GL_DEBUG=gocritic golangci-lint run`.
|
||||
# By default, list of stable checks is used.
|
||||
enabled-checks:
|
||||
- appendAssign
|
||||
- appendCombine
|
||||
- badCond
|
||||
- badLock
|
||||
- badRegexp
|
||||
- boolExprSimplify
|
||||
- builtinShadow
|
||||
- builtinShadowDecl
|
||||
- captLocal
|
||||
- caseOrder
|
||||
- commentFormatting
|
||||
- commentedOutCode
|
||||
- commentedOutImport
|
||||
- defaultCaseOrder
|
||||
- deferInLoop
|
||||
- deferUnlambda
|
||||
- deprecatedComment
|
||||
- docStub
|
||||
- dupArg
|
||||
- dupBranchBody
|
||||
- dupCase
|
||||
- dupImport
|
||||
- dupSubExpr
|
||||
- dynamicFmtString
|
||||
- elseif
|
||||
- emptyDecl
|
||||
- emptyFallthrough
|
||||
- emptyStringTest
|
||||
- exposedSyncMutex
|
||||
- externalErrorReassign
|
||||
- filepathJoin
|
||||
- flagDeref
|
||||
- flagName
|
||||
- hugeParam
|
||||
- ifElseChain
|
||||
- indexAlloc
|
||||
- mapKey
|
||||
- nestingReduce
|
||||
- nilValReturn
|
||||
- octalLiteral
|
||||
- offBy1
|
||||
- preferDecodeRune
|
||||
- preferFilepathJoin
|
||||
- ptrToRefParam
|
||||
- rangeExprCopy
|
||||
- rangeValCopy
|
||||
- regexpMust
|
||||
- regexpSimplify
|
||||
- singleCaseSwitch
|
||||
- sliceClear
|
||||
- sloppyLen
|
||||
- sloppyReassign
|
||||
- sloppyTypeAssert
|
||||
- stringConcatSimplify
|
||||
- stringXbytes
|
||||
- stringsCompare
|
||||
- switchTrue
|
||||
- timeCmpSimplify
|
||||
- timeExprSimplify
|
||||
- truncateCmp
|
||||
- typeAssertChain
|
||||
- typeDefFirst
|
||||
- typeSwitchVar
|
||||
- typeUnparen
|
||||
- uncheckedInlineErr
|
||||
- underef
|
||||
- unlambda
|
||||
- unnecessaryDefer
|
||||
- weakCond
|
||||
- whyNoLint
|
||||
- wrapperFunc
|
||||
- yodaStyleExpr
|
||||
# Settings passed to gocritic.
|
||||
# The settings key is the name of a supported gocritic checker.
|
||||
# The list of supported checkers can be find in https://go-critic.github.io/overview.
|
||||
settings:
|
||||
# Must be valid enabled check name.
|
||||
captLocal:
|
||||
# Whether to restrict checker to params only.
|
||||
# Default: true
|
||||
paramsOnly: true
|
||||
elseif:
|
||||
# Whether to skip balanced if-else pairs.
|
||||
# Default: true
|
||||
skipBalanced: true
|
||||
hugeParam:
|
||||
# Size in bytes that makes the warning trigger.
|
||||
# Default: 80
|
||||
sizeThreshold: 512
|
||||
nestingReduce:
|
||||
# Min number of statements inside a branch to trigger a warning.
|
||||
# Default: 5
|
||||
bodyWidth: 3
|
||||
rangeExprCopy:
|
||||
# Size in bytes that makes the warning trigger.
|
||||
# Default: 512
|
||||
sizeThreshold: 512
|
||||
# Whether to check test functions
|
||||
# Default: true
|
||||
skipTestFuncs: true
|
||||
rangeValCopy:
|
||||
# Size in bytes that makes the warning trigger.
|
||||
# Default: 128
|
||||
sizeThreshold: 128
|
||||
# Whether to check test functions.
|
||||
# Default: true
|
||||
skipTestFuncs: true
|
||||
truncateCmp:
|
||||
# Whether to skip int/uint/uintptr types.
|
||||
# Default: true
|
||||
skipArchDependent: true
|
||||
underef:
|
||||
# Whether to skip (*x).method() calls where x is a pointer receiver.
|
||||
# Default: true
|
||||
skipRecvDeref: true
|
||||
prealloc:
|
||||
# IMPORTANT: we don't recommend using this linter before doing performance profiling.
|
||||
# For most programs usage of prealloc will be a premature optimization.
|
||||
|
||||
# Report pre-allocation suggestions only on simple loops that have no returns/breaks/continues/gotos in them.
|
||||
# Default: true
|
||||
simple: true
|
||||
# Report pre-allocation suggestions on range loops.
|
||||
# Default: true
|
||||
range-loops: true
|
||||
# Report pre-allocation suggestions on for loops.
|
||||
# Default: false
|
||||
for-loops: true
|
||||
tagliatelle:
|
||||
# Check the struct tag name case.
|
||||
case:
|
||||
# Use the struct field name to check the name of the struct tag.
|
||||
# Default: false
|
||||
use-field-name: false
|
||||
# `camel` is used for `json` and `yaml`, and `header` is used for `header` (can be overridden)
|
||||
# Default: {}
|
||||
rules:
|
||||
# Any struct tag type can be used.
|
||||
# Support string case: `camel`, `pascal`, `kebab`, `snake`, `goCamel`, `goPascal`, `goKebab`, `goSnake`, `upper`, `lower`, `header`
|
||||
db: snake
|
||||
json: camel
|
||||
yaml: camel
|
||||
mapstructure: camel
|
||||
# xml: camel # TODO
|
||||
|
||||
issues:
|
||||
# List of regexps of issue texts to exclude.
|
||||
#
|
||||
# Independently of option `exclude` we use default exclude patterns,
|
||||
# it can be disabled by this option.
|
||||
# To list all excluded by default patterns execute `golangci-lint run --help`.
|
||||
# Default: true.
|
||||
exclude-use-default: true
|
||||
# Excluding configuration per-path, per-linter, per-text and per-source
|
||||
exclude-rules:
|
||||
# Exclude `lll` and `gocritic` issues for long lines with `go:generate`
|
||||
- source: "^//go:generate "
|
||||
linters:
|
||||
- lll
|
||||
- gocritic
|
||||
# Exclude `lll` issues for long struct tags
|
||||
- source: "`[^`\n]+`"
|
||||
linters:
|
||||
- lll
|
||||
# Exclude `lll` issues for swagger annotation
|
||||
- source: "^//[\\s]*@[\\w]+\\s+"
|
||||
linters:
|
||||
- lll
|
||||
# Exclude `lll` issues for long lines with `swag annotations`
|
||||
- linters:
|
||||
- lll
|
||||
source: "^//[\\s]*@"
|
||||
# Exclude `lll` and `funlen` for test files
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- funlen
|
||||
- lll
|
||||
- containedctx
|
||||
- exhaustruct
|
12
Dockerfile
Normal file
12
Dockerfile
Normal file
@ -0,0 +1,12 @@
|
||||
FROM golang:1.21.3-alpine3.18 AS builder
|
||||
|
||||
COPY go.mod go.sum /opt/minireader/
|
||||
RUN go mod download
|
||||
|
||||
COPY . /opt/minireader
|
||||
RUN go build -o /opt/minireader/minireader /opt/minireader/cmd/minireader
|
||||
|
||||
FROM alpine:3.18
|
||||
COPY --from=builder /opt/minireader/minireader /app/minireader
|
||||
|
||||
CMD ["/app/minireader"]
|
9
Dockerfile.dev
Normal file
9
Dockerfile.dev
Normal file
@ -0,0 +1,9 @@
|
||||
FROM golang:1.21.3-alpine3.18
|
||||
|
||||
EXPOSE 8080
|
||||
VOLUME /opt/app
|
||||
WORKDIR /opt/app
|
||||
|
||||
RUN go install github.com/cosmtrek/air@latest
|
||||
|
||||
CMD ["air", "-c", ".air.toml"]
|
19
LICENSE
Normal file
19
LICENSE
Normal file
@ -0,0 +1,19 @@
|
||||
Copyright (c) 2023 Pavel Kilin (aka borodyadka)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
|
||||
OR OTHER DEALINGS IN THE SOFTWARE.
|
11
Makefile
Normal file
11
Makefile
Normal file
@ -0,0 +1,11 @@
|
||||
.PHONY: dev
|
||||
dev:
|
||||
docker-compose -f docker-compose.yml -f docker-compose.dev.yml up
|
||||
|
||||
.PHONY: lint
|
||||
lint:
|
||||
docker run --rm -v $(shell pwd):/app -w /app golangci/golangci-lint:v1.55.2 golangci-lint run ./cmd/minireader
|
||||
|
||||
.PHONY: sqlc
|
||||
sqlc:
|
||||
docker run --rm -v $(shell pwd):/app -w /app -u $(shell id -u):$(shell id -g) sqlc/sqlc:1.23.0 generate
|
11
README.md
Normal file
11
README.md
Normal file
@ -0,0 +1,11 @@
|
||||
# MiniReader
|
||||
|
||||
Minireader is simple, lightweight and extensible newsfeed aggregator.
|
||||
|
||||
## TODO
|
||||
|
||||
* [ ] swagger docs;
|
||||
* [ ] filters (by name, by feed tags, by title, by url, regex/glob patterns) and rules (autotags);
|
||||
* [ ] user-defined tags;
|
||||
* [ ] strip `utm_` garbage from links;
|
||||
|
25
cmd/minireader/main.go
Normal file
25
cmd/minireader/main.go
Normal file
@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"borodyadka.dev/borodyadka/minireader/internal/minireader"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app, err := minireader.New()
|
||||
if err != nil {
|
||||
slog.Error("failed to init application", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
if err := app.Start(ctx); err != nil {
|
||||
app.Logger().Error("failed to start application", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
9
docker-compose.dev.yml
Normal file
9
docker-compose.dev.yml
Normal file
@ -0,0 +1,9 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
volumes:
|
||||
- .:/opt/app
|
32
docker-compose.yml
Normal file
32
docker-compose.yml
Normal file
@ -0,0 +1,32 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
environment:
|
||||
POSTGRES_USER: minireader
|
||||
POSTGRES_PASSWORD: minireader
|
||||
POSTGRES_DB: minireader
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
volumes:
|
||||
- postgres:/var/lib/postgresql/data
|
||||
tmpfs:
|
||||
- /tmp
|
||||
healthcheck:
|
||||
test: pg_isready -U minireader
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
server:
|
||||
image: borodyadka/minireader:dev
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
postgres: {}
|
61
go.mod
Normal file
61
go.mod
Normal file
@ -0,0 +1,61 @@
|
||||
module borodyadka.dev/borodyadka/minireader
|
||||
|
||||
go 1.21.1
|
||||
|
||||
require (
|
||||
github.com/go-playground/validator/v10 v10.16.0
|
||||
github.com/gofiber/contrib/jwt v1.0.7
|
||||
github.com/gofiber/fiber/v2 v2.50.0
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0
|
||||
github.com/jackc/pgx/v5 v5.4.3
|
||||
github.com/mmcdole/gofeed v1.2.1
|
||||
github.com/pressly/goose/v3 v3.15.1
|
||||
github.com/samber/slog-fiber v1.6.0
|
||||
github.com/samber/slog-loki v1.0.0
|
||||
github.com/sethvargo/go-envconfig v0.9.0
|
||||
github.com/stretchr/testify v1.8.2
|
||||
golang.org/x/sync v0.4.0
|
||||
gopkg.in/guregu/null.v4 v4.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/MicahParks/keyfunc/v2 v2.1.0 // indirect
|
||||
github.com/PuerkitoBio/goquery v1.8.0 // indirect
|
||||
github.com/afiskon/promtail-client v0.0.0-20190305142237-506f3f921e9c // indirect
|
||||
github.com/andybalholm/brotli v1.0.5 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/uuid v1.4.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.17.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/mmcdole/goxpp v1.1.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.11.0 // indirect
|
||||
github.com/samber/lo v1.38.1 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.50.0 // indirect
|
||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||
golang.org/x/crypto v0.13.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 // indirect
|
||||
golang.org/x/net v0.10.0 // indirect
|
||||
golang.org/x/sys v0.13.0 // indirect
|
||||
golang.org/x/text v0.13.0 // indirect
|
||||
google.golang.org/protobuf v1.28.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
171
go.sum
Normal file
171
go.sum
Normal file
@ -0,0 +1,171 @@
|
||||
github.com/MicahParks/keyfunc/v2 v2.1.0 h1:6ZXKb9Rp6qp1bDbJefnG7cTH8yMN1IC/4nf+GVjO99k=
|
||||
github.com/MicahParks/keyfunc/v2 v2.1.0/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4OeE/yHVMteCkw9k=
|
||||
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
|
||||
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
|
||||
github.com/afiskon/promtail-client v0.0.0-20190305142237-506f3f921e9c h1:AMDVOKGaiqse4qiRXSzRgpC9DCNTHCx6zpzdtXXrKM4=
|
||||
github.com/afiskon/promtail-client v0.0.0-20190305142237-506f3f921e9c/go.mod h1:p/7Wos+jcfrnwLqqzJMZ0s323kfVtJPW+HUvAANklVQ=
|
||||
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
|
||||
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
|
||||
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
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/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/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.16.0 h1:x+plE831WK4vaKHO/jpgUGsvLKIqRRkz6M78GuJAfGE=
|
||||
github.com/go-playground/validator/v10 v10.16.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/gofiber/contrib/jwt v1.0.7 h1:LZuCnjEq8AjiDTUjBQSd2zg3H5uDWjHxSXjo7nj9iAc=
|
||||
github.com/gofiber/contrib/jwt v1.0.7/go.mod h1:fA1apg9zQlUhax+Foc0BHATCDzBsemga1Yr9X0KSvrQ=
|
||||
github.com/gofiber/fiber/v2 v2.50.0 h1:ia0JaB+uw3GpNSCR5nvC5dsaxXjRU5OEu36aytx+zGw=
|
||||
github.com/gofiber/fiber/v2 v2.50.0/go.mod h1:21eytvay9Is7S6z+OgPi7c7n4++tnClWmhpimVHMimw=
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
|
||||
github.com/golang/protobuf v1.5.2/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/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/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
|
||||
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
|
||||
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY=
|
||||
github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM=
|
||||
github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
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.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mmcdole/gofeed v1.2.1 h1:tPbFN+mfOLcM1kDF1x2c/N68ChbdBatkppdzf/vDe1s=
|
||||
github.com/mmcdole/gofeed v1.2.1/go.mod h1:2wVInNpgmC85q16QTTuwbuKxtKkHLCDDtf0dCmnrNr4=
|
||||
github.com/mmcdole/goxpp v1.1.0 h1:WwslZNF7KNAXTFuzRtn/OKZxFLJAAyOA9w82mDz2ZGI=
|
||||
github.com/mmcdole/goxpp v1.1.0/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
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/pressly/goose/v3 v3.15.1 h1:dKaJ1SdLvS/+HtS8PzFT0KBEtICC1jewLXM+b3emlv8=
|
||||
github.com/pressly/goose/v3 v3.15.1/go.mod h1:0E3Yg/+EwYzO6Rz2P98MlClFgIcoujbVRs575yi3iIM=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||
github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM=
|
||||
github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
|
||||
github.com/samber/slog-fiber v1.6.0 h1:UK37csaDv9MGPKz5gdJvfL5EsjWg8g/Aph8/rm6/qKo=
|
||||
github.com/samber/slog-fiber v1.6.0/go.mod h1:TEQR0iowC2vWOqrVXCTVg+h05DpkQMN9Y5mgHgJSx1g=
|
||||
github.com/samber/slog-loki v1.0.0 h1:T/Dv0LlCfFEEi2PDzpxJgQeoFxAvjCnriItaBGgNT6s=
|
||||
github.com/samber/slog-loki v1.0.0/go.mod h1:7FpVL6ECdmstVLDogUDE7tLdyEIjW/B/u0+ctPmYzd0=
|
||||
github.com/sethvargo/go-envconfig v0.9.0 h1:Q6FQ6hVEeTECULvkJZakq3dZMeBQ3JUpcKMfPQbKMDE=
|
||||
github.com/sethvargo/go-envconfig v0.9.0/go.mod h1:Iz1Gy1Sf3T64TQlJSvee81qDhf7YIlt8GMUX6yyNFs0=
|
||||
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.7.0/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.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.50.0 h1:H7fweIlBm0rXLs2q0XbalvJ6r0CUPFWK3/bB4N13e9M=
|
||||
github.com/valyala/fasthttp v1.50.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA=
|
||||
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc=
|
||||
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
|
||||
golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ=
|
||||
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
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.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
|
||||
google.golang.org/protobuf v1.28.1/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/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/guregu/null.v4 v4.0.0 h1:1Wm3S1WEA2I26Kq+6vcW+w0gcDo44YKYD7YIEJNHDjg=
|
||||
gopkg.in/guregu/null.v4 v4.0.0/go.mod h1:YoQhUrADuG3i9WqesrCmpNRwm1ypAgSHYqoOcTu/JrI=
|
||||
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=
|
||||
lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo=
|
||||
lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||
modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q=
|
||||
modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y=
|
||||
modernc.org/ccgo/v3 v3.16.15 h1:KbDR3ZAVU+wiLyMESPtbtE/Add4elztFyfsWoNTgxS0=
|
||||
modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI=
|
||||
modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM=
|
||||
modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
|
||||
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sqlite v1.26.0 h1:SocQdLRSYlA8W99V8YH0NES75thx19d9sB/aFc4R8Lw=
|
||||
modernc.org/sqlite v1.26.0/go.mod h1:FL3pVXie73rg3Rii6V/u5BoHlSoyeZeIgKZEgHARyCU=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
152
internal/minireader/app.go
Normal file
152
internal/minireader/app.go
Normal file
@ -0,0 +1,152 @@
|
||||
package minireader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/pressly/goose/v3"
|
||||
"github.com/sethvargo/go-envconfig"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"borodyadka.dev/borodyadka/minireader/internal/minireader/config"
|
||||
"borodyadka.dev/borodyadka/minireader/internal/minireader/dto"
|
||||
"borodyadka.dev/borodyadka/minireader/internal/minireader/fetcher"
|
||||
"borodyadka.dev/borodyadka/minireader/internal/minireader/fetcher/rss"
|
||||
"borodyadka.dev/borodyadka/minireader/internal/minireader/reader"
|
||||
"borodyadka.dev/borodyadka/minireader/internal/minireader/repositories"
|
||||
"borodyadka.dev/borodyadka/minireader/internal/minireader/transport/http"
|
||||
"borodyadka.dev/borodyadka/minireader/migrations"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
logger *slog.Logger
|
||||
config config.Config
|
||||
}
|
||||
|
||||
func New() (*App, error) {
|
||||
config := config.Config{}
|
||||
if err := envconfig.Process(context.Background(), &config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger, err := newLogger(config.Log)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &App{
|
||||
config: config,
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (app *App) Start(ctx context.Context) error {
|
||||
err := app.migrate(
|
||||
ctx,
|
||||
app.logger.With("module", "migrator"),
|
||||
app.config.Postgres.DSN,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to apply migrations: %w", err)
|
||||
}
|
||||
|
||||
db, err := repositories.NewDatabase(ctx, app.config.Postgres.DSN)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fetcher := fetcher.New(
|
||||
fetcher.WithConfig(fetcher.Config{
|
||||
CheckInterval: app.config.Fetcher.Interval,
|
||||
Fetchers: map[dto.Provider]fetcher.Fetcher{
|
||||
dto.ProviderRSS: rss.New(rss.Config{
|
||||
HTTPTimeout: 5 * time.Minute,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
fetcher.WithRepositories(fetcher.Repositories{
|
||||
Feed: repositories.NewFeedRepository(),
|
||||
Item: repositories.NewItemRepository(),
|
||||
}),
|
||||
fetcher.WithLogger(app.logger.With("module", "fetcher")),
|
||||
)
|
||||
|
||||
reader := reader.New(
|
||||
reader.WithRepositories(reader.Repositories{
|
||||
Feed: repositories.NewFeedRepository(),
|
||||
Subscription: repositories.NewSubscriptionRepository(),
|
||||
Item: repositories.NewItemRepository(),
|
||||
User: repositories.NewUserRepository(),
|
||||
}),
|
||||
reader.WithFetcher(fetcher),
|
||||
reader.WithLogger(app.logger.With("module", "reader")),
|
||||
)
|
||||
|
||||
server := http.New(
|
||||
http.WithConfig(app.config.HTTP),
|
||||
http.WithServices(http.Services{
|
||||
Reader: reader,
|
||||
}),
|
||||
http.WithSecurity(app.config.Security),
|
||||
http.WithLogger(app.logger.With("module", "http")),
|
||||
)
|
||||
|
||||
server.Inject(repositories.ConnKey, db)
|
||||
|
||||
eg, ctx := errgroup.WithContext(ctx)
|
||||
eg.Go(func() error {
|
||||
return server.Start()
|
||||
})
|
||||
|
||||
eg.Go(func() error {
|
||||
fctx := context.WithValue(ctx, repositories.ConnKey, db)
|
||||
return fetcher.Start(fctx)
|
||||
})
|
||||
|
||||
eg.Go(func() error {
|
||||
sigHandler := make(chan os.Signal, 1)
|
||||
signal.Notify(sigHandler, syscall.SIGTERM, syscall.SIGINT)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-sigHandler:
|
||||
}
|
||||
|
||||
_ = server.Shutdown()
|
||||
return nil
|
||||
})
|
||||
|
||||
return eg.Wait()
|
||||
}
|
||||
|
||||
func (app *App) migrate(ctx context.Context, logger *slog.Logger, dsn string) error {
|
||||
goose.SetBaseFS(migrations.Migrations)
|
||||
goose.SetLogger(&gooseWrapper{logger: logger})
|
||||
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
goose.SetTableName("migrations")
|
||||
if err := goose.SetDialect("postgres"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := goose.UpContext(ctx, db, "."); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (app *App) Logger() *slog.Logger {
|
||||
return app.logger
|
||||
}
|
43
internal/minireader/config/config.go
Normal file
43
internal/minireader/config/config.go
Normal file
@ -0,0 +1,43 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Postgres PostgresConfig `env:",prefix=POSTGRES_"`
|
||||
HTTP HTTP `env:",prefix=HTTP_"`
|
||||
Log LogConfig `env:",prefix=LOG_"`
|
||||
Security Security `env:",prefix=SECURITY_"`
|
||||
Fetcher Fetcher `env:",prefix=FETCHER_"`
|
||||
}
|
||||
|
||||
type Security struct {
|
||||
JWKSURL string `env:"JWKS_URL" validate:"uri,required"`
|
||||
UserInfoURL string `env:"USER_INFO_URL" validate:"uri,optional"`
|
||||
}
|
||||
|
||||
type HTTP struct {
|
||||
Listen string `env:"LISTEN,default=0.0.0.0:8080"`
|
||||
APIPrefix string `env:"API_PREFIX"`
|
||||
AccessLog bool `env:"ENABLE_ACCESS_LOG,default=false"`
|
||||
EnableGUI bool `env:"ENABLE_GUI,default=true"` // TODO
|
||||
}
|
||||
|
||||
type PostgresConfig struct {
|
||||
DSN string `env:"DSN" validate:"uri"`
|
||||
}
|
||||
|
||||
type Fetcher struct {
|
||||
Interval time.Duration `env:"INTERVAL,default=1m"`
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
// possible values: DEBUG INFO WARN ERROR
|
||||
Level string `env:"LEVEL,default=INFO" validate:"oneof=DEBUG INFO WARN ERROR"`
|
||||
// possible values:
|
||||
// * "none"
|
||||
// * "stdout"
|
||||
// * "loki+http://localhost:3100"
|
||||
Output string `env:"OUTPUT,default=stdout"`
|
||||
}
|
9
internal/minireader/dto/errors.go
Normal file
9
internal/minireader/dto/errors.go
Normal file
@ -0,0 +1,9 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("not_found")
|
||||
)
|
77
internal/minireader/dto/reader.go
Normal file
77
internal/minireader/dto/reader.go
Normal file
@ -0,0 +1,77 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gopkg.in/guregu/null.v4"
|
||||
)
|
||||
|
||||
type Provider string
|
||||
|
||||
func (e *Provider) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = Provider(s)
|
||||
case string:
|
||||
*e = Provider(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for Provider: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
ProviderRSS Provider = "rss"
|
||||
)
|
||||
|
||||
type FeedState struct {
|
||||
LastFetchedAt time.Time `json:"-"`
|
||||
FetchInterval time.Duration `json:"-"`
|
||||
LastError null.String `json:"-"`
|
||||
ErrorsCount int `json:"-"`
|
||||
}
|
||||
|
||||
type Feed struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Provider Provider `json:"provider"`
|
||||
Source string `json:"source"`
|
||||
|
||||
UnreadCount int `json:"unread"`
|
||||
|
||||
State FeedState `json:"-"`
|
||||
}
|
||||
|
||||
type CreateFeed struct {
|
||||
Title null.String `json:"title" validate:"optional"`
|
||||
Provider Provider `json:"provider" validate:"required,oneof=rss"`
|
||||
Source string `json:"source" validate:"required"`
|
||||
}
|
||||
|
||||
type UpdateFeed struct {
|
||||
ID uuid.UUID
|
||||
Title null.String
|
||||
FetchIntervalMultiplier float64
|
||||
LastFetchedAt time.Time
|
||||
}
|
||||
|
||||
type UpdateFeedError struct {
|
||||
ID uuid.UUID
|
||||
Error string
|
||||
LastFetchedAt time.Time
|
||||
}
|
||||
|
||||
type Item struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UID string `json:"uid"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
FetchedAt time.Time `json:"fetchedAt"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Link string `json:"link"`
|
||||
Author string `json:"author"`
|
||||
//Tags []string `json:"tags"` // TODO: user-defined tags
|
||||
//Attachments []any `json:"attachments"` // TODO
|
||||
}
|
7
internal/minireader/dto/site.go
Normal file
7
internal/minireader/dto/site.go
Normal file
@ -0,0 +1,7 @@
|
||||
package dto
|
||||
|
||||
type WebsiteInfo struct {
|
||||
Title string
|
||||
Favicon []byte
|
||||
Feeds []string
|
||||
}
|
5
internal/minireader/dto/user.go
Normal file
5
internal/minireader/dto/user.go
Normal file
@ -0,0 +1,5 @@
|
||||
package dto
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
}
|
148
internal/minireader/fetcher/fetcher.go
Normal file
148
internal/minireader/fetcher/fetcher.go
Normal file
@ -0,0 +1,148 @@
|
||||
package fetcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gopkg.in/guregu/null.v4"
|
||||
|
||||
"borodyadka.dev/borodyadka/minireader/internal/minireader/dto"
|
||||
)
|
||||
|
||||
type Fetcher interface {
|
||||
Fetch(ctx context.Context, source string) (dto.Feed, []dto.Item, error)
|
||||
}
|
||||
|
||||
type feedRepository interface {
|
||||
ListOutdatedFeeds(ctx context.Context) ([]dto.Feed, error)
|
||||
UpdateFeed(ctx context.Context, input *dto.UpdateFeed) error
|
||||
SaveFeedError(ctx context.Context, input *dto.UpdateFeedError) error
|
||||
}
|
||||
|
||||
type itemRepository interface {
|
||||
UpsertItems(ctx context.Context, feed uuid.UUID, items []dto.Item) (inserted int, err error)
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
CheckInterval time.Duration
|
||||
|
||||
Fetchers map[dto.Provider]Fetcher
|
||||
}
|
||||
|
||||
type FetchScheduler struct {
|
||||
checkInterval time.Duration
|
||||
logger *slog.Logger
|
||||
|
||||
feeds feedRepository
|
||||
items itemRepository
|
||||
|
||||
fetchers map[dto.Provider]Fetcher
|
||||
}
|
||||
|
||||
func New(opts ...Option) *FetchScheduler {
|
||||
fetcher := &FetchScheduler{
|
||||
fetchers: make(map[dto.Provider]Fetcher),
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(fetcher)
|
||||
}
|
||||
|
||||
return fetcher
|
||||
}
|
||||
|
||||
func (s *FetchScheduler) Start(ctx context.Context) error {
|
||||
t := time.NewTicker(s.checkInterval)
|
||||
defer t.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-t.C:
|
||||
if err := s.refreshFeeds(ctx); err != nil {
|
||||
s.logger.Error("failed to refresh feeds", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *FetchScheduler) refreshFeeds(ctx context.Context) error {
|
||||
logger := s.logger.With("method", "refreshFeeds")
|
||||
|
||||
// TODO: make it parallel with N (configurable) workers
|
||||
feeds, err := s.feeds.ListOutdatedFeeds(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, feed := range feeds {
|
||||
err := s.refreshFeed(ctx, feed)
|
||||
if err != nil {
|
||||
logger.Error("failed to refresh feed", "feed", feed.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *FetchScheduler) refreshFeed(ctx context.Context, feed dto.Feed) error {
|
||||
logger := s.logger.With("method", "refreshFeeds")
|
||||
|
||||
fetcher := s.fetchers[feed.Provider]
|
||||
if fetcher == nil {
|
||||
logger.Error("fetcher not exists", "feed", feed.ID, "provider", feed.Provider, "source", feed.Source)
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
|
||||
info, items, err := fetcher.Fetch(ctx, feed.Source)
|
||||
if err != nil {
|
||||
return s.feeds.SaveFeedError(ctx, &dto.UpdateFeedError{
|
||||
ID: feed.ID,
|
||||
Error: err.Error(),
|
||||
LastFetchedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
inserted, err := s.items.UpsertItems(ctx, feed.ID, items)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// find out how often we should refresh this feed:
|
||||
// * if ratio of new items is greater than 90% then shorten interval in half
|
||||
// * if ratio of new items is less than 10% then double interval
|
||||
multiplier := 1.0
|
||||
ratio := float64(inserted) / float64(len(items))
|
||||
if ratio >= 0.9 {
|
||||
multiplier = 0.5
|
||||
} else if ratio <= 0.1 {
|
||||
multiplier = 2
|
||||
}
|
||||
|
||||
logger.Debug("items upserted",
|
||||
"feed", feed.ID,
|
||||
"provider", feed.Provider,
|
||||
"source", feed.Source,
|
||||
"ratio", ratio,
|
||||
"items", inserted,
|
||||
"total", len(items))
|
||||
|
||||
if err := s.feeds.UpdateFeed(ctx, &dto.UpdateFeed{
|
||||
ID: feed.ID,
|
||||
Title: null.StringFrom(info.Title),
|
||||
FetchIntervalMultiplier: multiplier,
|
||||
LastFetchedAt: now,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *FetchScheduler) ForceRefresh(ctx context.Context, feed dto.Feed) error {
|
||||
return s.refreshFeed(ctx, feed)
|
||||
}
|
38
internal/minireader/fetcher/options.go
Normal file
38
internal/minireader/fetcher/options.go
Normal file
@ -0,0 +1,38 @@
|
||||
package fetcher
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"borodyadka.dev/borodyadka/minireader/internal/pkg/validator"
|
||||
)
|
||||
|
||||
type Option func(fetcher *FetchScheduler)
|
||||
|
||||
type Repositories struct {
|
||||
Feed feedRepository `validate:"required"`
|
||||
Item itemRepository `validate:"required"`
|
||||
}
|
||||
|
||||
func WithRepositories(repo Repositories) Option {
|
||||
return func(fetcher *FetchScheduler) {
|
||||
if err := validator.Struct(repo); err != nil {
|
||||
panic("one or more repositories passed into FetchScheduler instance is invalid or not defined")
|
||||
}
|
||||
|
||||
fetcher.feeds = repo.Feed
|
||||
fetcher.items = repo.Item
|
||||
}
|
||||
}
|
||||
|
||||
func WithConfig(config Config) Option {
|
||||
return func(fetcher *FetchScheduler) {
|
||||
fetcher.checkInterval = config.CheckInterval
|
||||
fetcher.fetchers = config.Fetchers
|
||||
}
|
||||
}
|
||||
|
||||
func WithLogger(logger *slog.Logger) Option {
|
||||
return func(fetcher *FetchScheduler) {
|
||||
fetcher.logger = logger
|
||||
}
|
||||
}
|
64
internal/minireader/fetcher/rss/fetcher.go
Normal file
64
internal/minireader/fetcher/rss/fetcher.go
Normal file
@ -0,0 +1,64 @@
|
||||
package rss
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/mmcdole/gofeed"
|
||||
|
||||
"borodyadka.dev/borodyadka/minireader/internal/minireader/dto"
|
||||
"borodyadka.dev/borodyadka/minireader/internal/pkg/utils"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
HTTPTimeout time.Duration
|
||||
}
|
||||
|
||||
type Fetcher struct {
|
||||
config Config
|
||||
}
|
||||
|
||||
func New(config Config) *Fetcher {
|
||||
return &Fetcher{config: config}
|
||||
}
|
||||
|
||||
func (f *Fetcher) Fetch(ctx context.Context, source string) (dto.Feed, []dto.Item, error) {
|
||||
cl := http.Client{Timeout: f.config.HTTPTimeout}
|
||||
|
||||
req, err := http.NewRequest("GET", source, nil)
|
||||
if err != nil {
|
||||
return dto.Feed{}, nil, err
|
||||
}
|
||||
|
||||
resp, err := cl.Do(req)
|
||||
if err != nil {
|
||||
return dto.Feed{}, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
feed, err := gofeed.NewParser().Parse(resp.Body)
|
||||
if err != nil {
|
||||
return dto.Feed{}, nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
items := make([]dto.Item, 0, len(feed.Items))
|
||||
for _, item := range feed.Items {
|
||||
items = append(items, dto.Item{
|
||||
UID: item.GUID,
|
||||
CreatedAt: utils.DerefOrDefault(item.PublishedParsed, now),
|
||||
FetchedAt: now,
|
||||
Title: item.Title,
|
||||
Content: item.Content,
|
||||
Link: item.Link,
|
||||
// TODO: rest of fields
|
||||
})
|
||||
}
|
||||
|
||||
return dto.Feed{
|
||||
Title: feed.Title,
|
||||
Provider: dto.ProviderRSS,
|
||||
Source: source,
|
||||
}, items, nil
|
||||
}
|
83
internal/minireader/logger.go
Normal file
83
internal/minireader/logger.go
Normal file
@ -0,0 +1,83 @@
|
||||
package minireader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/samber/slog-loki"
|
||||
|
||||
"borodyadka.dev/borodyadka/minireader/internal/minireader/config"
|
||||
)
|
||||
|
||||
func parseLoggerOutput(dsn string) (typ string, host string, err error) {
|
||||
switch dsn {
|
||||
case "none", "stdout":
|
||||
return dsn, "", nil
|
||||
}
|
||||
|
||||
u, err := url.Parse(dsn)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if strings.Contains(u.Scheme, "loki") {
|
||||
u.Scheme = strings.NewReplacer("loki", "", "+", "").Replace(u.Scheme)
|
||||
return "loki", u.String(), nil
|
||||
}
|
||||
|
||||
return "", "", errors.New("cannot parse logger dsn")
|
||||
}
|
||||
|
||||
func newLogger(config config.LogConfig) (*slog.Logger, error) {
|
||||
var level slog.Level
|
||||
if err := level.UnmarshalText([]byte(config.Level)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
typ, host, err := parseLoggerOutput(config.Output)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch typ {
|
||||
case "none":
|
||||
return slog.New(discardHandler{}), nil
|
||||
case "loki":
|
||||
handler := slogloki.Option{
|
||||
Level: level,
|
||||
Endpoint: host,
|
||||
BatchWait: time.Second,
|
||||
BatchEntriesNumber: 32,
|
||||
}.NewLokiHandler()
|
||||
return slog.New(handler), nil
|
||||
}
|
||||
|
||||
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: level,
|
||||
})), nil
|
||||
}
|
||||
|
||||
type discardHandler struct{}
|
||||
|
||||
func (discardHandler) Enabled(context.Context, slog.Level) bool { return false }
|
||||
func (discardHandler) Handle(context.Context, slog.Record) error { return nil }
|
||||
func (d discardHandler) WithAttrs([]slog.Attr) slog.Handler { return d }
|
||||
func (d discardHandler) WithGroup(string) slog.Handler { return d }
|
||||
|
||||
type gooseWrapper struct {
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (l *gooseWrapper) Printf(msg string, args ...any) {
|
||||
l.logger.Info(fmt.Sprintf(strings.TrimSpace(msg), args...))
|
||||
}
|
||||
|
||||
func (l *gooseWrapper) Fatalf(msg string, args ...any) {
|
||||
l.logger.Error(fmt.Sprintf(strings.TrimSpace(msg), args...))
|
||||
}
|
46
internal/minireader/metadata/urlinfo.go
Normal file
46
internal/minireader/metadata/urlinfo.go
Normal file
@ -0,0 +1,46 @@
|
||||
package metadata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"borodyadka.dev/borodyadka/minireader/internal/minireader/dto"
|
||||
)
|
||||
|
||||
func fetch(ctx context.Context, cl *http.Client, page string) (io.ReadCloser, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", page, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := cl.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
func FetchPageInfo(ctx context.Context, page string) (dto.WebsiteInfo, error) {
|
||||
info := dto.WebsiteInfo{}
|
||||
|
||||
u, err := url.Parse(page)
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
|
||||
cl := &http.Client{}
|
||||
for {
|
||||
body, err := fetch(ctx, cl, u.String())
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
_ = body
|
||||
// TODO: handle http status codes (404, 500, 502)
|
||||
// TODO: if no info on this page go to parent dir ant try again (maybe add some sleep time?)
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
41
internal/minireader/reader/options.go
Normal file
41
internal/minireader/reader/options.go
Normal file
@ -0,0 +1,41 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"borodyadka.dev/borodyadka/minireader/internal/pkg/validator"
|
||||
)
|
||||
|
||||
type Option func(reader *Reader)
|
||||
|
||||
type Repositories struct {
|
||||
Feed feedRepository `validate:"required"`
|
||||
Subscription subscriptionRepository `validate:"required"`
|
||||
Item itemRepository `validate:"required"`
|
||||
User userRepository `validate:"required"`
|
||||
}
|
||||
|
||||
func WithRepositories(repo Repositories) Option {
|
||||
return func(reader *Reader) {
|
||||
if err := validator.Struct(repo); err != nil {
|
||||
panic("one or more repositories passed into Reader instance is invalid or not defined")
|
||||
}
|
||||
|
||||
reader.feeds = repo.Feed
|
||||
reader.subscriptions = repo.Subscription
|
||||
reader.items = repo.Item
|
||||
reader.users = repo.User
|
||||
}
|
||||
}
|
||||
|
||||
func WithFetcher(fetcher fetcher) Option {
|
||||
return func(reader *Reader) {
|
||||
reader.fetcher = fetcher
|
||||
}
|
||||
}
|
||||
|
||||
func WithLogger(logger *slog.Logger) Option {
|
||||
return func(reader *Reader) {
|
||||
reader.logger = logger
|
||||
}
|
||||
}
|
157
internal/minireader/reader/reader.go
Normal file
157
internal/minireader/reader/reader.go
Normal file
@ -0,0 +1,157 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"borodyadka.dev/borodyadka/minireader/internal/minireader/dto"
|
||||
"borodyadka.dev/borodyadka/minireader/internal/minireader/repositories"
|
||||
"borodyadka.dev/borodyadka/minireader/internal/pkg/validator"
|
||||
)
|
||||
|
||||
type feedRepository interface {
|
||||
ListAllUserFeeds(ctx context.Context, user *dto.User) ([]dto.Feed, int, error)
|
||||
FindOrCreate(ctx context.Context, input *dto.CreateFeed) (dto.Feed, bool, error)
|
||||
Delete(ctx context.Context, id uuid.UUID) (bool, error)
|
||||
}
|
||||
|
||||
type itemRepository interface{}
|
||||
|
||||
type subscriptionRepository interface {
|
||||
Create(ctx context.Context, user *dto.User, feed uuid.UUID) error
|
||||
Update(ctx context.Context, user *dto.User, feed uuid.UUID, input dto.UpdateFeed) (bool, error)
|
||||
Delete(ctx context.Context, user *dto.User) (bool, error)
|
||||
CountByFeed(ctx context.Context, feed uuid.UUID) (int, error)
|
||||
}
|
||||
|
||||
type userRepository interface {
|
||||
EnsureUser(ctx context.Context, user *dto.User) error
|
||||
}
|
||||
|
||||
type fetcher interface {
|
||||
ForceRefresh(ctx context.Context, feed dto.Feed) error
|
||||
}
|
||||
|
||||
type Reader struct {
|
||||
feeds feedRepository
|
||||
subscriptions subscriptionRepository
|
||||
items itemRepository
|
||||
users userRepository
|
||||
fetcher fetcher
|
||||
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func New(opts ...Option) *Reader {
|
||||
reader := &Reader{}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(reader)
|
||||
}
|
||||
|
||||
return reader
|
||||
}
|
||||
|
||||
func (r *Reader) EnsureUser(ctx context.Context, user *dto.User) error {
|
||||
return r.users.EnsureUser(ctx, user)
|
||||
}
|
||||
|
||||
func (r *Reader) ListAllUserFeeds(ctx context.Context, user *dto.User) ([]dto.Feed, int, error) {
|
||||
return r.feeds.ListAllUserFeeds(ctx, user)
|
||||
}
|
||||
|
||||
func (r *Reader) FindOrCreateFeed(ctx context.Context, input *dto.CreateFeed) (dto.Feed, error) {
|
||||
logger := r.logger.With("method", "FindOrCreateFeed")
|
||||
var feed dto.Feed
|
||||
|
||||
err := repositories.Transactional(ctx, func(ctx context.Context) (err error) {
|
||||
var created bool
|
||||
feed, created, err = r.feeds.FindOrCreate(ctx, input)
|
||||
if err != nil {
|
||||
logger.Error("find or create feed failed", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if created {
|
||||
logger.Debug("feed created", "feed", feed.ID)
|
||||
err := r.fetcher.ForceRefresh(ctx, feed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return feed, err
|
||||
}
|
||||
|
||||
// CreateSubscription will create a new subscription to feed (if feed not exists, it will be created)
|
||||
func (r *Reader) CreateSubscription(ctx context.Context, user *dto.User, input *dto.CreateFeed) (dto.Feed, error) {
|
||||
logger := r.logger.With("method", "CreateSubscription")
|
||||
var feed dto.Feed
|
||||
|
||||
if err := validator.Struct(input); err != nil {
|
||||
return feed, err
|
||||
}
|
||||
|
||||
err := repositories.Transactional(ctx, func(ctx context.Context) (err error) {
|
||||
feed, err = r.FindOrCreateFeed(ctx, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Debug("create subscription", "feed", feed.ID)
|
||||
return r.subscriptions.Create(ctx, user, feed.ID)
|
||||
})
|
||||
if err != nil {
|
||||
return feed, err
|
||||
}
|
||||
|
||||
return feed, nil
|
||||
}
|
||||
|
||||
// UpdateSubscription will update subscription info (e.g. change title)
|
||||
func (r *Reader) UpdateSubscription(ctx context.Context, user *dto.User, feed uuid.UUID, input dto.UpdateFeed) error {
|
||||
logger := r.logger.With("method", "UpdateSubscription")
|
||||
|
||||
updated, err := r.subscriptions.Update(ctx, user, feed, input)
|
||||
if !updated {
|
||||
return dto.ErrNotFound
|
||||
}
|
||||
|
||||
logger.Debug("subscription updated", "feed", feed)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteSubscription will delete user subscription and remove feed if no active subscriptions left
|
||||
func (r *Reader) DeleteSubscription(ctx context.Context, user *dto.User, feed uuid.UUID) error {
|
||||
logger := r.logger.With("method", "DeleteSubscription")
|
||||
|
||||
return repositories.Transactional(ctx, func(ctx context.Context) error {
|
||||
deleted, err := r.subscriptions.Delete(ctx, user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !deleted {
|
||||
return dto.ErrNotFound
|
||||
}
|
||||
|
||||
logger.Debug("subscription removed", "feed", feed)
|
||||
|
||||
subs, err := r.subscriptions.CountByFeed(ctx, feed)
|
||||
if err != nil {
|
||||
logger.Error("failed to count feed subscriptions", "error", err)
|
||||
} else if subs == 0 {
|
||||
_, err = r.feeds.Delete(ctx, feed)
|
||||
if err != nil {
|
||||
logger.Error("failed to delete feed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
77
internal/minireader/repositories/database.go
Normal file
77
internal/minireader/repositories/database.go
Normal file
@ -0,0 +1,77 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type ctxkey string
|
||||
|
||||
const (
|
||||
ConnKey ctxkey = "pg_conn"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCannotStartTransaction = errors.New("cannot start transaction (method not supported)")
|
||||
)
|
||||
|
||||
type txbeginner interface {
|
||||
Begin(ctx context.Context) (pgx.Tx, error)
|
||||
}
|
||||
|
||||
type Conn interface {
|
||||
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
|
||||
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||
Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error)
|
||||
SendBatch(context.Context, *pgx.Batch) pgx.BatchResults
|
||||
}
|
||||
|
||||
func NewDatabase(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
|
||||
cfg, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// TODO: logger
|
||||
|
||||
return pgxpool.NewWithConfig(ctx, cfg)
|
||||
}
|
||||
|
||||
func ConnFromContext(ctx context.Context) Conn {
|
||||
return ctx.Value(ConnKey).(Conn)
|
||||
}
|
||||
|
||||
func WithConn(ctx context.Context, conn Conn) context.Context {
|
||||
return context.WithValue(ctx, ConnKey, conn)
|
||||
}
|
||||
|
||||
type TransactionalFn func(ctx context.Context) error
|
||||
|
||||
func Transactional(ctx context.Context, fn TransactionalFn) error {
|
||||
conn := ConnFromContext(ctx)
|
||||
|
||||
// already in transaction
|
||||
if _, ok := conn.(*pgxpool.Tx); ok {
|
||||
return fn(ctx)
|
||||
}
|
||||
|
||||
b, ok := conn.(txbeginner)
|
||||
if !ok {
|
||||
return ErrCannotStartTransaction
|
||||
}
|
||||
|
||||
tx, err := b.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if err := fn(WithConn(ctx, tx)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit(ctx)
|
||||
}
|
92
internal/minireader/repositories/feed.go
Normal file
92
internal/minireader/repositories/feed.go
Normal file
@ -0,0 +1,92 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"borodyadka.dev/borodyadka/minireader/internal/minireader/dto"
|
||||
"borodyadka.dev/borodyadka/minireader/internal/minireader/repositories/sql"
|
||||
"borodyadka.dev/borodyadka/minireader/internal/pkg/utils"
|
||||
)
|
||||
|
||||
type FeedRepository struct {
|
||||
}
|
||||
|
||||
func NewFeedRepository() *FeedRepository {
|
||||
return &FeedRepository{}
|
||||
}
|
||||
|
||||
func (r *FeedRepository) FindOrCreate(ctx context.Context, input *dto.CreateFeed) (dto.Feed, bool, error) {
|
||||
result, err := sql.New(ConnFromContext(ctx)).FindOrCreateFeed(ctx, sql.FindOrCreateFeedParams{
|
||||
Title: input.Title,
|
||||
Provider: input.Provider,
|
||||
Source: input.Source,
|
||||
})
|
||||
if err != nil {
|
||||
return dto.Feed{}, false, err
|
||||
}
|
||||
|
||||
return dto.Feed{
|
||||
ID: result.ID,
|
||||
Title: result.Title,
|
||||
Provider: input.Provider,
|
||||
Source: input.Source,
|
||||
}, result.Created, nil
|
||||
}
|
||||
|
||||
func (r *FeedRepository) ListOutdatedFeeds(ctx context.Context) ([]dto.Feed, error) {
|
||||
items, err := sql.New(ConnFromContext(ctx)).ListOutdatedFeeds(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return utils.MapSlice(items, func(t sql.ListOutdatedFeedsRow) dto.Feed {
|
||||
return dto.Feed{
|
||||
ID: t.ID,
|
||||
Provider: t.Provider,
|
||||
Source: t.Source,
|
||||
}
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (r *FeedRepository) ListAllUserFeeds(ctx context.Context, user *dto.User) ([]dto.Feed, int, error) {
|
||||
items, err := sql.New(ConnFromContext(ctx)).ListAllUserFeeds(ctx, user.ID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return utils.MapSlice(items, func(t sql.ListAllUserFeedsRow) dto.Feed {
|
||||
return dto.Feed{
|
||||
ID: t.ID,
|
||||
Title: t.Title,
|
||||
Provider: t.Provider,
|
||||
Source: t.Source,
|
||||
UnreadCount: int(t.Unread),
|
||||
State: dto.FeedState{},
|
||||
}
|
||||
}), len(items), nil
|
||||
}
|
||||
|
||||
func (r *FeedRepository) UpdateFeed(ctx context.Context, input *dto.UpdateFeed) error {
|
||||
return sql.New(ConnFromContext(ctx)).UpdateFeed(ctx, sql.UpdateFeedParams{
|
||||
ID: input.ID,
|
||||
Title: input.Title.String,
|
||||
IntervalMultiplier: input.FetchIntervalMultiplier,
|
||||
LastFetchedAt: pgtype.Timestamptz{Time: input.LastFetchedAt, Valid: true},
|
||||
})
|
||||
}
|
||||
|
||||
func (r *FeedRepository) SaveFeedError(ctx context.Context, input *dto.UpdateFeedError) error {
|
||||
return sql.New(ConnFromContext(ctx)).SaveFeedError(ctx, sql.SaveFeedErrorParams{
|
||||
ID: input.ID,
|
||||
LastError: input.Error,
|
||||
LastFetchedAt: pgtype.Timestamptz{Time: input.LastFetchedAt, Valid: true},
|
||||
})
|
||||
}
|
||||
|
||||
func (r *FeedRepository) Delete(ctx context.Context, id uuid.UUID) (bool, error) {
|
||||
tag, err := sql.New(ConnFromContext(ctx)).DeleteFeed(ctx, id)
|
||||
return tag.RowsAffected() > 0, err
|
||||
}
|
53
internal/minireader/repositories/item.go
Normal file
53
internal/minireader/repositories/item.go
Normal file
@ -0,0 +1,53 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"borodyadka.dev/borodyadka/minireader/internal/minireader/dto"
|
||||
"borodyadka.dev/borodyadka/minireader/internal/minireader/repositories/sql"
|
||||
"borodyadka.dev/borodyadka/minireader/internal/pkg/utils"
|
||||
)
|
||||
|
||||
type ItemRepository struct {
|
||||
}
|
||||
|
||||
func NewItemRepository() *ItemRepository {
|
||||
return &ItemRepository{}
|
||||
}
|
||||
|
||||
func (r *ItemRepository) UpsertItems(ctx context.Context, feed uuid.UUID, items []dto.Item) (int, error) {
|
||||
input := utils.MapSlice(items, func(t dto.Item) sql.UpsertFeedItemBatchParams {
|
||||
return sql.UpsertFeedItemBatchParams{
|
||||
FeedID: feed,
|
||||
CreatedAt: pgtype.Timestamptz{Time: t.CreatedAt, Valid: true},
|
||||
FetchedAt: pgtype.Timestamptz{Time: t.FetchedAt, Valid: true},
|
||||
UID: t.UID,
|
||||
Title: t.Title,
|
||||
Content: t.Content,
|
||||
Link: t.Link,
|
||||
Author: t.Author,
|
||||
}
|
||||
})
|
||||
|
||||
res := sql.New(ConnFromContext(ctx)).UpsertFeedItemBatch(ctx, input)
|
||||
|
||||
var inserted int64
|
||||
res.QueryRow(func(_ int, created bool, err error) {
|
||||
if created {
|
||||
atomic.AddInt64(&inserted, 1)
|
||||
}
|
||||
if err != nil {
|
||||
// TODO: handle error
|
||||
}
|
||||
})
|
||||
|
||||
if err := res.Close(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return int(inserted), nil
|
||||
}
|
1
internal/minireader/repositories/sql/.gitignore
vendored
Normal file
1
internal/minireader/repositories/sql/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
*.go
|
63
internal/minireader/repositories/sql/query.sql
Normal file
63
internal/minireader/repositories/sql/query.sql
Normal file
@ -0,0 +1,63 @@
|
||||
-- name: ListOutdatedFeeds :many
|
||||
SELECT "id", "provider", "source"
|
||||
FROM "feeds"
|
||||
WHERE NOW() > "next_fetch_at"
|
||||
ORDER BY "next_fetch_at"
|
||||
LIMIT 1000;
|
||||
|
||||
-- name: ListAllUserFeeds :many
|
||||
WITH "counters" AS (SELECT fi."feed_id", si."user_id", COUNT(*) AS "unread"
|
||||
FROM "feed_items" AS fi
|
||||
LEFT JOIN "subscription_items" AS si
|
||||
ON (fi."feed_id" = "si".feed_id AND fi."id" = si."item_id")
|
||||
WHERE si."is_readed" = FALSE
|
||||
OR si."is_readed" IS NULL
|
||||
GROUP BY fi."feed_id", si."user_id")
|
||||
SELECT f."id",
|
||||
COALESCE(s."title", f."title") AS "title",
|
||||
f."provider",
|
||||
f."source",
|
||||
COALESCE(c."unread", 0) AS "unread"
|
||||
FROM "subscriptions" AS s
|
||||
JOIN "feeds" AS f ON (s."feed_id" = f."id")
|
||||
LEFT JOIN "counters" AS c ON (c."feed_id" = f."id" AND (s."user_id" = c."user_id" OR c."user_id" IS NULL))
|
||||
WHERE s."user_id" = @user_id;
|
||||
|
||||
-- name: FindOrCreateFeed :one
|
||||
INSERT INTO "feeds" ("title", "provider", "source")
|
||||
VALUES (COALESCE(@title, 'unnamed feed'), @provider, @source)
|
||||
ON CONFLICT ("source") DO NOTHING
|
||||
RETURNING (xmax = 0)::BOOLEAN AS "created", "id", "title", "last_fetched_at", "fetch_interval", "last_error", "errors_count";
|
||||
|
||||
-- name: UpdateFeed :exec
|
||||
UPDATE "feeds"
|
||||
SET "title" = COALESCE(NULLIF(@title, ''), "title"),
|
||||
"fetch_interval" = LEAST(GREATEST("fetch_interval" * @interval_multiplier::FLOAT, '1 minute'::interval), '12 hours'::interval),
|
||||
"last_fetched_at" = @last_fetched_at
|
||||
WHERE "id" = @id;
|
||||
|
||||
-- name: SaveFeedError :exec
|
||||
UPDATE "feeds"
|
||||
SET "last_error" = @last_error::TEXT,
|
||||
"errors_count" = "errors_count" + 1,
|
||||
"last_fetched_at" = @last_fetched_at
|
||||
WHERE "id" = @id;
|
||||
|
||||
-- name: DeleteFeed :execresult
|
||||
DELETE
|
||||
FROM "feeds"
|
||||
WHERE "id" = @id;
|
||||
|
||||
-- name: UpsertFeedItemBatch :batchone
|
||||
INSERT INTO "feed_items" ("feed_id", "created_at", "fetched_at", "uid", "title", "content", "link", "author", "tags")
|
||||
VALUES (@feed_id::UUID, @created_at::TIMESTAMPTZ, @fetched_at::TIMESTAMPTZ, @uid::TEXT, @title::TEXT, @content::TEXT,
|
||||
@link::TEXT, @author::TEXT, '{}'::TEXT[])
|
||||
ON CONFLICT ("feed_id", "uid") DO UPDATE SET "title" = @title::TEXT,
|
||||
"content" = @content::TEXT,
|
||||
"link" = @link::TEXT
|
||||
RETURNING (xmax = 0)::BOOLEAN AS "created";
|
||||
|
||||
-- name: CreateSubscription :exec
|
||||
INSERT INTO "subscriptions" ("user_id", "feed_id")
|
||||
VALUES (@user_id::TEXT, @feed_id::UUID)
|
||||
ON CONFLICT ("user_id", "feed_id") DO NOTHING;
|
36
internal/minireader/repositories/subscription.go
Normal file
36
internal/minireader/repositories/subscription.go
Normal file
@ -0,0 +1,36 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"borodyadka.dev/borodyadka/minireader/internal/minireader/dto"
|
||||
"borodyadka.dev/borodyadka/minireader/internal/minireader/repositories/sql"
|
||||
)
|
||||
|
||||
type SubscriptionRepository struct {
|
||||
}
|
||||
|
||||
func NewSubscriptionRepository() *SubscriptionRepository {
|
||||
return &SubscriptionRepository{}
|
||||
}
|
||||
|
||||
func (r *SubscriptionRepository) Create(ctx context.Context, user *dto.User, feed uuid.UUID) error {
|
||||
return sql.New(ConnFromContext(ctx)).CreateSubscription(ctx, sql.CreateSubscriptionParams{
|
||||
UserID: user.ID,
|
||||
FeedID: feed,
|
||||
})
|
||||
}
|
||||
|
||||
func (r *SubscriptionRepository) Update(ctx context.Context, user *dto.User, feed uuid.UUID, input dto.UpdateFeed) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (r *SubscriptionRepository) Delete(ctx context.Context, user *dto.User) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (r *SubscriptionRepository) CountByFeed(ctx context.Context, feed uuid.UUID) (int, error) {
|
||||
return 0, nil
|
||||
}
|
19
internal/minireader/repositories/user.go
Normal file
19
internal/minireader/repositories/user.go
Normal file
@ -0,0 +1,19 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"borodyadka.dev/borodyadka/minireader/internal/minireader/dto"
|
||||
)
|
||||
|
||||
type UserRepository struct {
|
||||
}
|
||||
|
||||
func NewUserRepository() *UserRepository {
|
||||
return &UserRepository{}
|
||||
}
|
||||
|
||||
func (r *UserRepository) EnsureUser(ctx context.Context, user *dto.User) error {
|
||||
_, err := ConnFromContext(ctx).Exec(ctx, `INSERT INTO "users" ("id") VALUES ($1) ON CONFLICT DO NOTHING`, user.ID)
|
||||
return err
|
||||
}
|
90
internal/minireader/transport/http/auth.go
Normal file
90
internal/minireader/transport/http/auth.go
Normal file
@ -0,0 +1,90 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
jwtware "github.com/gofiber/contrib/jwt"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/log"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"borodyadka.dev/borodyadka/minireader/internal/minireader/config"
|
||||
"borodyadka.dev/borodyadka/minireader/internal/minireader/dto"
|
||||
)
|
||||
|
||||
type ctxKey string
|
||||
|
||||
const userCtxKey ctxKey = "__user__"
|
||||
|
||||
func GetUserFromContext(ctx context.Context) *dto.User {
|
||||
user, ok := ctx.Value(userCtxKey).(*dto.User)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
func fetchUserID(ctx context.Context, url, token string) (string, error) {
|
||||
cl := http.DefaultClient
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Add(fiber.HeaderAuthorization, fmt.Sprintf("Bearer %s", token))
|
||||
|
||||
resp, err := cl.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var info struct {
|
||||
Sub string `json:"sub"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return info.Sub, nil
|
||||
}
|
||||
|
||||
func authMiddleware(security config.Security) fiber.Handler {
|
||||
return jwtware.New(jwtware.Config{
|
||||
SuccessHandler: func(ctx *fiber.Ctx) error {
|
||||
claims := ctx.Locals("claims")
|
||||
|
||||
tok, ok := claims.(*jwt.Token)
|
||||
if !ok {
|
||||
return errors.New("unauthorized") // TODO: normal error
|
||||
}
|
||||
|
||||
var sub string
|
||||
var err error
|
||||
if security.UserInfoURL != "" {
|
||||
sub, err = fetchUserID(ctx.Context(), security.UserInfoURL, tok.Raw)
|
||||
} else {
|
||||
sub, err = tok.Claims.GetSubject()
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if sub == "" {
|
||||
log.Error("unable to fetch user ID nor from token nor from auth provider")
|
||||
return errors.New("unauthorized") // TODO: normal error
|
||||
}
|
||||
|
||||
ctx.Locals(userCtxKey, &dto.User{
|
||||
ID: sub,
|
||||
})
|
||||
return ctx.Next()
|
||||
},
|
||||
ContextKey: "claims",
|
||||
JWKSetURLs: []string{security.JWKSURL},
|
||||
})
|
||||
}
|
27
internal/minireader/transport/http/dto.go
Normal file
27
internal/minireader/transport/http/dto.go
Normal file
@ -0,0 +1,27 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"borodyadka.dev/borodyadka/minireader/internal/minireader/dto"
|
||||
)
|
||||
|
||||
type User = dto.User
|
||||
|
||||
type Feed = dto.Feed
|
||||
type CreateFeed = dto.CreateFeed
|
||||
type UpdateFeed = dto.UpdateFeed
|
||||
|
||||
type Item = dto.Item
|
||||
|
||||
type Meta struct {
|
||||
Total int `json:"total"`
|
||||
Cursor string `json:"cursor,omitempty"`
|
||||
}
|
||||
|
||||
type Result[T any] struct {
|
||||
Data T `json:"data"`
|
||||
}
|
||||
|
||||
type Results[T any] struct {
|
||||
Data []T `json:"data"`
|
||||
Meta Meta `json:"meta"`
|
||||
}
|
86
internal/minireader/transport/http/handlers.go
Normal file
86
internal/minireader/transport/http/handlers.go
Normal file
@ -0,0 +1,86 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"borodyadka.dev/borodyadka/minireader/internal/minireader/dto"
|
||||
)
|
||||
|
||||
func (s *Server) UserInfo(ctx *fiber.Ctx) error {
|
||||
user := GetUserFromContext(ctx.Context())
|
||||
|
||||
if err := s.reader.EnsureUser(ctx.Context(), user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return ctx.JSON(Result[*User]{
|
||||
Data: user,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) ImportOPML(ctx *fiber.Ctx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) ListAllUserFeeds(ctx *fiber.Ctx) error {
|
||||
user := GetUserFromContext(ctx.Context())
|
||||
|
||||
feeds, total, err := s.reader.ListAllUserFeeds(ctx.Context(), user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return ctx.JSON(Results[Feed]{
|
||||
Data: feeds,
|
||||
Meta: Meta{
|
||||
Total: total,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) CreateNewFeed(ctx *fiber.Ctx) error {
|
||||
user := GetUserFromContext(ctx.Context())
|
||||
|
||||
var input dto.CreateFeed
|
||||
if err := ctx.BodyParser(&input); err != nil {
|
||||
return fiber.ErrBadRequest
|
||||
}
|
||||
|
||||
feed, err := s.reader.CreateSubscription(ctx.Context(), user, &input)
|
||||
if err != nil {
|
||||
// TODO: log
|
||||
return err
|
||||
}
|
||||
|
||||
return ctx.JSON(Result[Feed]{
|
||||
Data: feed,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) GetFeedInfo(ctx *fiber.Ctx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) UpdateFeedInfo(ctx *fiber.Ctx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) DeleteFeed(ctx *fiber.Ctx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) ListFeedItems(ctx *fiber.Ctx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) GetItemInfo(ctx *fiber.Ctx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) MarkItemReaded(ctx *fiber.Ctx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) MarkItemUnreaded(ctx *fiber.Ctx) error {
|
||||
return nil
|
||||
}
|
43
internal/minireader/transport/http/options.go
Normal file
43
internal/minireader/transport/http/options.go
Normal file
@ -0,0 +1,43 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"borodyadka.dev/borodyadka/minireader/internal/minireader/config"
|
||||
"borodyadka.dev/borodyadka/minireader/internal/pkg/validator"
|
||||
)
|
||||
|
||||
type Option func(*Server)
|
||||
|
||||
type Services struct {
|
||||
Reader reader `validate:"required"`
|
||||
}
|
||||
|
||||
func WithServices(services Services) Option {
|
||||
return func(server *Server) {
|
||||
if err := validator.Struct(services); err != nil {
|
||||
panic("one or more services passed into HTTP Server instance is invalid or not defined")
|
||||
}
|
||||
|
||||
server.reader = services.Reader
|
||||
}
|
||||
}
|
||||
|
||||
func WithConfig(conf config.HTTP) Option {
|
||||
return func(server *Server) {
|
||||
server.config = conf
|
||||
}
|
||||
}
|
||||
|
||||
func WithSecurity(sec config.Security) Option {
|
||||
// TODO: security MUST be a security provider, not a JWKS URL!
|
||||
return func(server *Server) {
|
||||
server.sec = sec
|
||||
}
|
||||
}
|
||||
|
||||
func WithLogger(logger *slog.Logger) Option {
|
||||
return func(server *Server) {
|
||||
server.logger = logger
|
||||
}
|
||||
}
|
103
internal/minireader/transport/http/server.go
Normal file
103
internal/minireader/transport/http/server.go
Normal file
@ -0,0 +1,103 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
slogfiber "github.com/samber/slog-fiber"
|
||||
|
||||
"borodyadka.dev/borodyadka/minireader/internal/minireader/config"
|
||||
"borodyadka.dev/borodyadka/minireader/internal/minireader/dto"
|
||||
)
|
||||
|
||||
type reader interface {
|
||||
EnsureUser(ctx context.Context, user *dto.User) error
|
||||
|
||||
ListAllUserFeeds(ctx context.Context, user *dto.User) ([]dto.Feed, int, error)
|
||||
CreateSubscription(ctx context.Context, user *dto.User, input *dto.CreateFeed) (dto.Feed, error)
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
server *fiber.App
|
||||
config config.HTTP
|
||||
sec config.Security
|
||||
reader reader
|
||||
logger *slog.Logger
|
||||
injections [][2]any
|
||||
}
|
||||
|
||||
func New(opts ...Option) *Server {
|
||||
server := &Server{
|
||||
server: fiber.New(fiber.Config{
|
||||
ReadTimeout: 0, // TODO
|
||||
WriteTimeout: 0, // TODO
|
||||
IdleTimeout: 0, // TODO
|
||||
ReadBufferSize: 0, // TODO
|
||||
WriteBufferSize: 0, // TODO
|
||||
ErrorHandler: nil, // TODO
|
||||
DisableStartupMessage: true,
|
||||
DisablePreParseMultipartForm: true,
|
||||
EnableSplittingOnParsers: false,
|
||||
}),
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(server)
|
||||
}
|
||||
|
||||
server.init()
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
func (s *Server) Inject(key, value any) {
|
||||
s.injections = append(s.injections, [2]any{key, value})
|
||||
}
|
||||
|
||||
func (s *Server) init() {
|
||||
s.server.Use(func(c *fiber.Ctx) error {
|
||||
for _, v := range s.injections {
|
||||
c.Locals(v[0], v[1])
|
||||
}
|
||||
return c.Next()
|
||||
})
|
||||
|
||||
if s.config.AccessLog {
|
||||
s.server.Use(slogfiber.New(s.logger))
|
||||
}
|
||||
|
||||
if s.sec.JWKSURL != "" {
|
||||
s.server.Use(authMiddleware(s.sec))
|
||||
}
|
||||
|
||||
api := s.server.Group(s.config.APIPrefix)
|
||||
v1 := api.Group("/v1")
|
||||
|
||||
user := v1.Group("/me")
|
||||
user.Get("/", s.UserInfo) // TODO: init user if not exists
|
||||
|
||||
feeds := v1.Group("/feeds")
|
||||
feeds.Get("/", s.ListAllUserFeeds)
|
||||
feeds.Post("/", s.CreateNewFeed)
|
||||
feeds.Post("/_import/opml", s.ImportOPML)
|
||||
feeds.Get("/:feed", s.GetFeedInfo) // there is some special :feed keys, such as "_all"
|
||||
feeds.Patch("/:feed", s.UpdateFeedInfo)
|
||||
feeds.Delete("/:feed", s.DeleteFeed)
|
||||
|
||||
items := feeds.Group("/:feed/items")
|
||||
items.Get("/", s.ListFeedItems)
|
||||
items.Get("/:item", s.GetItemInfo)
|
||||
items.Put("/:item/readed", s.MarkItemReaded)
|
||||
items.Delete("/:item/readed", s.MarkItemUnreaded)
|
||||
}
|
||||
|
||||
func (s *Server) Start() error {
|
||||
s.logger.Info("listen", "addr", s.config.Listen)
|
||||
return s.server.Listen(s.config.Listen)
|
||||
}
|
||||
|
||||
func (s *Server) Shutdown() error {
|
||||
s.logger.Info("shutdown")
|
||||
return s.server.Shutdown()
|
||||
}
|
66
internal/pkg/errx/details.go
Normal file
66
internal/pkg/errx/details.go
Normal file
@ -0,0 +1,66 @@
|
||||
package errx
|
||||
|
||||
// Package `errx` named like this to not confuse with built-in `errors` package.
|
||||
|
||||
import "errors"
|
||||
|
||||
type detailedError struct {
|
||||
error
|
||||
details []any
|
||||
}
|
||||
|
||||
func (e *detailedError) Unwrap() error {
|
||||
return e.error
|
||||
}
|
||||
|
||||
func (e *detailedError) Details() []any {
|
||||
return e.details
|
||||
}
|
||||
|
||||
// WithDetails creates new error value with embedded details
|
||||
func WithDetails(err error, details ...any) error {
|
||||
derr := &detailedError{}
|
||||
if errors.As(err, &derr) {
|
||||
derr.details = append(derr.details, details...)
|
||||
return err
|
||||
}
|
||||
|
||||
return &detailedError{
|
||||
error: err,
|
||||
details: details,
|
||||
}
|
||||
}
|
||||
|
||||
// Details will extract details array from error
|
||||
func Details(err error) []any {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// detailedError on top level of errors chain
|
||||
if derr, ok := err.(interface{ Details() []any }); ok {
|
||||
return derr.Details()
|
||||
}
|
||||
|
||||
// detailed error were wrapped into another error, and we need to dig it up
|
||||
derr := &detailedError{}
|
||||
if errors.As(err, &derr) {
|
||||
return derr.Details()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LookupDetails will find some specific details by its type
|
||||
// NOTE: I'm not sure it's good idea or expected behaviour for function named Lookup
|
||||
func LookupDetails[T any](err error) T {
|
||||
var empty T
|
||||
|
||||
for _, d := range Details(err) {
|
||||
if v, ok := d.(T); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
return empty
|
||||
}
|
101
internal/pkg/errx/details_test.go
Normal file
101
internal/pkg/errx/details_test.go
Normal file
@ -0,0 +1,101 @@
|
||||
package errx
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type DetailsSuite struct {
|
||||
suite.Suite
|
||||
}
|
||||
|
||||
func (suite *DetailsSuite) TestUnwrap() {
|
||||
err := errors.New("test")
|
||||
|
||||
tests := []struct {
|
||||
err error
|
||||
want error
|
||||
}{
|
||||
{
|
||||
err: &detailedError{},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
err: WithDetails(err),
|
||||
want: err,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
suite.Run("", func() {
|
||||
assert.Equal(suite.T(), test.want, errors.Unwrap(test.err))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *DetailsSuite) TestIs() {
|
||||
errSame := errors.New("test same")
|
||||
errWrong := errors.New("test wrong")
|
||||
|
||||
tests := []struct {
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
err: WithDetails(errSame),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
err: WithDetails(errWrong),
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
suite.Run("", func() {
|
||||
assert.Equal(suite.T(), test.want, errors.Is(test.err, errSame))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *DetailsSuite) TestDetails() {
|
||||
err := errors.New("test")
|
||||
|
||||
tests := []struct {
|
||||
err error
|
||||
want []any
|
||||
}{
|
||||
{
|
||||
err: WithDetails(err),
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
err: WithDetails(err, 1, 2, 3),
|
||||
want: []any{1, 2, 3},
|
||||
},
|
||||
{
|
||||
err: WithDetails(WithDetails(err, 4), 5, 6),
|
||||
want: []any{4, 5, 6},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
suite.Run("", func() {
|
||||
assert.Equal(suite.T(), test.want, Details(test.err))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *DetailsSuite) TestLookupDetails() {
|
||||
err := WithDetails(errors.New("test"), true, 2, "3")
|
||||
|
||||
assert.Equal(suite.T(), true, LookupDetails[bool](err))
|
||||
assert.Equal(suite.T(), 2, LookupDetails[int](err))
|
||||
assert.Equal(suite.T(), "3", LookupDetails[string](err))
|
||||
}
|
||||
|
||||
func TestDetails(t *testing.T) {
|
||||
suite.Run(t, new(DetailsSuite))
|
||||
}
|
9
internal/pkg/utils/deref.go
Normal file
9
internal/pkg/utils/deref.go
Normal file
@ -0,0 +1,9 @@
|
||||
package utils
|
||||
|
||||
func DerefOrDefault[T any](val *T, fallback T) T {
|
||||
if val == nil {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return *val
|
||||
}
|
13
internal/pkg/utils/slices.go
Normal file
13
internal/pkg/utils/slices.go
Normal file
@ -0,0 +1,13 @@
|
||||
package utils
|
||||
|
||||
type Mapper[T, R any] func(T) R
|
||||
|
||||
func MapSlice[T, R any](input []T, mapper Mapper[T, R]) []R {
|
||||
output := make([]R, len(input))
|
||||
|
||||
for i := range input {
|
||||
output[i] = mapper(input[i])
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
19
internal/pkg/validator/validator.go
Normal file
19
internal/pkg/validator/validator.go
Normal file
@ -0,0 +1,19 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
var (
|
||||
v = validator.New()
|
||||
)
|
||||
|
||||
func Struct(val any) error {
|
||||
return v.Struct(val)
|
||||
}
|
||||
|
||||
func init() {
|
||||
_ = v.RegisterValidation("optional", func(f validator.FieldLevel) bool {
|
||||
return true
|
||||
})
|
||||
}
|
145
migrations/20231021105313_init.sql
Normal file
145
migrations/20231021105313_init.sql
Normal file
@ -0,0 +1,145 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE "users"
|
||||
(
|
||||
"id" TEXT PRIMARY KEY
|
||||
);
|
||||
COMMENT ON COLUMN "users"."id"
|
||||
IS 'ID of the user. It is came from external system, so we can save it as a text field only';
|
||||
|
||||
|
||||
CREATE TYPE FEED_PROVIDER AS ENUM ('rss');
|
||||
|
||||
|
||||
CREATE TABLE "feeds"
|
||||
(
|
||||
"id" UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"title" TEXT NOT NULL DEFAULT '',
|
||||
"provider" FEED_PROVIDER NOT NULL,
|
||||
"source" TEXT NOT NULL,
|
||||
"last_fetched_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
"fetch_interval" INTERVAL NOT NULL DEFAULT '1 hour'::INTERVAL,
|
||||
"next_fetch_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
"last_error" TEXT DEFAULT NULL,
|
||||
"errors_count" INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE UNIQUE INDEX "u_feeds__source" ON "feeds" ("source");
|
||||
CREATE INDEX "i_feeds__next_fetch_at" ON "feeds" ("next_fetch_at");
|
||||
COMMENT ON COLUMN "feeds"."id" IS 'Feed ID';
|
||||
COMMENT ON COLUMN "feeds"."title" IS 'Title';
|
||||
COMMENT ON COLUMN "feeds"."provider"
|
||||
IS 'Data provider, such as RSS or some another supported source type';
|
||||
COMMENT ON COLUMN "feeds"."source" IS 'Address of feed data';
|
||||
COMMENT ON COLUMN "feeds"."last_fetched_at" IS 'Date of last fetch of current feed';
|
||||
COMMENT ON COLUMN "feeds"."fetch_interval"
|
||||
IS 'Period of fetching new data. Will be shorten for active feeds with lots of updates, and longen for inactive or errored feeds';
|
||||
COMMENT ON COLUMN "feeds"."next_fetch_at" IS 'Computed field used only for speed-up queries. DO NOT EDIT!';
|
||||
COMMENT ON COLUMN "feeds"."last_error" IS 'Text description of last fetching error';
|
||||
COMMENT ON COLUMN "feeds"."errors_count"
|
||||
IS 'Number of fetching errors. Will increase for every time fetch was failed. Will be reseted if fetch was successful';
|
||||
|
||||
-- +goose StatementBegin
|
||||
CREATE FUNCTION feeds_set_next_fetch_at() RETURNS trigger AS
|
||||
$feeds_set_next_fetch_at$
|
||||
BEGIN
|
||||
NEW."next_fetch_at" := NEW."last_fetched_at" + NEW."fetch_interval";
|
||||
RETURN NEW;
|
||||
END;
|
||||
$feeds_set_next_fetch_at$ LANGUAGE plpgsql;
|
||||
-- +goose StatementEnd
|
||||
|
||||
CREATE TRIGGER "feeds_set_next_fetch_at"
|
||||
BEFORE INSERT OR UPDATE
|
||||
ON "feeds"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION feeds_set_next_fetch_at();
|
||||
|
||||
|
||||
CREATE TABLE "feed_icons"
|
||||
(
|
||||
"feed_id" UUID NOT NULL PRIMARY KEY REFERENCES "feeds" ("id") ON DELETE CASCADE,
|
||||
"mimetype" TEXT NOT NULL,
|
||||
"icon" BYTEA DEFAULT NULL
|
||||
);
|
||||
COMMENT ON COLUMN "feed_icons"."feed_id" IS 'ID of feed';
|
||||
COMMENT ON COLUMN "feed_icons"."mimetype" IS 'Mimetype of the icon';
|
||||
COMMENT ON COLUMN "feed_icons"."icon" IS 'Icon of the feed (favicon)';
|
||||
|
||||
|
||||
CREATE TABLE "subscriptions"
|
||||
(
|
||||
"user_id" TEXT NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
"feed_id" UUID NOT NULL REFERENCES "feeds" ("id") ON DELETE RESTRICT,
|
||||
"title" TEXT DEFAULT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX "u_subscription__user_feed" ON "subscriptions" ("user_id", "feed_id");
|
||||
CREATE INDEX "i_subscription__user" ON "subscriptions" ("user_id");
|
||||
COMMENT ON COLUMN "subscriptions"."user_id" IS 'ID of user';
|
||||
COMMENT ON COLUMN "subscriptions"."feed_id" IS 'ID of feed';
|
||||
COMMENT ON COLUMN "subscriptions"."title" IS 'User-defined name of the feed';
|
||||
|
||||
|
||||
CREATE TABLE "feed_items"
|
||||
(
|
||||
"id" UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"feed_id" UUID NOT NULL REFERENCES "feeds" ("id") ON DELETE CASCADE,
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
"fetched_at" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
"uid" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL DEFAULT '',
|
||||
"link" TEXT NOT NULL DEFAULT '',
|
||||
"author" TEXT NOT NULL DEFAULT '',
|
||||
"tags" TEXT[] NOT NULL DEFAULT '{}'
|
||||
);
|
||||
CREATE INDEX "i_feed_items__feed" ON "feed_items" ("feed_id");
|
||||
CREATE UNIQUE INDEX "u_feed_items__feed_uid" ON "feed_items" ("feed_id", "uid");
|
||||
CREATE INDEX "i_feed_items__fetched" ON "feed_items" ("fetched_at" DESC);
|
||||
COMMENT ON COLUMN "feed_items"."id" IS 'Item ID';
|
||||
COMMENT ON COLUMN "feed_items"."feed_id" IS 'Feed ID';
|
||||
COMMENT ON COLUMN "feed_items"."created_at" IS 'Item creation time (taken from feed info)';
|
||||
COMMENT ON COLUMN "feed_items"."fetched_at"
|
||||
IS 'Time when item was fetched and saved into database. This field will be used for cursor-based pagination';
|
||||
COMMENT ON COLUMN "feed_items"."uid" IS 'Unique ID of item in feed';
|
||||
COMMENT ON COLUMN "feed_items"."title" IS 'Title';
|
||||
COMMENT ON COLUMN "feed_items"."content"
|
||||
IS 'Sanitized HTML content: stripped danger tags, links modified to open in new tab and to strip referrer';
|
||||
COMMENT ON COLUMN "feed_items"."link" IS 'Link to full article content';
|
||||
COMMENT ON COLUMN "feed_items"."author" IS 'Name of the author of article (taken from feed info)';
|
||||
COMMENT ON COLUMN "feed_items"."tags" IS 'List of normalized (lowercased, trimmed) tags (taken from feed info)';
|
||||
|
||||
|
||||
CREATE TABLE "feed_item_attachments"
|
||||
(
|
||||
"id" BIGINT NOT NULL GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
"item_id" UUID NOT NULL REFERENCES "feed_items" ("id") ON DELETE CASCADE,
|
||||
"mimetype" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"size" BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
|
||||
CREATE TABLE "subscription_items"
|
||||
(
|
||||
"user_id" TEXT NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
"feed_id" UUID NOT NULL REFERENCES "feeds" ("id") ON DELETE CASCADE,
|
||||
"item_id" UUID NOT NULL REFERENCES "feed_items" ("id") ON DELETE CASCADE,
|
||||
"is_readed" BOOLEAN NOT NULL DEFAULT TRUE
|
||||
);
|
||||
CREATE UNIQUE INDEX "u_subscription_item__user_feed_item" ON "subscription_items" ("user_id", "feed_id", "item_id");
|
||||
COMMENT ON COLUMN "subscription_items"."user_id" IS 'ID of subscribed user (used in subscription composite key)';
|
||||
COMMENT ON COLUMN "subscription_items"."feed_id" IS 'ID of related feed (used in subscription composite key)';
|
||||
COMMENT ON COLUMN "subscription_items"."item_id" IS 'ID of specific feed item';
|
||||
COMMENT ON COLUMN "subscription_items"."is_readed"
|
||||
IS 'This flag means item is marked as read and should not appear in feed (except special query). This flag MUST be treated as FALSE if row does not exists';
|
||||
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE "subscription_items";
|
||||
DROP TABLE "feed_item_attachments";
|
||||
DROP TABLE "feed_items";
|
||||
DROP TABLE "subscriptions";
|
||||
DROP TABLE "feed_icons";
|
||||
DROP TABLE "feeds";
|
||||
DROP FUNCTION "feeds_set_next_fetch_at";
|
||||
DROP TYPE FEED_PROVIDER;
|
||||
DROP TABLE "users";
|
9
migrations/embed.go
Normal file
9
migrations/embed.go
Normal file
@ -0,0 +1,9 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"embed"
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
//go:embed *.sql
|
||||
var Migrations embed.FS
|
32
sqlc.yaml
Normal file
32
sqlc.yaml
Normal file
@ -0,0 +1,32 @@
|
||||
version: "2"
|
||||
sql:
|
||||
- engine: "postgresql"
|
||||
queries: "internal/minireader/repositories/sql/query.sql"
|
||||
schema: "migrations"
|
||||
gen:
|
||||
go:
|
||||
package: "sql"
|
||||
sql_package: "pgx/v5"
|
||||
out: "internal/minireader/repositories/sql"
|
||||
emit_empty_slices: true
|
||||
rename:
|
||||
uid: UID
|
||||
overrides:
|
||||
# common types
|
||||
- db_type: "timestamptz"
|
||||
nullable: true
|
||||
engine: "postgresql"
|
||||
go_type:
|
||||
import: "gopkg.in/guregu/null.v4"
|
||||
package: "null"
|
||||
type: "Time"
|
||||
- db_type: "uuid"
|
||||
engine: "postgresql"
|
||||
go_type: "github.com/google/uuid.UUID"
|
||||
- db_type: "integer"
|
||||
engine: "postgresql"
|
||||
go_type: "int"
|
||||
# application-specific types
|
||||
- db_type: "feed_provider"
|
||||
engine: "postgresql"
|
||||
go_type: "borodyadka.dev/borodyadka/minireader/internal/minireader/dto.Provider"
|
Loading…
Reference in New Issue
Block a user