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#

  1. CLI Command Structure
  2. GeoServer Client Patterns
  3. Common Utilities
  4. Testing Patterns
  5. 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:

MethodUse CaseExample
getJSON()GET request, expect JSON responseList, Get operations
doJSON()POST/PUT/DELETE with JSON body and responseCreate, 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#

  1. One test per exported function
  2. Use table-driven tests for multiple scenarios
  3. 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...

Code Comments#

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 ./...