This commit is contained in:
Pavel Kilin 2023-10-25 21:49:11 +07:00
commit d02ff08aa7
47 changed files with 2712 additions and 0 deletions

46
.air.toml Normal file
View 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

0
.env.dist Normal file
View File

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
.idea
.env
tmp/*

348
.golangci.yml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,9 @@
version: '3'
services:
server:
build:
context: .
dockerfile: Dockerfile.dev
volumes:
- .:/opt/app

32
docker-compose.yml Normal file
View 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
View 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
View 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
View 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
}

View 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"`
}

View File

@ -0,0 +1,9 @@
package dto
import (
"errors"
)
var (
ErrNotFound = errors.New("not_found")
)

View 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
}

View File

@ -0,0 +1,7 @@
package dto
type WebsiteInfo struct {
Title string
Favicon []byte
Feeds []string
}

View File

@ -0,0 +1,5 @@
package dto
type User struct {
ID string `json:"id,omitempty"`
}

View 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)
}

View 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
}
}

View 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
}

View 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...))
}

View 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
}

View 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
}
}

View 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
})
}

View 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)
}

View 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
}

View 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
}

View File

@ -0,0 +1 @@
*.go

View 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;

View 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
}

View 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
}

View 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},
})
}

View 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"`
}

View 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
}

View 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
}
}

View 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()
}

View 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
}

View 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))
}

View File

@ -0,0 +1,9 @@
package utils
func DerefOrDefault[T any](val *T, fallback T) T {
if val == nil {
return fallback
}
return *val
}

View 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
}

View 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
})
}

View 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
View File

@ -0,0 +1,9 @@
package migrations
import (
"embed"
_ "embed"
)
//go:embed *.sql
var Migrations embed.FS

32
sqlc.yaml Normal file
View 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"