commit b08e71b3bbd6c23d4fbc8dccea1ce7e0825ab460 Author: Bakhtiyar Issakhmetov Date: Thu Jun 4 19:26:38 2026 +0500 First checkpoint diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..eb46b77 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +PORT: 8080 +DB_URL: postgres://gis:gis@postgres:5432/gis?sslmode=disable +S3_ENDPOINT: minio:9000 +S3_ACCESS_KEY: minioadmin +S3_SECRET_KEY: minioadmin +S3_BUCKET: geofiles +S3_USE_SSL: "false" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4cae3e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +.claude \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..9d4d586 --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,17 @@ + + + + + postgresql + true + org.postgresql.Driver + jdbc:postgresql://localhost:5432/gis + + + + + + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/gis.iml b/.idea/gis.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/gis.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..d65149d --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml new file mode 100644 index 0000000..87796fb --- /dev/null +++ b/.idea/sqldialects.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a777360 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM golang:1.26.1-alpine AS builder + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o /gis . + +FROM alpine:3.20 + +RUN apk add --no-cache ca-certificates tzdata + +WORKDIR /app +COPY --from=builder /gis . + +EXPOSE 8080 + +ENTRYPOINT ["/app/gis"] +CMD ["serve"] diff --git a/app/config.go b/app/config.go new file mode 100644 index 0000000..0297d53 --- /dev/null +++ b/app/config.go @@ -0,0 +1,34 @@ +package app + +import ( + "log" + + "github.com/caarlos0/env/v11" + "github.com/joho/godotenv" +) + +type Config struct { + Port int `env:"PORT" envDefault:"8080"` + DBURL string `env:"DB_URL"` + + S3Endpoint string `env:"S3_ENDPOINT"` + S3AccessKey string `env:"S3_ACCESS_KEY"` + S3SecretKey string `env:"S3_SECRET_KEY"` + S3Bucket string `env:"S3_BUCKET" envDefault:"geofiles"` + S3UseSSL bool `env:"S3_USE_SSL" envDefault:"false"` +} + +func loadConfig() (*Config, error) { + + if err := godotenv.Load(); err != nil { + log.Println("No .env file found, relying on system env") + } + + cfg := &Config{} + + if err := env.Parse(cfg); err != nil { + return nil, err + } + + return cfg, nil +} diff --git a/app/database.go b/app/database.go new file mode 100644 index 0000000..99b4221 --- /dev/null +++ b/app/database.go @@ -0,0 +1,29 @@ +package app + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type Store struct { + pool *pgxpool.Pool +} + +func newDB(ctx context.Context, cfg *Config) (*Store, error) { + pool, err := pgxpool.New(ctx, cfg.DBURL) + + if err != nil { + return nil, err + } + + if err := pool.Ping(ctx); err != nil { + return nil, err + } + + return &Store{pool: pool}, nil +} + +func (s *Store) closeDB() { + s.pool.Close() +} diff --git a/app/init.go b/app/init.go new file mode 100644 index 0000000..15ccd20 --- /dev/null +++ b/app/init.go @@ -0,0 +1,44 @@ +package app + +import ( + "context" + "log" + + "github.com/go-playground/validator/v10" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/minio/minio-go/v7" +) + +type App struct { + Ctx context.Context + Cfg *Config + Db *pgxpool.Pool + S3 *minio.Client + Validator *validator.Validate +} + +func NewApp(ctx context.Context) *App { + cfg, err := loadConfig() + + if err != nil { + log.Fatal(err) + } + + db, err := newDB(ctx, cfg) + if err != nil { + log.Fatal(err) + } + + s3, err := newS3Client(ctx, cfg) + if err != nil { + log.Fatal(err) + } + + return &App{ + Ctx: ctx, + Cfg: cfg, + Db: db.pool, + S3: s3, + Validator: validator.New(validator.WithRequiredStructEnabled()), + } +} diff --git a/app/storage.go b/app/storage.go new file mode 100644 index 0000000..211d953 --- /dev/null +++ b/app/storage.go @@ -0,0 +1,31 @@ +package app + +import ( + "context" + "fmt" + + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +func newS3Client(ctx context.Context, cfg *Config) (*minio.Client, error) { + client, err := minio.New(cfg.S3Endpoint, &minio.Options{ + Creds: credentials.NewStaticV4(cfg.S3AccessKey, cfg.S3SecretKey, ""), + Secure: cfg.S3UseSSL, + }) + if err != nil { + return nil, fmt.Errorf("s3 client: %w", err) + } + + exists, err := client.BucketExists(ctx, cfg.S3Bucket) + if err != nil { + return nil, fmt.Errorf("s3 bucket check: %w", err) + } + if !exists { + if err := client.MakeBucket(ctx, cfg.S3Bucket, minio.MakeBucketOptions{}); err != nil { + return nil, fmt.Errorf("s3 make bucket: %w", err) + } + } + + return client, nil +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..82f2b5e --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,23 @@ +package cmd + +import ( + "os" + + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "gis", + Short: "Microservices for parsing geo files to geojson", +} + +func Execute() { + err := rootCmd.Execute() + if err != nil { + os.Exit(1) + } +} + +func init() { + // rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.gis.yaml)") +} diff --git a/cmd/serve.go b/cmd/serve.go new file mode 100644 index 0000000..da9fded --- /dev/null +++ b/cmd/serve.go @@ -0,0 +1,72 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "gis/app" + "gis/server" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/spf13/cobra" +) + +// serveCmd represents the serve command +var serveCmd = &cobra.Command{ + Use: "serve", + Short: "Serve HTTP server", + Run: func(cmd *cobra.Command, args []string) { + application := app.NewApp(cmd.Context()) + + srv := &http.Server{ + Addr: fmt.Sprintf(":%d", application.Cfg.Port), + Handler: server.AppRouter(application), + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 120 * time.Second, + WriteTimeout: 120 * time.Second, + IdleTimeout: 60 * time.Second, + } + + idleClosed := make(chan struct{}) + + go func() { + sigint := make(chan os.Signal, 1) + signal.Notify(sigint, os.Interrupt, syscall.SIGTERM) + <-sigint + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + log.Printf("shutdown server error: %v", err) + } + + close(idleClosed) + }() + + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("listen: %s\n", err) + } + + <-idleClosed + }, +} + +func init() { + rootCmd.AddCommand(serveCmd) + + // Here you will define your flags and configuration settings. + + // Cobra supports Persistent Flags which will work for this command + // and all subcommands, e.g.: + // serveCmd.PersistentFlags().String("foo", "", "A help for foo") + + // Cobra supports local flags which will only run when this command + // is called directly, e.g.: + // serveCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..736d5fd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,58 @@ +services: + app: + build: . + ports: + - "8080:8080" + environment: + PORT: 8080 + DB_URL: postgres://gis:gis@postgres:5432/gis?sslmode=disable + S3_ENDPOINT: minio:9000 + S3_ACCESS_KEY: minioadmin + S3_SECRET_KEY: minioadmin + S3_BUCKET: geofiles + S3_USE_SSL: "false" + depends_on: + postgres: + condition: service_healthy + minio: + condition: service_healthy + restart: unless-stopped + + postgres: + image: postgres:17 + environment: + POSTGRES_USER: gis + POSTGRES_PASSWORD: gis + POSTGRES_DB: gis + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U gis -d gis"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + + minio: + image: minio/minio:latest + command: server /data --console-address ":9001" + ports: + - "9000:9000" + - "9001:9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + volumes: + - minio_data:/data + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + +volumes: + postgres_data: + minio_data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6f3ae17 --- /dev/null +++ b/go.mod @@ -0,0 +1,44 @@ +module gis + +go 1.26.1 + +require ( + github.com/caarlos0/env/v11 v11.4.1 + github.com/go-playground/validator/v10 v10.30.3 + github.com/jackc/pgx/v5 v5.10.0 + github.com/joho/godotenv v1.5.1 + github.com/minio/minio-go/v7 v7.2.0 + github.com/spf13/cobra v1.10.2 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // 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/klauspost/compress v1.18.6 // indirect + github.com/klauspost/cpuid/v2 v2.2.11 // indirect + github.com/klauspost/crc32 v1.3.0 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/minio/crc64nvme v1.1.1 // indirect + github.com/minio/md5-simd v1.1.2 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/rs/xid v1.6.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/tinylib/msgp v1.6.1 // indirect + github.com/zeebo/xxh3 v1.1.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + gopkg.in/ini.v1 v1.67.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4d52935 --- /dev/null +++ b/go.sum @@ -0,0 +1,105 @@ +github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw= +github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +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/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= +github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= +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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +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= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= +github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= +github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= +github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= +github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= +github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= +github.com/minio/minio-go/v7 v7.2.0 h1:RCJM0R1XOsRs+A3x3UCaf3ZYbByDaLjFeAi+YCQEPhs= +github.com/minio/minio-go/v7 v7.2.0/go.mod h1:EU9hENAStx/xXduNdrGO5e4X5vk19NtgB+RIPjZO8o0= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +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= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +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/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= +github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +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/ini.v1 v1.67.2 h1:JtOSMb9OuaCZKr7h5D/h6iii14sK0hLbplTc6frx4Ss= +gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..6f89cd1 --- /dev/null +++ b/main.go @@ -0,0 +1,11 @@ +/* +Copyright © 2026 NAME HERE + +*/ +package main + +import "gis/cmd" + +func main() { + cmd.Execute() +} diff --git a/migrations/20260604134433_create_datasets_table.sql b/migrations/20260604134433_create_datasets_table.sql new file mode 100644 index 0000000..2b963fe --- /dev/null +++ b/migrations/20260604134433_create_datasets_table.sql @@ -0,0 +1,11 @@ +-- +goose Up +CREATE TABLE datasets ( + id UUID PRIMARY KEY default gen_random_uuid(), + name varchar(255), + description text, + created_at timestamp default now(), + updated_at timestamp default now() +); + +-- +goose Down +DROP TABLE datasets; diff --git a/migrations/20260604141221_create_categories_table.sql b/migrations/20260604141221_create_categories_table.sql new file mode 100644 index 0000000..d3a8c8b --- /dev/null +++ b/migrations/20260604141221_create_categories_table.sql @@ -0,0 +1,12 @@ +-- +goose Up +CREATE TABLE categories +( + id UUID PRIMARY KEY default gen_random_uuid(), + name varchar(255), + description text, + created_at timestamp default now(), + updated_at timestamp default now() +); + +-- +goose Down +DROP TABLE categories; diff --git a/migrations/20260604141656_create_files_table.sql b/migrations/20260604141656_create_files_table.sql new file mode 100644 index 0000000..45eb4e2 --- /dev/null +++ b/migrations/20260604141656_create_files_table.sql @@ -0,0 +1,22 @@ +-- +goose Up +CREATE TYPE file_type AS ENUM ('vector_with_table', 'vector', 'raster'); +CREATE TYPE file_validation_status AS ENUM ('pending', 'valid', 'failed'); + +CREATE TABLE files ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + filename VARCHAR(255) NOT NULL, + storage_key TEXT NOT NULL, + file_type file_type NOT NULL, + validation_status file_validation_status NOT NULL DEFAULT 'pending', + validation_error TEXT, + kato_column VARCHAR(255), + crs VARCHAR(64), + feature_count INTEGER, + uploaded_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now() +); + +-- +goose Down +DROP TABLE files; +DROP TYPE file_validation_status; +DROP TYPE file_type; diff --git a/server/categories/create.go b/server/categories/create.go new file mode 100644 index 0000000..0d25a93 --- /dev/null +++ b/server/categories/create.go @@ -0,0 +1,38 @@ +package categories + +import ( + "gis/app" + "gis/server/httputil" + "net/http" +) + +type CreateCategoryRequest struct { + Name string `json:"name" validate:"required,max=255"` + Description string `json:"description" validate:"required"` +} + +func createCategoryRoute(application *app.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + req, err := httputil.DecodeJSON[CreateCategoryRequest](w, r) + if err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + if err := application.Validator.Struct(req); err != nil { + httputil.WriteValidationErrors(w, err) + return + } + + _, err = application.Db.Exec(application.Ctx, + "INSERT INTO categories (name, description) VALUES ($1, $2)", + req.Name, req.Description, + ) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) + } +} diff --git a/server/categories/delete.go b/server/categories/delete.go new file mode 100644 index 0000000..c01d561 --- /dev/null +++ b/server/categories/delete.go @@ -0,0 +1,27 @@ +package categories + +import ( + "gis/app" + "net/http" +) + +func deleteCategoryRoute(application *app.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + + tag, err := application.Db.Exec(application.Ctx, + "DELETE FROM categories WHERE id=$1", + id, + ) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + if tag.RowsAffected() == 0 { + w.WriteHeader(http.StatusNotFound) + return + } + + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/server/categories/index.go b/server/categories/index.go new file mode 100644 index 0000000..21bb544 --- /dev/null +++ b/server/categories/index.go @@ -0,0 +1,39 @@ +package categories + +import ( + "encoding/json" + "gis/app" + "net/http" +) + +func listCategoriesRoute(application *app.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + rows, err := application.Db.Query(application.Ctx, "SELECT id, name FROM categories") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + defer rows.Close() + + cats := make([]Category, 0) + for rows.Next() { + var c Category + if err := rows.Scan(&c.ID, &c.Name); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + cats = append(cats, c) + } + if err := rows.Err(); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + if err := json.NewEncoder(w).Encode(map[string][]Category{"data": cats}); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + } +} diff --git a/server/categories/routes.go b/server/categories/routes.go new file mode 100644 index 0000000..26ca5ca --- /dev/null +++ b/server/categories/routes.go @@ -0,0 +1,13 @@ +package categories + +import ( + "gis/app" + "net/http" +) + +func AddCategoriesRoutes(application *app.App, mux *http.ServeMux) { + mux.HandleFunc("GET /categories", listCategoriesRoute(application)) + mux.HandleFunc("POST /categories", createCategoryRoute(application)) + mux.HandleFunc("PUT /categories/{id}", updateCategoryRoute(application)) + mux.HandleFunc("DELETE /categories/{id}", deleteCategoryRoute(application)) +} diff --git a/server/categories/types.go b/server/categories/types.go new file mode 100644 index 0000000..e993faf --- /dev/null +++ b/server/categories/types.go @@ -0,0 +1,6 @@ +package categories + +type Category struct { + ID string `json:"id"` + Name string `json:"name"` +} diff --git a/server/categories/update.go b/server/categories/update.go new file mode 100644 index 0000000..f5f2b81 --- /dev/null +++ b/server/categories/update.go @@ -0,0 +1,44 @@ +package categories + +import ( + "gis/app" + "gis/server/httputil" + "net/http" +) + +type UpdateCategoryRequest struct { + Name string `json:"name" validate:"required,max=255"` + Description string `json:"description" validate:"required"` +} + +func updateCategoryRoute(application *app.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + + req, err := httputil.DecodeJSON[UpdateCategoryRequest](w, r) + if err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + if err := application.Validator.Struct(req); err != nil { + httputil.WriteValidationErrors(w, err) + return + } + + tag, err := application.Db.Exec(application.Ctx, + "UPDATE categories SET name=$1, description=$2, updated_at=now() WHERE id=$3", + req.Name, req.Description, id, + ) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + if tag.RowsAffected() == 0 { + w.WriteHeader(http.StatusNotFound) + return + } + + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/server/datasets/create.go b/server/datasets/create.go new file mode 100644 index 0000000..268886b --- /dev/null +++ b/server/datasets/create.go @@ -0,0 +1,30 @@ +package datasets + +import ( + "gis/app" + "gis/server/httputil" + "net/http" +) + +type CreateDatasetRequest struct { + Name string `json:"name" validate:"required,max=255"` + Description string `json:"description" validate:"required"` +} + +func createDatasetRoute(application *app.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + req, err := httputil.DecodeJSON[CreateDatasetRequest](w, r) + + if err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + if err := application.Validator.Struct(req); err != nil { + httputil.WriteValidationErrors(w, err) + return + } + + w.WriteHeader(http.StatusCreated) + } +} diff --git a/server/datasets/delete.go b/server/datasets/delete.go new file mode 100644 index 0000000..e8b69ff --- /dev/null +++ b/server/datasets/delete.go @@ -0,0 +1,27 @@ +package datasets + +import ( + "gis/app" + "net/http" +) + +func deleteDatasetRoute(application *app.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + + tag, err := application.Db.Exec(application.Ctx, + "DELETE FROM datasets WHERE id=$1", + id, + ) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + if tag.RowsAffected() == 0 { + w.WriteHeader(http.StatusNotFound) + return + } + + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/server/datasets/index.go b/server/datasets/index.go new file mode 100644 index 0000000..4a3ab41 --- /dev/null +++ b/server/datasets/index.go @@ -0,0 +1,45 @@ +package datasets + +import ( + "encoding/json" + "gis/app" + "net/http" +) + +func listDatasetsRoute(application *app.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + rows, err := application.Db.Query(application.Ctx, "select id, name from datasets") + + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + defer rows.Close() + + datasets := make([]Dataset, 0) + + for rows.Next() { + var dataset Dataset + if err := rows.Scan(&dataset.ID, &dataset.Name); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + datasets = append(datasets, dataset) + } + + if err := rows.Err(); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + err = json.NewEncoder(w).Encode(map[string][]Dataset{"data": datasets}) + + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + } +} diff --git a/server/datasets/routes.go b/server/datasets/routes.go new file mode 100644 index 0000000..40f4e54 --- /dev/null +++ b/server/datasets/routes.go @@ -0,0 +1,13 @@ +package datasets + +import ( + "gis/app" + "net/http" +) + +func AddDatasetsRoutes(application *app.App, mux *http.ServeMux) { + mux.HandleFunc("GET /datasets", listDatasetsRoute(application)) + mux.HandleFunc("POST /datasets", createDatasetRoute(application)) + mux.HandleFunc("PUT /datasets/{id}", updateDatasetRoute(application)) + mux.HandleFunc("DELETE /datasets/{id}", deleteDatasetRoute(application)) +} diff --git a/server/datasets/types.go b/server/datasets/types.go new file mode 100644 index 0000000..2920586 --- /dev/null +++ b/server/datasets/types.go @@ -0,0 +1,6 @@ +package datasets + +type Dataset struct { + ID string `json:"id"` + Name string `json:"name"` +} diff --git a/server/datasets/update.go b/server/datasets/update.go new file mode 100644 index 0000000..7a2cb73 --- /dev/null +++ b/server/datasets/update.go @@ -0,0 +1,44 @@ +package datasets + +import ( + "gis/app" + "gis/server/httputil" + "net/http" +) + +type UpdateDatasetRequest struct { + Name string `json:"name" validate:"required,max=255"` + Description string `json:"description" validate:"required"` +} + +func updateDatasetRoute(application *app.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + + req, err := httputil.DecodeJSON[UpdateDatasetRequest](w, r) + if err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + if err := application.Validator.Struct(req); err != nil { + httputil.WriteValidationErrors(w, err) + return + } + + tag, err := application.Db.Exec(application.Ctx, + "UPDATE datasets SET name=$1, description=$2, updated_at=now() WHERE id=$3", + req.Name, req.Description, id, + ) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + if tag.RowsAffected() == 0 { + w.WriteHeader(http.StatusNotFound) + return + } + + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/server/files/delete.go b/server/files/delete.go new file mode 100644 index 0000000..cbf3e5e --- /dev/null +++ b/server/files/delete.go @@ -0,0 +1,49 @@ +package files + +import ( + "errors" + "gis/app" + "gis/server/httputil" + "net/http" + + "github.com/jackc/pgx/v5" + "github.com/minio/minio-go/v7" +) + +func deleteFileRoute(application *app.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("file_id") + + var storageKey string + err := application.Db.QueryRow(r.Context(), + "SELECT storage_key FROM files WHERE id=$1", + id, + ).Scan(&storageKey) + if errors.Is(err, pgx.ErrNoRows) { + httputil.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) + return + } + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + if err := application.S3.RemoveObject( + r.Context(), + application.Cfg.S3Bucket, + storageKey, + minio.RemoveObjectOptions{}, + ); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + _, err = application.Db.Exec(r.Context(), "DELETE FROM files WHERE id=$1", id) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/server/files/get.go b/server/files/get.go new file mode 100644 index 0000000..c3593a5 --- /dev/null +++ b/server/files/get.go @@ -0,0 +1,39 @@ +package files + +import ( + "errors" + "gis/app" + "gis/server/httputil" + "net/http" + + "github.com/jackc/pgx/v5" +) + +func getFileRoute(application *app.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("file_id") + + var gf GeoFile + err := application.Db.QueryRow(r.Context(), + `SELECT id, filename, file_type, validation_status, + validation_error, kato_column, crs, feature_count, + uploaded_at, updated_at + FROM files WHERE id=$1`, + id, + ).Scan( + &gf.ID, &gf.Filename, &gf.FileType, &gf.ValidationStatus, + &gf.ValidationError, &gf.KatoColumn, &gf.CRS, &gf.FeatureCount, + &gf.UploadedAt, &gf.UpdatedAt, + ) + if errors.Is(err, pgx.ErrNoRows) { + httputil.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) + return + } + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + httputil.WriteJSON(w, http.StatusOK, gf) + } +} diff --git a/server/files/routes.go b/server/files/routes.go new file mode 100644 index 0000000..7824642 --- /dev/null +++ b/server/files/routes.go @@ -0,0 +1,12 @@ +package files + +import ( + "gis/app" + "net/http" +) + +func AddFilesRoutes(application *app.App, mux *http.ServeMux) { + mux.HandleFunc("POST /files", uploadFileRoute(application)) + mux.HandleFunc("GET /files/{file_id}", getFileRoute(application)) + mux.HandleFunc("DELETE /files/{file_id}", deleteFileRoute(application)) +} diff --git a/server/files/types.go b/server/files/types.go new file mode 100644 index 0000000..15c1c20 --- /dev/null +++ b/server/files/types.go @@ -0,0 +1,40 @@ +package files + +import "time" + +type FileType string + +const ( + FileTypeVectorWithTable FileType = "vector_with_table" + FileTypeVector FileType = "vector" + FileTypeRaster FileType = "raster" +) + +type ValidationStatus string + +const ( + ValidationStatusPending ValidationStatus = "pending" + ValidationStatusValid ValidationStatus = "valid" + ValidationStatusFailed ValidationStatus = "failed" +) + +type GeoFile struct { + ID string `json:"id"` + Filename string `json:"filename"` + FileType FileType `json:"file_type"` + ValidationStatus ValidationStatus `json:"validation_status"` + ValidationError *string `json:"validation_error"` + KatoColumn *string `json:"kato_column"` + CRS *string `json:"crs"` + FeatureCount *int `json:"feature_count"` + UploadedAt time.Time `json:"uploaded_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +var allowedExtensions = map[string]FileType{ + ".zip": FileTypeVectorWithTable, + ".geojson": FileTypeVectorWithTable, + ".gpkg": FileTypeVectorWithTable, + ".tif": FileTypeRaster, + ".tiff": FileTypeRaster, +} diff --git a/server/files/upload.go b/server/files/upload.go new file mode 100644 index 0000000..efd0c5f --- /dev/null +++ b/server/files/upload.go @@ -0,0 +1,85 @@ +package files + +import ( + "fmt" + "gis/app" + "gis/server/httputil" + "net/http" + "path/filepath" + "strings" + "time" + + "github.com/minio/minio-go/v7" +) + +func uploadFileRoute(application *app.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(64 << 20); err != nil { + httputil.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "request too large or not multipart"}) + return + } + + rawFileType := r.FormValue("file_type") + if rawFileType == "" { + httputil.WriteJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "file_type is required"}) + return + } + ft := FileType(rawFileType) + if ft != FileTypeVectorWithTable && ft != FileTypeVector && ft != FileTypeRaster { + httputil.WriteJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "invalid file_type"}) + return + } + + f, header, err := r.FormFile("file") + if err != nil { + httputil.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "file is required"}) + return + } + defer f.Close() + + ext := strings.ToLower(filepath.Ext(header.Filename)) + if ext == "" { + httputil.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "unsupported file format"}) + return + } + if _, ok := allowedExtensions[ext]; !ok { + httputil.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "unsupported file format"}) + return + } + + storageKey := fmt.Sprintf("%d_%s", time.Now().UnixNano(), header.Filename) + + _, err = application.S3.PutObject( + r.Context(), + application.Cfg.S3Bucket, + storageKey, + f, + header.Size, + minio.PutObjectOptions{ContentType: header.Header.Get("Content-Type")}, + ) + if err != nil { + httputil.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to store file"}) + return + } + + var gf GeoFile + err = application.Db.QueryRow(r.Context(), + `INSERT INTO files (filename, storage_key, file_type) + VALUES ($1, $2, $3) + RETURNING id, filename, file_type, validation_status, + validation_error, kato_column, crs, feature_count, + uploaded_at, updated_at`, + header.Filename, storageKey, ft, + ).Scan( + &gf.ID, &gf.Filename, &gf.FileType, &gf.ValidationStatus, + &gf.ValidationError, &gf.KatoColumn, &gf.CRS, &gf.FeatureCount, + &gf.UploadedAt, &gf.UpdatedAt, + ) + if err != nil { + httputil.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save file record"}) + return + } + + httputil.WriteJSON(w, http.StatusAccepted, gf) + } +} diff --git a/server/helpers.go b/server/helpers.go new file mode 100644 index 0000000..b471ddb --- /dev/null +++ b/server/helpers.go @@ -0,0 +1,18 @@ +package server + +import ( + "gis/server/httputil" + "net/http" +) + +func writeJSON(w http.ResponseWriter, status int, data any) { + httputil.WriteJSON(w, status, data) +} + +func decodeJSON[T any](w http.ResponseWriter, r *http.Request) (T, error) { + return httputil.DecodeJSON[T](w, r) +} + +func writeValidationErrors(w http.ResponseWriter, err error) { + httputil.WriteValidationErrors(w, err) +} diff --git a/server/httputil/httputil.go b/server/httputil/httputil.go new file mode 100644 index 0000000..f42bec5 --- /dev/null +++ b/server/httputil/httputil.go @@ -0,0 +1,62 @@ +package httputil + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/go-playground/validator/v10" +) + +func WriteJSON(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(data) +} + +func DecodeJSON[T any](w http.ResponseWriter, r *http.Request) (T, error) { + var v T + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + + if err := dec.Decode(&v); err != nil { + return v, err + } + return v, nil +} + +func WriteValidationErrors(w http.ResponseWriter, err error) { + var ve validator.ValidationErrors + if !errors.As(err, &ve) { + WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) + return + } + + problems := make(map[string]string, len(ve)) + for _, fe := range ve { + problems[fe.Field()] = messageForTag(fe) + } + WriteJSON(w, http.StatusBadRequest, map[string]any{"errors": problems}) +} + +func messageForTag(fe validator.FieldError) string { + switch fe.Tag() { + case "required": + return "is required" + case "email": + return "must be a valid email address" + case "min": + return fmt.Sprintf("must be at least %s characters", fe.Param()) + case "max": + return fmt.Sprintf("must be at most %s characters", fe.Param()) + case "gte": + return fmt.Sprintf("must be %s or greater", fe.Param()) + case "lte": + return fmt.Sprintf("must be %s or less", fe.Param()) + default: + return "is invalid" + } +} diff --git a/server/router.go b/server/router.go new file mode 100644 index 0000000..f6b6c9f --- /dev/null +++ b/server/router.go @@ -0,0 +1,20 @@ +package server + +import ( + "gis/app" + "gis/server/categories" + "gis/server/datasets" + "gis/server/files" + "net/http" +) + +func AppRouter(application *app.App) http.Handler { + mux := http.NewServeMux() + + mux.Handle("GET /up", upRoute(application)) + datasets.AddDatasetsRoutes(application, mux) + categories.AddCategoriesRoutes(application, mux) + files.AddFilesRoutes(application, mux) + + return mux +} diff --git a/server/up.go b/server/up.go new file mode 100644 index 0000000..81e51b5 --- /dev/null +++ b/server/up.go @@ -0,0 +1,14 @@ +package server + +import ( + "encoding/json" + "gis/app" + "net/http" +) + +func upRoute(application *app.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + } +}