Initial MVP commit
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
All checks were successful
ci/woodpecker/push/build-image Pipeline was successful
This commit is contained in:
120
internal/platform/config/config.go
Normal file
120
internal/platform/config/config.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config holds weatherapi datasource configuration.
|
||||
type Config struct {
|
||||
Databases []DatabaseConfig
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
Driver string `yaml:"driver"`
|
||||
Params DatabaseParams `yaml:"params"`
|
||||
}
|
||||
|
||||
type DatabaseParams struct {
|
||||
URI string `yaml:"uri"`
|
||||
Username string `yaml:"username"`
|
||||
Password string `yaml:"password"`
|
||||
}
|
||||
|
||||
type configWrapper struct {
|
||||
Databases []DatabaseConfig `yaml:"databases"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
path = "config.yml"
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("config.Load: read %q: %w", path, err)
|
||||
}
|
||||
|
||||
var list []DatabaseConfig
|
||||
if err := decodeStrict(raw, &list); err == nil && len(list) > 0 {
|
||||
cfg := &Config{Databases: list}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
var wrapped configWrapper
|
||||
if err := decodeStrict(raw, &wrapped); err != nil {
|
||||
return nil, fmt.Errorf("config.Load: parse YAML %q: %w", path, err)
|
||||
}
|
||||
|
||||
cfg := &Config{Databases: wrapped.Databases}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func decodeStrict(raw []byte, out any) error {
|
||||
dec := yaml.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.KnownFields(true)
|
||||
if err := dec.Decode(out); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var extra any
|
||||
if err := dec.Decode(&extra); err == nil {
|
||||
return fmt.Errorf("contains multiple YAML documents; expected exactly one")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("config validation failed: config is nil")
|
||||
}
|
||||
if len(c.Databases) == 0 {
|
||||
return fmt.Errorf("config validation failed: no databases configured")
|
||||
}
|
||||
|
||||
seen := map[string]struct{}{}
|
||||
for i, db := range c.Databases {
|
||||
path := fmt.Sprintf("databases[%d]", i)
|
||||
if strings.TrimSpace(db.Name) == "" {
|
||||
return fmt.Errorf("config validation failed: %s.name is required", path)
|
||||
}
|
||||
if _, ok := seen[db.Name]; ok {
|
||||
return fmt.Errorf("config validation failed: %s.name %q is duplicated", path, db.Name)
|
||||
}
|
||||
seen[db.Name] = struct{}{}
|
||||
|
||||
if strings.TrimSpace(db.Driver) == "" {
|
||||
return fmt.Errorf("config validation failed: %s.driver is required", path)
|
||||
}
|
||||
if strings.TrimSpace(db.Params.URI) == "" {
|
||||
return fmt.Errorf("config validation failed: %s.params.uri is required", path)
|
||||
}
|
||||
if strings.TrimSpace(db.Params.Username) == "" {
|
||||
return fmt.Errorf("config validation failed: %s.params.username is required", path)
|
||||
}
|
||||
if strings.TrimSpace(db.Params.Password) == "" {
|
||||
return fmt.Errorf("config validation failed: %s.params.password is required", path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) FindDatabase(name string) (DatabaseConfig, bool) {
|
||||
for _, db := range c.Databases {
|
||||
if db.Name == name {
|
||||
return db, true
|
||||
}
|
||||
}
|
||||
return DatabaseConfig{}, false
|
||||
}
|
||||
53
internal/platform/config/config_test.go
Normal file
53
internal/platform/config/config_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadRootList(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yml")
|
||||
content := `
|
||||
- name: weatherdb
|
||||
driver: postgres
|
||||
params:
|
||||
uri: postgres://weatherdb:5432/weatherdb?sslmode=disable
|
||||
username: weatherdb
|
||||
password: weatherdb
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write temp config: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load config: %v", err)
|
||||
}
|
||||
if len(cfg.Databases) != 1 {
|
||||
t.Fatalf("expected one database, got %d", len(cfg.Databases))
|
||||
}
|
||||
if cfg.Databases[0].Name != "weatherdb" {
|
||||
t.Fatalf("unexpected db name %q", cfg.Databases[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMissingRequiredFieldFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yml")
|
||||
content := `
|
||||
- name: weatherdb
|
||||
driver: postgres
|
||||
params:
|
||||
username: weatherdb
|
||||
password: weatherdb
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write temp config: %v", err)
|
||||
}
|
||||
|
||||
if _, err := Load(path); err == nil {
|
||||
t.Fatalf("expected validation error for missing params.uri")
|
||||
}
|
||||
}
|
||||
30
internal/platform/constants/constants.go
Normal file
30
internal/platform/constants/constants.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package constants
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
// ObservationWindow defines how far back observations are queried.
|
||||
ObservationWindow = 30 * time.Minute
|
||||
|
||||
// ForecastQueryLimit defines the max forecast periods returned.
|
||||
ForecastQueryLimit = 5
|
||||
|
||||
// DefaultOutputUnitSystem is the API's default output unit system.
|
||||
DefaultOutputUnitSystem = "us"
|
||||
|
||||
// DefaultHTTPAddr is the default listen address for the HTTP server.
|
||||
DefaultHTTPAddr = ":8080"
|
||||
)
|
||||
|
||||
var SupportedTimestampLayouts = []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
}
|
||||
|
||||
const (
|
||||
TempFahrenheitPrecision = 1
|
||||
WindMphPrecision = 1
|
||||
|
||||
KmhPerMph = 1.609344
|
||||
MphPerKmh = 1 / KmhPerMph
|
||||
)
|
||||
17
internal/platform/constants/conversion.go
Normal file
17
internal/platform/constants/conversion.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package constants
|
||||
|
||||
func CelsiusToFahrenheit(c float64) float64 {
|
||||
return c*9.0/5.0 + 32.0
|
||||
}
|
||||
|
||||
func FahrenheitToCelsius(f float64) float64 {
|
||||
return (f - 32.0) * 5.0 / 9.0
|
||||
}
|
||||
|
||||
func KmhToMph(kmh float64) float64 {
|
||||
return kmh * MphPerKmh
|
||||
}
|
||||
|
||||
func MphToKmh(mph float64) float64 {
|
||||
return mph * KmhPerMph
|
||||
}
|
||||
71
internal/platform/datasource/postgres/factory.go
Normal file
71
internal/platform/datasource/postgres/factory.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/config"
|
||||
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/datasource"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Factory struct{}
|
||||
|
||||
func (Factory) Driver() string {
|
||||
return "postgres"
|
||||
}
|
||||
|
||||
func (Factory) Open(ctx context.Context, cfg config.DatabaseConfig) (datasource.DataSource, error) {
|
||||
dsn, err := buildDSN(cfg.Params.URI, cfg.Params.Username, cfg.Params.Password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open postgres datasource %q: %w", cfg.Name, err)
|
||||
}
|
||||
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open postgres datasource %q: %w", cfg.Name, err)
|
||||
}
|
||||
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("open postgres datasource %q: ping: %w", cfg.Name, err)
|
||||
}
|
||||
|
||||
return &DataSource{pool: pool}, nil
|
||||
}
|
||||
|
||||
type DataSource struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func (d *DataSource) Pool() *pgxpool.Pool {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
return d.pool
|
||||
}
|
||||
|
||||
func (d *DataSource) Close() {
|
||||
if d == nil || d.pool == nil {
|
||||
return
|
||||
}
|
||||
d.pool.Close()
|
||||
}
|
||||
|
||||
func buildDSN(uri, username, password string) (string, error) {
|
||||
u, err := url.Parse(strings.TrimSpace(uri))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid params.uri: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(u.Scheme) == "" {
|
||||
return "", fmt.Errorf("invalid params.uri: missing scheme")
|
||||
}
|
||||
if strings.TrimSpace(u.Host) == "" {
|
||||
return "", fmt.Errorf("invalid params.uri: missing host")
|
||||
}
|
||||
|
||||
u.User = url.UserPassword(strings.TrimSpace(username), strings.TrimSpace(password))
|
||||
return u.String(), nil
|
||||
}
|
||||
64
internal/platform/datasource/registry.go
Normal file
64
internal/platform/datasource/registry.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package datasource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/config"
|
||||
)
|
||||
|
||||
// DataSource is a generic opened datasource handle.
|
||||
type DataSource interface {
|
||||
Close()
|
||||
}
|
||||
|
||||
// Factory constructs datasource handles for a specific driver.
|
||||
type Factory interface {
|
||||
Driver() string
|
||||
Open(ctx context.Context, cfg config.DatabaseConfig) (DataSource, error)
|
||||
}
|
||||
|
||||
// Registry maps driver names to datasource factories.
|
||||
type Registry struct {
|
||||
factories map[string]Factory
|
||||
}
|
||||
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{factories: map[string]Factory{}}
|
||||
}
|
||||
|
||||
func (r *Registry) Register(factory Factory) error {
|
||||
if factory == nil {
|
||||
return fmt.Errorf("register datasource factory: factory is nil")
|
||||
}
|
||||
driver := normalizeDriver(factory.Driver())
|
||||
if driver == "" {
|
||||
return fmt.Errorf("register datasource factory: factory driver is empty")
|
||||
}
|
||||
if _, exists := r.factories[driver]; exists {
|
||||
return fmt.Errorf("register datasource factory: driver %q already registered", driver)
|
||||
}
|
||||
r.factories[driver] = factory
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) Open(ctx context.Context, cfg config.DatabaseConfig) (DataSource, error) {
|
||||
if r == nil {
|
||||
return nil, fmt.Errorf("open datasource: registry is nil")
|
||||
}
|
||||
driver := normalizeDriver(cfg.Driver)
|
||||
factory, ok := r.factories[driver]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("open datasource: unsupported driver %q", cfg.Driver)
|
||||
}
|
||||
ds, err := factory.Open(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ds, nil
|
||||
}
|
||||
|
||||
func normalizeDriver(driver string) string {
|
||||
return strings.ToLower(strings.TrimSpace(driver))
|
||||
}
|
||||
30
internal/platform/timeparse/parse.go
Normal file
30
internal/platform/timeparse/parse.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package timeparse
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/ejr/weatherapi/internal/platform/constants"
|
||||
)
|
||||
|
||||
func ParseTimestamp(raw string) (time.Time, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return time.Time{}, fmt.Errorf("timestamp is required")
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, layout := range constants.SupportedTimestampLayouts {
|
||||
ts, err := time.Parse(layout, raw)
|
||||
if err == nil {
|
||||
return ts, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("invalid timestamp")
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("timestamp must be RFC3339 (example: 2026-03-17T12:30:00Z): %w", lastErr)
|
||||
}
|
||||
29
internal/platform/timeparse/parse_test.go
Normal file
29
internal/platform/timeparse/parse_test.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package timeparse
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseTimestamp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "rfc3339", input: "2026-03-17T12:30:00Z", wantErr: false},
|
||||
{name: "rfc3339nano", input: "2026-03-17T12:30:00.123456789Z", wantErr: false},
|
||||
{name: "missing timezone", input: "2026-03-17T12:30:00", wantErr: true},
|
||||
{name: "invalid", input: "not-a-time", wantErr: true},
|
||||
{name: "empty", input: "", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := ParseTimestamp(tc.input)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user