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