Compare commits

..

13 Commits

Author SHA1 Message Date
pedro d1b6e5523c update readme again 2025-10-31 13:59:15 +01:00
pedro f3c4b38009 update readme 2025-10-31 13:58:44 +01:00
pedro 1feb45539e update readme 2025-10-31 00:53:11 +01:00
pedro 311e5902c9 add .env.example and update .gitignore 2025-10-31 00:18:51 +01:00
pedro 8d908a0502 some fixes for docker and migrations 2025-10-31 00:17:48 +01:00
pedro 84ec54a893 move H to pkg 2025-10-30 17:50:03 +01:00
pedro 145028af37 add ristretto caching and process daily and rolling data 2025-10-30 17:15:33 +01:00
pedro 6f4090fcb3 add backoff 2025-10-30 15:12:36 +01:00
pedro 802dfc97a2 add simple fetch to another service 2025-10-30 14:44:26 +01:00
pedro e0929aff56 add service b 2025-10-30 11:40:41 +01:00
pedro 90020de1ec trim space in headers 2025-10-30 11:39:19 +01:00
pedro 4885dad4ab move common code to pkg 2025-10-29 17:29:30 +01:00
pedro 05ca5ac787 better error handling and slog response 2025-10-29 17:02:12 +01:00
30 changed files with 720 additions and 67 deletions
-2
View File
@@ -1,2 +0,0 @@
POSTGRES_USER=developer
POSTGRES_PW=secret
-2
View File
@@ -1,2 +0,0 @@
POSTGRES_USER=developer
POSTGRES_PW=secret
+6
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
View File
@@ -1,3 +1,4 @@
.sqlfluff
*.out
apuntes.md
.env
+83 -8
View File
@@ -2,7 +2,13 @@
Prueba técnica para el puesto de desarrollador Go/C++
## Comandos útiles
## Requisitos previos
- Docker
- Go
- Make, si prefieres usar la comodidad de Makefile
## Rutas disponibles
Compilar todos los servicios e iniciar los contenedores Docker.
@@ -13,16 +19,85 @@ docker compose --env-file <path/to/file> up --build`
Hacer petición POST con fichero a `/ingest/csv`
```bash
curl -X POST http://localhost:8080/ingest/csv -F "file=@meteo.csv"
curl -X POST "http://<host>/ingest/csv" -F "file=@<file>.csv"
curl -X POST "http://localhost:8080/ingest/csv" -F "file@meteo.csv"
```
## Decisiones técnica
Hacer petición GET a `/cities` para listar las ciudades
1. Hablar sobre la función normalize, repetición de parse float. Justificar que se puede haber extraído a una función, pero ambas opciones son válidas (YAGNI)
```bash
curl "http://<host>/cities"
2. Hablar sobre las sobreabstracciones que se hacen en el código. Hay que busca un punto de equlibrio entre abstraer o ser explícito.
curl "http://localhost:8080/cities"
```
## Entorno desarrollo
Hacer petición GET a `/data` para obtener datos en crudo
Linux Fedora 41 6.16.11-200.fc42.x86_64
Go 1.25.2
```bash
curl "http://<host>/data?city=<city>&from=<yyyy-mm-dd>&to=<yyyy-mm-dd>&page=<n>&limit=<n>"
curl "http://localhost:8080/data?city=Madrid&from=2025-01-01&to=2025-12-31&page=3&limit=2"
```
Hacer petición GET a `/weather/{city}`
```bash
curl "http://<host>/weather/<city>?date=<yyyy-mm-dd>&days=<1-10>&unit=<C | F>&agg=<daily | rolling>"
curl "http://localhost:8090/weather/madrid?date=2025-11-02&days=10&unit=F&agg=rolling"
```
## Requisitos cubiertos
### Servicio A
- [x] Base de datos
- [x] Endpoint POST /ingest/csv
- [x] Listar ciudades en base de datos
- [x] Mostrar registros en crudo
- [ ] Especificación OpenAPI
### Servicio B
- [ ] Especificación OpenAPI
- [x] Endpoint GET /weather/{city} + parámetros
- [x] Conversión entre Celsius y Fahrenheit
- [ ] Caché
- [x] Tolerancia a fallos
## Consideraciones
Hay partes de códigos que son _snippets_ extraídos de una librería de autoría
propia. [Repositorio GitHub](https://github.com/zepyrshut/gopher-toolbox). De
las cuales son:
- La conexión con la base de datos, usando el controlador [pgx](https://github.com/jackc/pgx).
- La carga de variables de entorno mediante fichero.
- La migración de base de datos.
## Diseño y arquitectura
Recientemente hice otra prueba técnica para el mismo puesto y mismo lenguaje
distintas tecnologías, en ese caso era para trabajar con mensajería NATS,
puedes ver más información en el [repositorio](https://git.pedroperez.dev/pedro/nats-app).
Cuyo proyecto integra un sistema de caché muy primitivo, sin embargo en este
se hace uso de _Ristretto_.
La estructuración del proyecto es la misma, basada en dominios pero con
ligeras modificaciones, no es DDD puro. Para evitar el abuso de la creación de
paquetes en un mismo dominio (complejidad innecesaria y exceso de directorios),
agrupo todas las partes implicadas (_handlers_, _repos_, _models_, _services_)
en un directorio por dominio.
El directorio _pkg_ contiene todo el código común a los dos servicios como
la carga de variables de entorno, errores de dominio, conversiones de tipos,
etc.
## IA Generativa
Es evidente que con el uso de la IA, el código se hace mucho más rápido pero
para la prueba como demostración de mis habilidades se ha usado lo mínimo
posible, centrando en el diseño y arquitectura que la generación de código
en sí. Se ha usado Claude para la generación de código y Grok para la discusión
y debate de diseño.
+20 -5
View File
@@ -4,26 +4,41 @@ services:
image: postgres:17.6-alpine3.22
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PW}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=meteologica
ports:
- "5432:5432"
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d meteologica"]
interval: 5s
timeout: 5s
retries: 5
service_a:
build:
context: ./service_a
dockerfile: Dockerfile
context: .
dockerfile: ./service_a/Dockerfile
container_name: service_a
environment:
- URL_SERVICE_A=${URL_SERVICE_A}
- DSN=${DSN}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
ports:
- "8080:8080"
restart: unless-stopped
depends_on:
database:
condition: service_healthy
service_b:
build:
context: ./service_b
dockerfile: Dockerfile
context: .
dockerfile: ./service_b/Dockerfile
container_name: service_b
environment:
- URL_SERVICE_A=${URL_SERVICE_A}
ports:
- "8090:8090"
restart: unless-stopped
+31
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
View File
@@ -0,0 +1,6 @@
package pkg
var (
SQLSTATE_25P02 = "25P02"
SQLSTATE_23505 = "23505"
)
+3
View File
@@ -0,0 +1,3 @@
module pkg
go 1.25.2
@@ -1,4 +1,4 @@
package domains
package pkg
import (
"encoding/json"
@@ -1,3 +1,3 @@
package app
package pkg
type H map[string]any
+9 -6
View File
@@ -2,10 +2,13 @@ FROM golang:1.25.2-alpine3.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
COPY server/ ./server/
COPY internal/ ./internal/
COPY assets/ ./assets/
COPY pkg/ ./pkg/
COPY service_a/go.mod service_a/go.sum ./service_a/
COPY service_a/server/ ./service_a/server/
COPY service_a/internal/ ./service_a/internal/
COPY service_a/assets/ ./service_a/assets/
WORKDIR /app/service_a
RUN go mod download
@@ -13,13 +16,13 @@ RUN go test ./... -v
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
WORKDIR /app
COPY --from=builder /app/service_a /app/service_a
COPY --from=builder /app/bin/service_a /app/service_a
EXPOSE 8080
+7
View File
@@ -3,16 +3,21 @@ module servicea
go 1.25.2
require (
github.com/golang-migrate/migrate/v4 v4.19.0
github.com/jackc/pgx/v5 v5.7.6
github.com/stretchr/testify v1.11.1
pkg v0.0.0-00010101000000-000000000000
)
require (
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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // 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/rogpeppe/go-internal v1.14.1 // indirect
golang.org/x/crypto v0.37.0 // indirect
@@ -20,3 +25,5 @@ require (
golang.org/x/text v0.24.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace pkg => ../pkg
+59
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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/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/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
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/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
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/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
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/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+41
View File
@@ -2,8 +2,16 @@ package app
import (
"context"
"database/sql"
"embed"
"errors"
"fmt"
"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/stdlib"
)
@@ -22,3 +30,36 @@ func NewPGXPool(datasource string) *pgxpool.Pool {
slog.Info("connected to database", "datasource", datasource)
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")
}
+11 -7
View File
@@ -4,7 +4,7 @@ import (
"encoding/csv"
"fmt"
"io"
"servicea/internal/app"
"pkg"
"strconv"
"strings"
"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)
}
for i := range header {
header[i] = strings.TrimSpace(header[i])
}
var meteoDataList []MeteoData
var rejectedDataList []RejectedMeteoData
@@ -70,7 +74,7 @@ func (c *CSV) Parse(r io.Reader) ([]MeteoData, []RejectedMeteoData, error) {
rowValue := strings.Join(row, ";")
record := make(app.H)
record := make(pkg.H)
for i, value := range row {
if i < len(header) {
record[header[i]] = value
@@ -100,7 +104,7 @@ func (c *CSV) Parse(r io.Reader) ([]MeteoData, []RejectedMeteoData, error) {
return meteoDataList, rejectedDataList, nil
}
func normalize(record app.H) (*MeteoData, error) {
func normalize(record pkg.H) (*MeteoData, error) {
meteoData := &MeteoData{}
var err error
@@ -138,7 +142,7 @@ func normalize(record app.H) (*MeteoData, error) {
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 != "" {
t, err := time.Parse("2006/01/02", str)
if err != nil {
@@ -149,14 +153,14 @@ func parseDate(record app.H, key string, errMissing error) (time.Time, error) {
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 != "" {
return str, nil
}
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 != "" {
str = strings.Replace(str, ",", ".", 1)
f, err := strconv.ParseFloat(str, 32)
@@ -168,7 +172,7 @@ func parseFloatField(record app.H, key string, errMissing error) (float32, error
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 != "" {
str = strings.TrimSpace(str)
i, err := strconv.Atoi(str)
@@ -32,7 +32,7 @@ func Test_CSV_ParseFile(t *testing.T) {
assert.Equal(t, float32(11.55), record.MaxTemp)
assert.Equal(t, float32(6.25), record.MinTemp)
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) {
assert.Empty(t, rejected)
@@ -61,7 +61,7 @@ func Test_CSV_ParseFile(t *testing.T) {
},
validateRejected: func(t *testing.T, rejected []meteo.RejectedMeteoData) {
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)
},
},
@@ -87,7 +87,7 @@ func Test_CSV_ParseFile(t *testing.T) {
},
validateRejected: func(t *testing.T, rejected []meteo.RejectedMeteoData) {
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)
},
},
@@ -101,7 +101,7 @@ func Test_CSV_ParseFile(t *testing.T) {
},
validateRejected: func(t *testing.T, rejected []meteo.RejectedMeteoData) {
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)
},
},
@@ -116,7 +116,7 @@ func Test_CSV_ParseFile(t *testing.T) {
},
validateRejected: func(t *testing.T, rejected []meteo.RejectedMeteoData) {
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)
},
},
+19 -15
View File
@@ -8,13 +8,12 @@ import (
"io"
"log/slog"
"net/http"
"servicea/internal/app"
"servicea/internal/domains"
"pkg"
"time"
)
type Handler struct {
domains.BaseHandler
pkg.BaseHandler
s *Service
}
@@ -27,20 +26,23 @@ func NewHandler(service *Service) *Handler {
func (h *Handler) IngestCSV(w http.ResponseWriter, r *http.Request) {
err := r.ParseMultipartForm(10 << 20)
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
}
file, header, err := r.FormFile("file")
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
}
defer file.Close()
content, err := io.ReadAll(file)
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
}
@@ -57,13 +59,13 @@ func (h *Handler) IngestCSV(w http.ResponseWriter, r *http.Request) {
slog.Error(ErrCannotParseFile.Error(),
"filename", header.Filename,
"error", err)
http.Error(w, err.Error(), http.StatusBadRequest)
h.ToJSON(w, http.StatusConflict, pkg.H{"error": err})
return
}
fileStats.ElapsedMS = int(time.Since(start).Milliseconds())
h.s.UpdateElapsedMS(r.Context(), fileStats.BatchID, fileStats.ElapsedMS)
slog.Info("CSV file processed",
slog.Info("csv file processed",
"filename", header.Filename,
"rows_inserted", fileStats.RowsInserted,
"rows_rejected", fileStats.RowsRejected,
@@ -71,7 +73,7 @@ func (h *Handler) IngestCSV(w http.ResponseWriter, r *http.Request) {
"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) {
@@ -79,7 +81,9 @@ func (h *Handler) IngestExcel(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) {
@@ -94,19 +98,19 @@ func (h *Handler) GetMeteoData(w http.ResponseWriter, r *http.Request) {
}
if err := params.Validate(); err != nil {
slog.Error("Error validating struct", "error", err)
h.ToJSON(w, http.StatusBadRequest, app.H{"error": err.Error()})
slog.Error("error validating struct", "error", err)
h.ToJSON(w, http.StatusBadRequest, pkg.H{"error": err.Error()})
return
}
meteoData, err := h.s.GetMeteoData(r.Context(), params)
if err != nil {
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
}
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})
}
@@ -3,6 +3,8 @@ package meteo
import (
"context"
"fmt"
"pkg"
"strings"
b "github.com/jackc/pgx/v5"
"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
err := tx.QueryRow(ctx, insertBatch, 0, fileChecksum).Scan(&batchID)
if err != nil {
if strings.Contains(err.Error(), pkg.SQLSTATE_23505) {
return 0, ErrRecordAlreadyExists
}
return 0, fmt.Errorf("error inserting batch: %w", err)
}
return batchID, nil
@@ -102,6 +107,9 @@ func (pgx *pgxRepo) insertAcceptedMeteoData(ctx context.Context, tx b.Tx, batchI
rowsInserted++
if err != nil {
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)
}
}
+15 -1
View File
@@ -1,17 +1,31 @@
package main
import (
"embed"
"fmt"
"log/slog"
"net/http"
"os"
"pkg"
"servicea/internal/app"
"servicea/internal/domains/meteo"
"servicea/internal/router"
"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() {
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()
+8 -4
View File
@@ -2,17 +2,21 @@ FROM golang:1.25.2-alpine3.22 AS builder
WORKDIR /app
COPY go.mod ./
COPY server/ ./server/
COPY pkg/ ./pkg/
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 build -o /app/service_b ./server/main.go
RUN go build -o /app/bin/service_b ./server/main.go
FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/service_b /app/service_b
COPY --from=builder /app/bin/service_b /app/service_b
EXPOSE 8090
+14
View File
@@ -1,3 +1,17 @@
module serviceb
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
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=
+1
View File
@@ -0,0 +1 @@
package app
+104
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")
)
@@ -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)
}
@@ -0,0 +1,7 @@
package meteo
import "net/http"
func RegisterRoutes(mux *http.ServeMux, handler *Handler) {
mux.HandleFunc("GET /weather/{city}", handler.GetMeteoData)
}
+155
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
}
+16
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
}
+32 -10
View File
@@ -2,17 +2,39 @@ package main
import (
"fmt"
"log"
"log/slog"
"net/http"
"pkg"
"serviceb/internal/domains/meteo"
"serviceb/internal/router"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) {
log.Println("Received request on /hello endpoint")
fmt.Fprintf(w, "Hello world from service B")
})
http.ListenAndServe(":8090", mux)
func init() {
err := pkg.LoadEnvFile("./../.env")
if err != nil {
slog.Warn("error loading env file", "error", err)
}
}
func main() {
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))
}
}