First checkpoint
This commit is contained in:
commit
b08e71b3bb
7
.env.example
Normal file
7
.env.example
Normal file
@ -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"
|
||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
.env
|
||||
.claude
|
||||
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@ -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
|
||||
17
.idea/dataSources.xml
generated
Normal file
17
.idea/dataSources.xml
generated
Normal file
@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="gis@localhost" uuid="0b44c99f-aa96-4dad-ad42-9bac71e16d4c">
|
||||
<driver-ref>postgresql</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:postgresql://localhost:5432/gis</jdbc-url>
|
||||
<jdbc-additional-properties>
|
||||
<property name="com.intellij.clouds.kubernetes.db.host.port" />
|
||||
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
|
||||
<property name="com.intellij.clouds.kubernetes.db.container.port" />
|
||||
</jdbc-additional-properties>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
||||
9
.idea/gis.iml
generated
Normal file
9
.idea/gis.iml
generated
Normal file
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="Go" enabled="true" />
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/gis.iml" filepath="$PROJECT_DIR$/.idea/gis.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
7
.idea/sqldialects.xml
generated
Normal file
7
.idea/sqldialects.xml
generated
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="SqlDialectMappings">
|
||||
<file url="file://$PROJECT_DIR$/migrations/20260604134433_create_datasets_table.sql" dialect="GenericSQL" />
|
||||
<file url="PROJECT" dialect="PostgreSQL" />
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
21
Dockerfile
Normal file
21
Dockerfile
Normal file
@ -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"]
|
||||
34
app/config.go
Normal file
34
app/config.go
Normal file
@ -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
|
||||
}
|
||||
29
app/database.go
Normal file
29
app/database.go
Normal file
@ -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()
|
||||
}
|
||||
44
app/init.go
Normal file
44
app/init.go
Normal file
@ -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()),
|
||||
}
|
||||
}
|
||||
31
app/storage.go
Normal file
31
app/storage.go
Normal file
@ -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
|
||||
}
|
||||
23
cmd/root.go
Normal file
23
cmd/root.go
Normal file
@ -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)")
|
||||
}
|
||||
72
cmd/serve.go
Normal file
72
cmd/serve.go
Normal file
@ -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")
|
||||
}
|
||||
58
docker-compose.yml
Normal file
58
docker-compose.yml
Normal file
@ -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:
|
||||
44
go.mod
Normal file
44
go.mod
Normal file
@ -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
|
||||
)
|
||||
105
go.sum
Normal file
105
go.sum
Normal file
@ -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=
|
||||
11
main.go
Normal file
11
main.go
Normal file
@ -0,0 +1,11 @@
|
||||
/*
|
||||
Copyright © 2026 NAME HERE <EMAIL ADDRESS>
|
||||
|
||||
*/
|
||||
package main
|
||||
|
||||
import "gis/cmd"
|
||||
|
||||
func main() {
|
||||
cmd.Execute()
|
||||
}
|
||||
11
migrations/20260604134433_create_datasets_table.sql
Normal file
11
migrations/20260604134433_create_datasets_table.sql
Normal file
@ -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;
|
||||
12
migrations/20260604141221_create_categories_table.sql
Normal file
12
migrations/20260604141221_create_categories_table.sql
Normal file
@ -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;
|
||||
22
migrations/20260604141656_create_files_table.sql
Normal file
22
migrations/20260604141656_create_files_table.sql
Normal file
@ -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;
|
||||
38
server/categories/create.go
Normal file
38
server/categories/create.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
27
server/categories/delete.go
Normal file
27
server/categories/delete.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
39
server/categories/index.go
Normal file
39
server/categories/index.go
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
13
server/categories/routes.go
Normal file
13
server/categories/routes.go
Normal file
@ -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))
|
||||
}
|
||||
6
server/categories/types.go
Normal file
6
server/categories/types.go
Normal file
@ -0,0 +1,6 @@
|
||||
package categories
|
||||
|
||||
type Category struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
44
server/categories/update.go
Normal file
44
server/categories/update.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
30
server/datasets/create.go
Normal file
30
server/datasets/create.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
27
server/datasets/delete.go
Normal file
27
server/datasets/delete.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
45
server/datasets/index.go
Normal file
45
server/datasets/index.go
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
13
server/datasets/routes.go
Normal file
13
server/datasets/routes.go
Normal file
@ -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))
|
||||
}
|
||||
6
server/datasets/types.go
Normal file
6
server/datasets/types.go
Normal file
@ -0,0 +1,6 @@
|
||||
package datasets
|
||||
|
||||
type Dataset struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
44
server/datasets/update.go
Normal file
44
server/datasets/update.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
49
server/files/delete.go
Normal file
49
server/files/delete.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
39
server/files/get.go
Normal file
39
server/files/get.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
12
server/files/routes.go
Normal file
12
server/files/routes.go
Normal file
@ -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))
|
||||
}
|
||||
40
server/files/types.go
Normal file
40
server/files/types.go
Normal file
@ -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,
|
||||
}
|
||||
85
server/files/upload.go
Normal file
85
server/files/upload.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
18
server/helpers.go
Normal file
18
server/helpers.go
Normal file
@ -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)
|
||||
}
|
||||
62
server/httputil/httputil.go
Normal file
62
server/httputil/httputil.go
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
20
server/router.go
Normal file
20
server/router.go
Normal file
@ -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
|
||||
}
|
||||
14
server/up.go
Normal file
14
server/up.go
Normal file
@ -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"})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user