First checkpoint

This commit is contained in:
Bakhtiyar Issakhmetov 2026-06-04 19:26:38 +05:00
commit b08e71b3bb
43 changed files with 1252 additions and 0 deletions

7
.env.example Normal file
View 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
View File

@ -0,0 +1,2 @@
.env
.claude

8
.idea/.gitignore generated vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,11 @@
/*
Copyright © 2026 NAME HERE <EMAIL ADDRESS>
*/
package main
import "gis/cmd"
func main() {
cmd.Execute()
}

View 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;

View 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;

View 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;

View 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)
}
}

View 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)
}
}

View 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
}
}
}

View 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))
}

View File

@ -0,0 +1,6 @@
package categories
type Category struct {
ID string `json:"id"`
Name string `json:"name"`
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)
}

View 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
View 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
View 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"})
}
}