Compare commits

...

10 Commits

29 changed files with 637 additions and 59 deletions

View File

@ -1,2 +0,0 @@
POSTGRES_USER=developer
POSTGRES_PW=secret

View File

@ -1,2 +0,0 @@
POSTGRES_USER=developer
POSTGRES_PW=secret

6
.env.example Normal file
View File

@ -0,0 +1,6 @@
POSTGRES_USER=developer
POSTGRES_PASSWORD=secret
DSN=database:5432/meteologica?sslmode=disable
URL_SERVICE_A=http://service_a:8080

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
.sqlfluff .sqlfluff
*.out *.out
apuntes.md apuntes.md
.env

View File

@ -4,26 +4,41 @@ services:
image: postgres:17.6-alpine3.22 image: postgres:17.6-alpine3.22
environment: environment:
- POSTGRES_USER=${POSTGRES_USER} - POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PW} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=meteologica - POSTGRES_DB=meteologica
ports: ports:
- "5432:5432" - "5432:5432"
restart: always restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d meteologica"]
interval: 5s
timeout: 5s
retries: 5
service_a: service_a:
build: build:
context: ./service_a context: .
dockerfile: Dockerfile dockerfile: ./service_a/Dockerfile
container_name: service_a container_name: service_a
environment:
- URL_SERVICE_A=${URL_SERVICE_A}
- DSN=${DSN}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
ports: ports:
- "8080:8080" - "8080:8080"
restart: unless-stopped restart: unless-stopped
depends_on:
database:
condition: service_healthy
service_b: service_b:
build: build:
context: ./service_b context: .
dockerfile: Dockerfile dockerfile: ./service_b/Dockerfile
container_name: service_b container_name: service_b
environment:
- URL_SERVICE_A=${URL_SERVICE_A}
ports: ports:
- "8090:8090" - "8090:8090"
restart: unless-stopped restart: unless-stopped

31
pkg/app.go Normal file
View File

@ -0,0 +1,31 @@
package pkg
import (
"bufio"
"os"
"strings"
)
func LoadEnvFile(envDirectory string) error {
file, err := os.Open(envDirectory)
if err != nil {
return err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if len(line) == 0 || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
os.Setenv(key, value)
}
return scanner.Err()
}

6
pkg/errors.go Normal file
View File

@ -0,0 +1,6 @@
package pkg
var (
SQLSTATE_25P02 = "25P02"
SQLSTATE_23505 = "23505"
)

3
pkg/go.mod Normal file
View File

@ -0,0 +1,3 @@
module pkg
go 1.25.2

View File

