service backend
This commit is contained in:
BIN
api/fx/ingestor/.DS_Store
vendored
Normal file
BIN
api/fx/ingestor/.DS_Store
vendored
Normal file
Binary file not shown.
32
api/fx/ingestor/.air.toml
Normal file
32
api/fx/ingestor/.air.toml
Normal file
@@ -0,0 +1,32 @@
|
||||
# Config file for Air in TOML format
|
||||
|
||||
root = "./../.."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
cmd = "go build -o app -ldflags \"-X 'github.com/tech/sendico/fx/ingestor/internal/appversion.BuildUser=$(whoami)' -X 'github.com/tech/sendico/fx/ingestor/internal/appversion.Version=$APP_V' -X 'github.com/tech/sendico/fx/ingestor/internal/appversion.Branch=$BUILD_BRANCH' -X 'github.com/tech/sendico/fx/ingestor/internal/appversion.Revision=$GIT_REV' -X 'github.com/tech/sendico/fx/ingestor/internal/appversion.BuildDate=$(date)'\""
|
||||
bin = "./app"
|
||||
full_bin = "./app --debug --config.file=config.yml"
|
||||
include_ext = ["go", "yaml", "yml"]
|
||||
exclude_dir = ["fx/ingestor/tmp", "pkg/.git", "fx/ingestor/env"]
|
||||
exclude_regex = ["_test\\.go"]
|
||||
exclude_unchanged = true
|
||||
follow_symlink = true
|
||||
log = "air.log"
|
||||
delay = 0
|
||||
stop_on_error = true
|
||||
send_interrupt = true
|
||||
kill_delay = 500
|
||||
args_bin = []
|
||||
|
||||
[log]
|
||||
time = false
|
||||
|
||||
[color]
|
||||
main = "magenta"
|
||||
watcher = "cyan"
|
||||
build = "yellow"
|
||||
runner = "green"
|
||||
|
||||
[misc]
|
||||
clean_on_exit = true
|
||||
3
api/fx/ingestor/.gitignore
vendored
Normal file
3
api/fx/ingestor/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
internal/generated
|
||||
.gocache
|
||||
app
|
||||
43
api/fx/ingestor/config.yml
Normal file
43
api/fx/ingestor/config.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
poll_interval_seconds: 30
|
||||
|
||||
market:
|
||||
sources:
|
||||
- driver: BINANCE
|
||||
settings:
|
||||
base_url: "https://api.binance.com"
|
||||
- driver: COINGECKO
|
||||
settings:
|
||||
base_url: "https://api.coingecko.com/api/v3"
|
||||
pairs:
|
||||
BINANCE:
|
||||
- base: "USDT"
|
||||
quote: "EUR"
|
||||
symbol: "EURUSDT"
|
||||
invert: true
|
||||
- base: "UAH"
|
||||
quote: "USDT"
|
||||
symbol: "USDTUAH"
|
||||
invert: true
|
||||
- base: "USDC"
|
||||
quote: "EUR"
|
||||
symbol: "EURUSDC"
|
||||
invert: true
|
||||
COINGECKO:
|
||||
- base: "USDT"
|
||||
quote: "RUB"
|
||||
symbol: "tether:rub"
|
||||
|
||||
metrics:
|
||||
enabled: true
|
||||
address: ":9102"
|
||||
|
||||
database:
|
||||
driver: mongodb
|
||||
settings:
|
||||
host_env: FX_MONGO_HOST
|
||||
port_env: FX_MONGO_PORT
|
||||
database_env: FX_MONGO_DATABASE
|
||||
user_env: FX_MONGO_USER
|
||||
password_env: FX_MONGO_PASSWORD
|
||||
auth_source_env: FX_MONGO_AUTH_SOURCE
|
||||
replica_set_env: FX_MONGO_REPLICA_SET
|
||||
1
api/fx/ingestor/env/.gitignore
vendored
Normal file
1
api/fx/ingestor/env/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.env.api
|
||||
55
api/fx/ingestor/go.mod
Normal file
55
api/fx/ingestor/go.mod
Normal file
@@ -0,0 +1,55 @@
|
||||
module github.com/tech/sendico/fx/ingestor
|
||||
|
||||
go 1.25.3
|
||||
|
||||
replace github.com/tech/sendico/pkg => ../../pkg
|
||||
|
||||
replace github.com/tech/sendico/fx/storage => ../storage
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
github.com/google/go-cmp v0.7.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/tech/sendico/fx/storage v0.0.0
|
||||
github.com/tech/sendico/pkg v0.1.0
|
||||
go.uber.org/zap v1.27.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||
github.com/casbin/casbin/v2 v2.132.0 // indirect
|
||||
github.com/casbin/govaluate v1.10.0 // indirect
|
||||
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.18.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/nats-io/nats.go v1.47.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.11 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.2 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.1.2 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
go.mongodb.org/mongo-driver v1.17.6 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/crypto v0.43.0 // indirect
|
||||
golang.org/x/net v0.46.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 // indirect
|
||||
google.golang.org/grpc v1.76.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
)
|
||||
225
api/fx/ingestor/go.sum
Normal file
225
api/fx/ingestor/go.sum
Normal file
@@ -0,0 +1,225 @@
|
||||
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
|
||||
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/casbin/casbin/v2 v2.132.0 h1:73hGmOszGSL3hTVquwkAi98XLl3gPJ+BxB6D7G9Fxtk=
|
||||
github.com/casbin/casbin/v2 v2.132.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||
github.com/casbin/mongodb-adapter/v3 v3.7.0 h1:w9c3bea1BGK4eZTAmk17JkY52yv/xSZDSHKji8q+z6E=
|
||||
github.com/casbin/mongodb-adapter/v3 v3.7.0/go.mod h1:F1mu4ojoJVE/8VhIMxMedhjfwRDdIXgANYs6Sd0MgVA=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
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/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
|
||||
github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg=
|
||||
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo=
|
||||
github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
||||
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
|
||||
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||
github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0=
|
||||
github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8=
|
||||
github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw=
|
||||
github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8=
|
||||
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY=
|
||||
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog=
|
||||
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
||||
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
|
||||
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 h1:tRPGkdGHuewF4UisLzzHHr1spKw92qLM98nIzxbC0wY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
||||
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
27
api/fx/ingestor/internal/appversion/version.go
Normal file
27
api/fx/ingestor/internal/appversion/version.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package appversion
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/version"
|
||||
vf "github.com/tech/sendico/pkg/version/factory"
|
||||
)
|
||||
|
||||
// Build information. Populated at build-time.
|
||||
var (
|
||||
Version string
|
||||
Revision string
|
||||
Branch string
|
||||
BuildUser string
|
||||
BuildDate string
|
||||
)
|
||||
|
||||
func Create() version.Printer {
|
||||
vi := version.Info{
|
||||
Program: "MeetX Connectica FX Ingestor Service",
|
||||
Revision: Revision,
|
||||
Branch: Branch,
|
||||
BuildUser: BuildUser,
|
||||
BuildDate: BuildDate,
|
||||
Version: Version,
|
||||
}
|
||||
return vf.Create(&vi)
|
||||
}
|
||||
147
api/fx/ingestor/internal/config/config.go
Normal file
147
api/fx/ingestor/internal/config/config.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
||||
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||
"github.com/tech/sendico/pkg/db"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const defaultPollInterval = 30 * time.Second
|
||||
|
||||
type Config struct {
|
||||
PollIntervalSeconds int `yaml:"poll_interval_seconds"`
|
||||
Market MarketConfig `yaml:"market"`
|
||||
Database *db.Config `yaml:"database"`
|
||||
Metrics *MetricsConfig `yaml:"metrics"`
|
||||
|
||||
pairs []Pair
|
||||
pairsBySource map[mmodel.Driver][]PairConfig
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
if path == "" {
|
||||
return nil, fmerrors.New("config: path is empty")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmerrors.Wrap("config: failed to read file", err)
|
||||
}
|
||||
|
||||
cfg := &Config{}
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
return nil, fmerrors.Wrap("config: failed to parse yaml", err)
|
||||
}
|
||||
|
||||
if len(cfg.Market.Sources) == 0 {
|
||||
return nil, fmerrors.New("config: no market sources configured")
|
||||
}
|
||||
sourceSet := make(map[mmodel.Driver]struct{}, len(cfg.Market.Sources))
|
||||
for idx := range cfg.Market.Sources {
|
||||
src := &cfg.Market.Sources[idx]
|
||||
if src.Driver.IsEmpty() {
|
||||
return nil, fmerrors.New("config: market source driver is empty")
|
||||
}
|
||||
sourceSet[src.Driver] = struct{}{}
|
||||
}
|
||||
|
||||
if len(cfg.Market.Pairs) == 0 {
|
||||
return nil, fmerrors.New("config: no pairs configured")
|
||||
}
|
||||
|
||||
normalizedPairs := make(map[string][]PairConfig, len(cfg.Market.Pairs))
|
||||
pairsBySource := make(map[mmodel.Driver][]PairConfig, len(cfg.Market.Pairs))
|
||||
var flattened []Pair
|
||||
|
||||
for rawSource, pairList := range cfg.Market.Pairs {
|
||||
driver := mmodel.Driver(rawSource)
|
||||
if driver.IsEmpty() {
|
||||
return nil, fmerrors.New("config: pair source is empty")
|
||||
}
|
||||
if _, ok := sourceSet[driver]; !ok {
|
||||
return nil, fmerrors.New("config: pair references unknown source: " + driver.String())
|
||||
}
|
||||
|
||||
processed := make([]PairConfig, len(pairList))
|
||||
for idx := range pairList {
|
||||
pair := pairList[idx]
|
||||
pair.Base = strings.ToUpper(strings.TrimSpace(pair.Base))
|
||||
pair.Quote = strings.ToUpper(strings.TrimSpace(pair.Quote))
|
||||
pair.Symbol = strings.TrimSpace(pair.Symbol)
|
||||
if pair.Base == "" || pair.Quote == "" || pair.Symbol == "" {
|
||||
return nil, fmerrors.New("config: pair entries must define base, quote, and symbol")
|
||||
}
|
||||
if strings.TrimSpace(pair.Provider) == "" {
|
||||
pair.Provider = strings.ToLower(driver.String())
|
||||
}
|
||||
processed[idx] = pair
|
||||
flattened = append(flattened, Pair{
|
||||
PairConfig: pair,
|
||||
Source: driver,
|
||||
})
|
||||
}
|
||||
pairsBySource[driver] = processed
|
||||
normalizedPairs[driver.String()] = processed
|
||||
}
|
||||
|
||||
cfg.Market.Pairs = normalizedPairs
|
||||
cfg.pairsBySource = pairsBySource
|
||||
cfg.pairs = flattened
|
||||
if cfg.Database == nil {
|
||||
return nil, fmerrors.New("config: database configuration is required")
|
||||
}
|
||||
|
||||
if cfg.Metrics != nil && cfg.Metrics.Enabled {
|
||||
cfg.Metrics.Address = strings.TrimSpace(cfg.Metrics.Address)
|
||||
if cfg.Metrics.Address == "" {
|
||||
cfg.Metrics.Address = ":9102"
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) PollInterval() time.Duration {
|
||||
if c == nil {
|
||||
return defaultPollInterval
|
||||
}
|
||||
if c.PollIntervalSeconds <= 0 {
|
||||
return defaultPollInterval
|
||||
}
|
||||
return time.Duration(c.PollIntervalSeconds) * time.Second
|
||||
}
|
||||
|
||||
func (c *Config) Pairs() []Pair {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]Pair, len(c.pairs))
|
||||
copy(out, c.pairs)
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *Config) PairsBySource() map[mmodel.Driver][]PairConfig {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[mmodel.Driver][]PairConfig, len(c.pairsBySource))
|
||||
for driver, pairs := range c.pairsBySource {
|
||||
cp := make([]PairConfig, len(pairs))
|
||||
copy(cp, pairs)
|
||||
out[driver] = cp
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *Config) MetricsConfig() *MetricsConfig {
|
||||
if c == nil || c.Metrics == nil {
|
||||
return nil
|
||||
}
|
||||
cp := *c.Metrics
|
||||
return &cp
|
||||
}
|
||||
24
api/fx/ingestor/internal/config/market.go
Normal file
24
api/fx/ingestor/internal/config/market.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||
pmodel "github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
type PairConfig struct {
|
||||
Base string `yaml:"base"`
|
||||
Quote string `yaml:"quote"`
|
||||
Symbol string `yaml:"symbol"`
|
||||
Provider string `yaml:"provider"`
|
||||
Invert bool `yaml:"invert"`
|
||||
}
|
||||
|
||||
type Pair struct {
|
||||
PairConfig `yaml:",inline"`
|
||||
Source mmodel.Driver `yaml:"-"`
|
||||
}
|
||||
|
||||
type MarketConfig struct {
|
||||
Sources []pmodel.DriverConfig[mmodel.Driver] `yaml:"sources"`
|
||||
Pairs map[string][]PairConfig `yaml:"pairs"`
|
||||
}
|
||||
6
api/fx/ingestor/internal/config/metrics.go
Normal file
6
api/fx/ingestor/internal/config/metrics.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package config
|
||||
|
||||
type MetricsConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Address string `yaml:"address"`
|
||||
}
|
||||
35
api/fx/ingestor/internal/fmerrors/market.go
Normal file
35
api/fx/ingestor/internal/fmerrors/market.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package fmerrors
|
||||
|
||||
type Error struct {
|
||||
message string
|
||||
cause error
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
if e.cause == nil {
|
||||
return e.message
|
||||
}
|
||||
return e.message + ": " + e.cause.Error()
|
||||
}
|
||||
|
||||
func (e *Error) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.cause
|
||||
}
|
||||
|
||||
func New(message string) error {
|
||||
return &Error{message: message}
|
||||
}
|
||||
|
||||
func Wrap(message string, cause error) error {
|
||||
return &Error{message: message, cause: cause}
|
||||
}
|
||||
|
||||
func NewDecimal(value string) error {
|
||||
return &Error{message: "invalid decimal \"" + value + "\""}
|
||||
}
|
||||
84
api/fx/ingestor/internal/ingestor/metrics.go
Normal file
84
api/fx/ingestor/internal/ingestor/metrics.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package ingestor
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/fx/ingestor/internal/config"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
type serviceMetrics struct {
|
||||
pollDuration *prometheus.HistogramVec
|
||||
pollTotal *prometheus.CounterVec
|
||||
pairDuration *prometheus.HistogramVec
|
||||
pairTotal *prometheus.CounterVec
|
||||
pairLastUpdate *prometheus.GaugeVec
|
||||
}
|
||||
|
||||
var (
|
||||
metricsOnce sync.Once
|
||||
globalMetricsRef *serviceMetrics
|
||||
)
|
||||
|
||||
func getServiceMetrics() *serviceMetrics {
|
||||
metricsOnce.Do(func() {
|
||||
reg := prometheus.DefaultRegisterer
|
||||
globalMetricsRef = &serviceMetrics{
|
||||
pollDuration: promauto.With(reg).NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "fx_ingestor_poll_duration_seconds",
|
||||
Help: "Duration of a polling cycle.",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
}, []string{"result"}),
|
||||
pollTotal: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "fx_ingestor_poll_total",
|
||||
Help: "Total polling cycles executed.",
|
||||
}, []string{"result"}),
|
||||
pairDuration: promauto.With(reg).NewHistogramVec(prometheus.HistogramOpts{
|
||||
Name: "fx_ingestor_pair_duration_seconds",
|
||||
Help: "Duration of individual pair ingestion.",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
}, []string{"source", "provider", "symbol", "result"}),
|
||||
pairTotal: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "fx_ingestor_pair_total",
|
||||
Help: "Total ingestion attempts per pair.",
|
||||
}, []string{"source", "provider", "symbol", "result"}),
|
||||
pairLastUpdate: promauto.With(reg).NewGaugeVec(prometheus.GaugeOpts{
|
||||
Name: "fx_ingestor_pair_last_success_unix",
|
||||
Help: "Unix timestamp of the last successful ingestion per pair.",
|
||||
}, []string{"source", "provider", "symbol"}),
|
||||
}
|
||||
})
|
||||
return globalMetricsRef
|
||||
}
|
||||
|
||||
func (m *serviceMetrics) observePoll(duration time.Duration, err error) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
result := labelForError(err)
|
||||
m.pollDuration.WithLabelValues(result).Observe(duration.Seconds())
|
||||
m.pollTotal.WithLabelValues(result).Inc()
|
||||
}
|
||||
|
||||
func (m *serviceMetrics) observePair(pair config.Pair, duration time.Duration, err error) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
result := labelForError(err)
|
||||
labels := []string{pair.Source.String(), pair.Provider, pair.Symbol, result}
|
||||
m.pairDuration.WithLabelValues(labels...).Observe(duration.Seconds())
|
||||
m.pairTotal.WithLabelValues(labels...).Inc()
|
||||
if err == nil {
|
||||
m.pairLastUpdate.WithLabelValues(pair.Source.String(), pair.Provider, pair.Symbol).
|
||||
Set(float64(time.Now().Unix()))
|
||||
}
|
||||
}
|
||||
|
||||
func labelForError(err error) string {
|
||||
if err != nil {
|
||||
return "error"
|
||||
}
|
||||
return "success"
|
||||
}
|
||||
207
api/fx/ingestor/internal/ingestor/service.go
Normal file
207
api/fx/ingestor/internal/ingestor/service.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package ingestor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/big"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/fx/ingestor/internal/config"
|
||||
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
||||
"github.com/tech/sendico/fx/ingestor/internal/market"
|
||||
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||
"github.com/tech/sendico/fx/storage"
|
||||
"github.com/tech/sendico/fx/storage/model"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
logger mlogger.Logger
|
||||
cfg *config.Config
|
||||
rates storage.RatesStore
|
||||
pairs []config.Pair
|
||||
connectors map[mmodel.Driver]mmodel.Connector
|
||||
metrics *serviceMetrics
|
||||
}
|
||||
|
||||
func New(logger mlogger.Logger, cfg *config.Config, repo storage.Repository) (*Service, error) {
|
||||
if logger == nil {
|
||||
return nil, fmerrors.New("ingestor: nil logger")
|
||||
}
|
||||
if cfg == nil {
|
||||
return nil, fmerrors.New("ingestor: nil config")
|
||||
}
|
||||
if repo == nil {
|
||||
return nil, fmerrors.New("ingestor: nil repository")
|
||||
}
|
||||
|
||||
connectors, err := market.BuildConnectors(logger, cfg.Market.Sources)
|
||||
if err != nil {
|
||||
return nil, fmerrors.Wrap("build connectors", err)
|
||||
}
|
||||
|
||||
return &Service{
|
||||
logger: logger.Named("ingestor"),
|
||||
cfg: cfg,
|
||||
rates: repo.Rates(),
|
||||
pairs: cfg.Pairs(),
|
||||
connectors: connectors,
|
||||
metrics: getServiceMetrics(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Run(ctx context.Context) error {
|
||||
interval := s.cfg.PollInterval()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
s.logger.Info("FX ingestion service started", zap.Duration("poll_interval", interval), zap.Int("pairs", len(s.pairs)))
|
||||
|
||||
if err := s.executePoll(ctx); err != nil {
|
||||
s.logger.Warn("Initial poll completed with errors", zap.Error(err))
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
s.logger.Info("Context cancelled, stopping ingestor")
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
if err := s.executePoll(ctx); err != nil {
|
||||
s.logger.Warn("Polling cycle completed with errors", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) executePoll(ctx context.Context) error {
|
||||
start := time.Now()
|
||||
err := s.pollOnce(ctx)
|
||||
if s.metrics != nil {
|
||||
s.metrics.observePoll(time.Since(start), err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) pollOnce(ctx context.Context) error {
|
||||
var firstErr error
|
||||
for _, pair := range s.pairs {
|
||||
start := time.Now()
|
||||
err := s.upsertPair(ctx, pair)
|
||||
elapsed := time.Since(start)
|
||||
if s.metrics != nil {
|
||||
s.metrics.observePair(pair, elapsed, err)
|
||||
}
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
s.logger.Warn("Failed to ingest pair",
|
||||
zap.String("symbol", pair.Symbol),
|
||||
zap.String("source", pair.Source.String()),
|
||||
zap.Duration("elapsed", elapsed),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
|
||||
connector, ok := s.connectors[pair.Source]
|
||||
if !ok {
|
||||
return fmerrors.Wrap("connector not configured for source "+pair.Source.String(), nil)
|
||||
}
|
||||
|
||||
ticker, err := connector.FetchTicker(ctx, pair.Symbol)
|
||||
if err != nil {
|
||||
return fmerrors.Wrap("fetch ticker", err)
|
||||
}
|
||||
|
||||
bid, err := parseDecimal(ticker.BidPrice)
|
||||
if err != nil {
|
||||
return fmerrors.Wrap("parse bid price", err)
|
||||
}
|
||||
ask, err := parseDecimal(ticker.AskPrice)
|
||||
if err != nil {
|
||||
return fmerrors.Wrap("parse ask price", err)
|
||||
}
|
||||
|
||||
if pair.Invert {
|
||||
bid, ask = invertPrices(bid, ask)
|
||||
}
|
||||
|
||||
if ask.Cmp(bid) < 0 {
|
||||
// Ensure bid <= ask to keep downstream logic predictable.
|
||||
bid, ask = ask, bid
|
||||
}
|
||||
|
||||
mid := new(big.Rat).Add(bid, ask)
|
||||
mid.Quo(mid, big.NewRat(2, 1))
|
||||
|
||||
spread := big.NewRat(0, 1)
|
||||
if mid.Sign() != 0 {
|
||||
spread.Sub(ask, bid)
|
||||
if spread.Sign() < 0 {
|
||||
spread.Neg(spread)
|
||||
}
|
||||
spread.Quo(spread, mid)
|
||||
spread.Mul(spread, big.NewRat(10000, 1)) // basis points
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
asOf := now
|
||||
snapshot := &model.RateSnapshot{
|
||||
RateRef: market.BuildRateReference(pair.Provider, pair.Symbol, now),
|
||||
Pair: model.CurrencyPair{Base: pair.Base, Quote: pair.Quote},
|
||||
Provider: pair.Provider,
|
||||
Mid: formatDecimal(mid),
|
||||
Bid: formatDecimal(bid),
|
||||
Ask: formatDecimal(ask),
|
||||
SpreadBps: formatDecimal(spread),
|
||||
AsOfUnixMs: now.UnixMilli(),
|
||||
AsOf: &asOf,
|
||||
Source: ticker.Provider,
|
||||
ProviderRef: ticker.Symbol,
|
||||
}
|
||||
|
||||
if err := s.rates.UpsertSnapshot(ctx, snapshot); err != nil {
|
||||
return fmerrors.Wrap("upsert snapshot", err)
|
||||
}
|
||||
|
||||
s.logger.Debug("Snapshot ingested",
|
||||
zap.String("pair", pair.Base+"/"+pair.Quote),
|
||||
zap.String("provider", pair.Provider),
|
||||
zap.String("bid", snapshot.Bid),
|
||||
zap.String("ask", snapshot.Ask),
|
||||
zap.String("mid", snapshot.Mid),
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseDecimal(value string) (*big.Rat, error) {
|
||||
r := new(big.Rat)
|
||||
if _, ok := r.SetString(value); !ok {
|
||||
return nil, fmerrors.NewDecimal(value)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func invertPrices(bid, ask *big.Rat) (*big.Rat, *big.Rat) {
|
||||
if bid.Sign() == 0 || ask.Sign() == 0 {
|
||||
return bid, ask
|
||||
}
|
||||
one := big.NewRat(1, 1)
|
||||
invBid := new(big.Rat).Quo(one, ask) // invert ask to get bid
|
||||
invAsk := new(big.Rat).Quo(one, bid) // invert bid to get ask
|
||||
return invBid, invAsk
|
||||
}
|
||||
|
||||
func formatDecimal(r *big.Rat) string {
|
||||
if r == nil {
|
||||
return "0"
|
||||
}
|
||||
// Format with 8 decimal places, trimming trailing zeros.
|
||||
return r.FloatString(8)
|
||||
}
|
||||
237
api/fx/ingestor/internal/ingestor/service_test.go
Normal file
237
api/fx/ingestor/internal/ingestor/service_test.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package ingestor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/tech/sendico/fx/ingestor/internal/config"
|
||||
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
||||
mmarket "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||
"github.com/tech/sendico/fx/storage"
|
||||
"github.com/tech/sendico/fx/storage/model"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestParseDecimal(t *testing.T) {
|
||||
got, err := parseDecimal("123.456")
|
||||
if err != nil {
|
||||
t.Fatalf("parseDecimal returned error: %v", err)
|
||||
}
|
||||
if got.String() != "15432/125" { // 123.456 expressed as a rational
|
||||
t.Fatalf("unexpected rational value: %s", got.String())
|
||||
}
|
||||
|
||||
if _, err := parseDecimal("not-a-number"); err == nil {
|
||||
t.Fatalf("parseDecimal should fail on invalid decimal string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvertPrices(t *testing.T) {
|
||||
bid, err := parseDecimal("2")
|
||||
if err != nil {
|
||||
t.Fatalf("parseDecimal: %v", err)
|
||||
}
|
||||
ask, err := parseDecimal("4")
|
||||
if err != nil {
|
||||
t.Fatalf("parseDecimal: %v", err)
|
||||
}
|
||||
|
||||
invBid, invAsk := invertPrices(bid, ask)
|
||||
if diff := cmp.Diff("0.5", invAsk.FloatString(1)); diff != "" {
|
||||
t.Fatalf("unexpected inverted ask (-want +got):\n%s", diff)
|
||||
}
|
||||
if diff := cmp.Diff("0.25", invBid.FloatString(2)); diff != "" {
|
||||
t.Fatalf("unexpected inverted bid (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceUpsertPairStoresSnapshot(t *testing.T) {
|
||||
store := &ratesStoreStub{}
|
||||
svc := testService(store, map[mmarket.Driver]mmarket.Connector{
|
||||
mmarket.DriverBinance: &connectorStub{
|
||||
id: mmarket.DriverBinance,
|
||||
ticker: &mmarket.Ticker{
|
||||
Symbol: "EURUSDT",
|
||||
BidPrice: "1.0000",
|
||||
AskPrice: "1.2000",
|
||||
Provider: "binance",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
pair := config.Pair{
|
||||
PairConfig: config.PairConfig{
|
||||
Base: "USDT",
|
||||
Quote: "EUR",
|
||||
Symbol: "EURUSDT",
|
||||
Provider: "binance",
|
||||
},
|
||||
Source: mmarket.DriverBinance,
|
||||
}
|
||||
|
||||
if err := svc.upsertPair(context.Background(), pair); err != nil {
|
||||
t.Fatalf("upsertPair returned error: %v", err)
|
||||
}
|
||||
if len(store.snapshots) != 1 {
|
||||
t.Fatalf("expected 1 snapshot stored, got %d", len(store.snapshots))
|
||||
}
|
||||
snap := store.snapshots[0]
|
||||
if snap.Pair.Base != "USDT" || snap.Pair.Quote != "EUR" {
|
||||
t.Fatalf("unexpected currency pair stored: %+v", snap.Pair)
|
||||
}
|
||||
if snap.Provider != "binance" {
|
||||
t.Fatalf("unexpected provider: %s", snap.Provider)
|
||||
}
|
||||
if snap.Bid != "1.00000000" || snap.Ask != "1.20000000" {
|
||||
t.Fatalf("unexpected bid/ask: %s / %s", snap.Bid, snap.Ask)
|
||||
}
|
||||
if snap.Mid != "1.10000000" {
|
||||
t.Fatalf("unexpected mid price: %s", snap.Mid)
|
||||
}
|
||||
if snap.SpreadBps != "1818.18181818" {
|
||||
t.Fatalf("unexpected spread bps: %s", snap.SpreadBps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceUpsertPairInvertsPrices(t *testing.T) {
|
||||
store := &ratesStoreStub{}
|
||||
svc := testService(store, map[mmarket.Driver]mmarket.Connector{
|
||||
mmarket.DriverCoinGecko: &connectorStub{
|
||||
id: mmarket.DriverCoinGecko,
|
||||
ticker: &mmarket.Ticker{
|
||||
Symbol: "RUBUSDT",
|
||||
BidPrice: "2",
|
||||
AskPrice: "4",
|
||||
Provider: "coingecko",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
pair := config.Pair{
|
||||
PairConfig: config.PairConfig{
|
||||
Base: "RUB",
|
||||
Quote: "USDT",
|
||||
Symbol: "RUBUSDT",
|
||||
Provider: "coingecko",
|
||||
Invert: true,
|
||||
},
|
||||
Source: mmarket.DriverCoinGecko,
|
||||
}
|
||||
|
||||
if err := svc.upsertPair(context.Background(), pair); err != nil {
|
||||
t.Fatalf("upsertPair returned error: %v", err)
|
||||
}
|
||||
|
||||
snap := store.snapshots[0]
|
||||
if snap.Bid != "0.25000000" || snap.Ask != "0.50000000" {
|
||||
t.Fatalf("unexpected inverted bid/ask: %s / %s", snap.Bid, snap.Ask)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePollOnceReturnsFirstError(t *testing.T) {
|
||||
errFetch := fmerrors.New("fetch failed")
|
||||
connectorSuccess := &connectorStub{
|
||||
id: mmarket.DriverBinance,
|
||||
ticker: &mmarket.Ticker{
|
||||
Symbol: "EURUSDT",
|
||||
BidPrice: "1",
|
||||
AskPrice: "1",
|
||||
Provider: "binance",
|
||||
},
|
||||
}
|
||||
connectorFail := &connectorStub{
|
||||
id: mmarket.DriverCoinGecko,
|
||||
err: errFetch,
|
||||
}
|
||||
|
||||
store := &ratesStoreStub{}
|
||||
svc := testService(store, map[mmarket.Driver]mmarket.Connector{
|
||||
mmarket.DriverBinance: connectorSuccess,
|
||||
mmarket.DriverCoinGecko: connectorFail,
|
||||
})
|
||||
svc.pairs = []config.Pair{
|
||||
{PairConfig: config.PairConfig{Base: "USDT", Quote: "EUR", Symbol: "EURUSDT"}, Source: mmarket.DriverBinance},
|
||||
{PairConfig: config.PairConfig{Base: "USDT", Quote: "RUB", Symbol: "RUBUSDT"}, Source: mmarket.DriverCoinGecko},
|
||||
}
|
||||
|
||||
err := svc.pollOnce(context.Background())
|
||||
if err == nil {
|
||||
t.Fatalf("pollOnce expected to return error")
|
||||
}
|
||||
if !errors.Is(err, errFetch) {
|
||||
t.Fatalf("pollOnce returned unexpected error: %v", err)
|
||||
}
|
||||
if connectorSuccess.calls != 1 {
|
||||
t.Fatalf("expected success connector called once, got %d", connectorSuccess.calls)
|
||||
}
|
||||
if connectorFail.calls != 1 {
|
||||
t.Fatalf("expected failing connector called once, got %d", connectorFail.calls)
|
||||
}
|
||||
if len(store.snapshots) != 1 {
|
||||
t.Fatalf("expected snapshot stored only for successful pair, got %d", len(store.snapshots))
|
||||
}
|
||||
}
|
||||
|
||||
// -- test helpers -----------------------------------------------------------------
|
||||
|
||||
type ratesStoreStub struct {
|
||||
snapshots []*model.RateSnapshot
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *ratesStoreStub) UpsertSnapshot(_ context.Context, snapshot *model.RateSnapshot) error {
|
||||
if r.err != nil {
|
||||
return r.err
|
||||
}
|
||||
cp := *snapshot
|
||||
r.snapshots = append(r.snapshots, &cp)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ratesStoreStub) LatestSnapshot(context.Context, model.CurrencyPair, string) (*model.RateSnapshot, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type repositoryStub struct {
|
||||
rates storage.RatesStore
|
||||
}
|
||||
|
||||
func (r *repositoryStub) Ping(context.Context) error { return nil }
|
||||
func (r *repositoryStub) Rates() storage.RatesStore { return r.rates }
|
||||
func (r *repositoryStub) Quotes() storage.QuotesStore { return nil }
|
||||
func (r *repositoryStub) Pairs() storage.PairStore { return nil }
|
||||
func (r *repositoryStub) Currencies() storage.CurrencyStore { return nil }
|
||||
|
||||
type connectorStub struct {
|
||||
id mmarket.Driver
|
||||
ticker *mmarket.Ticker
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (c *connectorStub) ID() mmarket.Driver {
|
||||
return c.id
|
||||
}
|
||||
|
||||
func (c *connectorStub) FetchTicker(_ context.Context, symbol string) (*mmarket.Ticker, error) {
|
||||
c.calls++
|
||||
if c.ticker != nil {
|
||||
cp := *c.ticker
|
||||
cp.Symbol = symbol
|
||||
return &cp, c.err
|
||||
}
|
||||
return nil, c.err
|
||||
}
|
||||
|
||||
func testService(store storage.RatesStore, connectors map[mmarket.Driver]mmarket.Connector) *Service {
|
||||
return &Service{
|
||||
logger: zap.NewNop(),
|
||||
cfg: &config.Config{},
|
||||
rates: store,
|
||||
connectors: connectors,
|
||||
pairs: nil,
|
||||
metrics: nil,
|
||||
}
|
||||
}
|
||||
139
api/fx/ingestor/internal/market/binance/connector.go
Normal file
139
api/fx/ingestor/internal/market/binance/connector.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package binance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
||||
"github.com/tech/sendico/fx/ingestor/internal/market/common"
|
||||
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type binanceConnector struct {
|
||||
id mmodel.Driver
|
||||
provider string
|
||||
client *http.Client
|
||||
base string
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
const defaultBinanceBaseURL = "https://api.binance.com"
|
||||
const (
|
||||
defaultDialTimeoutSeconds = 5 * time.Second
|
||||
defaultDialKeepAliveSeconds = 30 * time.Second
|
||||
defaultTLSHandshakeTimeoutSeconds = 5 * time.Second
|
||||
defaultResponseHeaderTimeoutSeconds = 10 * time.Second
|
||||
defaultRequestTimeoutSeconds = 10 * time.Second
|
||||
)
|
||||
|
||||
func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Connector, error) {
|
||||
baseURL := defaultBinanceBaseURL
|
||||
provider := strings.ToLower(mmodel.DriverBinance.String())
|
||||
dialTimeout := defaultDialTimeoutSeconds
|
||||
dialKeepAlive := defaultDialKeepAliveSeconds
|
||||
tlsHandshakeTimeout := defaultTLSHandshakeTimeoutSeconds
|
||||
responseHeaderTimeout := defaultResponseHeaderTimeoutSeconds
|
||||
requestTimeout := defaultRequestTimeoutSeconds
|
||||
|
||||
if settings != nil {
|
||||
if value, ok := settings["base_url"].(string); ok && strings.TrimSpace(value) != "" {
|
||||
baseURL = strings.TrimSpace(value)
|
||||
}
|
||||
if value, ok := settings["provider"].(string); ok && strings.TrimSpace(value) != "" {
|
||||
provider = strings.TrimSpace(value)
|
||||
}
|
||||
dialTimeout = common.DurationSetting(settings, "dial_timeout_seconds", dialTimeout)
|
||||
dialKeepAlive = common.DurationSetting(settings, "dial_keep_alive_seconds", dialKeepAlive)
|
||||
tlsHandshakeTimeout = common.DurationSetting(settings, "tls_handshake_timeout_seconds", tlsHandshakeTimeout)
|
||||
responseHeaderTimeout = common.DurationSetting(settings, "response_header_timeout_seconds", responseHeaderTimeout)
|
||||
requestTimeout = common.DurationSetting(settings, "request_timeout_seconds", requestTimeout)
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return nil, fmerrors.Wrap("binance: invalid base url", err)
|
||||
}
|
||||
|
||||
transport := &http.Transport{
|
||||
DialContext: (&net.Dialer{Timeout: dialTimeout, KeepAlive: dialKeepAlive}).DialContext,
|
||||
TLSHandshakeTimeout: tlsHandshakeTimeout,
|
||||
ResponseHeaderTimeout: responseHeaderTimeout,
|
||||
}
|
||||
|
||||
connector := &binanceConnector{
|
||||
id: mmodel.DriverBinance,
|
||||
provider: provider,
|
||||
client: &http.Client{
|
||||
Timeout: requestTimeout,
|
||||
Transport: transport,
|
||||
},
|
||||
base: parsed.String(),
|
||||
logger: logger.Named("binance"),
|
||||
}
|
||||
|
||||
return connector, nil
|
||||
}
|
||||
|
||||
func (c *binanceConnector) ID() mmodel.Driver {
|
||||
return c.id
|
||||
}
|
||||
|
||||
func (c *binanceConnector) FetchTicker(ctx context.Context, symbol string) (*mmodel.Ticker, error) {
|
||||
if strings.TrimSpace(symbol) == "" {
|
||||
return nil, fmerrors.New("binance: symbol is empty")
|
||||
}
|
||||
|
||||
endpoint, err := url.Parse(c.base)
|
||||
if err != nil {
|
||||
return nil, fmerrors.Wrap("binance: parse base url", err)
|
||||
}
|
||||
endpoint.Path = "/api/v3/ticker/bookTicker"
|
||||
query := endpoint.Query()
|
||||
query.Set("symbol", strings.ToUpper(strings.TrimSpace(symbol)))
|
||||
endpoint.RawQuery = query.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmerrors.Wrap("binance: build request", err)
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
c.logger.Warn("Binance request failed", zap.String("symbol", symbol), zap.Error(err))
|
||||
return nil, fmerrors.Wrap("binance: request failed", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
c.logger.Warn("Binance returned non-OK status", zap.String("symbol", symbol), zap.Int("status", resp.StatusCode))
|
||||
return nil, fmerrors.New("binance: unexpected status " + strconv.Itoa(resp.StatusCode))
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Symbol string `json:"symbol"`
|
||||
BidPrice string `json:"bidPrice"`
|
||||
AskPrice string `json:"askPrice"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
c.logger.Warn("Binance decode failed", zap.String("symbol", symbol), zap.Error(err))
|
||||
return nil, fmerrors.Wrap("binance: decode response", err)
|
||||
}
|
||||
|
||||
return &mmodel.Ticker{
|
||||
Symbol: payload.Symbol,
|
||||
BidPrice: payload.BidPrice,
|
||||
AskPrice: payload.AskPrice,
|
||||
Provider: c.provider,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}, nil
|
||||
}
|
||||
222
api/fx/ingestor/internal/market/coingecko/connector.go
Normal file
222
api/fx/ingestor/internal/market/coingecko/connector.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package coingecko
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
||||
"github.com/tech/sendico/fx/ingestor/internal/market/common"
|
||||
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type coingeckoConnector struct {
|
||||
id mmodel.Driver
|
||||
provider string
|
||||
client *http.Client
|
||||
base string
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
const defaultCoinGeckoBaseURL = "https://api.coingecko.com/api/v3"
|
||||
|
||||
const (
|
||||
defaultDialTimeoutSeconds = 5 * time.Second
|
||||
defaultDialKeepAliveSeconds = 30 * time.Second
|
||||
defaultTLSHandshakeTimeoutSeconds = 5 * time.Second
|
||||
defaultResponseHeaderTimeoutSeconds = 10 * time.Second
|
||||
defaultRequestTimeoutSeconds = 10 * time.Second
|
||||
)
|
||||
|
||||
func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Connector, error) {
|
||||
baseURL := defaultCoinGeckoBaseURL
|
||||
provider := strings.ToLower(mmodel.DriverCoinGecko.String())
|
||||
dialTimeout := defaultDialTimeoutSeconds
|
||||
dialKeepAlive := defaultDialKeepAliveSeconds
|
||||
tlsHandshakeTimeout := defaultTLSHandshakeTimeoutSeconds
|
||||
responseHeaderTimeout := defaultResponseHeaderTimeoutSeconds
|
||||
requestTimeout := defaultRequestTimeoutSeconds
|
||||
|
||||
if settings != nil {
|
||||
if value, ok := settings["base_url"].(string); ok && strings.TrimSpace(value) != "" {
|
||||
baseURL = strings.TrimSpace(value)
|
||||
}
|
||||
if value, ok := settings["provider"].(string); ok && strings.TrimSpace(value) != "" {
|
||||
provider = strings.TrimSpace(value)
|
||||
}
|
||||
dialTimeout = common.DurationSetting(settings, "dial_timeout_seconds", dialTimeout)
|
||||
dialKeepAlive = common.DurationSetting(settings, "dial_keep_alive_seconds", dialKeepAlive)
|
||||
tlsHandshakeTimeout = common.DurationSetting(settings, "tls_handshake_timeout_seconds", tlsHandshakeTimeout)
|
||||
responseHeaderTimeout = common.DurationSetting(settings, "response_header_timeout_seconds", responseHeaderTimeout)
|
||||
requestTimeout = common.DurationSetting(settings, "request_timeout_seconds", requestTimeout)
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return nil, fmerrors.Wrap("coingecko: invalid base url", err)
|
||||
}
|
||||
|
||||
transport := &http.Transport{
|
||||
DialContext: (&net.Dialer{Timeout: dialTimeout, KeepAlive: dialKeepAlive}).DialContext,
|
||||
TLSHandshakeTimeout: tlsHandshakeTimeout,
|
||||
ResponseHeaderTimeout: responseHeaderTimeout,
|
||||
}
|
||||
|
||||
connector := &coingeckoConnector{
|
||||
id: mmodel.DriverCoinGecko,
|
||||
provider: provider,
|
||||
client: &http.Client{
|
||||
Timeout: requestTimeout,
|
||||
Transport: transport,
|
||||
},
|
||||
base: strings.TrimRight(parsed.String(), "/"),
|
||||
logger: logger.Named("coingecko"),
|
||||
}
|
||||
|
||||
return connector, nil
|
||||
}
|
||||
|
||||
func (c *coingeckoConnector) ID() mmodel.Driver {
|
||||
return c.id
|
||||
}
|
||||
|
||||
func (c *coingeckoConnector) FetchTicker(ctx context.Context, symbol string) (*mmodel.Ticker, error) {
|
||||
coinID, vsCurrency, err := parseSymbol(symbol)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
endpoint, err := url.Parse(c.base)
|
||||
if err != nil {
|
||||
return nil, fmerrors.Wrap("coingecko: parse base url", err)
|
||||
}
|
||||
endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/simple/price"
|
||||
query := endpoint.Query()
|
||||
query.Set("ids", coinID)
|
||||
query.Set("vs_currencies", vsCurrency)
|
||||
query.Set("include_last_updated_at", "true")
|
||||
endpoint.RawQuery = query.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmerrors.Wrap("coingecko: build request", err)
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
c.logger.Warn("CoinGecko request failed", zap.String("symbol", symbol), zap.Error(err))
|
||||
return nil, fmerrors.Wrap("coingecko: request failed", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
c.logger.Warn("CoinGecko returned non-OK status", zap.String("symbol", symbol), zap.Int("status", resp.StatusCode))
|
||||
return nil, fmerrors.New("coingecko: unexpected status " + strconv.Itoa(resp.StatusCode))
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(resp.Body)
|
||||
decoder.UseNumber()
|
||||
|
||||
var payload map[string]map[string]interface{}
|
||||
if err := decoder.Decode(&payload); err != nil {
|
||||
c.logger.Warn("CoinGecko decode failed", zap.String("symbol", symbol), zap.Error(err))
|
||||
return nil, fmerrors.Wrap("coingecko: decode response", err)
|
||||
}
|
||||
|
||||
coinData, ok := payload[coinID]
|
||||
if !ok {
|
||||
return nil, fmerrors.New("coingecko: coin id not found in response")
|
||||
}
|
||||
priceValue, ok := coinData[vsCurrency]
|
||||
if !ok {
|
||||
return nil, fmerrors.New("coingecko: vs currency not found in response")
|
||||
}
|
||||
|
||||
price, ok := toFloat(priceValue)
|
||||
if !ok || price <= 0 {
|
||||
return nil, fmerrors.New("coingecko: invalid price value in response")
|
||||
}
|
||||
|
||||
priceStr := strconv.FormatFloat(price, 'f', -1, 64)
|
||||
|
||||
timestamp := time.Now().UnixMilli()
|
||||
if tsValue, ok := coinData["last_updated_at"]; ok {
|
||||
if tsFloat, ok := toFloat(tsValue); ok && tsFloat > 0 {
|
||||
tsMillis := int64(tsFloat * 1000)
|
||||
if tsMillis > 0 {
|
||||
timestamp = tsMillis
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
refSymbol := coinID + "_" + vsCurrency
|
||||
|
||||
return &mmodel.Ticker{
|
||||
Symbol: refSymbol,
|
||||
BidPrice: priceStr,
|
||||
AskPrice: priceStr,
|
||||
Provider: c.provider,
|
||||
Timestamp: timestamp,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseSymbol(symbol string) (string, string, error) {
|
||||
trimmed := strings.TrimSpace(symbol)
|
||||
if trimmed == "" {
|
||||
return "", "", fmerrors.New("coingecko: symbol is empty")
|
||||
}
|
||||
|
||||
parts := strings.FieldsFunc(strings.ToLower(trimmed), func(r rune) bool {
|
||||
switch r {
|
||||
case ':', '/', '-', '_':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
if len(parts) != 2 {
|
||||
return "", "", fmerrors.New("coingecko: symbol must be <coin_id>/<vs_currency>")
|
||||
}
|
||||
|
||||
coinID := strings.TrimSpace(parts[0])
|
||||
vsCurrency := strings.TrimSpace(parts[1])
|
||||
if coinID == "" || vsCurrency == "" {
|
||||
return "", "", fmerrors.New("coingecko: symbol contains empty segments")
|
||||
}
|
||||
|
||||
return coinID, vsCurrency, nil
|
||||
}
|
||||
|
||||
func toFloat(value interface{}) (float64, bool) {
|
||||
switch v := value.(type) {
|
||||
case json.Number:
|
||||
f, err := v.Float64()
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
case float64:
|
||||
return v, true
|
||||
case float32:
|
||||
return float64(v), true
|
||||
case int:
|
||||
return float64(v), true
|
||||
case int64:
|
||||
return float64(v), true
|
||||
case uint64:
|
||||
return float64(v), true
|
||||
case string:
|
||||
if parsed, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
46
api/fx/ingestor/internal/market/common/settings.go
Normal file
46
api/fx/ingestor/internal/market/common/settings.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
// DurationSetting reads a positive duration override from settings or returns def when the value is missing or invalid.
|
||||
func DurationSetting(settings model.SettingsT, key string, def time.Duration) time.Duration {
|
||||
if settings == nil {
|
||||
return def
|
||||
}
|
||||
value, ok := settings[key]
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case time.Duration:
|
||||
if v > 0 {
|
||||
return v
|
||||
}
|
||||
case int:
|
||||
if v > 0 {
|
||||
return time.Duration(v) * time.Second
|
||||
}
|
||||
case int64:
|
||||
if v > 0 {
|
||||
return time.Duration(v) * time.Second
|
||||
}
|
||||
case float64:
|
||||
if v > 0 {
|
||||
return time.Duration(v * float64(time.Second))
|
||||
}
|
||||
case string:
|
||||
if parsed, err := time.ParseDuration(v); err == nil && parsed > 0 {
|
||||
return parsed
|
||||
}
|
||||
if seconds, err := strconv.ParseFloat(v, 64); err == nil && seconds > 0 {
|
||||
return time.Duration(seconds * float64(time.Second))
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
55
api/fx/ingestor/internal/market/factory.go
Normal file
55
api/fx/ingestor/internal/market/factory.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package market
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
||||
"github.com/tech/sendico/fx/ingestor/internal/market/binance"
|
||||
"github.com/tech/sendico/fx/ingestor/internal/market/coingecko"
|
||||
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
type ConnectorFactory func(logger mlogger.Logger, settings model.SettingsT) (mmodel.Connector, error)
|
||||
|
||||
func BuildConnectors(logger mlogger.Logger, configs []model.DriverConfig[mmodel.Driver]) (map[mmodel.Driver]mmodel.Connector, error) {
|
||||
connectors := make(map[mmodel.Driver]mmodel.Connector, len(configs))
|
||||
|
||||
for _, cfg := range configs {
|
||||
driver := mmodel.NormalizeDriver(cfg.Driver)
|
||||
if driver.IsEmpty() {
|
||||
return nil, fmerrors.New("market: connector driver is empty")
|
||||
}
|
||||
|
||||
var (
|
||||
conn mmodel.Connector
|
||||
err error
|
||||
)
|
||||
|
||||
switch driver {
|
||||
case mmodel.DriverBinance:
|
||||
conn, err = binance.NewConnector(logger, cfg.Settings)
|
||||
case mmodel.DriverCoinGecko:
|
||||
conn, err = coingecko.NewConnector(logger, cfg.Settings)
|
||||
default:
|
||||
err = fmerrors.New("market: unsupported driver " + driver.String())
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmerrors.Wrap("market: build connector "+driver.String(), err)
|
||||
}
|
||||
connectors[driver] = conn
|
||||
}
|
||||
|
||||
return connectors, nil
|
||||
}
|
||||
|
||||
func BuildRateReference(provider, symbol string, now time.Time) string {
|
||||
if strings.TrimSpace(provider) == "" {
|
||||
provider = "unknown"
|
||||
}
|
||||
return provider + ":" + symbol + ":" + strconv.FormatInt(now.UnixMilli(), 10)
|
||||
}
|
||||
134
api/fx/ingestor/internal/metrics/server.go
Normal file
134
api/fx/ingestor/internal/metrics/server.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/tech/sendico/fx/ingestor/internal/config"
|
||||
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
||||
"github.com/tech/sendico/pkg/api/routers"
|
||||
"github.com/tech/sendico/pkg/api/routers/health"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAddress = ":9102"
|
||||
readHeaderTimeout = 5 * time.Second
|
||||
defaultShutdownWindow = 5 * time.Second
|
||||
)
|
||||
|
||||
type Server interface {
|
||||
SetStatus(health.ServiceStatus)
|
||||
Close(context.Context)
|
||||
}
|
||||
|
||||
func NewServer(logger mlogger.Logger, cfg *config.MetricsConfig) (Server, error) {
|
||||
if logger == nil {
|
||||
return nil, fmerrors.New("metrics: logger is nil")
|
||||
}
|
||||
if cfg == nil || !cfg.Enabled {
|
||||
logger.Debug("Metrics disabled; using noop server")
|
||||
return noopServer{}, nil
|
||||
}
|
||||
|
||||
address := strings.TrimSpace(cfg.Address)
|
||||
if address == "" {
|
||||
address = defaultAddress
|
||||
}
|
||||
|
||||
metricsLogger := logger.Named("metrics")
|
||||
router := chi.NewRouter()
|
||||
router.Handle("/metrics", promhttp.Handler())
|
||||
|
||||
var healthRouter routers.Health
|
||||
if hr, err := routers.NewHealthRouter(metricsLogger, router, ""); err != nil {
|
||||
metricsLogger.Warn("Failed to initialise health router", zap.Error(err))
|
||||
} else {
|
||||
hr.SetStatus(health.SSStarting)
|
||||
healthRouter = hr
|
||||
}
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: address,
|
||||
Handler: router,
|
||||
ReadHeaderTimeout: readHeaderTimeout,
|
||||
}
|
||||
|
||||
ms := &httpServerWrapper{
|
||||
logger: metricsLogger,
|
||||
server: httpServer,
|
||||
health: healthRouter,
|
||||
timeout: defaultShutdownWindow,
|
||||
}
|
||||
|
||||
go func() {
|
||||
metricsLogger.Info("Prometheus endpoint listening", zap.String("address", address))
|
||||
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
metricsLogger.Error("Prometheus endpoint stopped unexpectedly", zap.Error(err))
|
||||
if healthRouter != nil {
|
||||
healthRouter.SetStatus(health.SSTerminating)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return ms, nil
|
||||
}
|
||||
|
||||
type httpServerWrapper struct {
|
||||
logger mlogger.Logger
|
||||
server *http.Server
|
||||
health routers.Health
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func (s *httpServerWrapper) SetStatus(status health.ServiceStatus) {
|
||||
if s == nil || s.health == nil {
|
||||
return
|
||||
}
|
||||
s.logger.Debug("Updating metrics health status", zap.String("status", string(status)))
|
||||
s.health.SetStatus(status)
|
||||
}
|
||||
|
||||
func (s *httpServerWrapper) Close(ctx context.Context) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if s.health != nil {
|
||||
s.health.SetStatus(health.SSTerminating)
|
||||
s.health.Finish()
|
||||
s.health = nil
|
||||
}
|
||||
|
||||
if s.server == nil {
|
||||
return
|
||||
}
|
||||
|
||||
shutdownCtx := ctx
|
||||
if shutdownCtx == nil {
|
||||
shutdownCtx = context.Background()
|
||||
}
|
||||
if s.timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
shutdownCtx, cancel = context.WithTimeout(shutdownCtx, s.timeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
if err := s.server.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
s.logger.Warn("Failed to stop metrics server", zap.Error(err))
|
||||
} else {
|
||||
s.logger.Info("Metrics server stopped")
|
||||
}
|
||||
}
|
||||
|
||||
type noopServer struct{}
|
||||
|
||||
func (noopServer) SetStatus(health.ServiceStatus) {}
|
||||
|
||||
func (noopServer) Close(context.Context) {}
|
||||
30
api/fx/ingestor/internal/model/connector.go
Normal file
30
api/fx/ingestor/internal/model/connector.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Driver string
|
||||
|
||||
const (
|
||||
DriverBinance Driver = "BINANCE"
|
||||
DriverCoinGecko Driver = "COINGECKO"
|
||||
)
|
||||
|
||||
func (d Driver) String() string {
|
||||
return string(d)
|
||||
}
|
||||
|
||||
func (d Driver) IsEmpty() bool {
|
||||
return strings.TrimSpace(string(d)) == ""
|
||||
}
|
||||
|
||||
func NormalizeDriver(d Driver) Driver {
|
||||
return Driver(strings.ToUpper(strings.TrimSpace(string(d))))
|
||||
}
|
||||
|
||||
type Connector interface {
|
||||
ID() Driver
|
||||
FetchTicker(ctx context.Context, symbol string) (*Ticker, error)
|
||||
}
|
||||
9
api/fx/ingestor/internal/model/ticker.go
Normal file
9
api/fx/ingestor/internal/model/ticker.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package model
|
||||
|
||||
type Ticker struct {
|
||||
Symbol string
|
||||
BidPrice string
|
||||
AskPrice string
|
||||
Provider string
|
||||
Timestamp int64
|
||||
}
|
||||
14
api/fx/ingestor/internal/signalctx/signalctx.go
Normal file
14
api/fx/ingestor/internal/signalctx/signalctx.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package signalctx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/signal"
|
||||
)
|
||||
|
||||
func WithSignals(parent context.Context, sig ...os.Signal) (context.Context, context.CancelFunc) {
|
||||
if parent == nil {
|
||||
parent = context.Background()
|
||||
}
|
||||
return signal.NotifyContext(parent, sig...)
|
||||
}
|
||||
55
api/fx/ingestor/main.go
Normal file
55
api/fx/ingestor/main.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"github.com/tech/sendico/fx/ingestor/internal/app"
|
||||
"github.com/tech/sendico/fx/ingestor/internal/appversion"
|
||||
"github.com/tech/sendico/fx/ingestor/internal/signalctx"
|
||||
lf "github.com/tech/sendico/pkg/mlogger/factory"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var (
|
||||
configFile = flag.String("config.file", app.DefaultConfigPath, "Path to the configuration file.")
|
||||
debugFlag = flag.Bool("debug", false, "Enable debug logging.")
|
||||
versionFlag = flag.Bool("version", false, "Show version information.")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
logger := lf.NewLogger(*debugFlag).Named("fx_ingestor")
|
||||
defer logger.Sync()
|
||||
|
||||
av := appversion.Create()
|
||||
if *versionFlag {
|
||||
fmt.Fprintln(os.Stdout, av.Print())
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info(fmt.Sprintf("Starting %s", av.Program()), zap.String("version", av.Info()))
|
||||
|
||||
ctx, cancel := signalctx.WithSignals(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
application, err := app.New(logger, *configFile)
|
||||
if err != nil {
|
||||
logger.Fatal("Failed to initialise application", zap.Error(err))
|
||||
}
|
||||
|
||||
if err := application.Run(ctx); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
logger.Info("FX ingestor stopped")
|
||||
return
|
||||
}
|
||||
logger.Fatal("Ingestor terminated with error", zap.Error(err))
|
||||
}
|
||||
|
||||
logger.Info("FX ingestor stopped")
|
||||
}
|
||||
32
api/fx/oracle/.air.toml
Normal file
32
api/fx/oracle/.air.toml
Normal file
@@ -0,0 +1,32 @@
|
||||
# Config file for Air in TOML format
|
||||
|
||||
root = "./../.."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
cmd = "go build -o app -ldflags \"-X 'github.com/tech/sendico/fx/oracle/internal/appversion.BuildUser=$(whoami)' -X 'github.com/tech/sendico/fx/oracle/internal/appversion.Version=$APP_V' -X 'github.com/tech/sendico/fx/oracle/internal/appversion.Branch=$BUILD_BRANCH' -X 'github.com/tech/sendico/fx/oracle/internal/appversion.Revision=$GIT_REV' -X 'github.com/tech/sendico/fx/oracle/internal/appversion.BuildDate=$(date)'\""
|
||||
bin = "./app"
|
||||
full_bin = "./app --debug --config.file=config.yml"
|
||||
include_ext = ["go", "yaml", "yml"]
|
||||
exclude_dir = ["fx/oracle/tmp", "pkg/.git", "fx/oracle/env"]
|
||||
exclude_regex = ["_test\\.go"]
|
||||
exclude_unchanged = true
|
||||
follow_symlink = true
|
||||
log = "air.log"
|
||||
delay = 0
|
||||
stop_on_error = true
|
||||
send_interrupt = true
|
||||
kill_delay = 500
|
||||
args_bin = []
|
||||
|
||||
[log]
|
||||
time = false
|
||||
|
||||
[color]
|
||||
main = "magenta"
|
||||
watcher = "cyan"
|
||||
build = "yellow"
|
||||
runner = "green"
|
||||
|
||||
[misc]
|
||||
clean_on_exit = true
|
||||
3
api/fx/oracle/.gitignore
vendored
Normal file
3
api/fx/oracle/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
internal/generated
|
||||
.gocache
|
||||
app
|
||||
252
api/fx/oracle/client/client.go
Normal file
252
api/fx/oracle/client/client.go
Normal file
@@ -0,0 +1,252 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
// Client exposes typed helpers around the oracle gRPC API.
|
||||
type Client interface {
|
||||
LatestRate(ctx context.Context, req LatestRateParams) (*RateSnapshot, error)
|
||||
GetQuote(ctx context.Context, req GetQuoteParams) (*Quote, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// RequestMeta carries optional multi-tenant context for oracle calls.
|
||||
type RequestMeta struct {
|
||||
TenantRef string
|
||||
OrganizationRef string
|
||||
Trace *tracev1.TraceContext
|
||||
}
|
||||
|
||||
type LatestRateParams struct {
|
||||
Meta RequestMeta
|
||||
Pair *fxv1.CurrencyPair
|
||||
Provider string
|
||||
}
|
||||
|
||||
type RateSnapshot struct {
|
||||
Pair *fxv1.CurrencyPair
|
||||
Mid string
|
||||
Bid string
|
||||
Ask string
|
||||
SpreadBps string
|
||||
Provider string
|
||||
RateRef string
|
||||
AsOf time.Time
|
||||
}
|
||||
|
||||
type GetQuoteParams struct {
|
||||
Meta RequestMeta
|
||||
Pair *fxv1.CurrencyPair
|
||||
Side fxv1.Side
|
||||
BaseAmount *moneyv1.Money
|
||||
QuoteAmount *moneyv1.Money
|
||||
Firm bool
|
||||
TTL time.Duration
|
||||
PreferredProvider string
|
||||
MaxAge time.Duration
|
||||
}
|
||||
|
||||
type Quote struct {
|
||||
QuoteRef string
|
||||
Pair *fxv1.CurrencyPair
|
||||
Side fxv1.Side
|
||||
Price string
|
||||
BaseAmount *moneyv1.Money
|
||||
QuoteAmount *moneyv1.Money
|
||||
ExpiresAt time.Time
|
||||
Provider string
|
||||
RateRef string
|
||||
Firm bool
|
||||
}
|
||||
|
||||
type grpcOracleClient interface {
|
||||
GetQuote(ctx context.Context, in *oraclev1.GetQuoteRequest, opts ...grpc.CallOption) (*oraclev1.GetQuoteResponse, error)
|
||||
LatestRate(ctx context.Context, in *oraclev1.LatestRateRequest, opts ...grpc.CallOption) (*oraclev1.LatestRateResponse, error)
|
||||
}
|
||||
|
||||
type oracleClient struct {
|
||||
cfg Config
|
||||
conn *grpc.ClientConn
|
||||
client grpcOracleClient
|
||||
}
|
||||
|
||||
// New dials the oracle endpoint and returns a ready client.
|
||||
func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, error) {
|
||||
cfg.setDefaults()
|
||||
if strings.TrimSpace(cfg.Address) == "" {
|
||||
return nil, errors.New("oracle: address is required")
|
||||
}
|
||||
|
||||
dialCtx, cancel := context.WithTimeout(ctx, cfg.DialTimeout)
|
||||
defer cancel()
|
||||
|
||||
dialOpts := make([]grpc.DialOption, 0, len(opts)+1)
|
||||
dialOpts = append(dialOpts, opts...)
|
||||
|
||||
if cfg.Insecure {
|
||||
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
} else {
|
||||
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
|
||||
}
|
||||
|
||||
conn, err := grpc.DialContext(dialCtx, cfg.Address, dialOpts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oracle: dial %s: %w", cfg.Address, err)
|
||||
}
|
||||
|
||||
return &oracleClient{
|
||||
cfg: cfg,
|
||||
conn: conn,
|
||||
client: oraclev1.NewOracleClient(conn),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewWithClient injects a pre-built oracle client (useful for tests).
|
||||
func NewWithClient(cfg Config, oc grpcOracleClient) Client {
|
||||
cfg.setDefaults()
|
||||
return &oracleClient{
|
||||
cfg: cfg,
|
||||
client: oc,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *oracleClient) Close() error {
|
||||
if c.conn != nil {
|
||||
return c.conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *oracleClient) LatestRate(ctx context.Context, req LatestRateParams) (*RateSnapshot, error) {
|
||||
if req.Pair == nil {
|
||||
return nil, errors.New("oracle: pair is required")
|
||||
}
|
||||
|
||||
callCtx, cancel := c.callContext(ctx)
|
||||
defer cancel()
|
||||
|
||||
resp, err := c.client.LatestRate(callCtx, &oraclev1.LatestRateRequest{
|
||||
Meta: toProtoMeta(req.Meta),
|
||||
Pair: req.Pair,
|
||||
Provider: req.Provider,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oracle: latest rate: %w", err)
|
||||
}
|
||||
if resp.GetRate() == nil {
|
||||
return nil, errors.New("oracle: latest rate: empty payload")
|
||||
}
|
||||
return fromProtoRate(resp.GetRate()), nil
|
||||
}
|
||||
|
||||
func (c *oracleClient) GetQuote(ctx context.Context, req GetQuoteParams) (*Quote, error) {
|
||||
if req.Pair == nil {
|
||||
return nil, errors.New("oracle: pair is required")
|
||||
}
|
||||
if req.Side == fxv1.Side_SIDE_UNSPECIFIED {
|
||||
return nil, errors.New("oracle: side is required")
|
||||
}
|
||||
|
||||
baseSupplied := req.BaseAmount != nil
|
||||
quoteSupplied := req.QuoteAmount != nil
|
||||
if baseSupplied == quoteSupplied {
|
||||
return nil, errors.New("oracle: exactly one of base_amount or quote_amount must be set")
|
||||
}
|
||||
|
||||
callCtx, cancel := c.callContext(ctx)
|
||||
defer cancel()
|
||||
|
||||
protoReq := &oraclev1.GetQuoteRequest{
|
||||
Meta: toProtoMeta(req.Meta),
|
||||
Pair: req.Pair,
|
||||
Side: req.Side,
|
||||
Firm: req.Firm,
|
||||
PreferredProvider: req.PreferredProvider,
|
||||
}
|
||||
if req.TTL > 0 {
|
||||
protoReq.TtlMs = req.TTL.Milliseconds()
|
||||
}
|
||||
if req.MaxAge > 0 {
|
||||
protoReq.MaxAgeMs = int32(req.MaxAge.Milliseconds())
|
||||
}
|
||||
if baseSupplied {
|
||||
protoReq.AmountInput = &oraclev1.GetQuoteRequest_BaseAmount{BaseAmount: req.BaseAmount}
|
||||
} else {
|
||||
protoReq.AmountInput = &oraclev1.GetQuoteRequest_QuoteAmount{QuoteAmount: req.QuoteAmount}
|
||||
}
|
||||
|
||||
resp, err := c.client.GetQuote(callCtx, protoReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oracle: get quote: %w", err)
|
||||
}
|
||||
if resp.GetQuote() == nil {
|
||||
return nil, errors.New("oracle: get quote: empty payload")
|
||||
}
|
||||
return fromProtoQuote(resp.GetQuote()), nil
|
||||
}
|
||||
|
||||
func (c *oracleClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
if _, ok := ctx.Deadline(); ok {
|
||||
return context.WithCancel(ctx)
|
||||
}
|
||||
return context.WithTimeout(ctx, c.cfg.CallTimeout)
|
||||
}
|
||||
|
||||
func toProtoMeta(meta RequestMeta) *oraclev1.RequestMeta {
|
||||
if meta.TenantRef == "" && meta.OrganizationRef == "" && meta.Trace == nil {
|
||||
return nil
|
||||
}
|
||||
return &oraclev1.RequestMeta{
|
||||
TenantRef: meta.TenantRef,
|
||||
OrganizationRef: meta.OrganizationRef,
|
||||
Trace: meta.Trace,
|
||||
}
|
||||
}
|
||||
|
||||
func fromProtoRate(rate *oraclev1.RateSnapshot) *RateSnapshot {
|
||||
if rate == nil {
|
||||
return nil
|
||||
}
|
||||
return &RateSnapshot{
|
||||
Pair: rate.Pair,
|
||||
Mid: rate.GetMid().GetValue(),
|
||||
Bid: rate.GetBid().GetValue(),
|
||||
Ask: rate.GetAsk().GetValue(),
|
||||
SpreadBps: rate.GetSpreadBps().GetValue(),
|
||||
Provider: rate.GetProvider(),
|
||||
RateRef: rate.GetRateRef(),
|
||||
AsOf: time.UnixMilli(rate.GetAsofUnixMs()),
|
||||
}
|
||||
}
|
||||
|
||||
func fromProtoQuote(quote *oraclev1.Quote) *Quote {
|
||||
if quote == nil {
|
||||
return nil
|
||||
}
|
||||
return &Quote{
|
||||
QuoteRef: quote.GetQuoteRef(),
|
||||
Pair: quote.Pair,
|
||||
Side: quote.GetSide(),
|
||||
Price: quote.GetPrice().GetValue(),
|
||||
BaseAmount: quote.BaseAmount,
|
||||
QuoteAmount: quote.QuoteAmount,
|
||||
ExpiresAt: time.UnixMilli(quote.GetExpiresAtUnixMs()),
|
||||
Provider: quote.GetProvider(),
|
||||
RateRef: quote.GetRateRef(),
|
||||
Firm: quote.GetFirm(),
|
||||
}
|
||||
}
|
||||
116
api/fx/oracle/client/client_test.go
Normal file
116
api/fx/oracle/client/client_test.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
type stubOracle struct {
|
||||
latestResp *oraclev1.LatestRateResponse
|
||||
latestErr error
|
||||
|
||||
quoteResp *oraclev1.GetQuoteResponse
|
||||
quoteErr error
|
||||
|
||||
lastLatest *oraclev1.LatestRateRequest
|
||||
lastQuote *oraclev1.GetQuoteRequest
|
||||
}
|
||||
|
||||
func (s *stubOracle) LatestRate(ctx context.Context, in *oraclev1.LatestRateRequest, _ ...grpc.CallOption) (*oraclev1.LatestRateResponse, error) {
|
||||
s.lastLatest = in
|
||||
return s.latestResp, s.latestErr
|
||||
}
|
||||
|
||||
func (s *stubOracle) GetQuote(ctx context.Context, in *oraclev1.GetQuoteRequest, _ ...grpc.CallOption) (*oraclev1.GetQuoteResponse, error) {
|
||||
s.lastQuote = in
|
||||
return s.quoteResp, s.quoteErr
|
||||
}
|
||||
|
||||
func TestLatestRate(t *testing.T) {
|
||||
expectedTime := time.Date(2024, 1, 1, 15, 0, 0, 0, time.UTC)
|
||||
stub := &stubOracle{
|
||||
latestResp: &oraclev1.LatestRateResponse{
|
||||
Rate: &oraclev1.RateSnapshot{
|
||||
Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"},
|
||||
Mid: &moneyv1.Decimal{Value: "1.1000"},
|
||||
Bid: &moneyv1.Decimal{Value: "1.0995"},
|
||||
Ask: &moneyv1.Decimal{Value: "1.1005"},
|
||||
SpreadBps: &moneyv1.Decimal{Value: "5"},
|
||||
Provider: "ECB",
|
||||
RateRef: "ECB-20240101",
|
||||
AsofUnixMs: expectedTime.UnixMilli(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
client := NewWithClient(Config{}, stub)
|
||||
resp, err := client.LatestRate(context.Background(), LatestRateParams{
|
||||
Meta: RequestMeta{
|
||||
TenantRef: "tenant",
|
||||
OrganizationRef: "org",
|
||||
},
|
||||
Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"},
|
||||
Provider: "ECB",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LatestRate returned error: %v", err)
|
||||
}
|
||||
|
||||
if stub.lastLatest.GetProvider() != "ECB" {
|
||||
t.Fatalf("expected provider to propagate, got %s", stub.lastLatest.GetProvider())
|
||||
}
|
||||
if resp.Provider != "ECB" || resp.RateRef != "ECB-20240101" {
|
||||
t.Fatalf("unexpected response: %+v", resp)
|
||||
}
|
||||
if !resp.AsOf.Equal(expectedTime) {
|
||||
t.Fatalf("expected as-of %s, got %s", expectedTime, resp.AsOf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetQuote(t *testing.T) {
|
||||
expiresAt := time.Date(2024, 2, 2, 12, 0, 0, 0, time.UTC)
|
||||
stub := &stubOracle{
|
||||
quoteResp: &oraclev1.GetQuoteResponse{
|
||||
Quote: &oraclev1.Quote{
|
||||
QuoteRef: "quote-123",
|
||||
Pair: &fxv1.CurrencyPair{Base: "GBP", Quote: "USD"},
|
||||
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
|
||||
Price: &moneyv1.Decimal{Value: "1.2500"},
|
||||
BaseAmount: &moneyv1.Money{Amount: "100.00", Currency: "GBP"},
|
||||
QuoteAmount: &moneyv1.Money{Amount: "125.00", Currency: "USD"},
|
||||
ExpiresAtUnixMs: expiresAt.UnixMilli(),
|
||||
Provider: "Test",
|
||||
RateRef: "test-ref",
|
||||
Firm: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
client := NewWithClient(Config{}, stub)
|
||||
resp, err := client.GetQuote(context.Background(), GetQuoteParams{
|
||||
Pair: &fxv1.CurrencyPair{Base: "GBP", Quote: "USD"},
|
||||
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
|
||||
BaseAmount: &moneyv1.Money{Amount: "100.00", Currency: "GBP"},
|
||||
Firm: true,
|
||||
TTL: 2 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GetQuote returned error: %v", err)
|
||||
}
|
||||
|
||||
if stub.lastQuote.GetFirm() != true {
|
||||
t.Fatalf("expected firm flag to propagate")
|
||||
}
|
||||
if stub.lastQuote.GetTtlMs() == 0 {
|
||||
t.Fatalf("expected ttl to be populated")
|
||||
}
|
||||
if resp.QuoteRef != "quote-123" || resp.Price != "1.2500" || !resp.ExpiresAt.Equal(expiresAt) {
|
||||
t.Fatalf("unexpected quote response: %+v", resp)
|
||||
}
|
||||
}
|
||||
20
api/fx/oracle/client/config.go
Normal file
20
api/fx/oracle/client/config.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package client
|
||||
|
||||
import "time"
|
||||
|
||||
// Config captures connection settings for the FX oracle gRPC service.
|
||||
type Config struct {
|
||||
Address string
|
||||
DialTimeout time.Duration
|
||||
CallTimeout time.Duration
|
||||
Insecure bool
|
||||
}
|
||||
|
||||
func (c *Config) setDefaults() {
|
||||
if c.DialTimeout <= 0 {
|
||||
c.DialTimeout = 5 * time.Second
|
||||
}
|
||||
if c.CallTimeout <= 0 {
|
||||
c.CallTimeout = 3 * time.Second
|
||||
}
|
||||
}
|
||||
60
api/fx/oracle/client/fake.go
Normal file
60
api/fx/oracle/client/fake.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
)
|
||||
|
||||
// Fake implements Client for tests.
|
||||
type Fake struct {
|
||||
LatestRateFn func(ctx context.Context, req LatestRateParams) (*RateSnapshot, error)
|
||||
GetQuoteFn func(ctx context.Context, req GetQuoteParams) (*Quote, error)
|
||||
CloseFn func() error
|
||||
}
|
||||
|
||||
func (f *Fake) LatestRate(ctx context.Context, req LatestRateParams) (*RateSnapshot, error) {
|
||||
if f.LatestRateFn != nil {
|
||||
return f.LatestRateFn(ctx, req)
|
||||
}
|
||||
return &RateSnapshot{
|
||||
Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"},
|
||||
Mid: "1.1000",
|
||||
Bid: "1.0995",
|
||||
Ask: "1.1005",
|
||||
SpreadBps: "5",
|
||||
Provider: "fake",
|
||||
RateRef: "fake",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *Fake) GetQuote(ctx context.Context, req GetQuoteParams) (*Quote, error) {
|
||||
if f.GetQuoteFn != nil {
|
||||
return f.GetQuoteFn(ctx, req)
|
||||
}
|
||||
return &Quote{
|
||||
QuoteRef: "fake-quote",
|
||||
Pair: req.Pair,
|
||||
Side: req.Side,
|
||||
Price: "1.1000",
|
||||
BaseAmount: &moneyv1.Money{
|
||||
Amount: "100.00",
|
||||
Currency: req.Pair.GetBase(),
|
||||
},
|
||||
QuoteAmount: &moneyv1.Money{
|
||||
Amount: "110.00",
|
||||
Currency: req.Pair.GetQuote(),
|
||||
},
|
||||
Provider: "fake",
|
||||
RateRef: "fake",
|
||||
Firm: req.Firm,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *Fake) Close() error {
|
||||
if f.CloseFn != nil {
|
||||
return f.CloseFn()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
34
api/fx/oracle/config.yml
Normal file
34
api/fx/oracle/config.yml
Normal file
@@ -0,0 +1,34 @@
|
||||
runtime:
|
||||
shutdown_timeout_seconds: 15
|
||||
|
||||
grpc:
|
||||
network: tcp
|
||||
address: ":50051"
|
||||
enable_reflection: true
|
||||
enable_health: true
|
||||
|
||||
metrics:
|
||||
address: ":9400"
|
||||
|
||||
database:
|
||||
driver: mongodb
|
||||
settings:
|
||||
host_env: FX_MONGO_HOST
|
||||
port_env: FX_MONGO_PORT
|
||||
database_env: FX_MONGO_DATABASE
|
||||
user_env: FX_MONGO_USER
|
||||
password_env: FX_MONGO_PASSWORD
|
||||
auth_source_env: FX_MONGO_AUTH_SOURCE
|
||||
replica_set_env: FX_MONGO_REPLICA_SET
|
||||
|
||||
messaging:
|
||||
driver: NATS
|
||||
settings:
|
||||
url_env: NATS_URL
|
||||
host_env: NATS_HOST
|
||||
port_env: NATS_PORT
|
||||
username_env: NATS_USER
|
||||
password_env: NATS_PASSWORD
|
||||
broker_name: FX Oracle
|
||||
max_reconnects: 10
|
||||
reconnect_wait: 5
|
||||
1
api/fx/oracle/env/.gitignore
vendored
Normal file
1
api/fx/oracle/env/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.env.api
|
||||
54
api/fx/oracle/go.mod
Normal file
54
api/fx/oracle/go.mod
Normal file
@@ -0,0 +1,54 @@
|
||||
module github.com/tech/sendico/fx/oracle
|
||||
|
||||
go 1.25.3
|
||||
|
||||
replace github.com/tech/sendico/pkg => ../../pkg
|
||||
|
||||
replace github.com/tech/sendico/fx/storage => ../storage
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/tech/sendico/fx/storage v0.0.0
|
||||
github.com/tech/sendico/pkg v0.1.0
|
||||
go.mongodb.org/mongo-driver v1.17.6
|
||||
go.uber.org/zap v1.27.0
|
||||
google.golang.org/grpc v1.76.0
|
||||
google.golang.org/protobuf v1.36.10
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||
github.com/casbin/casbin/v2 v2.132.0 // indirect
|
||||
github.com/casbin/govaluate v1.10.0 // indirect
|
||||
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/go-chi/chi/v5 v5.2.3 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/klauspost/compress v1.18.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/nats-io/nats.go v1.47.0 // indirect
|
||||
github.com/nats-io/nkeys v0.4.11 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.2 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.1.2 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/crypto v0.43.0 // indirect
|
||||
golang.org/x/net v0.46.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 // indirect
|
||||
)
|
||||
225
api/fx/oracle/go.sum
Normal file
225
api/fx/oracle/go.sum
Normal file
@@ -0,0 +1,225 @@
|
||||
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
|
||||
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/casbin/casbin/v2 v2.132.0 h1:73hGmOszGSL3hTVquwkAi98XLl3gPJ+BxB6D7G9Fxtk=
|
||||
github.com/casbin/casbin/v2 v2.132.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||
github.com/casbin/mongodb-adapter/v3 v3.7.0 h1:w9c3bea1BGK4eZTAmk17JkY52yv/xSZDSHKji8q+z6E=
|
||||
github.com/casbin/mongodb-adapter/v3 v3.7.0/go.mod h1:F1mu4ojoJVE/8VhIMxMedhjfwRDdIXgANYs6Sd0MgVA=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
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/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
|
||||
github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg=
|
||||
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo=
|
||||
github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
||||
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
|
||||
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||
github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0=
|
||||
github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8=
|
||||
github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw=
|
||||
github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8=
|
||||
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY=
|
||||
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog=
|
||||
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
||||
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
|
||||
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 h1:tRPGkdGHuewF4UisLzzHHr1spKw92qLM98nIzxbC0wY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
||||
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
27
api/fx/oracle/internal/appversion/version.go
Normal file
27
api/fx/oracle/internal/appversion/version.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package appversion
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/version"
|
||||
vf "github.com/tech/sendico/pkg/version/factory"
|
||||
)
|
||||
|
||||
// Build information. Populated at build-time.
|
||||
var (
|
||||
Version string
|
||||
Revision string
|
||||
Branch string
|
||||
BuildUser string
|
||||
BuildDate string
|
||||
)
|
||||
|
||||
func Create() version.Printer {
|
||||
vi := version.Info{
|
||||
Program: "MeetX Connectica FX Oracle Service",
|
||||
Revision: Revision,
|
||||
Branch: Branch,
|
||||
BuildUser: BuildUser,
|
||||
BuildDate: BuildDate,
|
||||
Version: Version,
|
||||
}
|
||||
return vf.Create(&vi)
|
||||
}
|
||||
101
api/fx/oracle/internal/server/internal/serverimp.go
Normal file
101
api/fx/oracle/internal/server/internal/serverimp.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package serverimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/fx/oracle/internal/service/oracle"
|
||||
"github.com/tech/sendico/fx/storage"
|
||||
mongostorage "github.com/tech/sendico/fx/storage/mongo"
|
||||
"github.com/tech/sendico/pkg/api/routers"
|
||||
"github.com/tech/sendico/pkg/db"
|
||||
msg "github.com/tech/sendico/pkg/messaging"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/server/grpcapp"
|
||||
"go.uber.org/zap"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Imp struct {
|
||||
logger mlogger.Logger
|
||||
file string
|
||||
debug bool
|
||||
|
||||
config *grpcapp.Config
|
||||
app *grpcapp.App[storage.Repository]
|
||||
}
|
||||
|
||||
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
||||
return &Imp{
|
||||
logger: logger.Named("server"),
|
||||
file: file,
|
||||
debug: debug,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (i *Imp) Shutdown() {
|
||||
if i.app == nil {
|
||||
return
|
||||
}
|
||||
timeout := 15 * time.Second
|
||||
if i.config != nil && i.config.Runtime != nil {
|
||||
timeout = i.config.Runtime.ShutdownTimeout()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
i.app.Shutdown(ctx)
|
||||
cancel()
|
||||
}
|
||||
|
||||
func (i *Imp) Start() error {
|
||||
cfg, err := i.loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i.config = cfg
|
||||
|
||||
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
|
||||
return mongostorage.New(logger, conn)
|
||||
}
|
||||
|
||||
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
|
||||
return oracle.NewService(logger, repo, producer), nil
|
||||
}
|
||||
|
||||
app, err := grpcapp.NewApp(i.logger, "fx_oracle", cfg, i.debug, repoFactory, serviceFactory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i.app = app
|
||||
|
||||
return i.app.Start()
|
||||
}
|
||||
|
||||
func (i *Imp) loadConfig() (*grpcapp.Config, error) {
|
||||
data, err := os.ReadFile(i.file)
|
||||
if err != nil {
|
||||
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := &grpcapp.Config{}
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
i.logger.Error("Failed to parse configuration", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cfg.Runtime == nil {
|
||||
cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: 15}
|
||||
}
|
||||
|
||||
if cfg.GRPC == nil {
|
||||
cfg.GRPC = &routers.GRPCConfig{
|
||||
Network: "tcp",
|
||||
Address: ":50051",
|
||||
EnableReflection: true,
|
||||
EnableHealth: true,
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
11
api/fx/oracle/internal/server/server.go
Normal file
11
api/fx/oracle/internal/server/server.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
serverimp "github.com/tech/sendico/fx/oracle/internal/server/internal"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/server"
|
||||
)
|
||||
|
||||
func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
|
||||
return serverimp.Create(logger, file, debug)
|
||||
}
|
||||
223
api/fx/oracle/internal/service/oracle/calculator.go
Normal file
223
api/fx/oracle/internal/service/oracle/calculator.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package oracle
|
||||
|
||||
import (
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/tech/sendico/fx/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
type quoteComputation struct {
|
||||
pair *model.Pair
|
||||
rate *model.RateSnapshot
|
||||
sideProto fxv1.Side
|
||||
sideModel model.QuoteSide
|
||||
price *big.Rat
|
||||
baseInput *big.Rat
|
||||
quoteInput *big.Rat
|
||||
amountType model.QuoteAmountType
|
||||
baseRounded *big.Rat
|
||||
quoteRounded *big.Rat
|
||||
priceRounded *big.Rat
|
||||
baseScale uint32
|
||||
quoteScale uint32
|
||||
priceScale uint32
|
||||
provider string
|
||||
}
|
||||
|
||||
func newQuoteComputation(pair *model.Pair, rate *model.RateSnapshot, side fxv1.Side, provider string) (*quoteComputation, error) {
|
||||
if pair == nil || rate == nil {
|
||||
return nil, merrors.InvalidArgument("oracle: missing pair or rate")
|
||||
}
|
||||
sideModel := protoSideToModel(side)
|
||||
if sideModel == "" {
|
||||
return nil, merrors.InvalidArgument("oracle: unsupported side")
|
||||
}
|
||||
price, err := priceFromRate(rate, side)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(provider) == "" {
|
||||
provider = rate.Provider
|
||||
}
|
||||
return "eComputation{
|
||||
pair: pair,
|
||||
rate: rate,
|
||||
sideProto: side,
|
||||
sideModel: sideModel,
|
||||
price: price,
|
||||
baseScale: pair.BaseMeta.Decimals,
|
||||
quoteScale: pair.QuoteMeta.Decimals,
|
||||
priceScale: pair.QuoteMeta.Decimals,
|
||||
provider: provider,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (qc *quoteComputation) withBaseInput(m *moneyv1.Money) error {
|
||||
if m == nil {
|
||||
return merrors.InvalidArgument("oracle: base amount missing")
|
||||
}
|
||||
if !strings.EqualFold(m.GetCurrency(), qc.pair.Pair.Base) {
|
||||
return merrors.InvalidArgument("oracle: base amount currency mismatch")
|
||||
}
|
||||
val, err := ratFromString(m.GetAmount())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
qc.baseInput = val
|
||||
qc.amountType = model.QuoteAmountTypeBase
|
||||
return nil
|
||||
}
|
||||
|
||||
func (qc *quoteComputation) withQuoteInput(m *moneyv1.Money) error {
|
||||
if m == nil {
|
||||
return merrors.InvalidArgument("oracle: quote amount missing")
|
||||
}
|
||||
if !strings.EqualFold(m.GetCurrency(), qc.pair.Pair.Quote) {
|
||||
return merrors.InvalidArgument("oracle: quote amount currency mismatch")
|
||||
}
|
||||
val, err := ratFromString(m.GetAmount())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
qc.quoteInput = val
|
||||
qc.amountType = model.QuoteAmountTypeQuote
|
||||
return nil
|
||||
}
|
||||
|
||||
func (qc *quoteComputation) compute() error {
|
||||
var baseRaw, quoteRaw *big.Rat
|
||||
switch qc.amountType {
|
||||
case model.QuoteAmountTypeBase:
|
||||
baseRaw = qc.baseInput
|
||||
quoteRaw = mulRat(qc.baseInput, qc.price)
|
||||
case model.QuoteAmountTypeQuote:
|
||||
quoteRaw = qc.quoteInput
|
||||
base, err := divRat(qc.quoteInput, qc.price)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
baseRaw = base
|
||||
default:
|
||||
return merrors.InvalidArgument("oracle: amount type not set")
|
||||
}
|
||||
|
||||
var err error
|
||||
qc.baseRounded, err = roundRatToScale(baseRaw, qc.baseScale, qc.pair.BaseMeta.Rounding)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
qc.quoteRounded, err = roundRatToScale(quoteRaw, qc.quoteScale, qc.pair.QuoteMeta.Rounding)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
qc.priceRounded, err = roundRatToScale(qc.price, qc.priceScale, qc.pair.QuoteMeta.Rounding)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (qc *quoteComputation) buildModelQuote(firm bool, expiryMillis int64, req *oraclev1.GetQuoteRequest) (*model.Quote, error) {
|
||||
if qc.baseRounded == nil || qc.quoteRounded == nil || qc.priceRounded == nil {
|
||||
return nil, merrors.Internal("oracle: computation not executed")
|
||||
}
|
||||
|
||||
quote := &model.Quote{
|
||||
QuoteRef: uuid.NewString(),
|
||||
Firm: firm,
|
||||
Status: model.QuoteStatusIssued,
|
||||
Pair: qc.pair.Pair,
|
||||
Side: qc.sideModel,
|
||||
Price: formatRat(qc.priceRounded, qc.priceScale),
|
||||
BaseAmount: model.Money{
|
||||
Currency: qc.pair.Pair.Base,
|
||||
Amount: formatRat(qc.baseRounded, qc.baseScale),
|
||||
},
|
||||
QuoteAmount: model.Money{
|
||||
Currency: qc.pair.Pair.Quote,
|
||||
Amount: formatRat(qc.quoteRounded, qc.quoteScale),
|
||||
},
|
||||
AmountType: qc.amountType,
|
||||
RateRef: qc.rate.RateRef,
|
||||
Provider: qc.provider,
|
||||
PreferredProvider: req.GetPreferredProvider(),
|
||||
RequestedTTLMs: req.GetTtlMs(),
|
||||
MaxAgeToleranceMs: int64(req.GetMaxAgeMs()),
|
||||
Meta: buildQuoteMeta(req.GetMeta()),
|
||||
}
|
||||
|
||||
if firm {
|
||||
quote.ExpiresAtUnixMs = expiryMillis
|
||||
expiry := time.UnixMilli(expiryMillis)
|
||||
quote.ExpiresAt = &expiry
|
||||
}
|
||||
|
||||
return quote, nil
|
||||
}
|
||||
|
||||
func buildQuoteMeta(meta *oraclev1.RequestMeta) *model.QuoteMeta {
|
||||
if meta == nil {
|
||||
return nil
|
||||
}
|
||||
trace := meta.GetTrace()
|
||||
qm := &model.QuoteMeta{
|
||||
RequestRef: deriveRequestRef(meta, trace),
|
||||
TenantRef: meta.GetTenantRef(),
|
||||
TraceRef: deriveTraceRef(meta, trace),
|
||||
IdempotencyKey: deriveIdempotencyKey(meta, trace),
|
||||
}
|
||||
if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" {
|
||||
if objID, err := primitive.ObjectIDFromHex(org); err == nil {
|
||||
qm.SetOrganizationRef(objID)
|
||||
}
|
||||
}
|
||||
return qm
|
||||
}
|
||||
|
||||
func protoSideToModel(side fxv1.Side) model.QuoteSide {
|
||||
switch side {
|
||||
case fxv1.Side_BUY_BASE_SELL_QUOTE:
|
||||
return model.QuoteSideBuyBaseSellQuote
|
||||
case fxv1.Side_SELL_BASE_BUY_QUOTE:
|
||||
return model.QuoteSideSellBaseBuyQuote
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func computeExpiry(now time.Time, ttlMs int64) (int64, error) {
|
||||
if ttlMs <= 0 {
|
||||
return 0, merrors.InvalidArgument("oracle: ttl must be positive")
|
||||
}
|
||||
return now.Add(time.Duration(ttlMs) * time.Millisecond).UnixMilli(), nil
|
||||
}
|
||||
|
||||
func deriveRequestRef(meta *oraclev1.RequestMeta, trace *tracev1.TraceContext) string {
|
||||
if trace != nil && trace.GetRequestRef() != "" {
|
||||
return trace.GetRequestRef()
|
||||
}
|
||||
return meta.GetRequestRef()
|
||||
}
|
||||
|
||||
func deriveTraceRef(meta *oraclev1.RequestMeta, trace *tracev1.TraceContext) string {
|
||||
if trace != nil && trace.GetTraceRef() != "" {
|
||||
return trace.GetTraceRef()
|
||||
}
|
||||
return meta.GetTraceRef()
|
||||
}
|
||||
|
||||
func deriveIdempotencyKey(meta *oraclev1.RequestMeta, trace *tracev1.TraceContext) string {
|
||||
if trace != nil && trace.GetIdempotencyKey() != "" {
|
||||
return trace.GetIdempotencyKey()
|
||||
}
|
||||
return meta.GetIdempotencyKey()
|
||||
}
|
||||
221
api/fx/oracle/internal/service/oracle/cross.go
Normal file
221
api/fx/oracle/internal/service/oracle/cross.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package oracle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/fx/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
)
|
||||
|
||||
type priceSet struct {
|
||||
bid *big.Rat
|
||||
ask *big.Rat
|
||||
mid *big.Rat
|
||||
}
|
||||
|
||||
func (s *Service) computeCrossRate(ctx context.Context, pair *model.Pair, provider string) (*model.RateSnapshot, error) {
|
||||
if pair == nil || pair.Cross == nil || !pair.Cross.Enabled {
|
||||
return nil, merrors.ErrNoData
|
||||
}
|
||||
|
||||
baseSnap, err := s.fetchCrossLegSnapshot(ctx, pair.Cross.BaseLeg, provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
quoteSnap, err := s.fetchCrossLegSnapshot(ctx, pair.Cross.QuoteLeg, provider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
basePrices, err := buildPriceSet(baseSnap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
quotePrices, err := buildPriceSet(quoteSnap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if pair.Cross.BaseLeg.Invert {
|
||||
basePrices, err = invertPriceSet(basePrices)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if pair.Cross.QuoteLeg.Invert {
|
||||
quotePrices, err = invertPriceSet(quotePrices)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
result := multiplyPriceSets(basePrices, quotePrices)
|
||||
if result.ask.Cmp(result.bid) < 0 {
|
||||
result.ask, result.bid = result.bid, result.ask
|
||||
}
|
||||
|
||||
spread := calcSpreadBps(result)
|
||||
|
||||
asOfMs := minNonZero(baseSnap.AsOfUnixMs, quoteSnap.AsOfUnixMs)
|
||||
if asOfMs == 0 {
|
||||
asOfMs = time.Now().UnixMilli()
|
||||
}
|
||||
asOf := time.UnixMilli(asOfMs)
|
||||
|
||||
rateRef := fmt.Sprintf("cross|%s/%s|%s|%s+%s", pair.Pair.Base, pair.Pair.Quote, provider, baseSnap.RateRef, quoteSnap.RateRef)
|
||||
|
||||
return &model.RateSnapshot{
|
||||
RateRef: rateRef,
|
||||
Pair: pair.Pair,
|
||||
Provider: provider,
|
||||
Mid: formatPrice(result.mid),
|
||||
Bid: formatPrice(result.bid),
|
||||
Ask: formatPrice(result.ask),
|
||||
SpreadBps: formatPrice(spread),
|
||||
AsOfUnixMs: asOfMs,
|
||||
AsOf: &asOf,
|
||||
Source: "cross_rate",
|
||||
ProviderRef: rateRef,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) fetchCrossLegSnapshot(ctx context.Context, leg model.CrossRateLeg, fallbackProvider string) (*model.RateSnapshot, error) {
|
||||
provider := fallbackProvider
|
||||
if strings.TrimSpace(leg.Provider) != "" {
|
||||
provider = leg.Provider
|
||||
}
|
||||
if provider == "" {
|
||||
return nil, merrors.InvalidArgument("oracle: cross leg provider missing")
|
||||
}
|
||||
return s.storage.Rates().LatestSnapshot(ctx, leg.Pair, provider)
|
||||
}
|
||||
|
||||
func buildPriceSet(rate *model.RateSnapshot) (priceSet, error) {
|
||||
if rate == nil {
|
||||
return priceSet{}, merrors.InvalidArgument("oracle: cross rate requires underlying snapshot")
|
||||
}
|
||||
ask, err := parsePrice(rate.Ask)
|
||||
if err != nil {
|
||||
return priceSet{}, err
|
||||
}
|
||||
bid, err := parsePrice(rate.Bid)
|
||||
if err != nil {
|
||||
return priceSet{}, err
|
||||
}
|
||||
mid, err := parsePrice(rate.Mid)
|
||||
if err != nil {
|
||||
return priceSet{}, err
|
||||
}
|
||||
|
||||
if ask == nil && bid == nil {
|
||||
if mid == nil {
|
||||
return priceSet{}, merrors.InvalidArgument("oracle: cross rate snapshot missing price data")
|
||||
}
|
||||
ask = new(big.Rat).Set(mid)
|
||||
bid = new(big.Rat).Set(mid)
|
||||
}
|
||||
if ask == nil && mid != nil {
|
||||
ask = new(big.Rat).Set(mid)
|
||||
}
|
||||
if bid == nil && mid != nil {
|
||||
bid = new(big.Rat).Set(mid)
|
||||
}
|
||||
if ask == nil || bid == nil {
|
||||
return priceSet{}, merrors.InvalidArgument("oracle: cross rate snapshot missing bid/ask data")
|
||||
}
|
||||
|
||||
ps := priceSet{
|
||||
bid: new(big.Rat).Set(bid),
|
||||
ask: new(big.Rat).Set(ask),
|
||||
mid: averageOrMid(bid, ask, mid),
|
||||
}
|
||||
if ps.ask.Cmp(ps.bid) < 0 {
|
||||
ps.ask, ps.bid = ps.bid, ps.ask
|
||||
}
|
||||
return ps, nil
|
||||
}
|
||||
|
||||
func parsePrice(value string) (*big.Rat, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return ratFromString(value)
|
||||
}
|
||||
|
||||
func averageOrMid(bid, ask, mid *big.Rat) *big.Rat {
|
||||
if mid != nil {
|
||||
return new(big.Rat).Set(mid)
|
||||
}
|
||||
sum := new(big.Rat).Add(bid, ask)
|
||||
return sum.Quo(sum, big.NewRat(2, 1))
|
||||
}
|
||||
|
||||
func invertPriceSet(ps priceSet) (priceSet, error) {
|
||||
if ps.ask.Sign() == 0 || ps.bid.Sign() == 0 {
|
||||
return priceSet{}, merrors.InvalidArgument("oracle: cannot invert zero price")
|
||||
}
|
||||
one := big.NewRat(1, 1)
|
||||
invBid := new(big.Rat).Quo(one, ps.ask)
|
||||
invAsk := new(big.Rat).Quo(one, ps.bid)
|
||||
var invMid *big.Rat
|
||||
if ps.mid != nil && ps.mid.Sign() != 0 {
|
||||
invMid = new(big.Rat).Quo(one, ps.mid)
|
||||
} else {
|
||||
invMid = averageOrMid(invBid, invAsk, nil)
|
||||
}
|
||||
result := priceSet{
|
||||
bid: invBid,
|
||||
ask: invAsk,
|
||||
mid: invMid,
|
||||
}
|
||||
if result.ask.Cmp(result.bid) < 0 {
|
||||
result.ask, result.bid = result.bid, result.ask
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func multiplyPriceSets(a, b priceSet) priceSet {
|
||||
result := priceSet{
|
||||
bid: mulRat(a.bid, b.bid),
|
||||
ask: mulRat(a.ask, b.ask),
|
||||
}
|
||||
result.mid = averageOrMid(result.bid, result.ask, nil)
|
||||
return result
|
||||
}
|
||||
|
||||
func calcSpreadBps(ps priceSet) *big.Rat {
|
||||
if ps.mid == nil || ps.mid.Sign() == 0 {
|
||||
return nil
|
||||
}
|
||||
spread := new(big.Rat).Sub(ps.ask, ps.bid)
|
||||
if spread.Sign() < 0 {
|
||||
spread.Neg(spread)
|
||||
}
|
||||
spread.Quo(spread, ps.mid)
|
||||
spread.Mul(spread, big.NewRat(10000, 1))
|
||||
return spread
|
||||
}
|
||||
|
||||
func minNonZero(values ...int64) int64 {
|
||||
var result int64
|
||||
for _, v := range values {
|
||||
if v <= 0 {
|
||||
continue
|
||||
}
|
||||
if result == 0 || v < result {
|
||||
result = v
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func formatPrice(r *big.Rat) string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return r.FloatString(8)
|
||||
}
|
||||
67
api/fx/oracle/internal/service/oracle/math.go
Normal file
67
api/fx/oracle/internal/service/oracle/math.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package oracle
|
||||
|
||||
import (
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/fx/storage/model"
|
||||
"github.com/tech/sendico/pkg/decimal"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
)
|
||||
|
||||
// Convenience aliases to pkg/decimal for backward compatibility
|
||||
var (
|
||||
ratFromString = decimal.RatFromString
|
||||
mulRat = decimal.MulRat
|
||||
divRat = decimal.DivRat
|
||||
formatRat = decimal.FormatRat
|
||||
)
|
||||
|
||||
// roundRatToScale wraps pkg/decimal.RoundRatToScale with model RoundingMode conversion
|
||||
func roundRatToScale(value *big.Rat, scale uint32, mode model.RoundingMode) (*big.Rat, error) {
|
||||
return decimal.RoundRatToScale(value, scale, convertRoundingMode(mode))
|
||||
}
|
||||
|
||||
// convertRoundingMode converts fx/storage model.RoundingMode to pkg/decimal.RoundingMode
|
||||
func convertRoundingMode(mode model.RoundingMode) decimal.RoundingMode {
|
||||
switch mode {
|
||||
case model.RoundingModeHalfEven:
|
||||
return decimal.RoundingModeHalfEven
|
||||
case model.RoundingModeHalfUp:
|
||||
return decimal.RoundingModeHalfUp
|
||||
case model.RoundingModeDown:
|
||||
return decimal.RoundingModeDown
|
||||
case model.RoundingModeUnspecified:
|
||||
return decimal.RoundingModeUnspecified
|
||||
default:
|
||||
return decimal.RoundingModeHalfEven
|
||||
}
|
||||
}
|
||||
|
||||
func priceFromRate(rate *model.RateSnapshot, side fxv1.Side) (*big.Rat, error) {
|
||||
var priceStr string
|
||||
switch side {
|
||||
case fxv1.Side_BUY_BASE_SELL_QUOTE:
|
||||
priceStr = rate.Ask
|
||||
case fxv1.Side_SELL_BASE_BUY_QUOTE:
|
||||
priceStr = rate.Bid
|
||||
default:
|
||||
priceStr = ""
|
||||
}
|
||||
|
||||
if strings.TrimSpace(priceStr) == "" {
|
||||
priceStr = rate.Mid
|
||||
}
|
||||
|
||||
if strings.TrimSpace(priceStr) == "" {
|
||||
return nil, merrors.InvalidArgument("oracle: rate snapshot missing price")
|
||||
}
|
||||
|
||||
return ratFromString(priceStr)
|
||||
}
|
||||
|
||||
func timeFromUnixMilli(ms int64) time.Time {
|
||||
return time.Unix(0, ms*int64(time.Millisecond))
|
||||
}
|
||||
65
api/fx/oracle/internal/service/oracle/metrics.go
Normal file
65
api/fx/oracle/internal/service/oracle/metrics.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package oracle
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
var (
|
||||
metricsOnce sync.Once
|
||||
|
||||
rpcRequestsTotal *prometheus.CounterVec
|
||||
rpcLatency *prometheus.HistogramVec
|
||||
)
|
||||
|
||||
func initMetrics() {
|
||||
metricsOnce.Do(func() {
|
||||
rpcRequestsTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Namespace: "fx",
|
||||
Subsystem: "oracle",
|
||||
Name: "requests_total",
|
||||
Help: "Total number of FX oracle RPC calls handled.",
|
||||
},
|
||||
[]string{"method", "result"},
|
||||
)
|
||||
|
||||
rpcLatency = promauto.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Namespace: "fx",
|
||||
Subsystem: "oracle",
|
||||
Name: "request_latency_seconds",
|
||||
Help: "Latency of FX oracle RPC calls.",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
},
|
||||
[]string{"method", "result"},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func observeRPC(start time.Time, method string, err error) {
|
||||
result := labelFromError(err)
|
||||
rpcRequestsTotal.WithLabelValues(method, result).Inc()
|
||||
rpcLatency.WithLabelValues(method, result).Observe(time.Since(start).Seconds())
|
||||
}
|
||||
|
||||
func labelFromError(err error) string {
|
||||
if err == nil {
|
||||
return strings.ToLower(codes.OK.String())
|
||||
}
|
||||
st, ok := status.FromError(err)
|
||||
if !ok {
|
||||
return "error"
|
||||
}
|
||||
code := st.Code()
|
||||
if code == codes.OK {
|
||||
return strings.ToLower(code.String())
|
||||
}
|
||||
return strings.ToLower(code.String())
|
||||
}
|
||||
402
api/fx/oracle/internal/service/oracle/service.go
Normal file
402
api/fx/oracle/internal/service/oracle/service.go
Normal file
@@ -0,0 +1,402 @@
|
||||
package oracle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/fx/storage"
|
||||
"github.com/tech/sendico/fx/storage/model"
|
||||
"github.com/tech/sendico/pkg/api/routers"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
pmessaging "github.com/tech/sendico/pkg/messaging"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
type serviceError string
|
||||
|
||||
func (e serviceError) Error() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
var (
|
||||
errSideRequired = serviceError("oracle: side is required")
|
||||
errAmountsMutuallyExclusive = serviceError("oracle: exactly one amount must be provided")
|
||||
errAmountRequired = serviceError("oracle: amount is required")
|
||||
errQuoteRefRequired = serviceError("oracle: quote_ref is required")
|
||||
errEmptyRequest = serviceError("oracle: request payload is empty")
|
||||
errLedgerTxnRefRequired = serviceError("oracle: ledger_txn_ref is required")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
logger mlogger.Logger
|
||||
storage storage.Repository
|
||||
producer pmessaging.Producer
|
||||
oraclev1.UnimplementedOracleServer
|
||||
}
|
||||
|
||||
func NewService(logger mlogger.Logger, repo storage.Repository, prod pmessaging.Producer) *Service {
|
||||
initMetrics()
|
||||
return &Service{
|
||||
logger: logger.Named("oracle"),
|
||||
storage: repo,
|
||||
producer: prod,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) Register(router routers.GRPC) error {
|
||||
return router.Register(func(reg grpc.ServiceRegistrar) {
|
||||
oraclev1.RegisterOracleServer(reg, s)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) GetQuote(ctx context.Context, req *oraclev1.GetQuoteRequest) (*oraclev1.GetQuoteResponse, error) {
|
||||
start := time.Now()
|
||||
responder := s.getQuoteResponder(ctx, req)
|
||||
resp, err := responder(ctx)
|
||||
observeRPC(start, "GetQuote", err)
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func (s *Service) ValidateQuote(ctx context.Context, req *oraclev1.ValidateQuoteRequest) (*oraclev1.ValidateQuoteResponse, error) {
|
||||
start := time.Now()
|
||||
responder := s.validateQuoteResponder(ctx, req)
|
||||
resp, err := responder(ctx)
|
||||
observeRPC(start, "ValidateQuote", err)
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func (s *Service) ConsumeQuote(ctx context.Context, req *oraclev1.ConsumeQuoteRequest) (*oraclev1.ConsumeQuoteResponse, error) {
|
||||
start := time.Now()
|
||||
responder := s.consumeQuoteResponder(ctx, req)
|
||||
resp, err := responder(ctx)
|
||||
observeRPC(start, "ConsumeQuote", err)
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func (s *Service) LatestRate(ctx context.Context, req *oraclev1.LatestRateRequest) (*oraclev1.LatestRateResponse, error) {
|
||||
start := time.Now()
|
||||
responder := s.latestRateResponder(ctx, req)
|
||||
resp, err := responder(ctx)
|
||||
observeRPC(start, "LatestRate", err)
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func (s *Service) ListPairs(ctx context.Context, req *oraclev1.ListPairsRequest) (*oraclev1.ListPairsResponse, error) {
|
||||
start := time.Now()
|
||||
responder := s.listPairsResponder(ctx, req)
|
||||
resp, err := responder(ctx)
|
||||
observeRPC(start, "ListPairs", err)
|
||||
return resp, err
|
||||
}
|
||||
|
||||
func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteRequest) gsresponse.Responder[oraclev1.GetQuoteResponse] {
|
||||
if req == nil {
|
||||
req = &oraclev1.GetQuoteRequest{}
|
||||
}
|
||||
s.logger.Debug("Handling GetQuote", zap.String("pair", req.GetPair().GetBase()+"/"+req.GetPair().GetQuote()), zap.Bool("firm", req.GetFirm()))
|
||||
if req.GetSide() == fxv1.Side_SIDE_UNSPECIFIED {
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errSideRequired)
|
||||
}
|
||||
if req.GetBaseAmount() != nil && req.GetQuoteAmount() != nil {
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errAmountsMutuallyExclusive)
|
||||
}
|
||||
if req.GetBaseAmount() == nil && req.GetQuoteAmount() == nil {
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errAmountRequired)
|
||||
}
|
||||
if err := s.pingStorage(ctx); err != nil {
|
||||
s.logger.Warn("Storage unavailable during GetQuote", zap.Error(err))
|
||||
return gsresponse.Unavailable[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
pairMsg := req.GetPair()
|
||||
if pairMsg == nil || strings.TrimSpace(pairMsg.GetBase()) == "" || strings.TrimSpace(pairMsg.GetQuote()) == "" {
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errEmptyRequest)
|
||||
}
|
||||
pairKey := model.CurrencyPair{Base: strings.ToUpper(pairMsg.GetBase()), Quote: strings.ToUpper(pairMsg.GetQuote())}
|
||||
|
||||
pair, err := s.storage.Pairs().Get(ctx, pairKey)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, merrors.InvalidArgument("pair_not_supported"))
|
||||
default:
|
||||
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
}
|
||||
|
||||
provider := req.GetPreferredProvider()
|
||||
if provider == "" {
|
||||
provider = pair.DefaultProvider
|
||||
}
|
||||
if provider == "" && len(pair.Providers) > 0 {
|
||||
provider = pair.Providers[0]
|
||||
}
|
||||
|
||||
rate, err := s.getLatestRate(ctx, pair, provider)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
return gsresponse.FailedPrecondition[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, "rate_not_found", err)
|
||||
default:
|
||||
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if maxAge := req.GetMaxAgeMs(); maxAge > 0 {
|
||||
age := now.UnixMilli() - rate.AsOfUnixMs
|
||||
if age > int64(maxAge) {
|
||||
s.logger.Warn("Rate snapshot stale", zap.Int64("age_ms", age), zap.Int32("max_age_ms", req.GetMaxAgeMs()), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider))
|
||||
return gsresponse.FailedPrecondition[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, "stale_rate", merrors.InvalidArgument("rate older than allowed window"))
|
||||
}
|
||||
}
|
||||
|
||||
comp, err := newQuoteComputation(pair, rate, req.GetSide(), provider)
|
||||
if err != nil {
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
|
||||
if req.GetBaseAmount() != nil {
|
||||
if err := comp.withBaseInput(req.GetBaseAmount()); err != nil {
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
} else if req.GetQuoteAmount() != nil {
|
||||
if err := comp.withQuoteInput(req.GetQuoteAmount()); err != nil {
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := comp.compute(); err != nil {
|
||||
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
|
||||
expiresAt := int64(0)
|
||||
if req.GetFirm() {
|
||||
expiry, err := computeExpiry(now, req.GetTtlMs())
|
||||
if err != nil {
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
expiresAt = expiry
|
||||
}
|
||||
|
||||
quoteModel, err := comp.buildModelQuote(req.GetFirm(), expiresAt, req)
|
||||
if err != nil {
|
||||
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
|
||||
if req.GetFirm() {
|
||||
if err := s.storage.Quotes().Issue(ctx, quoteModel); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrDataConflict):
|
||||
return gsresponse.Conflict[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
default:
|
||||
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
}
|
||||
s.logger.Info("Firm quote stored", zap.String("quote_ref", quoteModel.QuoteRef), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", quoteModel.Provider), zap.Int64("expires_at_ms", quoteModel.ExpiresAtUnixMs))
|
||||
}
|
||||
|
||||
resp := &oraclev1.GetQuoteResponse{
|
||||
Meta: buildResponseMeta(req.GetMeta()),
|
||||
Quote: quoteModelToProto(quoteModel),
|
||||
}
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
|
||||
func (s *Service) validateQuoteResponder(ctx context.Context, req *oraclev1.ValidateQuoteRequest) gsresponse.Responder[oraclev1.ValidateQuoteResponse] {
|
||||
if req == nil {
|
||||
req = &oraclev1.ValidateQuoteRequest{}
|
||||
}
|
||||
s.logger.Debug("Handling ValidateQuote", zap.String("quote_ref", req.GetQuoteRef()))
|
||||
if req.GetQuoteRef() == "" {
|
||||
return gsresponse.InvalidArgument[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, errQuoteRefRequired)
|
||||
}
|
||||
if err := s.pingStorage(ctx); err != nil {
|
||||
s.logger.Warn("Storage unavailable during ValidateQuote", zap.Error(err))
|
||||
return gsresponse.Unavailable[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
quote, err := s.storage.Quotes().GetByRef(ctx, req.GetQuoteRef())
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
resp := &oraclev1.ValidateQuoteResponse{
|
||||
Meta: buildResponseMeta(req.GetMeta()),
|
||||
Quote: nil,
|
||||
Valid: false,
|
||||
Reason: "not_found",
|
||||
}
|
||||
return gsresponse.Success(resp)
|
||||
default:
|
||||
return gsresponse.Internal[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
valid := true
|
||||
reason := ""
|
||||
if quote.IsExpired(now) {
|
||||
valid = false
|
||||
reason = "expired"
|
||||
} else if quote.Status == model.QuoteStatusConsumed {
|
||||
valid = false
|
||||
reason = "consumed"
|
||||
}
|
||||
|
||||
resp := &oraclev1.ValidateQuoteResponse{
|
||||
Meta: buildResponseMeta(req.GetMeta()),
|
||||
Quote: quoteModelToProto(quote),
|
||||
Valid: valid,
|
||||
Reason: reason,
|
||||
}
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
|
||||
func (s *Service) consumeQuoteResponder(ctx context.Context, req *oraclev1.ConsumeQuoteRequest) gsresponse.Responder[oraclev1.ConsumeQuoteResponse] {
|
||||
if req == nil {
|
||||
req = &oraclev1.ConsumeQuoteRequest{}
|
||||
}
|
||||
s.logger.Debug("Handling ConsumeQuote", zap.String("quote_ref", req.GetQuoteRef()), zap.String("ledger_txn_ref", req.GetLedgerTxnRef()))
|
||||
if req.GetQuoteRef() == "" {
|
||||
return gsresponse.InvalidArgument[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, errQuoteRefRequired)
|
||||
}
|
||||
if req.GetLedgerTxnRef() == "" {
|
||||
return gsresponse.InvalidArgument[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, errLedgerTxnRefRequired)
|
||||
}
|
||||
if err := s.pingStorage(ctx); err != nil {
|
||||
s.logger.Warn("Storage unavailable during ConsumeQuote", zap.Error(err))
|
||||
return gsresponse.Unavailable[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
_, err := s.storage.Quotes().Consume(ctx, req.GetQuoteRef(), req.GetLedgerTxnRef(), time.Now())
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrQuoteExpired):
|
||||
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "expired", err)
|
||||
case errors.Is(err, storage.ErrQuoteConsumed):
|
||||
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "consumed", err)
|
||||
case errors.Is(err, storage.ErrQuoteNotFirm):
|
||||
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "not_firm", err)
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
return gsresponse.NotFound[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
default:
|
||||
return gsresponse.Internal[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
}
|
||||
|
||||
resp := &oraclev1.ConsumeQuoteResponse{
|
||||
Meta: buildResponseMeta(req.GetMeta()),
|
||||
Consumed: true,
|
||||
Reason: "consumed",
|
||||
}
|
||||
s.logger.Debug("Quote consumed", zap.String("quote_ref", req.GetQuoteRef()), zap.String("ledger_txn_ref", req.GetLedgerTxnRef()))
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
|
||||
func (s *Service) latestRateResponder(ctx context.Context, req *oraclev1.LatestRateRequest) gsresponse.Responder[oraclev1.LatestRateResponse] {
|
||||
if req == nil {
|
||||
req = &oraclev1.LatestRateRequest{}
|
||||
}
|
||||
s.logger.Debug("Handling LatestRate", zap.String("pair", req.GetPair().GetBase()+"/"+req.GetPair().GetQuote()))
|
||||
if err := s.pingStorage(ctx); err != nil {
|
||||
s.logger.Warn("Storage unavailable during LatestRate", zap.Error(err))
|
||||
return gsresponse.Unavailable[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
pairMsg := req.GetPair()
|
||||
if pairMsg == nil || strings.TrimSpace(pairMsg.GetBase()) == "" || strings.TrimSpace(pairMsg.GetQuote()) == "" {
|
||||
return gsresponse.InvalidArgument[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, errEmptyRequest)
|
||||
}
|
||||
pair := model.CurrencyPair{Base: strings.ToUpper(pairMsg.GetBase()), Quote: strings.ToUpper(pairMsg.GetQuote())}
|
||||
|
||||
pairMeta, err := s.storage.Pairs().Get(ctx, pair)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
return gsresponse.NotFound[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
|
||||
default:
|
||||
return gsresponse.Internal[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
}
|
||||
|
||||
provider := req.GetProvider()
|
||||
if provider == "" {
|
||||
provider = pairMeta.DefaultProvider
|
||||
}
|
||||
if provider == "" && len(pairMeta.Providers) > 0 {
|
||||
provider = pairMeta.Providers[0]
|
||||
}
|
||||
|
||||
rate, err := s.getLatestRate(ctx, pairMeta, provider)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
return gsresponse.NotFound[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
|
||||
default:
|
||||
return gsresponse.Internal[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
}
|
||||
|
||||
resp := &oraclev1.LatestRateResponse{
|
||||
Meta: buildResponseMeta(req.GetMeta()),
|
||||
Rate: rateModelToProto(rate),
|
||||
}
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
|
||||
func (s *Service) listPairsResponder(ctx context.Context, req *oraclev1.ListPairsRequest) gsresponse.Responder[oraclev1.ListPairsResponse] {
|
||||
if req == nil {
|
||||
req = &oraclev1.ListPairsRequest{}
|
||||
}
|
||||
s.logger.Debug("Handling ListPairs")
|
||||
if err := s.pingStorage(ctx); err != nil {
|
||||
s.logger.Warn("Storage unavailable during ListPairs", zap.Error(err))
|
||||
return gsresponse.Unavailable[oraclev1.ListPairsResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
pairs, err := s.storage.Pairs().ListEnabled(ctx)
|
||||
if err != nil {
|
||||
return gsresponse.Internal[oraclev1.ListPairsResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
result := make([]*oraclev1.PairMeta, 0, len(pairs))
|
||||
for _, pair := range pairs {
|
||||
result = append(result, pairModelToProto(pair))
|
||||
}
|
||||
resp := &oraclev1.ListPairsResponse{
|
||||
Meta: buildResponseMeta(req.GetMeta()),
|
||||
Pairs: result,
|
||||
}
|
||||
s.logger.Debug("ListPairs returning metadata", zap.Int("pairs", len(resp.GetPairs())))
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
|
||||
func (s *Service) pingStorage(ctx context.Context) error {
|
||||
if s.storage == nil {
|
||||
return nil
|
||||
}
|
||||
return s.storage.Ping(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) getLatestRate(ctx context.Context, pair *model.Pair, provider string) (*model.RateSnapshot, error) {
|
||||
rate, err := s.storage.Rates().LatestSnapshot(ctx, pair.Pair, provider)
|
||||
if err == nil {
|
||||
return rate, nil
|
||||
}
|
||||
if !errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, err
|
||||
}
|
||||
crossRate, crossErr := s.computeCrossRate(ctx, pair, provider)
|
||||
if crossErr != nil {
|
||||
if errors.Is(crossErr, merrors.ErrNoData) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, crossErr
|
||||
}
|
||||
s.logger.Debug("Derived cross rate", zap.String("pair", pair.Pair.Base+"/"+pair.Pair.Quote), zap.String("provider", provider))
|
||||
return crossRate, nil
|
||||
}
|
||||
|
||||
var _ oraclev1.OracleServer = (*Service)(nil)
|
||||
467
api/fx/oracle/internal/service/oracle/service_test.go
Normal file
467
api/fx/oracle/internal/service/oracle/service_test.go
Normal file
@@ -0,0 +1,467 @@
|
||||
package oracle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/fx/storage"
|
||||
"github.com/tech/sendico/fx/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type repositoryStub struct {
|
||||
rates storage.RatesStore
|
||||
quotes storage.QuotesStore
|
||||
pairs storage.PairStore
|
||||
currencies storage.CurrencyStore
|
||||
pingErr error
|
||||
}
|
||||
|
||||
func (r *repositoryStub) Ping(ctx context.Context) error { return r.pingErr }
|
||||
func (r *repositoryStub) Rates() storage.RatesStore { return r.rates }
|
||||
func (r *repositoryStub) Quotes() storage.QuotesStore { return r.quotes }
|
||||
func (r *repositoryStub) Pairs() storage.PairStore { return r.pairs }
|
||||
func (r *repositoryStub) Currencies() storage.CurrencyStore {
|
||||
return r.currencies
|
||||
}
|
||||
|
||||
type ratesStoreStub struct {
|
||||
latestFn func(ctx context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error)
|
||||
}
|
||||
|
||||
func (r *ratesStoreStub) UpsertSnapshot(ctx context.Context, snapshot *model.RateSnapshot) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ratesStoreStub) LatestSnapshot(ctx context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error) {
|
||||
if r.latestFn != nil {
|
||||
return r.latestFn(ctx, pair, provider)
|
||||
}
|
||||
return nil, merrors.ErrNoData
|
||||
}
|
||||
|
||||
type quotesStoreStub struct {
|
||||
issueFn func(ctx context.Context, quote *model.Quote) error
|
||||
getFn func(ctx context.Context, ref string) (*model.Quote, error)
|
||||
consumeFn func(ctx context.Context, ref, ledger string, when time.Time) (*model.Quote, error)
|
||||
}
|
||||
|
||||
func (q *quotesStoreStub) Issue(ctx context.Context, quote *model.Quote) error {
|
||||
if q.issueFn != nil {
|
||||
return q.issueFn(ctx, quote)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *quotesStoreStub) GetByRef(ctx context.Context, ref string) (*model.Quote, error) {
|
||||
if q.getFn != nil {
|
||||
return q.getFn(ctx, ref)
|
||||
}
|
||||
return nil, merrors.ErrNoData
|
||||
}
|
||||
|
||||
func (q *quotesStoreStub) Consume(ctx context.Context, ref, ledger string, when time.Time) (*model.Quote, error) {
|
||||
if q.consumeFn != nil {
|
||||
return q.consumeFn(ctx, ref, ledger, when)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (q *quotesStoreStub) ExpireIssuedBefore(ctx context.Context, cutoff time.Time) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
type pairStoreStub struct {
|
||||
getFn func(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error)
|
||||
listFn func(ctx context.Context) ([]*model.Pair, error)
|
||||
}
|
||||
|
||||
func (p *pairStoreStub) ListEnabled(ctx context.Context) ([]*model.Pair, error) {
|
||||
if p.listFn != nil {
|
||||
return p.listFn(ctx)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (p *pairStoreStub) Get(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
|
||||
if p.getFn != nil {
|
||||
return p.getFn(ctx, pair)
|
||||
}
|
||||
return nil, merrors.ErrNoData
|
||||
}
|
||||
|
||||
func (p *pairStoreStub) Upsert(ctx context.Context, pair *model.Pair) error { return nil }
|
||||
|
||||
type currencyStoreStub struct{}
|
||||
|
||||
func (currencyStoreStub) Get(ctx context.Context, code string) (*model.Currency, error) {
|
||||
return nil, merrors.ErrNoData
|
||||
}
|
||||
func (currencyStoreStub) List(ctx context.Context, codes ...string) ([]*model.Currency, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (currencyStoreStub) Upsert(ctx context.Context, currency *model.Currency) error { return nil }
|
||||
|
||||
func TestServiceGetQuoteFirm(t *testing.T) {
|
||||
repo := &repositoryStub{}
|
||||
repo.pairs = &pairStoreStub{
|
||||
getFn: func(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
|
||||
return &model.Pair{
|
||||
Pair: pair,
|
||||
BaseMeta: model.CurrencySettings{Code: pair.Base, Decimals: 2, Rounding: model.RoundingModeHalfEven},
|
||||
QuoteMeta: model.CurrencySettings{Code: pair.Quote, Decimals: 2, Rounding: model.RoundingModeHalfEven},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
repo.rates = &ratesStoreStub{
|
||||
latestFn: func(ctx context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error) {
|
||||
return &model.RateSnapshot{
|
||||
Pair: pair,
|
||||
Provider: provider,
|
||||
Ask: "1.10",
|
||||
Bid: "1.08",
|
||||
RateRef: "rate#1",
|
||||
AsOfUnixMs: time.Now().UnixMilli(),
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
savedQuote := &model.Quote{}
|
||||
repo.quotes = "esStoreStub{
|
||||
issueFn: func(ctx context.Context, quote *model.Quote) error {
|
||||
*savedQuote = *quote
|
||||
return nil
|
||||
},
|
||||
}
|
||||
repo.currencies = currencyStoreStub{}
|
||||
|
||||
svc := NewService(zap.NewNop(), repo, nil)
|
||||
|
||||
req := &oraclev1.GetQuoteRequest{
|
||||
Meta: &oraclev1.RequestMeta{
|
||||
TenantRef: "tenant",
|
||||
Trace: &tracev1.TraceContext{RequestRef: "req"},
|
||||
},
|
||||
Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"},
|
||||
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
|
||||
AmountInput: &oraclev1.GetQuoteRequest_BaseAmount{BaseAmount: &moneyv1.Money{
|
||||
Currency: "USD",
|
||||
Amount: "100",
|
||||
}},
|
||||
Firm: true,
|
||||
TtlMs: 60000,
|
||||
}
|
||||
|
||||
resp, err := svc.GetQuote(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.GetQuote().GetFirm() != true {
|
||||
t.Fatalf("expected firm quote")
|
||||
}
|
||||
if resp.GetQuote().GetQuoteAmount().GetAmount() != "110.00" {
|
||||
t.Fatalf("unexpected quote amount: %s", resp.GetQuote().GetQuoteAmount().GetAmount())
|
||||
}
|
||||
if savedQuote.QuoteRef == "" {
|
||||
t.Fatalf("expected quote persisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceGetQuoteRateNotFound(t *testing.T) {
|
||||
repo := &repositoryStub{
|
||||
pairs: &pairStoreStub{
|
||||
getFn: func(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
|
||||
return &model.Pair{
|
||||
Pair: pair,
|
||||
BaseMeta: model.CurrencySettings{Code: pair.Base, Decimals: 2, Rounding: model.RoundingModeHalfEven},
|
||||
QuoteMeta: model.CurrencySettings{Code: pair.Quote, Decimals: 2, Rounding: model.RoundingModeHalfEven},
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
rates: &ratesStoreStub{latestFn: func(context.Context, model.CurrencyPair, string) (*model.RateSnapshot, error) {
|
||||
return nil, merrors.ErrNoData
|
||||
}},
|
||||
}
|
||||
svc := NewService(zap.NewNop(), repo, nil)
|
||||
|
||||
_, err := svc.GetQuote(context.Background(), &oraclev1.GetQuoteRequest{
|
||||
Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"},
|
||||
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
|
||||
AmountInput: &oraclev1.GetQuoteRequest_BaseAmount{BaseAmount: &moneyv1.Money{Currency: "USD", Amount: "1"}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceGetQuoteCrossRate(t *testing.T) {
|
||||
repo := &repositoryStub{}
|
||||
targetPair := model.CurrencyPair{Base: "EUR", Quote: "RUB"}
|
||||
baseLegPair := model.CurrencyPair{Base: "USDT", Quote: "EUR"}
|
||||
quoteLegPair := model.CurrencyPair{Base: "USDT", Quote: "RUB"}
|
||||
|
||||
repo.pairs = &pairStoreStub{
|
||||
getFn: func(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
|
||||
if pair != targetPair {
|
||||
t.Fatalf("unexpected pair lookup: %v", pair)
|
||||
}
|
||||
return &model.Pair{
|
||||
Pair: pair,
|
||||
BaseMeta: model.CurrencySettings{Code: pair.Base, Decimals: 2, Rounding: model.RoundingModeHalfEven},
|
||||
QuoteMeta: model.CurrencySettings{Code: pair.Quote, Decimals: 2, Rounding: model.RoundingModeHalfEven},
|
||||
DefaultProvider: "CROSSPROV",
|
||||
Cross: &model.CrossRateConfig{
|
||||
Enabled: true,
|
||||
BaseLeg: model.CrossRateLeg{
|
||||
Pair: baseLegPair,
|
||||
Invert: true,
|
||||
},
|
||||
QuoteLeg: model.CrossRateLeg{
|
||||
Pair: quoteLegPair,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
repo.rates = &ratesStoreStub{
|
||||
latestFn: func(ctx context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error) {
|
||||
switch pair {
|
||||
case targetPair:
|
||||
return nil, merrors.ErrNoData
|
||||
case baseLegPair:
|
||||
return &model.RateSnapshot{
|
||||
Pair: pair,
|
||||
Provider: provider,
|
||||
Ask: "0.90",
|
||||
Bid: "0.90",
|
||||
Mid: "0.90",
|
||||
RateRef: "base-leg",
|
||||
AsOfUnixMs: 1_000,
|
||||
}, nil
|
||||
case quoteLegPair:
|
||||
return &model.RateSnapshot{
|
||||
Pair: pair,
|
||||
Provider: provider,
|
||||
Ask: "90",
|
||||
Bid: "90",
|
||||
Mid: "90",
|
||||
RateRef: "quote-leg",
|
||||
AsOfUnixMs: 2_000,
|
||||
}, nil
|
||||
default:
|
||||
return nil, merrors.ErrNoData
|
||||
}
|
||||
},
|
||||
}
|
||||
repo.quotes = "esStoreStub{}
|
||||
repo.currencies = currencyStoreStub{}
|
||||
|
||||
svc := NewService(zap.NewNop(), repo, nil)
|
||||
|
||||
req := &oraclev1.GetQuoteRequest{
|
||||
Pair: &fxv1.CurrencyPair{Base: "EUR", Quote: "RUB"},
|
||||
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
|
||||
AmountInput: &oraclev1.GetQuoteRequest_BaseAmount{BaseAmount: &moneyv1.Money{Currency: "EUR", Amount: "1"}},
|
||||
}
|
||||
|
||||
resp, err := svc.GetQuote(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.GetQuote().GetPrice().GetValue() != "100.00" {
|
||||
t.Fatalf("unexpected cross price: %s", resp.GetQuote().GetPrice().GetValue())
|
||||
}
|
||||
if resp.GetQuote().GetQuoteAmount().GetAmount() != "100.00" {
|
||||
t.Fatalf("unexpected cross quote amount: %s", resp.GetQuote().GetQuoteAmount().GetAmount())
|
||||
}
|
||||
if !strings.HasPrefix(resp.GetQuote().GetRateRef(), "cross|") {
|
||||
t.Fatalf("expected cross rate ref, got %s", resp.GetQuote().GetRateRef())
|
||||
}
|
||||
if resp.GetQuote().GetProvider() != "CROSSPROV" {
|
||||
t.Fatalf("unexpected provider: %s", resp.GetQuote().GetProvider())
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestServiceLatestRateCross(t *testing.T) {
|
||||
repo := &repositoryStub{}
|
||||
targetPair := model.CurrencyPair{Base: "EUR", Quote: "RUB"}
|
||||
baseLegPair := model.CurrencyPair{Base: "USDT", Quote: "EUR"}
|
||||
quoteLegPair := model.CurrencyPair{Base: "USDT", Quote: "RUB"}
|
||||
|
||||
repo.pairs = &pairStoreStub{
|
||||
getFn: func(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
|
||||
if pair != targetPair {
|
||||
t.Fatalf("unexpected pair lookup: %v", pair)
|
||||
}
|
||||
return &model.Pair{
|
||||
Pair: pair,
|
||||
BaseMeta: model.CurrencySettings{Code: pair.Base, Decimals: 2, Rounding: model.RoundingModeHalfEven},
|
||||
QuoteMeta: model.CurrencySettings{Code: pair.Quote, Decimals: 2, Rounding: model.RoundingModeHalfEven},
|
||||
DefaultProvider: "CROSSPROV",
|
||||
Cross: &model.CrossRateConfig{
|
||||
Enabled: true,
|
||||
BaseLeg: model.CrossRateLeg{
|
||||
Pair: baseLegPair,
|
||||
Invert: true,
|
||||
},
|
||||
QuoteLeg: model.CrossRateLeg{
|
||||
Pair: quoteLegPair,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
repo.rates = &ratesStoreStub{
|
||||
latestFn: func(ctx context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error) {
|
||||
switch pair {
|
||||
case targetPair:
|
||||
return nil, merrors.ErrNoData
|
||||
case baseLegPair:
|
||||
return &model.RateSnapshot{
|
||||
Pair: pair,
|
||||
Provider: provider,
|
||||
Ask: "0.90",
|
||||
Bid: "0.90",
|
||||
Mid: "0.90",
|
||||
RateRef: "base-leg",
|
||||
AsOfUnixMs: 1_000,
|
||||
}, nil
|
||||
case quoteLegPair:
|
||||
return &model.RateSnapshot{
|
||||
Pair: pair,
|
||||
Provider: provider,
|
||||
Ask: "90",
|
||||
Bid: "90",
|
||||
Mid: "90",
|
||||
RateRef: "quote-leg",
|
||||
AsOfUnixMs: 2_000,
|
||||
}, nil
|
||||
default:
|
||||
return nil, merrors.ErrNoData
|
||||
}
|
||||
},
|
||||
}
|
||||
repo.quotes = "esStoreStub{}
|
||||
repo.currencies = currencyStoreStub{}
|
||||
|
||||
svc := NewService(zap.NewNop(), repo, nil)
|
||||
|
||||
resp, err := svc.LatestRate(context.Background(), &oraclev1.LatestRateRequest{
|
||||
Pair: &fxv1.CurrencyPair{Base: "EUR", Quote: "RUB"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if resp.GetRate().GetMid().GetValue() != "100.00000000" {
|
||||
t.Fatalf("unexpected mid price: %s", resp.GetRate().GetMid().GetValue())
|
||||
}
|
||||
if resp.GetRate().GetProvider() != "CROSSPROV" {
|
||||
t.Fatalf("unexpected provider: %s", resp.GetRate().GetProvider())
|
||||
}
|
||||
if !strings.HasPrefix(resp.GetRate().GetRateRef(), "cross|") {
|
||||
t.Fatalf("expected cross rate ref, got %s", resp.GetRate().GetRateRef())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceValidateQuote(t *testing.T) {
|
||||
now := time.Now().Add(time.Minute)
|
||||
repo := &repositoryStub{
|
||||
quotes: "esStoreStub{
|
||||
getFn: func(context.Context, string) (*model.Quote, error) {
|
||||
return &model.Quote{
|
||||
QuoteRef: "q1",
|
||||
Pair: model.CurrencyPair{Base: "USD", Quote: "EUR"},
|
||||
Side: model.QuoteSideBuyBaseSellQuote,
|
||||
Price: "1.10",
|
||||
BaseAmount: model.Money{Currency: "USD", Amount: "100"},
|
||||
QuoteAmount: model.Money{Currency: "EUR", Amount: "110"},
|
||||
ExpiresAtUnixMs: now.UnixMilli(),
|
||||
Status: model.QuoteStatusIssued,
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
svc := NewService(zap.NewNop(), repo, nil)
|
||||
|
||||
resp, err := svc.ValidateQuote(context.Background(), &oraclev1.ValidateQuoteRequest{QuoteRef: "q1"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !resp.GetValid() {
|
||||
t.Fatalf("expected quote valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceConsumeQuoteExpired(t *testing.T) {
|
||||
repo := &repositoryStub{
|
||||
quotes: "esStoreStub{
|
||||
consumeFn: func(context.Context, string, string, time.Time) (*model.Quote, error) {
|
||||
return nil, storage.ErrQuoteExpired
|
||||
},
|
||||
},
|
||||
}
|
||||
svc := NewService(zap.NewNop(), repo, nil)
|
||||
|
||||
_, err := svc.ConsumeQuote(context.Background(), &oraclev1.ConsumeQuoteRequest{QuoteRef: "q1", LedgerTxnRef: "ledger"})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceLatestRateSuccess(t *testing.T) {
|
||||
repo := &repositoryStub{
|
||||
rates: &ratesStoreStub{latestFn: func(_ context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error) {
|
||||
if pair != (model.CurrencyPair{Base: "USD", Quote: "EUR"}) {
|
||||
t.Fatalf("unexpected pair: %v", pair)
|
||||
}
|
||||
if provider != "DEFAULT" {
|
||||
t.Fatalf("unexpected provider: %s", provider)
|
||||
}
|
||||
return &model.RateSnapshot{Pair: pair, RateRef: "rate", Provider: provider}, nil
|
||||
}},
|
||||
pairs: &pairStoreStub{
|
||||
getFn: func(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
|
||||
return &model.Pair{
|
||||
Pair: pair,
|
||||
BaseMeta: model.CurrencySettings{Code: pair.Base, Decimals: 2, Rounding: model.RoundingModeHalfEven},
|
||||
QuoteMeta: model.CurrencySettings{Code: pair.Quote, Decimals: 2, Rounding: model.RoundingModeHalfEven},
|
||||
DefaultProvider: "DEFAULT",
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
svc := NewService(zap.NewNop(), repo, nil)
|
||||
|
||||
resp, err := svc.LatestRate(context.Background(), &oraclev1.LatestRateRequest{Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"}})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.GetRate().GetRateRef() != "rate" {
|
||||
t.Fatalf("unexpected rate ref")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceListPairs(t *testing.T) {
|
||||
repo := &repositoryStub{
|
||||
pairs: &pairStoreStub{listFn: func(context.Context) ([]*model.Pair, error) {
|
||||
return []*model.Pair{{Pair: model.CurrencyPair{Base: "USD", Quote: "EUR"}}}, nil
|
||||
}},
|
||||
}
|
||||
svc := NewService(zap.NewNop(), repo, nil)
|
||||
|
||||
resp, err := svc.ListPairs(context.Background(), &oraclev1.ListPairsRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(resp.GetPairs()) != 1 {
|
||||
t.Fatalf("expected one pair")
|
||||
}
|
||||
}
|
||||
126
api/fx/oracle/internal/service/oracle/transform.go
Normal file
126
api/fx/oracle/internal/service/oracle/transform.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package oracle
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/fx/storage/model"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
)
|
||||
|
||||
func buildResponseMeta(meta *oraclev1.RequestMeta) *oraclev1.ResponseMeta {
|
||||
resp := &oraclev1.ResponseMeta{}
|
||||
if meta == nil {
|
||||
return resp
|
||||
}
|
||||
resp.RequestRef = meta.GetRequestRef()
|
||||
resp.TraceRef = meta.GetTraceRef()
|
||||
|
||||
trace := meta.GetTrace()
|
||||
if trace == nil {
|
||||
trace = &tracev1.TraceContext{
|
||||
RequestRef: meta.GetRequestRef(),
|
||||
IdempotencyKey: meta.GetIdempotencyKey(),
|
||||
TraceRef: meta.GetTraceRef(),
|
||||
}
|
||||
}
|
||||
resp.Trace = trace
|
||||
return resp
|
||||
}
|
||||
|
||||
func quoteModelToProto(q *model.Quote) *oraclev1.Quote {
|
||||
if q == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &oraclev1.Quote{
|
||||
QuoteRef: q.QuoteRef,
|
||||
Pair: &fxv1.CurrencyPair{Base: q.Pair.Base, Quote: q.Pair.Quote},
|
||||
Side: sideModelToProto(q.Side),
|
||||
Price: decimalStringToProto(q.Price),
|
||||
BaseAmount: moneyModelToProto(&q.BaseAmount),
|
||||
QuoteAmount: moneyModelToProto(&q.QuoteAmount),
|
||||
ExpiresAtUnixMs: q.ExpiresAtUnixMs,
|
||||
Provider: q.Provider,
|
||||
RateRef: q.RateRef,
|
||||
Firm: q.Firm,
|
||||
}
|
||||
}
|
||||
|
||||
func moneyModelToProto(m *model.Money) *moneyv1.Money {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return &moneyv1.Money{Currency: m.Currency, Amount: m.Amount}
|
||||
}
|
||||
|
||||
func sideModelToProto(side model.QuoteSide) fxv1.Side {
|
||||
switch side {
|
||||
case model.QuoteSideBuyBaseSellQuote:
|
||||
return fxv1.Side_BUY_BASE_SELL_QUOTE
|
||||
case model.QuoteSideSellBaseBuyQuote:
|
||||
return fxv1.Side_SELL_BASE_BUY_QUOTE
|
||||
default:
|
||||
return fxv1.Side_SIDE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func rateModelToProto(rate *model.RateSnapshot) *oraclev1.RateSnapshot {
|
||||
if rate == nil {
|
||||
return nil
|
||||
}
|
||||
return &oraclev1.RateSnapshot{
|
||||
Pair: &fxv1.CurrencyPair{Base: rate.Pair.Base, Quote: rate.Pair.Quote},
|
||||
Mid: decimalStringToProto(rate.Mid),
|
||||
Bid: decimalStringToProto(rate.Bid),
|
||||
Ask: decimalStringToProto(rate.Ask),
|
||||
AsofUnixMs: rate.AsOfUnixMs,
|
||||
Provider: rate.Provider,
|
||||
RateRef: rate.RateRef,
|
||||
SpreadBps: decimalStringToProto(rate.SpreadBps),
|
||||
}
|
||||
}
|
||||
|
||||
func pairModelToProto(pair *model.Pair) *oraclev1.PairMeta {
|
||||
if pair == nil {
|
||||
return nil
|
||||
}
|
||||
return &oraclev1.PairMeta{
|
||||
Pair: &fxv1.CurrencyPair{Base: pair.Pair.Base, Quote: pair.Pair.Quote},
|
||||
BaseMeta: currencySettingsToProto(&pair.BaseMeta),
|
||||
QuoteMeta: currencySettingsToProto(&pair.QuoteMeta),
|
||||
}
|
||||
}
|
||||
|
||||
func currencySettingsToProto(c *model.CurrencySettings) *moneyv1.CurrencyMeta {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
return &moneyv1.CurrencyMeta{
|
||||
Code: c.Code,
|
||||
Decimals: c.Decimals,
|
||||
Rounding: roundingModeToProto(c.Rounding),
|
||||
}
|
||||
}
|
||||
|
||||
func roundingModeToProto(mode model.RoundingMode) moneyv1.RoundingMode {
|
||||
switch mode {
|
||||
case model.RoundingModeHalfUp:
|
||||
return moneyv1.RoundingMode_ROUND_HALF_UP
|
||||
case model.RoundingModeDown:
|
||||
return moneyv1.RoundingMode_ROUND_DOWN
|
||||
case model.RoundingModeHalfEven, model.RoundingModeUnspecified:
|
||||
return moneyv1.RoundingMode_ROUND_HALF_EVEN
|
||||
default:
|
||||
return moneyv1.RoundingMode_ROUNDING_MODE_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
|
||||
func decimalStringToProto(value string) *moneyv1.Decimal {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
return &moneyv1.Decimal{Value: value}
|
||||
}
|
||||
17
api/fx/oracle/main.go
Normal file
17
api/fx/oracle/main.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/fx/oracle/internal/appversion"
|
||||
si "github.com/tech/sendico/fx/oracle/internal/server"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/server"
|
||||
smain "github.com/tech/sendico/pkg/server/main"
|
||||
)
|
||||
|
||||
func factory(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
|
||||
return si.Create(logger, file, debug)
|
||||
}
|
||||
|
||||
func main() {
|
||||
smain.RunServer("main", appversion.Create(), factory)
|
||||
}
|
||||
2
api/fx/storage/.gitignore
vendored
Normal file
2
api/fx/storage/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
internal/generated
|
||||
.gocache
|
||||
32
api/fx/storage/go.mod
Normal file
32
api/fx/storage/go.mod
Normal file
@@ -0,0 +1,32 @@
|
||||
module github.com/tech/sendico/fx/storage
|
||||
|
||||
go 1.25.3
|
||||
|
||||
replace github.com/tech/sendico/pkg => ../../pkg
|
||||
|
||||
require (
|
||||
github.com/tech/sendico/pkg v0.1.0
|
||||
go.mongodb.org/mongo-driver v1.17.6
|
||||
go.uber.org/zap v1.27.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||
github.com/casbin/casbin/v2 v2.128.0 // indirect
|
||||
github.com/casbin/govaluate v1.10.0 // indirect
|
||||
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.18.1 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.1.2 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.43.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
)
|
||||
177
api/fx/storage/go.sum
Normal file
177
api/fx/storage/go.sum
Normal file
@@ -0,0 +1,177 @@
|
||||
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
|
||||
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/casbin/casbin/v2 v2.128.0 h1:761dLmXLy/ZNSckAITvpUZ8VdrxARyIlwmdafHzRb7Y=
|
||||
github.com/casbin/casbin/v2 v2.128.0/go.mod h1:iAwqzcYzJtAK5QWGT2uRl9WfRxXyKFBG1AZuhk2NAQg=
|
||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||
github.com/casbin/mongodb-adapter/v3 v3.7.0 h1:w9c3bea1BGK4eZTAmk17JkY52yv/xSZDSHKji8q+z6E=
|
||||
github.com/casbin/mongodb-adapter/v3 v3.7.0/go.mod h1:F1mu4ojoJVE/8VhIMxMedhjfwRDdIXgANYs6Sd0MgVA=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
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/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
|
||||
github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg=
|
||||
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo=
|
||||
github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
||||
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw=
|
||||
github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8=
|
||||
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY=
|
||||
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog=
|
||||
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
||||
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw=
|
||||
go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
|
||||
go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
|
||||
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
18
api/fx/storage/model/cross.go
Normal file
18
api/fx/storage/model/cross.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package model
|
||||
|
||||
// CrossRateConfig describes how to synthetically derive a currency pair using
|
||||
// two other pairs connected by a pivot currency.
|
||||
type CrossRateConfig struct {
|
||||
Enabled bool `bson:"enabled" json:"enabled"`
|
||||
PivotCurrency string `bson:"pivotCurrency,omitempty" json:"pivotCurrency,omitempty"`
|
||||
BaseLeg CrossRateLeg `bson:"baseLeg" json:"baseLeg"`
|
||||
QuoteLeg CrossRateLeg `bson:"quoteLeg" json:"quoteLeg"`
|
||||
}
|
||||
|
||||
// CrossRateLeg identifies a supporting currency pair and optional overrides to
|
||||
// fetch or orient its pricing data for cross-rate calculations.
|
||||
type CrossRateLeg struct {
|
||||
Pair CurrencyPair `bson:"pair" json:"pair"`
|
||||
Invert bool `bson:"invert,omitempty" json:"invert,omitempty"`
|
||||
Provider string `bson:"provider,omitempty" json:"provider,omitempty"`
|
||||
}
|
||||
27
api/fx/storage/model/currency.go
Normal file
27
api/fx/storage/model/currency.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package model
|
||||
|
||||
import "github.com/tech/sendico/pkg/db/storable"
|
||||
|
||||
// Currency captures rounding metadata for a given currency code.
|
||||
type Currency struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
|
||||
Code string `bson:"code" json:"code"`
|
||||
Decimals uint32 `bson:"decimals" json:"decimals"`
|
||||
Rounding RoundingMode `bson:"rounding" json:"rounding"`
|
||||
DisplayName string `bson:"displayName,omitempty" json:"displayName,omitempty"`
|
||||
Symbol string `bson:"symbol,omitempty" json:"symbol,omitempty"`
|
||||
MinUnit string `bson:"minUnit,omitempty" json:"minUnit,omitempty"`
|
||||
}
|
||||
|
||||
// Collection implements storable.Storable.
|
||||
func (*Currency) Collection() string {
|
||||
return CurrenciesCollection
|
||||
}
|
||||
|
||||
// CurrencySettings embeds precision details inside a Pair document.
|
||||
type CurrencySettings struct {
|
||||
Code string `bson:"code" json:"code"`
|
||||
Decimals uint32 `bson:"decimals" json:"decimals"`
|
||||
Rounding RoundingMode `bson:"rounding" json:"rounding"`
|
||||
}
|
||||
26
api/fx/storage/model/pair.go
Normal file
26
api/fx/storage/model/pair.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package model
|
||||
|
||||
import "github.com/tech/sendico/pkg/db/storable"
|
||||
|
||||
// Pair describes a supported FX currency pair and related metadata.
|
||||
type Pair struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
|
||||
Pair CurrencyPair `bson:"pair" json:"pair"`
|
||||
BaseMeta CurrencySettings `bson:"baseMeta" json:"baseMeta"`
|
||||
QuoteMeta CurrencySettings `bson:"quoteMeta" json:"quoteMeta"`
|
||||
Providers []string `bson:"providers,omitempty" json:"providers,omitempty"`
|
||||
IsEnabled bool `bson:"isEnabled" json:"isEnabled"`
|
||||
TenantRef string `bson:"tenantRef,omitempty" json:"tenantRef,omitempty"`
|
||||
DefaultProvider string `bson:"defaultProvider,omitempty" json:"defaultProvider,omitempty"`
|
||||
Attributes map[string]any `bson:"attributes,omitempty" json:"attributes,omitempty"`
|
||||
SupportedSides []QuoteSide `bson:"supportedSides,omitempty" json:"supportedSides,omitempty"`
|
||||
FallbackProviders []string `bson:"fallbackProviders,omitempty" json:"fallbackProviders,omitempty"`
|
||||
Tags []string `bson:"tags,omitempty" json:"tags,omitempty"`
|
||||
Cross *CrossRateConfig `bson:"cross,omitempty" json:"cross,omitempty"`
|
||||
}
|
||||
|
||||
// Collection implements storable.Storable.
|
||||
func (*Pair) Collection() string {
|
||||
return PairsCollection
|
||||
}
|
||||
63
api/fx/storage/model/quote.go
Normal file
63
api/fx/storage/model/quote.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
)
|
||||
|
||||
// Quote represents a firm or indicative quote persisted by the oracle.
|
||||
type Quote struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
|
||||
QuoteRef string `bson:"quoteRef" json:"quoteRef"`
|
||||
Firm bool `bson:"firm" json:"firm"`
|
||||
Status QuoteStatus `bson:"status" json:"status"`
|
||||
Pair CurrencyPair `bson:"pair" json:"pair"`
|
||||
Side QuoteSide `bson:"side" json:"side"`
|
||||
Price string `bson:"price" json:"price"`
|
||||
BaseAmount Money `bson:"baseAmount" json:"baseAmount"`
|
||||
QuoteAmount Money `bson:"quoteAmount" json:"quoteAmount"`
|
||||
AmountType QuoteAmountType `bson:"amountType" json:"amountType"`
|
||||
ExpiresAtUnixMs int64 `bson:"expiresAtUnixMs" json:"expiresAtUnixMs"`
|
||||
ExpiresAt *time.Time `bson:"expiresAt,omitempty" json:"expiresAt,omitempty"`
|
||||
RateRef string `bson:"rateRef" json:"rateRef"`
|
||||
Provider string `bson:"provider" json:"provider"`
|
||||
PreferredProvider string `bson:"preferredProvider,omitempty" json:"preferredProvider,omitempty"`
|
||||
RequestedTTLMs int64 `bson:"requestedTtlMs,omitempty" json:"requestedTtlMs,omitempty"`
|
||||
MaxAgeToleranceMs int64 `bson:"maxAgeToleranceMs,omitempty" json:"maxAgeToleranceMs,omitempty"`
|
||||
ConsumedByLedgerTxnRef string `bson:"consumedByLedgerTxnRef,omitempty" json:"consumedByLedgerTxnRef,omitempty"`
|
||||
ConsumedAtUnixMs *int64 `bson:"consumedAtUnixMs,omitempty" json:"consumedAtUnixMs,omitempty"`
|
||||
Meta *QuoteMeta `bson:"meta,omitempty" json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// Collection implements storable.Storable.
|
||||
func (*Quote) Collection() string {
|
||||
return QuotesCollection
|
||||
}
|
||||
|
||||
// MarkConsumed switches the quote to consumed status and links it to a ledger transaction.
|
||||
func (q *Quote) MarkConsumed(ledgerTxnRef string, consumedAt time.Time) {
|
||||
if ledgerTxnRef == "" {
|
||||
return
|
||||
}
|
||||
q.Status = QuoteStatusConsumed
|
||||
q.ConsumedByLedgerTxnRef = ledgerTxnRef
|
||||
ts := consumedAt.UnixMilli()
|
||||
q.ConsumedAtUnixMs = &ts
|
||||
q.Base.Update()
|
||||
}
|
||||
|
||||
// MarkExpired marks the quote as expired.
|
||||
func (q *Quote) MarkExpired() {
|
||||
q.Status = QuoteStatusExpired
|
||||
q.Base.Update()
|
||||
}
|
||||
|
||||
// IsExpired reports whether the quote has passed its expiration instant.
|
||||
func (q *Quote) IsExpired(now time.Time) bool {
|
||||
if q.ExpiresAtUnixMs == 0 {
|
||||
return false
|
||||
}
|
||||
return now.UnixMilli() >= q.ExpiresAtUnixMs
|
||||
}
|
||||
34
api/fx/storage/model/rate.go
Normal file
34
api/fx/storage/model/rate.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
)
|
||||
|
||||
// RateSnapshot stores a normalized FX rate observation.
|
||||
type RateSnapshot struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
|
||||
RateRef string `bson:"rateRef" json:"rateRef"`
|
||||
Pair CurrencyPair `bson:"pair" json:"pair"`
|
||||
Provider string `bson:"provider" json:"provider"`
|
||||
Mid string `bson:"mid,omitempty" json:"mid,omitempty"`
|
||||
Bid string `bson:"bid,omitempty" json:"bid,omitempty"`
|
||||
Ask string `bson:"ask,omitempty" json:"ask,omitempty"`
|
||||
SpreadBps string `bson:"spreadBps,omitempty" json:"spreadBps,omitempty"`
|
||||
AsOfUnixMs int64 `bson:"asOfUnixMs" json:"asOfUnixMs"`
|
||||
AsOf *time.Time `bson:"asOf,omitempty" json:"asOf,omitempty"`
|
||||
Source string `bson:"source,omitempty" json:"source,omitempty"`
|
||||
ProviderRef string `bson:"providerRef,omitempty" json:"providerRef,omitempty"`
|
||||
}
|
||||
|
||||
// Collection implements storable.Storable.
|
||||
func (*RateSnapshot) Collection() string {
|
||||
return RatesCollection
|
||||
}
|
||||
|
||||
// AsOfTime converts the stored millisecond timestamp to time.Time.
|
||||
func (r *RateSnapshot) AsOfTime() time.Time {
|
||||
return time.UnixMilli(r.AsOfUnixMs)
|
||||
}
|
||||
68
api/fx/storage/model/types.go
Normal file
68
api/fx/storage/model/types.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package model
|
||||
|
||||
import "github.com/tech/sendico/pkg/model"
|
||||
|
||||
// Collection names used by the FX oracle persistence layer.
|
||||
const (
|
||||
RatesCollection = "rates"
|
||||
QuotesCollection = "quotes"
|
||||
CurrenciesCollection = "currencies"
|
||||
PairsCollection = "pairs"
|
||||
)
|
||||
|
||||
// QuoteStatus tracks the lifecycle state of a quote.
|
||||
type QuoteStatus string
|
||||
|
||||
const (
|
||||
QuoteStatusIssued QuoteStatus = "issued"
|
||||
QuoteStatusConsumed QuoteStatus = "consumed"
|
||||
QuoteStatusExpired QuoteStatus = "expired"
|
||||
)
|
||||
|
||||
// QuoteSide expresses the trade direction for the requested quote.
|
||||
type QuoteSide string
|
||||
|
||||
const (
|
||||
QuoteSideBuyBaseSellQuote QuoteSide = "buy_base_sell_quote"
|
||||
QuoteSideSellBaseBuyQuote QuoteSide = "sell_base_buy_quote"
|
||||
)
|
||||
|
||||
// QuoteAmountType indicates which leg amount was provided by the caller.
|
||||
type QuoteAmountType string
|
||||
|
||||
const (
|
||||
QuoteAmountTypeBase QuoteAmountType = "base"
|
||||
QuoteAmountTypeQuote QuoteAmountType = "quote"
|
||||
)
|
||||
|
||||
// RoundingMode describes how rounding should be applied for a currency.
|
||||
type RoundingMode string
|
||||
|
||||
const (
|
||||
RoundingModeUnspecified RoundingMode = "unspecified"
|
||||
RoundingModeHalfEven RoundingMode = "half_even"
|
||||
RoundingModeHalfUp RoundingMode = "half_up"
|
||||
RoundingModeDown RoundingMode = "down"
|
||||
)
|
||||
|
||||
// CurrencyPair identifies an FX pair.
|
||||
type CurrencyPair struct {
|
||||
Base string `bson:"base" json:"base"`
|
||||
Quote string `bson:"quote" json:"quote"`
|
||||
}
|
||||
|
||||
// Money represents an exact decimal amount with its currency.
|
||||
type Money struct {
|
||||
Currency string `bson:"currency" json:"currency"`
|
||||
Amount string `bson:"amount" json:"amount"`
|
||||
}
|
||||
|
||||
// QuoteMeta carries request-scoped metadata associated with a quote.
|
||||
type QuoteMeta struct {
|
||||
model.OrganizationBoundBase `bson:",inline" json:",inline"`
|
||||
|
||||
RequestRef string `bson:"requestRef,omitempty" json:"requestRef,omitempty"`
|
||||
TenantRef string `bson:"tenantRef,omitempty" json:"tenantRef,omitempty"`
|
||||
TraceRef string `bson:"traceRef,omitempty" json:"traceRef,omitempty"`
|
||||
IdempotencyKey string `bson:"idempotencyKey,omitempty" json:"idempotencyKey,omitempty"`
|
||||
}
|
||||
115
api/fx/storage/mongo/repository.go
Normal file
115
api/fx/storage/mongo/repository.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package mongo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/fx/storage"
|
||||
"github.com/tech/sendico/fx/storage/mongo/store"
|
||||
"github.com/tech/sendico/pkg/db"
|
||||
"github.com/tech/sendico/pkg/db/transaction"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
logger mlogger.Logger
|
||||
conn *db.MongoConnection
|
||||
db *mongo.Database
|
||||
txFactory transaction.Factory
|
||||
|
||||
rates storage.RatesStore
|
||||
quotes storage.QuotesStore
|
||||
pairs storage.PairStore
|
||||
currencies storage.CurrencyStore
|
||||
}
|
||||
|
||||
func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
|
||||
if conn == nil {
|
||||
return nil, merrors.InvalidArgument("mongo connection is nil")
|
||||
}
|
||||
|
||||
client := conn.Client()
|
||||
if client == nil {
|
||||
return nil, merrors.Internal("mongo client not initialised")
|
||||
}
|
||||
|
||||
db := conn.Database()
|
||||
txFactory := newMongoTransactionFactory(client)
|
||||
|
||||
s := &Store{
|
||||
logger: logger.Named("storage").Named("mongo"),
|
||||
conn: conn,
|
||||
db: db,
|
||||
txFactory: txFactory,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := s.Ping(ctx); err != nil {
|
||||
s.logger.Error("mongo ping failed during store init", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ratesStore, err := store.NewRates(s.logger, db)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to initialize rates store", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
quotesStore, err := store.NewQuotes(s.logger, db, txFactory)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to initialize quotes store", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
pairsStore, err := store.NewPair(s.logger, db)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to initialize pair store", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
currencyStore, err := store.NewCurrency(s.logger, db)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to initialize currency store", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.rates = ratesStore
|
||||
s.quotes = quotesStore
|
||||
s.pairs = pairsStore
|
||||
s.currencies = currencyStore
|
||||
|
||||
s.logger.Info("mongo storage ready")
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) Ping(ctx context.Context) error {
|
||||
return s.conn.Ping(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) Rates() storage.RatesStore {
|
||||
return s.rates
|
||||
}
|
||||
|
||||
func (s *Store) Quotes() storage.QuotesStore {
|
||||
return s.quotes
|
||||
}
|
||||
|
||||
func (s *Store) Pairs() storage.PairStore {
|
||||
return s.pairs
|
||||
}
|
||||
|
||||
func (s *Store) Currencies() storage.CurrencyStore {
|
||||
return s.currencies
|
||||
}
|
||||
|
||||
func (s *Store) Database() *mongo.Database {
|
||||
return s.db
|
||||
}
|
||||
|
||||
func (s *Store) TransactionFactory() transaction.Factory {
|
||||
return s.txFactory
|
||||
}
|
||||
|
||||
var _ storage.Repository = (*Store)(nil)
|
||||
113
api/fx/storage/mongo/store/currency.go
Normal file
113
api/fx/storage/mongo/store/currency.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/tech/sendico/fx/storage"
|
||||
"github.com/tech/sendico/fx/storage/model"
|
||||
"github.com/tech/sendico/pkg/db/repository"
|
||||
ri "github.com/tech/sendico/pkg/db/repository/index"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type currencyStore struct {
|
||||
logger mlogger.Logger
|
||||
repo repository.Repository
|
||||
}
|
||||
|
||||
func NewCurrency(logger mlogger.Logger, db *mongo.Database) (storage.CurrencyStore, error) {
|
||||
repo := repository.CreateMongoRepository(db, model.CurrenciesCollection)
|
||||
|
||||
index := &ri.Definition{
|
||||
Keys: []ri.Key{
|
||||
{Field: "code", Sort: ri.Asc},
|
||||
},
|
||||
Unique: true,
|
||||
}
|
||||
if err := repo.CreateIndex(index); err != nil {
|
||||
logger.Error("failed to ensure currencies index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
childLogger := logger.Named(model.CurrenciesCollection)
|
||||
childLogger.Debug("currency store initialised", zap.String("collection", model.CurrenciesCollection))
|
||||
|
||||
return ¤cyStore{
|
||||
logger: childLogger,
|
||||
repo: repo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *currencyStore) Get(ctx context.Context, code string) (*model.Currency, error) {
|
||||
if code == "" {
|
||||
c.logger.Warn("attempt to fetch currency with empty code")
|
||||
return nil, merrors.InvalidArgument("currencyStore: empty code")
|
||||
}
|
||||
result := &model.Currency{}
|
||||
if err := c.repo.FindOneByFilter(ctx, repository.Filter("code", code), result); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
c.logger.Debug("currency not found", zap.String("code", code))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
c.logger.Debug("currency loaded", zap.String("code", code))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *currencyStore) List(ctx context.Context, codes ...string) ([]*model.Currency, error) {
|
||||
query := repository.Query()
|
||||
if len(codes) > 0 {
|
||||
values := make([]any, len(codes))
|
||||
for i, code := range codes {
|
||||
values[i] = code
|
||||
}
|
||||
query = query.In(repository.Field("code"), values...)
|
||||
}
|
||||
|
||||
currencies := make([]*model.Currency, 0)
|
||||
err := c.repo.FindManyByFilter(ctx, query, func(cur *mongo.Cursor) error {
|
||||
doc := &model.Currency{}
|
||||
if err := cur.Decode(doc); err != nil {
|
||||
return err
|
||||
}
|
||||
currencies = append(currencies, doc)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
c.logger.Error("failed to list currencies", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
c.logger.Debug("listed currencies", zap.Int("count", len(currencies)))
|
||||
return currencies, nil
|
||||
}
|
||||
|
||||
func (c *currencyStore) Upsert(ctx context.Context, currency *model.Currency) error {
|
||||
if currency == nil {
|
||||
c.logger.Warn("attempt to upsert nil currency")
|
||||
return merrors.InvalidArgument("currencyStore: nil currency")
|
||||
}
|
||||
if currency.Code == "" {
|
||||
c.logger.Warn("attempt to upsert currency with empty code")
|
||||
return merrors.InvalidArgument("currencyStore: empty code")
|
||||
}
|
||||
|
||||
existing := &model.Currency{}
|
||||
filter := repository.Filter("code", currency.Code)
|
||||
if err := c.repo.FindOneByFilter(ctx, filter, existing); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
c.logger.Debug("inserting new currency", zap.String("code", currency.Code))
|
||||
return c.repo.Insert(ctx, currency, filter)
|
||||
}
|
||||
c.logger.Error("failed to fetch currency", zap.Error(err), zap.String("code", currency.Code))
|
||||
return err
|
||||
}
|
||||
|
||||
if existing.GetID() != nil {
|
||||
currency.SetID(*existing.GetID())
|
||||
}
|
||||
c.logger.Debug("updating currency", zap.String("code", currency.Code))
|
||||
return c.repo.Update(ctx, currency)
|
||||
}
|
||||
104
api/fx/storage/mongo/store/currency_test.go
Normal file
104
api/fx/storage/mongo/store/currency_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/fx/storage/model"
|
||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||
rd "github.com/tech/sendico/pkg/db/repository/decoder"
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestCurrencyStoreGet(t *testing.T) {
|
||||
repo := &repoStub{
|
||||
findOneFn: func(_ context.Context, _ builder.Query, result storable.Storable) error {
|
||||
currency := result.(*model.Currency)
|
||||
currency.Code = "USD"
|
||||
return nil
|
||||
},
|
||||
}
|
||||
store := ¤cyStore{logger: zap.NewNop(), repo: repo}
|
||||
|
||||
res, err := store.Get(context.Background(), "USD")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if res.Code != "USD" {
|
||||
t.Fatalf("unexpected code: %s", res.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrencyStoreList(t *testing.T) {
|
||||
repo := &repoStub{
|
||||
findManyFn: func(_ context.Context, _ builder.Query, decode rd.DecodingFunc) error {
|
||||
return runDecoderWithDocs(t, decode, &model.Currency{Code: "USD"})
|
||||
},
|
||||
}
|
||||
store := ¤cyStore{logger: zap.NewNop(), repo: repo}
|
||||
|
||||
currencies, err := store.List(context.Background(), "USD")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(currencies) != 1 || currencies[0].Code != "USD" {
|
||||
t.Fatalf("unexpected list result: %+v", currencies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrencyStoreUpsertInsert(t *testing.T) {
|
||||
inserted := false
|
||||
repo := &repoStub{
|
||||
findOneFn: func(context.Context, builder.Query, storable.Storable) error {
|
||||
return merrors.ErrNoData
|
||||
},
|
||||
insertFn: func(_ context.Context, obj storable.Storable, _ builder.Query) error {
|
||||
_ = cloneCurrency(t, obj)
|
||||
inserted = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
store := ¤cyStore{logger: zap.NewNop(), repo: repo}
|
||||
|
||||
if err := store.Upsert(context.Background(), &model.Currency{Code: "USD"}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !inserted {
|
||||
t.Fatalf("expected insert to be called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrencyStoreGetInvalid(t *testing.T) {
|
||||
store := ¤cyStore{logger: zap.NewNop(), repo: &repoStub{}}
|
||||
if _, err := store.Get(context.Background(), ""); !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid argument error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrencyStoreUpsertUpdate(t *testing.T) {
|
||||
var updated *model.Currency
|
||||
repo := &repoStub{
|
||||
findOneFn: func(_ context.Context, _ builder.Query, result storable.Storable) error {
|
||||
currency := result.(*model.Currency)
|
||||
currency.SetID(primitive.NewObjectID())
|
||||
currency.Code = "USD"
|
||||
return nil
|
||||
},
|
||||
updateFn: func(_ context.Context, obj storable.Storable) error {
|
||||
updated = cloneCurrency(t, obj)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
store := ¤cyStore{logger: zap.NewNop(), repo: repo}
|
||||
|
||||
if err := store.Upsert(context.Background(), &model.Currency{Code: "USD"}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if updated == nil || updated.GetID() == nil {
|
||||
t.Fatalf("expected update to preserve ID")
|
||||
}
|
||||
}
|
||||
111
api/fx/storage/mongo/store/pair.go
Normal file
111
api/fx/storage/mongo/store/pair.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/tech/sendico/fx/storage"
|
||||
"github.com/tech/sendico/fx/storage/model"
|
||||
"github.com/tech/sendico/pkg/db/repository"
|
||||
ri "github.com/tech/sendico/pkg/db/repository/index"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type pairStore struct {
|
||||
logger mlogger.Logger
|
||||
repo repository.Repository
|
||||
}
|
||||
|
||||
func NewPair(logger mlogger.Logger, db *mongo.Database) (storage.PairStore, error) {
|
||||
repo := repository.CreateMongoRepository(db, model.PairsCollection)
|
||||
index := &ri.Definition{
|
||||
Keys: []ri.Key{
|
||||
{Field: "pair.base", Sort: ri.Asc},
|
||||
{Field: "pair.quote", Sort: ri.Asc},
|
||||
},
|
||||
Unique: true,
|
||||
}
|
||||
if err := repo.CreateIndex(index); err != nil {
|
||||
logger.Error("failed to ensure pairs index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
logger.Debug("pair store initialised", zap.String("collection", model.PairsCollection))
|
||||
|
||||
return &pairStore{
|
||||
logger: logger.Named(model.PairsCollection),
|
||||
repo: repo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *pairStore) ListEnabled(ctx context.Context) ([]*model.Pair, error) {
|
||||
filter := repository.Query().Filter(repository.Field("isEnabled"), true)
|
||||
|
||||
pairs := make([]*model.Pair, 0)
|
||||
err := p.repo.FindManyByFilter(ctx, filter, func(cur *mongo.Cursor) error {
|
||||
doc := &model.Pair{}
|
||||
if err := cur.Decode(doc); err != nil {
|
||||
return err
|
||||
}
|
||||
pairs = append(pairs, doc)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
p.logger.Error("failed to list enabled pairs", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
p.logger.Debug("listed enabled pairs", zap.Int("count", len(pairs)))
|
||||
return pairs, nil
|
||||
}
|
||||
|
||||
func (p *pairStore) Get(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error) {
|
||||
if pair.Base == "" || pair.Quote == "" {
|
||||
p.logger.Warn("attempt to fetch pair with empty currency", zap.String("base", pair.Base), zap.String("quote", pair.Quote))
|
||||
return nil, merrors.InvalidArgument("pairStore: incomplete pair")
|
||||
}
|
||||
result := &model.Pair{}
|
||||
query := repository.Query().
|
||||
Filter(repository.Field("pair").Dot("base"), pair.Base).
|
||||
Filter(repository.Field("pair").Dot("quote"), pair.Quote)
|
||||
if err := p.repo.FindOneByFilter(ctx, query, result); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
p.logger.Debug("pair not found", zap.String("base", pair.Base), zap.String("quote", pair.Quote))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
p.logger.Debug("pair loaded", zap.String("base", pair.Base), zap.String("quote", pair.Quote))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *pairStore) Upsert(ctx context.Context, pair *model.Pair) error {
|
||||
if pair == nil {
|
||||
p.logger.Warn("attempt to upsert nil pair")
|
||||
return merrors.InvalidArgument("pairStore: nil pair")
|
||||
}
|
||||
if pair.Pair.Base == "" || pair.Pair.Quote == "" {
|
||||
p.logger.Warn("attempt to upsert pair with empty currency", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
|
||||
return merrors.InvalidArgument("pairStore: incomplete pair")
|
||||
}
|
||||
|
||||
existing := &model.Pair{}
|
||||
query := repository.Query().
|
||||
Filter(repository.Field("pair").Dot("base"), pair.Pair.Base).
|
||||
Filter(repository.Field("pair").Dot("quote"), pair.Pair.Quote)
|
||||
err := p.repo.FindOneByFilter(ctx, query, existing)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
p.logger.Debug("inserting new pair", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
|
||||
return p.repo.Insert(ctx, pair, query)
|
||||
}
|
||||
p.logger.Error("failed to fetch pair", zap.Error(err), zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
|
||||
return err
|
||||
}
|
||||
|
||||
if existing.GetID() != nil {
|
||||
pair.SetID(*existing.GetID())
|
||||
}
|
||||
p.logger.Debug("updating pair", zap.String("base", pair.Pair.Base), zap.String("quote", pair.Pair.Quote))
|
||||
return p.repo.Update(ctx, pair)
|
||||
}
|
||||
101
api/fx/storage/mongo/store/pair_test.go
Normal file
101
api/fx/storage/mongo/store/pair_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/fx/storage/model"
|
||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||
rd "github.com/tech/sendico/pkg/db/repository/decoder"
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestPairStoreListEnabled(t *testing.T) {
|
||||
repo := &repoStub{
|
||||
findManyFn: func(_ context.Context, _ builder.Query, decode rd.DecodingFunc) error {
|
||||
docs := []interface{}{
|
||||
&model.Pair{Pair: model.CurrencyPair{Base: "USD", Quote: "EUR"}},
|
||||
}
|
||||
return runDecoderWithDocs(t, decode, docs...)
|
||||
},
|
||||
}
|
||||
store := &pairStore{logger: zap.NewNop(), repo: repo}
|
||||
|
||||
pairs, err := store.ListEnabled(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(pairs) != 1 || pairs[0].Pair.Base != "USD" {
|
||||
t.Fatalf("unexpected pairs result: %+v", pairs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairStoreGetInvalid(t *testing.T) {
|
||||
store := &pairStore{logger: zap.NewNop(), repo: &repoStub{}}
|
||||
if _, err := store.Get(context.Background(), model.CurrencyPair{}); !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid argument error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairStoreGetNotFound(t *testing.T) {
|
||||
repo := &repoStub{
|
||||
findOneFn: func(context.Context, builder.Query, storable.Storable) error {
|
||||
return merrors.ErrNoData
|
||||
},
|
||||
}
|
||||
store := &pairStore{logger: zap.NewNop(), repo: repo}
|
||||
|
||||
if _, err := store.Get(context.Background(), model.CurrencyPair{Base: "USD", Quote: "EUR"}); !errors.Is(err, merrors.ErrNoData) {
|
||||
t.Fatalf("expected ErrNoData, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairStoreUpsertInsert(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var inserted *model.Pair
|
||||
repo := &repoStub{
|
||||
findOneFn: func(context.Context, builder.Query, storable.Storable) error {
|
||||
return merrors.ErrNoData
|
||||
},
|
||||
insertFn: func(_ context.Context, obj storable.Storable, _ builder.Query) error {
|
||||
inserted = clonePair(t, obj)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
store := &pairStore{logger: zap.NewNop(), repo: repo}
|
||||
pair := &model.Pair{Pair: model.CurrencyPair{Base: "USD", Quote: "EUR"}}
|
||||
if err := store.Upsert(ctx, pair); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if inserted == nil {
|
||||
t.Fatalf("expected insert to be called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPairStoreUpsertUpdate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var updated *model.Pair
|
||||
repo := &repoStub{
|
||||
findOneFn: func(_ context.Context, _ builder.Query, result storable.Storable) error {
|
||||
pair := result.(*model.Pair)
|
||||
pair.SetID(primitive.NewObjectID())
|
||||
return nil
|
||||
},
|
||||
updateFn: func(_ context.Context, obj storable.Storable) error {
|
||||
updated = clonePair(t, obj)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
store := &pairStore{logger: zap.NewNop(), repo: repo}
|
||||
|
||||
if err := store.Upsert(ctx, &model.Pair{Pair: model.CurrencyPair{Base: "USD", Quote: "EUR"}}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if updated == nil || updated.GetID() == nil {
|
||||
t.Fatalf("expected update to preserve existing ID")
|
||||
}
|
||||
}
|
||||
198
api/fx/storage/mongo/store/quotes.go
Normal file
198
api/fx/storage/mongo/store/quotes.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/fx/storage"
|
||||
"github.com/tech/sendico/fx/storage/model"
|
||||
"github.com/tech/sendico/pkg/db/repository"
|
||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||
ri "github.com/tech/sendico/pkg/db/repository/index"
|
||||
"github.com/tech/sendico/pkg/db/transaction"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type quotesStore struct {
|
||||
logger mlogger.Logger
|
||||
repo repository.Repository
|
||||
txFactory transaction.Factory
|
||||
}
|
||||
|
||||
func NewQuotes(logger mlogger.Logger, db *mongo.Database, txFactory transaction.Factory) (storage.QuotesStore, error) {
|
||||
repo := repository.CreateMongoRepository(db, model.QuotesCollection)
|
||||
indexes := []*ri.Definition{
|
||||
{
|
||||
Keys: []ri.Key{
|
||||
{Field: "quoteRef", Sort: ri.Asc},
|
||||
},
|
||||
Unique: true,
|
||||
},
|
||||
{
|
||||
Keys: []ri.Key{
|
||||
{Field: "status", Sort: ri.Asc},
|
||||
{Field: "expiresAtUnixMs", Sort: ri.Asc},
|
||||
},
|
||||
},
|
||||
{
|
||||
Keys: []ri.Key{
|
||||
{Field: "consumedByLedgerTxnRef", Sort: ri.Asc},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ttlSeconds := int32(0)
|
||||
indexes = append(indexes, &ri.Definition{
|
||||
Keys: []ri.Key{
|
||||
{Field: "expiresAt", Sort: ri.Asc},
|
||||
},
|
||||
TTL: &ttlSeconds,
|
||||
Name: "quotes_expires_at_ttl",
|
||||
})
|
||||
|
||||
for _, def := range indexes {
|
||||
if err := repo.CreateIndex(def); err != nil {
|
||||
logger.Error("failed to ensure quotes index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
childLogger := logger.Named(model.QuotesCollection)
|
||||
childLogger.Debug("quotes store initialised", zap.String("collection", model.QuotesCollection))
|
||||
|
||||
return "esStore{
|
||||
logger: childLogger,
|
||||
repo: repo,
|
||||
txFactory: txFactory,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (q *quotesStore) Issue(ctx context.Context, quote *model.Quote) error {
|
||||
if quote == nil {
|
||||
q.logger.Warn("attempt to issue nil quote")
|
||||
return merrors.InvalidArgument("quotesStore: nil quote")
|
||||
}
|
||||
if quote.QuoteRef == "" {
|
||||
q.logger.Warn("attempt to issue quote with empty ref")
|
||||
return merrors.InvalidArgument("quotesStore: empty quoteRef")
|
||||
}
|
||||
|
||||
if quote.ExpiresAtUnixMs > 0 && quote.ExpiresAt == nil {
|
||||
expiry := time.UnixMilli(quote.ExpiresAtUnixMs)
|
||||
quote.ExpiresAt = &expiry
|
||||
}
|
||||
|
||||
quote.Status = model.QuoteStatusIssued
|
||||
quote.ConsumedByLedgerTxnRef = ""
|
||||
quote.ConsumedAtUnixMs = nil
|
||||
if err := q.repo.Insert(ctx, quote, repository.Filter("quoteRef", quote.QuoteRef)); err != nil {
|
||||
q.logger.Error("failed to insert quote", zap.Error(err), zap.String("quote_ref", quote.QuoteRef))
|
||||
return err
|
||||
}
|
||||
q.logger.Debug("quote issued", zap.String("quote_ref", quote.QuoteRef), zap.Bool("firm", quote.Firm))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *quotesStore) GetByRef(ctx context.Context, quoteRef string) (*model.Quote, error) {
|
||||
if quoteRef == "" {
|
||||
q.logger.Warn("attempt to fetch quote with empty ref")
|
||||
return nil, merrors.InvalidArgument("quotesStore: empty quoteRef")
|
||||
}
|
||||
quote := &model.Quote{}
|
||||
if err := q.repo.FindOneByFilter(ctx, repository.Filter("quoteRef", quoteRef), quote); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
q.logger.Debug("quote not found", zap.String("quote_ref", quoteRef))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
q.logger.Debug("quote loaded", zap.String("quote_ref", quoteRef), zap.String("status", string(quote.Status)))
|
||||
return quote, nil
|
||||
}
|
||||
|
||||
func (q *quotesStore) Consume(ctx context.Context, quoteRef, ledgerTxnRef string, when time.Time) (*model.Quote, error) {
|
||||
if quoteRef == "" || ledgerTxnRef == "" {
|
||||
q.logger.Warn("attempt to consume quote with missing identifiers", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
||||
return nil, merrors.InvalidArgument("quotesStore: missing identifiers")
|
||||
}
|
||||
|
||||
if when.IsZero() {
|
||||
when = time.Now()
|
||||
}
|
||||
|
||||
q.logger.Debug("consuming quote", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
||||
txn := q.txFactory.CreateTransaction()
|
||||
result, err := txn.Execute(ctx, func(txCtx context.Context) (any, error) {
|
||||
quote := &model.Quote{}
|
||||
if err := q.repo.FindOneByFilter(txCtx, repository.Filter("quoteRef", quoteRef), quote); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !quote.Firm {
|
||||
q.logger.Warn("quote not firm", zap.String("quote_ref", quoteRef))
|
||||
return nil, storage.ErrQuoteNotFirm
|
||||
}
|
||||
|
||||
if quote.Status == model.QuoteStatusExpired || quote.IsExpired(when) {
|
||||
quote.MarkExpired()
|
||||
if err := q.repo.Update(txCtx, quote); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q.logger.Info("quote expired during consume", zap.String("quote_ref", quoteRef))
|
||||
return nil, storage.ErrQuoteExpired
|
||||
}
|
||||
|
||||
if quote.Status == model.QuoteStatusConsumed {
|
||||
if quote.ConsumedByLedgerTxnRef == ledgerTxnRef {
|
||||
q.logger.Debug("quote already consumed by ledger", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
||||
return quote, nil
|
||||
}
|
||||
q.logger.Warn("quote consumed by different ledger", zap.String("quote_ref", quoteRef), zap.String("existing_ledger_ref", quote.ConsumedByLedgerTxnRef))
|
||||
return nil, storage.ErrQuoteConsumed
|
||||
}
|
||||
|
||||
quote.MarkConsumed(ledgerTxnRef, when)
|
||||
if err := q.repo.Update(txCtx, quote); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q.logger.Info("quote consumed", zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
||||
return quote, nil
|
||||
})
|
||||
if err != nil {
|
||||
q.logger.Error("quote consumption failed", zap.Error(err), zap.String("quote_ref", quoteRef), zap.String("ledger_ref", ledgerTxnRef))
|
||||
return nil, err
|
||||
}
|
||||
quote, _ := result.(*model.Quote)
|
||||
if quote == nil {
|
||||
return nil, merrors.Internal("quotesStore: transaction returned nil quote")
|
||||
}
|
||||
return quote, nil
|
||||
}
|
||||
|
||||
func (q *quotesStore) ExpireIssuedBefore(ctx context.Context, cutoff time.Time) (int, error) {
|
||||
if cutoff.IsZero() {
|
||||
q.logger.Warn("attempt to expire quotes with zero cutoff")
|
||||
return 0, merrors.InvalidArgument("quotesStore: cutoff time is zero")
|
||||
}
|
||||
|
||||
filter := repository.Query().
|
||||
Filter(repository.Field("status"), model.QuoteStatusIssued).
|
||||
Comparison(repository.Field("expiresAtUnixMs"), builder.Lt, cutoff.UnixMilli())
|
||||
|
||||
patch := repository.Patch().
|
||||
Set(repository.Field("status"), model.QuoteStatusExpired).
|
||||
Unset(repository.Field("consumedByLedgerTxnRef")).
|
||||
Unset(repository.Field("consumedAtUnixMs"))
|
||||
|
||||
updated, err := q.repo.PatchMany(ctx, filter, patch)
|
||||
if err != nil {
|
||||
q.logger.Error("failed to expire quotes", zap.Error(err))
|
||||
return 0, err
|
||||
}
|
||||
if updated > 0 {
|
||||
q.logger.Info("quotes expired", zap.Int("count", updated))
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
184
api/fx/storage/mongo/store/quotes_test.go
Normal file
184
api/fx/storage/mongo/store/quotes_test.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/fx/storage"
|
||||
"github.com/tech/sendico/fx/storage/model"
|
||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestQuotesStoreIssue(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var inserted *model.Quote
|
||||
repo := &repoStub{
|
||||
insertFn: func(_ context.Context, obj storable.Storable, _ builder.Query) error {
|
||||
inserted = cloneQuote(t, obj)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
store := "esStore{logger: zap.NewNop(), repo: repo, txFactory: &txFactoryStub{}}
|
||||
|
||||
quote := &model.Quote{QuoteRef: "q1"}
|
||||
if err := store.Issue(ctx, quote); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if inserted == nil || inserted.Status != model.QuoteStatusIssued {
|
||||
t.Fatalf("expected issued quote to be inserted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotesStoreIssueSetsExpiryDate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var inserted *model.Quote
|
||||
repo := &repoStub{
|
||||
insertFn: func(_ context.Context, obj storable.Storable, _ builder.Query) error {
|
||||
inserted = cloneQuote(t, obj)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
store := "esStore{logger: zap.NewNop(), repo: repo, txFactory: &txFactoryStub{}}
|
||||
|
||||
expiry := time.Now().Add(2 * time.Minute).UnixMilli()
|
||||
quote := &model.Quote{
|
||||
QuoteRef: "q1",
|
||||
ExpiresAtUnixMs: expiry,
|
||||
}
|
||||
|
||||
if err := store.Issue(ctx, quote); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if inserted == nil || inserted.ExpiresAt == nil {
|
||||
t.Fatalf("expected expiry timestamp to be populated")
|
||||
}
|
||||
if inserted.ExpiresAt.UnixMilli() != expiry {
|
||||
t.Fatalf("expected expiry to equal %d, got %d", expiry, inserted.ExpiresAt.UnixMilli())
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotesStoreIssueInvalidInput(t *testing.T) {
|
||||
store := "esStore{logger: zap.NewNop(), repo: &repoStub{}, txFactory: &txFactoryStub{}}
|
||||
if err := store.Issue(context.Background(), nil); !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid argument error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotesStoreConsumeSuccess(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
ledgerRef := "ledger-1"
|
||||
|
||||
stored := &model.Quote{
|
||||
QuoteRef: "q1",
|
||||
Firm: true,
|
||||
Status: model.QuoteStatusIssued,
|
||||
ExpiresAtUnixMs: now.Add(5 * time.Minute).UnixMilli(),
|
||||
}
|
||||
var updated *model.Quote
|
||||
repo := &repoStub{
|
||||
findOneFn: func(_ context.Context, _ builder.Query, result storable.Storable) error {
|
||||
quote := result.(*model.Quote)
|
||||
*quote = *stored
|
||||
return nil
|
||||
},
|
||||
updateFn: func(_ context.Context, obj storable.Storable) error {
|
||||
updated = cloneQuote(t, obj)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
factory := &txFactoryStub{}
|
||||
store := "esStore{logger: zap.NewNop(), repo: repo, txFactory: factory}
|
||||
|
||||
res, err := store.Consume(ctx, "q1", ledgerRef, now)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if res == nil || res.Status != model.QuoteStatusConsumed {
|
||||
t.Fatalf("expected consumed quote")
|
||||
}
|
||||
if updated == nil || updated.ConsumedByLedgerTxnRef != ledgerRef {
|
||||
t.Fatalf("expected update with ledger ref")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotesStoreConsumeExpired(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
stored := &model.Quote{
|
||||
QuoteRef: "q1",
|
||||
Firm: true,
|
||||
Status: model.QuoteStatusIssued,
|
||||
ExpiresAtUnixMs: time.Now().Add(-time.Minute).UnixMilli(),
|
||||
}
|
||||
var updated *model.Quote
|
||||
repo := &repoStub{
|
||||
findOneFn: func(_ context.Context, _ builder.Query, result storable.Storable) error {
|
||||
quote := result.(*model.Quote)
|
||||
*quote = *stored
|
||||
return nil
|
||||
},
|
||||
updateFn: func(_ context.Context, obj storable.Storable) error {
|
||||
updated = cloneQuote(t, obj)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
factory := &txFactoryStub{}
|
||||
store := "esStore{logger: zap.NewNop(), repo: repo, txFactory: factory}
|
||||
|
||||
_, err := store.Consume(ctx, "q1", "ledger", time.Now())
|
||||
if !errors.Is(err, storage.ErrQuoteExpired) {
|
||||
t.Fatalf("expected ErrQuoteExpired, got %v", err)
|
||||
}
|
||||
if updated == nil || updated.Status != model.QuoteStatusExpired {
|
||||
t.Fatalf("expected quote marked expired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotesStoreExpireIssuedBefore(t *testing.T) {
|
||||
repo := &repoStub{
|
||||
patchManyFn: func(context.Context, builder.Query, builder.Patch) (int, error) {
|
||||
return 3, nil
|
||||
},
|
||||
}
|
||||
store := "esStore{logger: zap.NewNop(), repo: repo, txFactory: &txFactoryStub{}}
|
||||
|
||||
count, err := store.ExpireIssuedBefore(context.Background(), time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("expected 3 expired quotes, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotesStoreExpireZeroCutoff(t *testing.T) {
|
||||
store := "esStore{logger: zap.NewNop(), repo: &repoStub{}, txFactory: &txFactoryStub{}}
|
||||
if _, err := store.ExpireIssuedBefore(context.Background(), time.Time{}); !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid argument error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotesStoreGetByRefNotFound(t *testing.T) {
|
||||
repo := &repoStub{
|
||||
findOneFn: func(context.Context, builder.Query, storable.Storable) error {
|
||||
return merrors.ErrNoData
|
||||
},
|
||||
}
|
||||
store := "esStore{logger: zap.NewNop(), repo: repo, txFactory: &txFactoryStub{}}
|
||||
|
||||
if _, err := store.GetByRef(context.Background(), "missing"); !errors.Is(err, merrors.ErrNoData) {
|
||||
t.Fatalf("expected ErrNoData, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotesStoreGetByRefInvalid(t *testing.T) {
|
||||
store := "esStore{logger: zap.NewNop(), repo: &repoStub{}, txFactory: &txFactoryStub{}}
|
||||
if _, err := store.GetByRef(context.Background(), ""); !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid argument error")
|
||||
}
|
||||
}
|
||||
127
api/fx/storage/mongo/store/rates.go
Normal file
127
api/fx/storage/mongo/store/rates.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/fx/storage"
|
||||
"github.com/tech/sendico/fx/storage/model"
|
||||
"github.com/tech/sendico/pkg/db/repository"
|
||||
ri "github.com/tech/sendico/pkg/db/repository/index"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type ratesStore struct {
|
||||
logger mlogger.Logger
|
||||
repo repository.Repository
|
||||
}
|
||||
|
||||
func NewRates(logger mlogger.Logger, db *mongo.Database) (storage.RatesStore, error) {
|
||||
repo := repository.CreateMongoRepository(db, model.RatesCollection)
|
||||
|
||||
indexes := []*ri.Definition{
|
||||
{
|
||||
Keys: []ri.Key{
|
||||
{Field: "pair.base", Sort: ri.Asc},
|
||||
{Field: "pair.quote", Sort: ri.Asc},
|
||||
{Field: "provider", Sort: ri.Asc},
|
||||
{Field: "asOfUnixMs", Sort: ri.Desc},
|
||||
},
|
||||
},
|
||||
{
|
||||
Keys: []ri.Key{
|
||||
{Field: "rateRef", Sort: ri.Asc},
|
||||
},
|
||||
Unique: true,
|
||||
},
|
||||
}
|
||||
|
||||
ttlSeconds := int32(24 * 60 * 60)
|
||||
indexes = append(indexes, &ri.Definition{
|
||||
Keys: []ri.Key{
|
||||
{Field: "asOf", Sort: ri.Asc},
|
||||
},
|
||||
TTL: &ttlSeconds,
|
||||
Name: "rates_as_of_ttl",
|
||||
})
|
||||
|
||||
for _, def := range indexes {
|
||||
if err := repo.CreateIndex(def); err != nil {
|
||||
logger.Error("failed to ensure rates index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
logger.Debug("rates store initialised", zap.String("collection", model.RatesCollection))
|
||||
return &ratesStore{
|
||||
logger: logger.Named(model.RatesCollection),
|
||||
repo: repo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *ratesStore) UpsertSnapshot(ctx context.Context, snapshot *model.RateSnapshot) error {
|
||||
if snapshot == nil {
|
||||
r.logger.Warn("attempt to upsert nil snapshot")
|
||||
return merrors.InvalidArgument("ratesStore: nil snapshot")
|
||||
}
|
||||
if snapshot.RateRef == "" {
|
||||
r.logger.Warn("attempt to upsert snapshot with empty rate_ref")
|
||||
return merrors.InvalidArgument("ratesStore: empty rateRef")
|
||||
}
|
||||
|
||||
if snapshot.AsOfUnixMs > 0 && snapshot.AsOf == nil {
|
||||
asOf := time.UnixMilli(snapshot.AsOfUnixMs).UTC()
|
||||
snapshot.AsOf = &asOf
|
||||
}
|
||||
|
||||
existing := &model.RateSnapshot{}
|
||||
filter := repository.Filter("rateRef", snapshot.RateRef)
|
||||
err := r.repo.FindOneByFilter(ctx, filter, existing)
|
||||
if err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
r.logger.Debug("inserting new rate snapshot", zap.String("rate_ref", snapshot.RateRef))
|
||||
return r.repo.Insert(ctx, snapshot, filter)
|
||||
}
|
||||
r.logger.Error("failed to query rate snapshot", zap.Error(err), zap.String("rate_ref", snapshot.RateRef))
|
||||
return err
|
||||
}
|
||||
|
||||
if existing.GetID() != nil {
|
||||
snapshot.SetID(*existing.GetID())
|
||||
}
|
||||
r.logger.Debug("updating rate snapshot", zap.String("rate_ref", snapshot.RateRef))
|
||||
return r.repo.Update(ctx, snapshot)
|
||||
}
|
||||
|
||||
func (r *ratesStore) LatestSnapshot(ctx context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error) {
|
||||
query := repository.Query().
|
||||
Filter(repository.Field("pair").Dot("base"), pair.Base).
|
||||
Filter(repository.Field("pair").Dot("quote"), pair.Quote)
|
||||
|
||||
if provider != "" {
|
||||
query = query.Filter(repository.Field("provider"), provider)
|
||||
}
|
||||
|
||||
limit := int64(1)
|
||||
query = query.Sort(repository.Field("asOfUnixMs"), false).Limit(&limit)
|
||||
|
||||
var result *model.RateSnapshot
|
||||
err := r.repo.FindManyByFilter(ctx, query, func(cur *mongo.Cursor) error {
|
||||
doc := &model.RateSnapshot{}
|
||||
if err := cur.Decode(doc); err != nil {
|
||||
return err
|
||||
}
|
||||
result = doc
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result == nil {
|
||||
return nil, merrors.ErrNoData
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
87
api/fx/storage/mongo/store/rates_test.go
Normal file
87
api/fx/storage/mongo/store/rates_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/fx/storage/model"
|
||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||
rd "github.com/tech/sendico/pkg/db/repository/decoder"
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestRatesStoreUpsertInsert(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var inserted *model.RateSnapshot
|
||||
|
||||
repo := &repoStub{
|
||||
findOneFn: func(context.Context, builder.Query, storable.Storable) error {
|
||||
return merrors.ErrNoData
|
||||
},
|
||||
insertFn: func(_ context.Context, obj storable.Storable, _ builder.Query) error {
|
||||
inserted = cloneRate(t, obj)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
store := &ratesStore{logger: zap.NewNop(), repo: repo}
|
||||
|
||||
snapshot := &model.RateSnapshot{RateRef: "r1"}
|
||||
if err := store.UpsertSnapshot(ctx, snapshot); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if inserted == nil || inserted.RateRef != "r1" {
|
||||
t.Fatalf("expected snapshot to be inserted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRatesStoreUpsertUpdate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
existingID := primitive.NewObjectID()
|
||||
var updated *model.RateSnapshot
|
||||
|
||||
repo := &repoStub{
|
||||
findOneFn: func(_ context.Context, _ builder.Query, result storable.Storable) error {
|
||||
snap := result.(*model.RateSnapshot)
|
||||
snap.SetID(existingID)
|
||||
snap.RateRef = "existing"
|
||||
return nil
|
||||
},
|
||||
updateFn: func(_ context.Context, obj storable.Storable) error {
|
||||
snap := obj.(*model.RateSnapshot)
|
||||
updated = snap
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
store := &ratesStore{logger: zap.NewNop(), repo: repo}
|
||||
toUpdate := &model.RateSnapshot{RateRef: "existing"}
|
||||
if err := store.UpsertSnapshot(ctx, toUpdate); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if updated == nil || updated.GetID() == nil || *updated.GetID() != existingID {
|
||||
t.Fatalf("expected update to preserve ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRatesStoreLatestSnapshot(t *testing.T) {
|
||||
now := time.Now().UnixMilli()
|
||||
repo := &repoStub{
|
||||
findManyFn: func(_ context.Context, _ builder.Query, decode rd.DecodingFunc) error {
|
||||
doc := &model.RateSnapshot{RateRef: "latest", AsOfUnixMs: now}
|
||||
return runDecoderWithDocs(t, decode, doc)
|
||||
},
|
||||
}
|
||||
|
||||
store := &ratesStore{logger: zap.NewNop(), repo: repo}
|
||||
res, err := store.LatestSnapshot(context.Background(), model.CurrencyPair{Base: "USD", Quote: "EUR"}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if res.RateRef != "latest" || res.AsOfUnixMs != now {
|
||||
t.Fatalf("unexpected snapshot returned: %+v", res)
|
||||
}
|
||||
}
|
||||
189
api/fx/storage/mongo/store/testing_helpers_test.go
Normal file
189
api/fx/storage/mongo/store/testing_helpers_test.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/fx/storage/model"
|
||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||
rd "github.com/tech/sendico/pkg/db/repository/decoder"
|
||||
ri "github.com/tech/sendico/pkg/db/repository/index"
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/db/transaction"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
pmodel "github.com/tech/sendico/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
)
|
||||
|
||||
type repoStub struct {
|
||||
insertFn func(ctx context.Context, obj storable.Storable, filter builder.Query) error
|
||||
insertManyFn func(ctx context.Context, objects []storable.Storable) error
|
||||
findOneFn func(ctx context.Context, query builder.Query, result storable.Storable) error
|
||||
findManyFn func(ctx context.Context, query builder.Query, decoder rd.DecodingFunc) error
|
||||
updateFn func(ctx context.Context, obj storable.Storable) error
|
||||
patchManyFn func(ctx context.Context, filter builder.Query, patch builder.Patch) (int, error)
|
||||
createIdxFn func(def *ri.Definition) error
|
||||
}
|
||||
|
||||
func (r *repoStub) Aggregate(ctx context.Context, b builder.Pipeline, decoder rd.DecodingFunc) error {
|
||||
return merrors.NotImplemented("Aggregate not used")
|
||||
}
|
||||
|
||||
func (r *repoStub) Insert(ctx context.Context, obj storable.Storable, filter builder.Query) error {
|
||||
if r.insertFn != nil {
|
||||
return r.insertFn(ctx, obj, filter)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repoStub) InsertMany(ctx context.Context, objects []storable.Storable) error {
|
||||
if r.insertManyFn != nil {
|
||||
return r.insertManyFn(ctx, objects)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repoStub) Get(ctx context.Context, id primitive.ObjectID, result storable.Storable) error {
|
||||
return merrors.NotImplemented("Get not used")
|
||||
}
|
||||
|
||||
func (r *repoStub) FindOneByFilter(ctx context.Context, query builder.Query, result storable.Storable) error {
|
||||
if r.findOneFn != nil {
|
||||
return r.findOneFn(ctx, query, result)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repoStub) FindManyByFilter(ctx context.Context, query builder.Query, decoder rd.DecodingFunc) error {
|
||||
if r.findManyFn != nil {
|
||||
return r.findManyFn(ctx, query, decoder)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repoStub) Update(ctx context.Context, obj storable.Storable) error {
|
||||
if r.updateFn != nil {
|
||||
return r.updateFn(ctx, obj)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repoStub) Patch(ctx context.Context, id primitive.ObjectID, patch builder.Patch) error {
|
||||
return merrors.NotImplemented("Patch not used")
|
||||
}
|
||||
|
||||
func (r *repoStub) PatchMany(ctx context.Context, filter builder.Query, patch builder.Patch) (int, error) {
|
||||
if r.patchManyFn != nil {
|
||||
return r.patchManyFn(ctx, filter, patch)
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (r *repoStub) Delete(ctx context.Context, id primitive.ObjectID) error {
|
||||
return merrors.NotImplemented("Delete not used")
|
||||
}
|
||||
|
||||
func (r *repoStub) DeleteMany(ctx context.Context, query builder.Query) error {
|
||||
return merrors.NotImplemented("DeleteMany not used")
|
||||
}
|
||||
|
||||
func (r *repoStub) CreateIndex(def *ri.Definition) error {
|
||||
if r.createIdxFn != nil {
|
||||
return r.createIdxFn(def)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repoStub) ListIDs(ctx context.Context, query builder.Query) ([]primitive.ObjectID, error) {
|
||||
return nil, merrors.NotImplemented("ListIDs not used")
|
||||
}
|
||||
|
||||
func (r *repoStub) ListPermissionBound(ctx context.Context, query builder.Query) ([]pmodel.PermissionBoundStorable, error) {
|
||||
return nil, merrors.NotImplemented("ListPermissionBound not used")
|
||||
}
|
||||
|
||||
func (r *repoStub) ListAccountBound(ctx context.Context, query builder.Query) ([]pmodel.AccountBoundStorable, error) {
|
||||
return nil, merrors.NotImplemented("ListAccountBound not used")
|
||||
}
|
||||
|
||||
func (r *repoStub) Collection() string { return "test" }
|
||||
|
||||
type txFactoryStub struct {
|
||||
executeFn func(ctx context.Context, cb transaction.Callback) (any, error)
|
||||
}
|
||||
|
||||
func (f *txFactoryStub) CreateTransaction() transaction.Transaction {
|
||||
return &txStub{executeFn: f.executeFn}
|
||||
}
|
||||
|
||||
type txStub struct {
|
||||
executeFn func(ctx context.Context, cb transaction.Callback) (any, error)
|
||||
}
|
||||
|
||||
func (t *txStub) Execute(ctx context.Context, cb transaction.Callback) (any, error) {
|
||||
if t.executeFn != nil {
|
||||
return t.executeFn(ctx, cb)
|
||||
}
|
||||
return cb(ctx)
|
||||
}
|
||||
|
||||
func cloneRate(t *testing.T, obj storable.Storable) *model.RateSnapshot {
|
||||
t.Helper()
|
||||
rate, ok := obj.(*model.RateSnapshot)
|
||||
if !ok {
|
||||
t.Fatalf("expected *model.RateSnapshot, got %T", obj)
|
||||
}
|
||||
copy := *rate
|
||||
return ©
|
||||
}
|
||||
|
||||
func cloneQuote(t *testing.T, obj storable.Storable) *model.Quote {
|
||||
t.Helper()
|
||||
quote, ok := obj.(*model.Quote)
|
||||
if !ok {
|
||||
t.Fatalf("expected *model.Quote, got %T", obj)
|
||||
}
|
||||
copy := *quote
|
||||
return ©
|
||||
}
|
||||
|
||||
func clonePair(t *testing.T, obj storable.Storable) *model.Pair {
|
||||
t.Helper()
|
||||
pair, ok := obj.(*model.Pair)
|
||||
if !ok {
|
||||
t.Fatalf("expected *model.Pair, got %T", obj)
|
||||
}
|
||||
copy := *pair
|
||||
return ©
|
||||
}
|
||||
|
||||
func cloneCurrency(t *testing.T, obj storable.Storable) *model.Currency {
|
||||
t.Helper()
|
||||
currency, ok := obj.(*model.Currency)
|
||||
if !ok {
|
||||
t.Fatalf("expected *model.Currency, got %T", obj)
|
||||
}
|
||||
copy := *currency
|
||||
return ©
|
||||
}
|
||||
|
||||
func runDecoderWithDocs(t *testing.T, decode rd.DecodingFunc, docs ...interface{}) error {
|
||||
t.Helper()
|
||||
cur, err := mongo.NewCursorFromDocuments(docs, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create cursor: %v", err)
|
||||
}
|
||||
defer cur.Close(context.Background())
|
||||
|
||||
if len(docs) > 0 {
|
||||
if !cur.Next(context.Background()) {
|
||||
return cur.Err()
|
||||
}
|
||||
}
|
||||
|
||||
if err := decode(cur); err != nil {
|
||||
return err
|
||||
}
|
||||
return cur.Err()
|
||||
}
|
||||
38
api/fx/storage/mongo/transaction.go
Normal file
38
api/fx/storage/mongo/transaction.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package mongo
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/pkg/db/transaction"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
)
|
||||
|
||||
type mongoTransactionFactory struct {
|
||||
client *mongo.Client
|
||||
}
|
||||
|
||||
func (f *mongoTransactionFactory) CreateTransaction() transaction.Transaction {
|
||||
return &mongoTransaction{client: f.client}
|
||||
}
|
||||
|
||||
type mongoTransaction struct {
|
||||
client *mongo.Client
|
||||
}
|
||||
|
||||
func (t *mongoTransaction) Execute(ctx context.Context, cb transaction.Callback) (any, error) {
|
||||
session, err := t.client.StartSession()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer session.EndSession(ctx)
|
||||
|
||||
run := func(sessCtx mongo.SessionContext) (any, error) {
|
||||
return cb(sessCtx)
|
||||
}
|
||||
|
||||
return session.WithTransaction(ctx, run)
|
||||
}
|
||||
|
||||
func newMongoTransactionFactory(client *mongo.Client) transaction.Factory {
|
||||
return &mongoTransactionFactory{client: client}
|
||||
}
|
||||
53
api/fx/storage/storage.go
Normal file
53
api/fx/storage/storage.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/fx/storage/model"
|
||||
)
|
||||
|
||||
type storageError string
|
||||
|
||||
func (e storageError) Error() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
var (
|
||||
ErrQuoteExpired = storageError("fx.storage: quote expired")
|
||||
ErrQuoteConsumed = storageError("fx.storage: quote consumed")
|
||||
ErrQuoteNotFirm = storageError("fx.storage: quote is not firm")
|
||||
ErrQuoteConsumptionRace = storageError("fx.storage: quote consumption collision")
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
Ping(ctx context.Context) error
|
||||
Rates() RatesStore
|
||||
Quotes() QuotesStore
|
||||
Pairs() PairStore
|
||||
Currencies() CurrencyStore
|
||||
}
|
||||
|
||||
type RatesStore interface {
|
||||
UpsertSnapshot(ctx context.Context, snapshot *model.RateSnapshot) error
|
||||
LatestSnapshot(ctx context.Context, pair model.CurrencyPair, provider string) (*model.RateSnapshot, error)
|
||||
}
|
||||
|
||||
type QuotesStore interface {
|
||||
Issue(ctx context.Context, quote *model.Quote) error
|
||||
GetByRef(ctx context.Context, quoteRef string) (*model.Quote, error)
|
||||
Consume(ctx context.Context, quoteRef, ledgerTxnRef string, when time.Time) (*model.Quote, error)
|
||||
ExpireIssuedBefore(ctx context.Context, cutoff time.Time) (int, error)
|
||||
}
|
||||
|
||||
type PairStore interface {
|
||||
ListEnabled(ctx context.Context) ([]*model.Pair, error)
|
||||
Get(ctx context.Context, pair model.CurrencyPair) (*model.Pair, error)
|
||||
Upsert(ctx context.Context, p *model.Pair) error
|
||||
}
|
||||
|
||||
type CurrencyStore interface {
|
||||
Get(ctx context.Context, code string) (*model.Currency, error)
|
||||
List(ctx context.Context, codes ...string) ([]*model.Currency, error)
|
||||
Upsert(ctx context.Context, currency *model.Currency) error
|
||||
}
|
||||
Reference in New Issue
Block a user