GeoServer CLI - Code Style Guide#
This document outlines the coding patterns and conventions used throughout the geoserver-cli project. Follow these guidelines when implementing new features to maintain consistency.
Table of Contents#
- CLI Command Structure
- GeoServer Client Patterns
- Common Utilities
- Testing Patterns
- Documentation
CLI Command Structure#
Command Organization#
Commands are organized by resource type with consistent sub-commands:
1
2
3
4
5
6
7
8
9
| coverage
├── store
│ ├── list
│ ├── get
│ ├── create
│ ├── update
│ └── delete
├── list
└── update
|
Command Function Pattern#
Each command command has a dedicated function:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| func coverageStoreCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "store",
Short: "Manage coverage stores (raster data sources)",
}
cmd.AddCommand(coverageStoreListCmd())
cmd.AddCommand(coverageStoreGetCmd())
cmd.AddCommand(coverageStoreCreateCmd())
cmd.AddCommand(coverageStoreUpdateCmd())
cmd.AddCommand(coverageStoreDeleteCmd())
return cmd
}
func coverageStoreListCmd() *cobra.Command {
var ws string
cmd := &cobra.Command{
Use: "list",
Short: "List coverage stores in a workspace",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
// Implementation
return nil
},
}
cmd.Flags().StringVarP(&ws, "workspace", "w", "", "Workspace name (defaults to GEOSRVCLI_DEFAULT_WORKSPACE)")
return cmd
}
|
Shared Patterns#
Workspace Resolution:
- Always use
resolveWorkspace() helper to handle --workspace flag, env var, and interactive defaults - Example:
workspace, err := resolveWorkspace(cmd, ws)
Boolean Flags:
- Use
StringVar (not BoolVar) to accept “true”/“false” strings - Parse with
parseOptionalBool() helper - Example:
1
2
3
4
5
6
7
8
9
10
| var enabledStr string
cmd.Flags().StringVar(&enabledStr, "enabled", "", "Enable/disable (true/false)")
// In RunE:
if enabledStr != "" {
enabled, err := parseOptionalBool(enabledStr)
if err != nil {
return err
}
// Use *enabled
}
|
Client Creation:
1
2
3
| ctx, cancel := context.WithTimeout(cmd.Context(), settings.Timeout)
defer cancel()
client := geoserver.NewClient(settings.BaseURL, settings.Username, settings.Password, settings.Timeout)
|
Output:
- Use
cmd.OutOrStdout() instead of fmt.Print* for testability - Example:
fmt.Fprintf(cmd.OutOrStdout(), "Coverage store %q created\n", args[0])
GeoServer Client Patterns#
Endpoint Naming#
Endpoints are built using resource names, not full URLs:
1
2
3
4
5
6
| // ✅ Correct
endpoint := fmt.Sprintf("workspaces/%s/coveragestores/%s", workspace, store)
err := c.getJSON(ctx, endpoint, &result)
// ❌ Wrong - don't use full URLs
url := fmt.Sprintf("%s/workspaces/%s/coveragestores/%s", c.baseURL, workspace, store)
|
HTTP Methods#
Use available helper methods:
| Method | Use Case | Example |
|---|
getJSON() | GET request, expect JSON response | List, Get operations |
doJSON() | POST/PUT/DELETE with JSON body and response | Create, Update operations |
doRaw() | Raw bytes (e.g., SLD upload) | Style upload |
Error Handling#
API Errors:
1
2
3
4
5
6
7
| var result struct { Store CoverageStore `json:"coverageStore"` }
if err := c.getJSON(ctx, endpoint, &result); err != nil {
if IsAPIStatus(err, 404) {
return CoverageStore{}, fmt.Errorf("coveragestore %q not found", store)
}
return CoverageStore{}, fmt.Errorf("get coveragestore: %w", err)
}
|
Wrapping Errors:
- Always wrap errors with context:
fmt.Errorf("operation: %w", err) - Provides stack trace and additional context
Type Definitions#
Complex Types:
1
2
3
4
5
6
7
| type CoverageStore struct {
Name string `json:"name"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
URL string `json:"url"`
Description string `json:"description,omitempty"`
}
|
Optional Pointers for Partial Updates:
1
2
3
4
5
| type Coverage struct {
DefaultStyle *StyleRef `json:"defaultStyle,omitempty"`
Enabled *bool `json:"enabled,omitempty"` // *bool for optional updates
Advertised *bool `json:"advertised,omitempty"`
}
|
Testing#
HTTP Test Server Pattern:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| func TestGetCoveragestore(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"coverageStore": map[string]interface{}{
"name": "dem",
"type": "GeoTIFF",
},
})
}))
defer srv.Close()
client := NewClient(srv.URL, "admin", "geoserver", 0)
store, err := client.GetCoveragestore(context.Background(), "ws", "dem")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Assertions
}
|
Common Utilities#
Available Helpers (in internal/cli/)#
workspace resolution:
1
| workspace, err := resolveWorkspace(cmd, wsFlag)
|
Boolean parsing:
1
| enabled, err := parseOptionalBool("true") // Returns *bool
|
Config resolution:
1
| cfgPath := resolveConfigSelector("dev") // "dev" -> "configs/dev.config.toml"
|
Interactive prompts:
1
2
3
4
| ok, err := askYesNo("Continue? [y/N] ")
if !ok {
return fmt.Errorf("aborted")
}
|
Persistence:
1
2
| maybeOfferPersistConfig(cmd, cfgPath) // Persist config choice
maybeOfferPersistDefaultWorkspace(cmd, "myws") // Persist workspace choice
|
Global Settings#
Available in all commands via settings variable:
1
2
3
4
| settings.BaseURL // GeoServer REST base URL
settings.Username // Auth username
settings.Password // Auth password
settings.Timeout // HTTP timeout (duration)
|
Testing Patterns#
Unit Tests Structure#
- One test per exported function
- Use table-driven tests for multiple scenarios
- Test both success and error cases
Example: Coverage Store Tests#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| func TestListCoveragestores(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"coverageStores": []map[string]string{
{"name": "dem"},
{"name": "imagery"},
},
})
}))
defer srv.Close()
client := NewClient(srv.URL, "admin", "geoserver", 0)
stores, err := client.ListCoveragestores(context.Background(), "test_ws")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(stores) != 2 || stores[0] != "dem" || stores[1] != "imagery" {
t.Errorf("expected [dem imagery], got %v", stores)
}
}
|
Pre-commit Checks#
All code must pass:
1
2
3
4
| gofmt -w ./internal ./cmd # Format code
go vet ./... # Vet code
./bin/golangci-lint run ./... # Lint (extensive)
go test ./... # Unit tests
|
Commit with: PATH="./bin:$PATH" git commit
Documentation#
Command Documentation#
Each command should have a markdown file in docs/content/commands/:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
| ---
title: "Coverages"
weight: 8
---
# Coverage Commands
Brief description of the feature.
## Coverage Store Management
### List Coverage Stores
**Command:**
./geoserver-cli coverage store list -w <workspace>
**Example Output:**
dem
satellite_imagery
### Create Coverage Store
**Command:**
./geoserver-cli coverage store create <store> --type <type> --url <path> -w <workspace>
**Supported Types:**
- GeoTIFF
- ImageMosaic
- ArcGrid
- WorldImage
### Common Workflows
Real-world usage examples...
|
Exported Functions:
1
2
3
4
| // ListCoveragestores lists all coveragestores in a workspace.
func (c *Client) ListCoveragestores(ctx context.Context, workspace string) ([]string, error) {
// ...
}
|
Complex Logic:
1
2
3
4
5
6
7
8
| // Fetch current state to detect what needs updating.
current, err := client.GetCoveragestore(ctx, workspace, args[0])
// Apply updates (only include fields that were explicitly set).
update := current
if url != "" {
update.URL = url
}
|
Common Mistakes to Avoid#
❌ Don’t:
- Use full URLs in client methods (use endpoint paths only)
- Use
BoolVar for optional boolean flags (use StringVar + parseOptionalBool) - Print directly to stdout (use
cmd.OutOrStdout()) - Forget to handle error returns (all errors must be checked)
- Create clients without timeout
- Import unused packages
✅ Do:
- Use endpoint paths:
"workspaces/ws/stores/store" - Use string flags for optional bools:
"--enabled true|false" - Use output helpers:
fmt.Fprintf(cmd.OutOrStdout(), ...) - Wrap errors with context:
fmt.Errorf("operation: %w", err) - Always set timeout:
settings.Timeout - Remove unused imports:
go mod tidy
Reusable Components Summary#
For CRUD Operations#
List Template:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| func xyzListCmd() *cobra.Command {
var ws string
cmd := &cobra.Command{
Use: "list",
Short: "List xyz in a workspace",
RunE: func(cmd *cobra.Command, args []string) error {
workspace, err := resolveWorkspace(cmd, ws)
if err != nil { return err }
ctx, cancel := context.WithTimeout(cmd.Context(), settings.Timeout)
defer cancel()
client := geoserver.NewClient(...)
items, err := client.ListXyz(ctx, workspace)
if err != nil { return err }
for _, item := range items {
fmt.Fprintln(cmd.OutOrStdout(), item)
}
return nil
},
}
cmd.Flags().StringVarP(&ws, "workspace", "w", "", "Workspace name")
return cmd
}
|
Get Template:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| func xyzGetCmd() *cobra.Command {
var ws string
cmd := &cobra.Command{
Use: "get <name>",
Short: "Get xyz details",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
workspace, err := resolveWorkspace(cmd, ws)
if err != nil { return err }
ctx, cancel := context.WithTimeout(cmd.Context(), settings.Timeout)
defer cancel()
client := geoserver.NewClient(...)
item, err := client.GetXyz(ctx, workspace, args[0])
if err != nil { return err }
fmt.Fprintf(cmd.OutOrStdout(), "Name: %s\n", item.Name)
return nil
},
}
cmd.Flags().StringVarP(&ws, "workspace", "w", "", "Workspace name")
return cmd
}
|
Quick Reference#
File Organization#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| internal/
├── cli/
│ ├── root.go # Root command, settings, helpers
│ ├── common.go # Shared utilities (resolveWorkspace, etc.)
│ ├── workspace.go # Workspace commands
│ ├── store.go # Datastore commands
│ ├── coverage.go # Coverage (raster) commands
│ ├── layer.go # Layer config commands
│ ├── style.go # Style commands
│ ├── publish.go # Publishing commands
│ └── qgis.go # QGIS export commands
├── geoserver/
│ ├── client.go # HTTP helpers, error types
│ ├── workspace.go # Workspace types & endpoints
│ ├── store.go # Datastore types & endpoints
│ ├── coverage.go # Coverage types & endpoints (NEW)
│ ├── layer.go # Layer types & endpoints
│ ├── style.go # Style types & endpoints
│ ├── capabilities.go # GetCapabilities parsing
│ └── *_test.go # Unit tests
├── config/
│ └── *.go # Config loading & parsing
├── postgis/
│ └── *.go # PostGIS client
└── qgis/
└── *.go # QGIS export utilities
|
Test Running#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| # Run all tests
go test ./...
# Run specific package tests
go test ./internal/geoserver
# Run with coverage
go test -cover ./...
# Run pre-commit checks
gofmt -w ./internal ./cmd
go vet ./...
./bin/golangci-lint run ./...
go test ./...
|