@ -1,4 +1,4 @@
package domains package pkg
import ( import (
"encoding/json" "encoding/json"

View File

@ -1,3 +1,3 @@
package app package pkg
type H map[string]any type H map[string]any

View File

@ -2,10 +2,13 @@ FROM golang:1.25.2-alpine3.22 AS builder
WORKDIR /app WORKDIR /app
COPY go.mod go.sum ./ COPY pkg/ ./pkg/
COPY server/ ./server/ COPY service_a/go.mod service_a/go.sum ./service_a/
COPY internal/ ./internal/ COPY service_a/server/ ./service_a/server/
COPY assets/ ./assets/ COPY service_a/internal/ ./service_a/internal/
COPY service_a/assets/ ./service_a/assets/
WORKDIR /app/service_a
RUN go mod download RUN go mod download
@ -13,13 +16,13 @@ RUN go test ./... -v
RUN rm -rf ./assets/ RUN rm -rf ./assets/
RUN go build -o /app/service_a ./server/main.go RUN go build -o /app/bin/service_a ./server/main.go
FROM alpine:latest FROM alpine:latest
WORKDIR /app WORKDIR /app
COPY --from=builder /app/service_a /app/service_a COPY --from=builder /app/bin/service_a /app/service_a
EXPOSE 8080 EXPOSE 8080

View File

@ -3,16 +3,21 @@ module servicea
go 1.25.2 go 1.25.2
require ( require (
github.com/golang-migrate/migrate/v4 v4.19.0
github.com/jackc/pgx/v5 v5.7.6 github.com/jackc/pgx/v5 v5.7.6
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
pkg v0.0.0-00010101000000-000000000000
) )
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/kr/text v0.2.0 // indirect github.com/kr/text v0.2.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
golang.org/x/crypto v0.37.0 // indirect golang.org/x/crypto v0.37.0 // indirect
@ -20,3 +25,5 @@ require (
golang.org/x/text v0.24.0 // indirect golang.org/x/text v0.24.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
replace pkg => ../pkg

View File

@ -1,7 +1,40 @@
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/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
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 v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
github.com/docker/docker v28.3.3+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/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE=
github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@ -14,6 +47,20 @@ github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
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/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@ -23,10 +70,22 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 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/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
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.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
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=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@ -2,8 +2,16 @@ package app
import ( import (
"context" "context"
"database/sql"
"embed"
"errors"
"fmt"
"log/slog" "log/slog"
"os"
mig "github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
_ "github.com/jackc/pgx/v5/stdlib" _ "github.com/jackc/pgx/v5/stdlib"
) )
@ -22,3 +30,36 @@ func NewPGXPool(datasource string) *pgxpool.Pool {
slog.Info("connected to database", "datasource", datasource) slog.Info("connected to database", "datasource", datasource)
return dbPool return dbPool
} }
func Migrate(database embed.FS) {
dbConn, err := sql.Open("pgx", os.Getenv("DATASOURCE"))
if err != nil {
slog.Error("error opening database connection", "error", err)
return
}
defer dbConn.Close()
d, err := iofs.New(database, "database/migrations")
if err != nil {
slog.Error("error creating migration source", "error", err)
return
}
m, err := mig.NewWithSourceInstance("iofs", d, fmt.Sprintf("postgres://%s:%s@%s", os.Getenv("POSTGRES_USER"), os.Getenv("POSTGRES_PASSWORD"), os.Getenv("DSN")))
if err != nil {
slog.Error("error creating migration instance", "error", err)
return
}
err = m.Up()
if err != nil && !errors.Is(err, mig.ErrNoChange) {
slog.Error("cannot migrate", "error", err)
panic(err)
}
if errors.Is(err, mig.ErrNoChange) {
slog.Info("migration has no changes")
return
}
slog.Info("migration done")
}

View File

@ -4,7 +4,7 @@ import (
"encoding/csv" "encoding/csv"
"fmt" "fmt"
"io" "io"
"servicea/internal/app" "pkg"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -52,6 +52,10 @@ func (c *CSV) Parse(r io.Reader) ([]MeteoData, []RejectedMeteoData, error) {
return nil, nil, fmt.Errorf("%w: invalid separator detected, expected semicolon (;)", ErrCannotParseFile) return nil, nil, fmt.Errorf("%w: invalid separator detected, expected semicolon (;)", ErrCannotParseFile)
} }
for i := range header {
header[i] = strings.TrimSpace(header[i])
}
var meteoDataList []MeteoData var meteoDataList []MeteoData
var rejectedDataList []RejectedMeteoData var rejectedDataList []RejectedMeteoData
@ -70,7 +74,7 @@ func (c *CSV) Parse(r io.Reader) ([]MeteoData, []RejectedMeteoData, error) {
rowValue := strings.Join(row, ";") rowValue := strings.Join(row, ";")
record := make(app.H) record := make(pkg.H)
for i, value := range row { for i, value := range row {
if i < len(header) { if i < len(header) {
record[header[i]] = value record[header[i]] = value
@ -100,7 +104,7 @@ func (c *CSV) Parse(r io.Reader) ([]MeteoData, []RejectedMeteoData, error) {
return meteoDataList, rejectedDataList, nil return meteoDataList, rejectedDataList, nil
} }
func normalize(record app.H) (*MeteoData, error) { func normalize(record pkg.H) (*MeteoData, error) {
meteoData := &MeteoData{} meteoData := &MeteoData{}
var err error var err error
@ -138,7 +142,7 @@ func normalize(record app.H) (*MeteoData, error) {
return meteoData, nil return meteoData, nil
} }
func parseDate(record app.H, key string, errMissing error) (time.Time, error) { func parseDate(record pkg.H, key string, errMissing error) (time.Time, error) {
if str, ok := record[key].(string); ok && str != "" { if str, ok := record[key].(string); ok && str != "" {
t, err := time.Parse("2006/01/02", str) t, err := time.Parse("2006/01/02", str)
if err != nil { if err != nil {
@ -149,14 +153,14 @@ func parseDate(record app.H, key string, errMissing error) (time.Time, error) {
return time.Time{}, errMissing return time.Time{}, errMissing
} }
func parseString(record app.H, key string, errMissing error) (string, error) { func parseString(record pkg.H, key string, errMissing error) (string, error) {
if str, ok := record[key].(string); ok && str != "" { if str, ok := record[key].(string); ok && str != "" {
return str, nil return str, nil
} }
return "", errMissing return "", errMissing
} }
func parseFloatField(record app.H, key string, errMissing error) (float32, error) { func parseFloatField(record pkg.H, key string, errMissing error) (float32, error) {
if str, ok := record[key].(string); ok && str != "" { if str, ok := record[key].(string); ok && str != "" {
str = strings.Replace(str, ",", ".", 1) str = strings.Replace(str, ",", ".", 1)
f, err := strconv.ParseFloat(str, 32) f, err := strconv.ParseFloat(str, 32)
@ -168,7 +172,7 @@ func parseFloatField(record app.H, key string, errMissing error) (float32, error
return 0, errMissing return 0, errMissing
} }
func parseIntField(record app.H, key string, errMissing error) (int, error) { func parseIntField(record pkg.H, key string, errMissing error) (int, error) {
if str, ok := record[key].(string); ok && str != "" { if str, ok := record[key].(string); ok && str != "" {
str = strings.TrimSpace(str) str = strings.TrimSpace(str)
i, err := strconv.Atoi(str) i, err := strconv.Atoi(str)

View File

@ -32,7 +32,7 @@ func Test_CSV_ParseFile(t *testing.T) {
assert.Equal(t, float32(11.55), record.MaxTemp) assert.Equal(t, float32(11.55), record.MaxTemp)
assert.Equal(t, float32(6.25), record.MinTemp) assert.Equal(t, float32(6.25), record.MinTemp)
assert.Equal(t, float32(0), record.Rainfall) assert.Equal(t, float32(0), record.Rainfall)
assert.Equal(t, float32(10), record.Cloudiness) assert.Equal(t, int(10), record.Cloudiness)
}, },
validateRejected: func(t *testing.T, rejected []meteo.RejectedMeteoData) { validateRejected: func(t *testing.T, rejected []meteo.RejectedMeteoData) {
assert.Empty(t, rejected) assert.Empty(t, rejected)
@ -61,7 +61,7 @@ func Test_CSV_ParseFile(t *testing.T) {
}, },
validateRejected: func(t *testing.T, rejected []meteo.RejectedMeteoData) { validateRejected: func(t *testing.T, rejected []meteo.RejectedMeteoData) {
assert.Equal(t, 1, len(rejected)) assert.Equal(t, 1, len(rejected))
assert.Contains(t, rejected[0].Reason, "missing or invalid city field") assert.Contains(t, rejected[0].Reason, "missing or invalid location")
assert.Equal(t, "2025/10/12;11,55;6,25;0;10", rejected[0].RowValue) assert.Equal(t, "2025/10/12;11,55;6,25;0;10", rejected[0].RowValue)
}, },
}, },
@ -87,7 +87,7 @@ func Test_CSV_ParseFile(t *testing.T) {
}, },
validateRejected: func(t *testing.T, rejected []meteo.RejectedMeteoData) { validateRejected: func(t *testing.T, rejected []meteo.RejectedMeteoData) {
assert.Equal(t, 1, len(rejected)) assert.Equal(t, 1, len(rejected))
assert.Contains(t, rejected[0].Reason, "missing or invalid max temp field") assert.Contains(t, rejected[0].Reason, "missing or invalid max temp")
assert.Equal(t, "2025/10/12;Madrid;;6,25;0;10", rejected[0].RowValue) assert.Equal(t, "2025/10/12;Madrid;;6,25;0;10", rejected[0].RowValue)
}, },
}, },
@ -101,7 +101,7 @@ func Test_CSV_ParseFile(t *testing.T) {
}, },
validateRejected: func(t *testing.T, rejected []meteo.RejectedMeteoData) { validateRejected: func(t *testing.T, rejected []meteo.RejectedMeteoData) {
assert.Equal(t, 1, len(rejected)) assert.Equal(t, 1, len(rejected))
assert.Contains(t, rejected[0].Reason, "missing or invalid city field") assert.Contains(t, rejected[0].Reason, "missing or invalid location")
assert.Equal(t, "2025/10/12;;11,55;6,25;0;10", rejected[0].RowValue) assert.Equal(t, "2025/10/12;;11,55;6,25;0;10", rejected[0].RowValue)
}, },
}, },
@ -116,7 +116,7 @@ func Test_CSV_ParseFile(t *testing.T) {
}, },
validateRejected: func(t *testing.T, rejected []meteo.RejectedMeteoData) { validateRejected: func(t *testing.T, rejected []meteo.RejectedMeteoData) {
assert.Equal(t, 1, len(rejected)) assert.Equal(t, 1, len(rejected))
assert.Contains(t, rejected[0].Reason, "missing or invalid date field") assert.Contains(t, rejected[0].Reason, "missing or invalid date")
assert.Equal(t, ";Madrid;11,55;6,25;0;10", rejected[0].RowValue) assert.Equal(t, ";Madrid;11,55;6,25;0;10", rejected[0].RowValue)
}, },
}, },

View File

@ -8,13 +8,12 @@ import (
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
"servicea/internal/app" "pkg"
"servicea/internal/domains"
"time" "time"
) )
type Handler struct { type Handler struct {
domains.BaseHandler pkg.BaseHandler
s *Service s *Service
} }
@ -27,20 +26,23 @@ func NewHandler(service *Service) *Handler {
func (h *Handler) IngestCSV(w http.ResponseWriter, r *http.Request) { func (h *Handler) IngestCSV(w http.ResponseWriter, r *http.Request) {
err := r.ParseMultipartForm(10 << 20) err := r.ParseMultipartForm(10 << 20)
if err != nil { if err != nil {
http.Error(w, ErrParsingForm.Error(), http.StatusBadRequest) slog.Error(ErrParsingForm.Error(), "error", err)
h.ToJSON(w, http.StatusBadRequest, pkg.H{"error": ErrParsingForm})
return return
} }
file, header, err := r.FormFile("file") file, header, err := r.FormFile("file")
if err != nil { if err != nil {
http.Error(w, ErrRetrievingFile.Error(), http.StatusBadRequest) slog.Error(ErrRetrievingFile.Error(), "error", err)
h.ToJSON(w, http.StatusBadRequest, pkg.H{"error": ErrRetrievingFile})
return return
} }
defer file.Close() defer file.Close()
content, err := io.ReadAll(file) content, err := io.ReadAll(file)
if err != nil { if err != nil {
http.Error(w, ErrReadingFile.Error(), http.StatusInternalServerError) slog.Error(ErrReadingFile.Error(), "error", err)
h.ToJSON(w, http.StatusInternalServerError, pkg.H{"error": ErrReadingFile})
return return
} }
@ -57,13 +59,13 @@ func (h *Handler) IngestCSV(w http.ResponseWriter, r *http.Request) {
slog.Error(ErrCannotParseFile.Error(), slog.Error(ErrCannotParseFile.Error(),
"filename", header.Filename, "filename", header.Filename,
"error", err) "error", err)
http.Error(w, err.Error(), http.StatusBadRequest) h.ToJSON(w, http.StatusConflict, pkg.H{"error": err})
return return
} }
fileStats.ElapsedMS = int(time.Since(start).Milliseconds()) fileStats.ElapsedMS = int(time.Since(start).Milliseconds())
h.s.UpdateElapsedMS(r.Context(), fileStats.BatchID, fileStats.ElapsedMS) h.s.UpdateElapsedMS(r.Context(), fileStats.BatchID, fileStats.ElapsedMS)
slog.Info("CSV file processed", slog.Info("csv file processed",
"filename", header.Filename, "filename", header.Filename,
"rows_inserted", fileStats.RowsInserted, "rows_inserted", fileStats.RowsInserted,
"rows_rejected", fileStats.RowsRejected, "rows_rejected", fileStats.RowsRejected,
@ -71,7 +73,7 @@ func (h *Handler) IngestCSV(w http.ResponseWriter, r *http.Request) {
"file_checksum", fileStats.FileChecksum, "file_checksum", fileStats.FileChecksum,
) )
h.ToJSON(w, http.StatusOK, app.H{"stats": fileStats}) h.ToJSON(w, http.StatusOK, pkg.H{"stats": fileStats})
} }
func (h *Handler) IngestExcel(w http.ResponseWriter, r *http.Request) { func (h *Handler) IngestExcel(w http.ResponseWriter, r *http.Request) {
@ -79,7 +81,9 @@ func (h *Handler) IngestExcel(w http.ResponseWriter, r *http.Request) {
} }
func (h *Handler) GetCities(w http.ResponseWriter, r *http.Request) { func (h *Handler) GetCities(w http.ResponseWriter, r *http.Request) {
h.ToJSON(w, http.StatusOK, app.H{"cities": h.s.GetCities(r.Context())}) cities := h.s.GetCities(r.Context())
slog.Info("cities retrieved", "count", len(cities))
h.ToJSON(w, http.StatusOK, pkg.H{"cities": cities})
} }
func (h *Handler) GetMeteoData(w http.ResponseWriter, r *http.Request) { func (h *Handler) GetMeteoData(w http.ResponseWriter, r *http.Request) {
@ -94,19 +98,19 @@ func (h *Handler) GetMeteoData(w http.ResponseWriter, r *http.Request) {
} }
if err := params.Validate(); err != nil { if err := params.Validate(); err != nil {
slog.Error("Error validating struct", "error", err) slog.Error("error validating struct", "error", err)
h.ToJSON(w, http.StatusBadRequest, app.H{"error": err.Error()}) h.ToJSON(w, http.StatusBadRequest, pkg.H{"error": err.Error()})
return return
} }
meteoData, err := h.s.GetMeteoData(r.Context(), params) meteoData, err := h.s.GetMeteoData(r.Context(), params)
if err != nil { if err != nil {
slog.Error(ErrReadingData.Error(), "error", err) slog.Error(ErrReadingData.Error(), "error", err)
h.ToJSON(w, http.StatusNotFound, app.H{"error": ErrReadingData.Error()}) h.ToJSON(w, http.StatusNotFound, pkg.H{"error": ErrReadingData.Error()})
return return
} }
slog.Info("Data retrieved", "location", params.Location) slog.Info("data retrieved", "location", params.Location)
h.ToJSON(w, http.StatusOK, app.H{"meteo_data": meteoData}) h.ToJSON(w, http.StatusOK, pkg.H{"meteo_data": meteoData})
} }

View File

@ -3,6 +3,8 @@ package meteo
import ( import (
"context" "context"
"fmt" "fmt"
"pkg"
"strings"
b "github.com/jackc/pgx/v5" b "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
@ -76,6 +78,9 @@ func (pgx *pgxRepo) insertBatch(ctx context.Context, tx b.Tx, fileChecksum strin
var batchID int var batchID int
err := tx.QueryRow(ctx, insertBatch, 0, fileChecksum).Scan(&batchID) err := tx.QueryRow(ctx, insertBatch, 0, fileChecksum).Scan(&batchID)
if err != nil { if err != nil {
if strings.Contains(err.Error(), pkg.SQLSTATE_23505) {
return 0, ErrRecordAlreadyExists
}
return 0, fmt.Errorf("error inserting batch: %w", err) return 0, fmt.Errorf("error inserting batch: %w", err)
} }
return batchID, nil return batchID, nil
@ -102,6 +107,9 @@ func (pgx *pgxRepo) insertAcceptedMeteoData(ctx context.Context, tx b.Tx, batchI
rowsInserted++ rowsInserted++
if err != nil { if err != nil {
results.Close() results.Close()
if strings.Contains(err.Error(), pkg.SQLSTATE_23505) {
return 0, ErrRecordAlreadyExists
}
return 0, fmt.Errorf("error executing batch command %d: %w", i, err) return 0, fmt.Errorf("error executing batch command %d: %w", i, err)
} }
} }

View File

@ -1,17 +1,31 @@
package main package main
import ( import (
"embed"
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"os"
"pkg"
"servicea/internal/app" "servicea/internal/app"
"servicea/internal/domains/meteo" "servicea/internal/domains/meteo"
"servicea/internal/router" "servicea/internal/router"
"time" "time"
) )
//go:embed database/migrations
var database embed.FS
func init() {
err := pkg.LoadEnvFile("./../.env")
if err != nil {
slog.Warn("error loading env file", "error", err)
}
}
func main() { func main() {
pool := app.NewPGXPool("postgres://developer:secret@localhost:5432/meteologica?sslmode=disable") pool := app.NewPGXPool(fmt.Sprintf("postgres://%s:%s@%s", os.Getenv("POSTGRES_USER"), os.Getenv("POSTGRES_PASSWORD"), os.Getenv("DSN")))
app.Migrate(database)
mux := router.SetupRoutes() mux := router.SetupRoutes()

View File

@ -2,17 +2,21 @@ FROM golang:1.25.2-alpine3.22 AS builder
WORKDIR /app WORKDIR /app
COPY go.mod ./ COPY pkg/ ./pkg/
COPY server/ ./server/ COPY service_b/go.mod service_b/go.sum ./service_b/
COPY service_b/server/ ./service_b/server/
COPY service_b/internal/ ./service_b/internal/
WORKDIR /app/service_b
RUN go mod download RUN go mod download
RUN go build -o /app/service_b ./server/main.go RUN go build -o /app/bin/service_b ./server/main.go
FROM alpine:latest FROM alpine:latest
WORKDIR /app WORKDIR /app
COPY --from=builder /app/service_b /app/service_b COPY --from=builder /app/bin/service_b /app/service_b
EXPOSE 8090 EXPOSE 8090

View File

@ -1,3 +1,17 @@
module serviceb module serviceb
go 1.25.2 go 1.25.2
replace pkg => ../pkg
require (
github.com/cenkalti/backoff/v5 v5.0.3
pkg v0.0.0-00010101000000-000000000000
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgraph-io/ristretto/v2 v2.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
golang.org/x/sys v0.35.0 // indirect
)

10
service_b/go.sum Normal file
View File

@ -0,0 +1,10 @@
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
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/dgraph-io/ristretto/v2 v2.3.0 h1:qTQ38m7oIyd4GAed/QkUZyPFNMnvVWyazGXRwvOt5zk=
github.com/dgraph-io/ristretto/v2 v2.3.0/go.mod h1:gpoRV3VzrEY1a9dWAYV6T1U7YzfgttXdd/ZzL1s9OZM=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=

View File

@ -0,0 +1 @@
package app

View File

@ -0,0 +1,104 @@
package meteo
import (
"errors"
"fmt"
"time"
)
type MeteoDataFromServiceA struct {
Timestamp time.Time `csv:"fecha" json:"timestamp"`
Location string `csv:"ciudad" json:"location"`
MaxTemp float32 `csv:"temperatura maxima" json:"max_temp"`
MinTemp float32 `csv:"temperatura minima" json:"min_temp"`
Rainfall float32 `csv:"precipitacion" json:"rainfall"`
Cloudiness int `csv:"nubosidad" json:"cloudiness"`
}
type MeteoDataPerDay struct {
MaxTemp float32 `json:"max_temp"`
MinTemp float32 `json:"min_temp"`
AvgTemp float32 `json:"avg_temp"`
Rainfall float32 `json:"rainfall"`
Cloudiness int `json:"cloudiness"`
}
func (mtpd *MeteoDataPerDay) ConvertValue() {
mtpd.MaxTemp = mtpd.MaxTemp*9/5 + 32
mtpd.MinTemp = mtpd.MinTemp*9/5 + 32
mtpd.AvgTemp = mtpd.AvgTemp*9/5 + 32
}
type Rolling7Data struct {
AvgTemp float32 `json:"avg_temp"`
AvgCloudiness int `json:"avg_cloudiness"`
SumRainfall float32 `json:"sum_rainfall"`
}
type MeteoData struct {
Location string `json:"location"`
Unit Unit `json:"unit"`
From string `json:"from"`
Days *[]MeteoDataPerDay `json:"days,omitempty"`
Rolling7 *Rolling7Data `json:"rolling7,omitempty"`
}
func (mt *MeteoData) ComputeCacheKey() string {
return fmt.Sprintf("meteo:%s:%s", mt.Location, mt.From)
}
type Unit string
const (
UnitC Unit = "C"
UnitF Unit = "F"
)
type Agg string
const (
AggDaily Agg = "daily"
AggRolling7 Agg = "rolling7"
)
type GetMeteoData struct {
Location string
Date string
Days int
Unit Unit
Agg Agg
}
func (mt *GetMeteoData) Validate() error {
if mt.Date == "" {
mt.Date = time.Now().Format("2006-01-02")
}
if _, err := time.Parse("2006-01-02", mt.Date); err != nil {
return ErrMissingOrInvalidDate
}
if mt.Days == 0 {
mt.Days = 5
}
if mt.Days < 1 || mt.Days > 10 {
return ErrInvalidDays
}
if mt.Unit == "" {
mt.Unit = UnitC
}
if mt.Agg == "" {
mt.Agg = AggDaily
}
return nil
}
var (
ErrCityNotFound = errors.New("city not found")
ErrReadingData = errors.New("error reading data")
ErrMissingOrInvalidDate = errors.New("missing or invalid date")
ErrInvalidDays = errors.New("days must be between 1 and 10")
)

View File

@ -0,0 +1,47 @@
package meteo
import (
"log/slog"
"net/http"
"pkg"
)
type Handler struct {
pkg.BaseHandler
s *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{
s: service,
}
}
func (h *Handler) GetMeteoData(w http.ResponseWriter, r *http.Request) {
locationValue := r.PathValue("city")
queryParams := r.URL.Query()
params := GetMeteoData{
Location: locationValue,
Date: queryParams.Get("date"),
Days: h.ParamToInt(queryParams.Get("days"), 5),
Unit: Unit(queryParams.Get("unit")),
Agg: Agg(queryParams.Get("agg")),
}
if err := params.Validate(); err != nil {
slog.Error("error validating struct", "error", err)
h.ToJSON(w, http.StatusBadRequest, pkg.H{"error": err.Error()})
return
}
data, err := h.s.GetWeatherByCity(r.Context(), params)
if err != nil {
slog.Error("error", "err", err)
h.ToJSON(w, http.StatusInternalServerError, pkg.H{"error": err})
return
}
slog.Info("data retrieved", "location", params.Location)
h.ToJSON(w, http.StatusOK, data)
}

View File

@ -0,0 +1,7 @@
package meteo
import "net/http"
func RegisterRoutes(mux *http.ServeMux, handler *Handler) {
mux.HandleFunc("GET /weather/{city}", handler.GetMeteoData)
}

View File

@ -0,0 +1,155 @@
package meteo
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"time"
"github.com/cenkalti/backoff/v5"
"github.com/dgraph-io/ristretto/v2"
)
type Service struct {
cache *ristretto.Cache[string, string]
}
func NewService() *Service {
cache, err := ristretto.NewCache(&ristretto.Config[string, string]{
NumCounters: 1024,
MaxCost: 1 << 30,
BufferItems: 64,
})
if err != nil {
slog.Error("cannot init cache", "err", err)
return nil
}
return &Service{
cache: cache,
}
}
func (s *Service) GetWeatherByCity(ctx context.Context, params GetMeteoData) (MeteoData, error) {
fromDate, err := time.Parse("2006-01-02", params.Date)
if err != nil {
return MeteoData{}, err
}
toDate := fromDate.AddDate(0, 0, params.Days-1)
operation := func() (*http.Response, error) {
url := fmt.Sprintf("%s/data?city=%s&from=%s&to=%s", os.Getenv("URL_SERVICE_A"),
params.Location, params.Date, toDate.Format("2006-01-02"))
slog.Info("url", "url", url)
resp, err := http.Get(url)
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusBadRequest {
resp.Body.Close()
return nil, backoff.Permanent(errors.New("bad request"))
}
return resp, nil
}
result, err := backoff.Retry(ctx, operation, backoff.WithBackOff(backoff.NewExponentialBackOff()))
if err != nil {
slog.Error("somethin happened")
return MeteoData{}, err
}
defer result.Body.Close()
body, err := io.ReadAll(result.Body)
if err != nil {
slog.Error("error reading response body", "err", err)
return MeteoData{}, err
}
var serviceAResponse struct {
MeteoData []MeteoDataFromServiceA `json:"meteo_data"`
}
if err := json.Unmarshal(body, &serviceAResponse); err != nil {
slog.Error("error unmarshaling response", "err", err)
return MeteoData{}, err
}
if len(serviceAResponse.MeteoData) == 0 {
return MeteoData{}, nil
}
if params.Agg == AggDaily {
return s.processDailyData(serviceAResponse.MeteoData, params)
}
return s.processRolling7Data(serviceAResponse.MeteoData, params)
}
func (s *Service) processDailyData(data []MeteoDataFromServiceA, params GetMeteoData) (MeteoData, error) {
days := make([]MeteoDataPerDay, 0, len(data))
for _, d := range data {
avgTemp := (d.MaxTemp + d.MinTemp) / 2
day := MeteoDataPerDay{
MaxTemp: d.MaxTemp,
MinTemp: d.MinTemp,
AvgTemp: avgTemp,
Rainfall: d.Rainfall,
Cloudiness: d.Cloudiness,
}
if params.Unit == UnitF {
day.ConvertValue()
}
days = append(days, day)
}
return MeteoData{
Location: params.Location,
Unit: params.Unit,
From: params.Date,
Days: &days,
}, nil
}
func (s *Service) processRolling7Data(data []MeteoDataFromServiceA, params GetMeteoData) (MeteoData, error) {
if len(data) < 7 {
return MeteoData{}, errors.New("insufficient data for rolling 7-day calculation")
}
var sumTemp, sumRainfall float32
var sumCloudiness int
for i := len(data) - 7; i < len(data); i++ {
avgTemp := (data[i].MaxTemp + data[i].MinTemp) / 2
sumTemp += avgTemp
sumRainfall += data[i].Rainfall
sumCloudiness += data[i].Cloudiness
}
rolling7 := &Rolling7Data{
AvgTemp: sumTemp / 7,
AvgCloudiness: sumCloudiness / 7,
SumRainfall: sumRainfall,
}
if params.Unit == UnitF {
rolling7.AvgTemp = rolling7.AvgTemp*9/5 + 32
}
return MeteoData{
Location: params.Location,
Unit: params.Unit,
From: params.Date,
Rolling7: rolling7,
}, nil
}

View File

@ -0,0 +1,16 @@
package router
import (
"fmt"
"net/http"
)
func SetupRoutes() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "hello world")
})
return mux
}

View File

@ -2,17 +2,39 @@ package main
import ( import (
"fmt" "fmt"
"log" "log/slog"
"net/http" "net/http"
"pkg"
"serviceb/internal/domains/meteo"
"serviceb/internal/router"
"time"
) )
func main() { func init() {
mux := http.NewServeMux() err := pkg.LoadEnvFile("./../.env")
if err != nil {
mux.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) { slog.Warn("error loading env file", "error", err)
log.Println("Received request on /hello endpoint") }
fmt.Fprintf(w, "Hello world from service B") }
})
func main() {
http.ListenAndServe(":8090", mux) mux := router.SetupRoutes()
meteoService := meteo.NewService()
meteoHandler := meteo.NewHandler(meteoService)
meteo.RegisterRoutes(mux, meteoHandler)
server := http.Server{
Addr: ":8090",
Handler: mux,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
ReadHeaderTimeout: 5 * time.Second,
}
slog.Info("server starting on :8090")
if err := server.ListenAndServe(); err != nil {
panic(fmt.Sprintf("server failed, error %s", err))
}
} }