postcode

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 2, 2026 License: MIT Imports: 16 Imported by: 1

README

Nigerian Postcode Go SDK (postcode)

A high-performance, zero-allocation Go client library for Nigeria's National Digital Alphanumeric Postcode system (docs.postcode.gov.ng).

Go Reference Go Version

Features

  • Zero-Allocation Parsing: postcode.Parse() parses and validates 11-character postcodes with 0 heap escapes (~28ns/op).
  • Safe & Idiomatic Go: Fully memory-safe accessors, Go range-over-func iterators (postcode.ParseSeq), standard errors (errors.As), and functional options.
  • Protocol Conformance: Built to match the official NIPOST OpenAPI spec (GET /v1/lookup, GET /v1/search/autocomplete, GET /v1/search/nearby, GET /v1/search/reverse, and POST /v1/assembly/assemble).
  • Resilient Transport: Production-tuned HTTP transport with exponential backoff, full jitter, Retry-After header support, and safe connection draining.
  • Pluggable Observability: Zero external dependencies in the core module, with an optional OpenTelemetry module (github.com/abcubed3/postcode/otelpostcode).

Installation

Requires Go 1.27+:

go get github.com/abcubed3/postcode

Quickstart

package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"time"

	"github.com/abcubed3/postcode"
)

func main() {
	ctx := context.Background()

	// 1. High-throughput client with functional options
	client, err := postcode.NewClient()
	if err != nil {
		log.Fatal(err)
	}

	// 2. Graded postcode lookup (Level 1 is free, Level 2-3 require commercial credits)
	res, err := client.Lookup(ctx, "EK 01 A03 FK 01", postcode.Level1)
	if err != nil {
		var apiErr *postcode.APIError
		if errors.As(err, &apiErr) && apiErr.IsInsufficientCredits() {
			log.Fatalf("Top up required: %v", apiErr)
		}
		log.Fatalf("Lookup failed: %v", err)
	}
	fmt.Printf("Postcode: %s, Valid: %t\n", res.Postcode, res.Valid)

	// 3. Segment-aware autocomplete
	suggs, err := client.Autocomplete(ctx, "EK 01 A")
	if err != nil {
		log.Fatalf("Autocomplete failed: %v", err)
	}
	fmt.Printf("Active segment: %s, Suggestions: %+v\n", suggs.Segment, suggs.Suggestions)

	// 4. Batch streaming parse 
	records := []string{"EK 01 A03 FK 01", "INVALID_CODE", "FC 02 A09 DB 09"}
	for p, err := range postcode.ParseSeq(records) {
		if err != nil {
			fmt.Printf("Skipping invalid entry: %v\n", err)
			continue
		}
		fmt.Printf("Parsed: %s (State: %s, LGA: %s, Unit: %s)\n",
			p.Formatted(), p.State(), p.LGA(), p.BuildingUnit())
	}
}

API Surface

Method Endpoint Description
client.Lookup(ctx, code, level) GET /v1/lookup Graded postcode lookup (Levels 1–5).
client.Autocomplete(ctx, q) GET /v1/search/autocomplete Segment-aware suggestions for partial input.
client.Nearby(ctx, params) GET /v1/search/nearby Units within a radius (default 300m) of a coordinate.
client.Reverse(ctx, params) GET /v1/search/reverse Snaps coordinate to nearest active unit (default 25m).
client.Assemble(ctx, segs) POST /v1/assembly/assemble Assembles 5 segments into canonical format.
client.Disassemble(ctx, code) GET /v1/assembly/disassemble Decomposes code into its 5 administrative segments.

Live Server Simulator

For local development and automated CI testing without incurring API fees or requiring live network access, the SDK includes an in-memory mock server pre-loaded with all official test postcodes from docs.postcode.gov.ng:

1. Run as a Standalone Server
# Start on localhost:8080 (or specify -port 8080)
go run ./cmd/simulator

Test with curl:

# L1 Public validity lookup (Free, no auth)
curl "http://localhost:8080/v1/lookup?code=EK-01-A03-FK-01&level=1"

# L3 Commercial lookup (Requires X-API-Key header)
curl "http://localhost:8080/v1/lookup?code=EK-01-A03-FK-01&level=3" \
  -H "X-API-Key: nipost_live_test"
2. Embed Directly in Go Unit & Integration Tests
import "github.com/abcubed3/postcode/simulator"

func TestMyService(t *testing.T) {
    srv := simulator.NewServer() // Starts httptest.Server
    defer srv.Close()

    client, err := postcode.NewClient(
        postcode.WithBaseURL(srv.URL),
        postcode.WithAPIKey("test_key"),
    )
    // Run your application tests against srv.URL
}
3. Preloaded Test Postcodes
Postcode State Locality / Address
EK-01-A03-FK-01 Ekiti NTA Road, Back of Fabian Hotel, Ado Ekiti
AK-11-I61-ZF-12 Akwa Ibom 12 Oron Road, Uyo
AK-11-H40-WD-11 Akwa Ibom 11 Wellington Bassey Way, Uyo
BA-02-M67-BL-69 Bauchi 69 Bank Road, GRA, Bauchi
BA-02-E99-NE-30 Bauchi 30 Ahmadu Bello Way, Bauchi
EB-13-G95-FR-90 Ebonyi 90 Ogoja Road, Abakaliki
EB-13-I97-AB-30 Ebonyi 30 Water Works Road, Abakaliki
EN-05-V19-CD-22 Enugu 22 Chime Avenue, New Haven, Enugu
EN-05-V19-FT-20 Enugu 20 Ogui Road, Enugu
FC-03-B06-AG-12 FCT 12 Shehu Shagari Way, Garki, Abuja
FC-02-B19-RT-30 FCT 30 Gado Nasko Way, Phase 4, Kubwa, Abuja
JI-24-O18-JP-23 Jigawa 23 Sani Abacha Way, Dutse
JI-24-N11-VM-58 Jigawa 58 Kano-Dutse Expressway, Dutse
KN-31-F82-WJ-80 Kano 80 Badu Road, Bompai, Kano
KN-31-D78-IQ-38 Kano 38 Ibrahim Taiwo Road, Kano
LA-11-W06-TC-10 Lagos 10 Obafemi Awolowo Way, Ikeja, Lagos
LA-11-U34-ZR-63 Lagos 63 Isaac John Street, GRA Ikeja, Lagos
NI-09-J67-QC-65 Niger 65 Bosso Road, Minna
NI-09-A75-DA-10 Niger 10 Paida Road, Minna
OG-14-T18-BN-16 Ogun 16 Lalubu Street, Oke-Ilewo, Abeokuta
OG-14-M82-QA-09 Ogun 9 Quarry Road, Abeokuta

Rate Limiting & Resilient Retries

As specified in the NIPOST Authentication Documentation, all authenticated gateway requests are rate-limited per key. The gateway returns quota headers on every response:

X-RateLimit-Limit: 600
X-RateLimit-Remaining: 597

When quota is exceeded, the gateway returns 429 Too Many Requests with a cooldown duration.

Intelligent 429 Handling
  • Automatic Cooldown Synchronization: On receiving HTTP 429, the transport extracts cooldown intervals from Retry-After (integer seconds or RFC1123 HTTP dates) and X-RateLimit-Reset, adding randomized jitter to avoid thundering herds when the window resets.
  • Fail-Fast Defense (MaxRateLimitDelay): If the gateway requests a cooldown longer than the configured threshold (default: 10s), the client aborts immediately, preventing goroutine hangs and wasted retries.
  • Live Quota Introspection: Call client.RateLimit() at any time to inspect Remaining, Limit, and ResetAt in a thread-safe manner without making extra network calls.
  • Rich Error Context: apiErr.IsRateLimit() returns true on 429 errors, exposing apiErr.RetryAfter and apiErr.RateLimit.
// 1. Fine-tune rate limit retries
client, err := postcode.NewClient(
    postcode.WithAPIKey("nipost_live_..."),
    postcode.WithRateLimitRetry(true, 5*time.Second), // Max wait threshold: 5s
)

// 2. Inspect rate limit quota
if rl := client.RateLimit(); rl != nil {
    fmt.Printf("Quota remaining: %d/%d (resets at %v)\n", rl.Remaining, rl.Limit, rl.ResetAt)
}

// 3. Structured error handling
res, err := client.Lookup(ctx, "EK-01-A03-FK-01", postcode.Level2)
if err != nil {
    var apiErr *postcode.APIError
    if errors.As(err, &apiErr) && apiErr.IsRateLimit() {
        log.Printf("Rate limit exceeded. Try again in %v", apiErr.RetryAfter)
    }
}

OpenTelemetry Integration

Install the telemetry adapter submodule:

go get github.com/abcubed3/postcode/otelpostcode

Hook it into your client:

package main

import (
	"context"
	"log"

	"github.com/abcubed3/postcode"
	"github.com/abcubed3/postcode/otelpostcode"
)

func main() {
	// Initialize tracing and metrics from global OTel providers
	tel, err := otelpostcode.NewTelemetry()
	if err != nil {
		log.Fatal(err)
	}

	client, err := postcode.NewClient(
		postcode.WithAPIKey("YOUR_API_KEY"),
		postcode.WithTelemetry(tel),
	)
	if err != nil {
		log.Fatal(err)
	}

	_, _ = client.Lookup(context.Background(), "EK 01 A03 FK 01", postcode.Level2)
}

Benchmarks

Microbenchmarks measured on darwin/arm64 (Apple M2 Max) using go test -bench=. -benchmem:

BenchmarkParse-12          38183524        28.20 ns/op          0 B/op        0 allocs/op
BenchmarkFormatted-12     195602528         6.15 ns/op          0 B/op        0 allocs/op
  • postcode.Parse: Fully validates, normalizes, and extracts segments with 0 B/op and 0 allocs/op in ~28 ns/op.
  • postcode.Formatted: Formats canonical hyphenated postcodes (AA-99-H77-BB-55) with 0 B/op and 0 allocs/op in ~6 ns/op.

Documentation

Index

Constants

View Source
const (
	DefaultBaseURL = "https://api.postcode.gov.ng"
)

Variables

View Source
var (
	ErrInvalidLength = errors.New("postcode: invalid length, must be exactly 11 alphanumeric characters")
	ErrInvalidFormat = errors.New("postcode: invalid format structure")
)

Functions

func BatchParseSeq

func BatchParseSeq(inputs []string) iter.Seq2[int, Result[Postcode]]

BatchParseSeq yields an iter.Seq2 iterator with slice index and Result wrapper.

func ParseSeq

func ParseSeq(inputs []string) iter.Seq2[Postcode, error]

ParseSeq streams parsed postcodes as (Postcode, error) pairs using Go iterators.

Types

type APIError

type APIError struct {
	StatusCode int            `json:"-"`
	Code       string         `json:"code"`
	Message    string         `json:"message"`
	RateLimit  *RateLimitInfo `json:"-"`
	RetryAfter time.Duration  `json:"-"`
}

APIError represents an error returned by the NIPOST Postcode API gateway.

func (*APIError) Error

func (e *APIError) Error() string

func (*APIError) IsInsufficientCredits

func (e *APIError) IsInsufficientCredits() bool

func (*APIError) IsNotFound

func (e *APIError) IsNotFound() bool

func (*APIError) IsRateLimit

func (e *APIError) IsRateLimit() bool

func (*APIError) IsUnauthorized

func (e *APIError) IsUnauthorized() bool

func (*APIError) UnmarshalJSON

func (e *APIError) UnmarshalJSON(data []byte) error

UnmarshalJSON handles both the official nested envelope {"error": {"code": ..., "message": ...}} and flat payloads {"code": ..., "message": ...}.

type AdministrativeAddress

type AdministrativeAddress struct {
	State        string `json:"state,omitempty"`
	StateName    string `json:"state_name,omitempty"`
	LGA          string `json:"lga,omitempty"`
	LGAName      string `json:"lga_name,omitempty"`
	District     string `json:"district,omitempty"`
	DistrictName string `json:"district_name,omitempty"`
	Area         string `json:"area,omitempty"`
	AreaName     string `json:"area_name,omitempty"`
	Unit         string `json:"unit,omitempty"`
	Zone         string `json:"zone,omitempty"`
	LocalityName string `json:"locality_name,omitempty"`
}

AdministrativeAddress details administrative boundaries up to area level (L2+).

type AssembledPostcode

type AssembledPostcode struct {
	Postcode string `json:"postcode"` // Canonical: "EK-01-A03-FK-01"
	Display  string `json:"display"`  // Spaced: "EK 01 A03 FK 01"
	Compact  string `json:"compact"`  // Compact: "EK01A03FK01"
}

AssembledPostcode represents the output of POST /v1/assembly/assemble.

type AutocompleteResponse

type AutocompleteResponse struct {
	Segment     string                   `json:"segment"`
	Suggestions []AutocompleteSuggestion `json:"suggestions"`
}

AutocompleteResponse contains suggestions for the currently active segment.

type AutocompleteSuggestion

type AutocompleteSuggestion struct {
	Code  string `json:"code"`
	Label string `json:"label"`
}

AutocompleteSuggestion represents an individual suggestion returned by autocomplete.

type Client

type Client struct {
	// contains filtered or unexported fields
}

func NewClient

func NewClient(opts ...ClientOption) (*Client, error)

NewClient instantiates a production-tuned HTTP client.

func (*Client) Assemble

func (c *Client) Assemble(ctx context.Context, segs Segments) (*AssembledPostcode, error)

Assemble builds canonical, display, and compact postcodes from individual segments.

func (*Client) Autocomplete

func (c *Client) Autocomplete(ctx context.Context, query string) (*AutocompleteResponse, error)

Autocomplete retrieves segment-aware suggestions for a partial postcode input (e.g. "EK 01 A").

func (*Client) Disassemble

func (c *Client) Disassemble(ctx context.Context, code string) (*Segments, error)

Disassemble decomposes a postcode string into its 5 structural segments.

func (*Client) Lookup

func (c *Client) Lookup(ctx context.Context, code string, level LookupLevel) (*LookupResponse, error)

Lookup queries the gateway for detailed attributes of a postcode. Level defaults to Level1 (free) if 0 or unspecified.

func (*Client) Nearby

func (c *Client) Nearby(ctx context.Context, params NearbyParams) (*NearbyResponse, error)

Nearby searches for active postcode units within a radius (default 300m) of a coordinate.

func (*Client) RateLimit

func (c *Client) RateLimit() *RateLimitInfo

RateLimit returns the most recent rate limit quotas reported by the NIPOST gateway, or nil if no request has yet returned rate limit headers.

func (*Client) Reverse

func (c *Client) Reverse(ctx context.Context, params ReverseParams) (*ReverseResponse, error)

Reverse resolves a geographic coordinate to the nearest active postcode unit.

type ClientOption

type ClientOption func(*Client)

func WithAPIKey

func WithAPIKey(key string) ClientOption

WithAPIKey sets the NIPOST API key sent in the X-API-Key header. An apiKey is optional for public endpoints (L1 lookup, search, assembly).

func WithBaseTransport

func WithBaseTransport(rt http.RoundTripper) ClientOption

WithBaseTransport sets the underlying round tripper underneath the retry middleware.

func WithBaseURL

func WithBaseURL(customURL string) ClientOption

WithBaseURL overrides the default gateway URL (e.g. for testing with httptest.Server).

func WithRateLimitRetry

func WithRateLimitRetry(enabled bool, maxDelay time.Duration) ClientOption

WithRateLimitRetry configures automatic retry on 429 Too Many Requests responses. If enabled is false, 429 responses will not be retried. maxDelay sets the maximum duration the client is willing to sleep for a rate limit cooldown.

func WithRetryConfig

func WithRetryConfig(cfg RetryConfig) ClientOption

WithRetryConfig configures backoff, jitter, and retry limits.

func WithTelemetry

func WithTelemetry(t Telemetry) ClientOption

WithTelemetry registers custom observability hooks.

func WithTimeout

func WithTimeout(d time.Duration) ClientOption

WithTimeout sets the overall client request timeout.

func WithTransport

func WithTransport(rt http.RoundTripper) ClientOption

WithTransport explicitly overrides the entire round tripper stack (bypassing retries).

type LookupLevel

type LookupLevel int

LookupLevel specifies the depth of attributes returned by a lookup request. Levels 1 is free; Levels 2-4 require commercial credits; Level 5 is restricted.

const (
	LevelDefault LookupLevel = 0 // Default to level 1
	Level1       LookupLevel = 1 // Free: Administrative boundary
	Level2       LookupLevel = 2 // Commercial: Street and locality names
	Level3       LookupLevel = 3 // Commercial: Building unit metadata
	Level4       LookupLevel = 4 // Commercial: Additional building use info
	Level5       LookupLevel = 5 // Restricted: High-precision point geometry
)

type LookupResponse

type LookupResponse struct {
	Postcode              string                 `json:"postcode"`
	Valid                 bool                   `json:"valid"`
	AdministrativeAddress *AdministrativeAddress `json:"administrative_address,omitempty"`
	RecentHouseAddress    *RecentHouseAddress    `json:"recent_house_address,omitempty"`
	BuildingUseStatus     string                 `json:"building_use_status,omitempty"`
	OtherBuildingInfo     map[string]any         `json:"other_building_info,omitempty"`
	PointGeometry         *PointGeometry         `json:"point_geometry,omitempty"`
}

LookupResponse represents the graded attributes returned by GET /v1/lookup.

type MetricsRecorder

type MetricsRecorder interface {
	RecordDuration(ctx context.Context, method, endpoint string, statusCode int, duration time.Duration)
	AddInflight(ctx context.Context, delta int64)
}

MetricsRecorder defines standard metrics contracts for client operations.

type NearbyParams

type NearbyParams struct {
	Latitude  float64
	Longitude float64
	RadiusM   float64 // Search radius in meters (default 300, max 300)
}

NearbyParams configures location search around a geographic coordinate.

type NearbyResponse

type NearbyResponse struct {
	Results []NearbyUnit `json:"results"`
}

NearbyResponse contains location units within the requested radius.

type NearbyUnit

type NearbyUnit struct {
	Postcode   string  `json:"postcode"`
	Display    string  `json:"display"`
	DistanceM  float64 `json:"distance_m"`
	Confidence string  `json:"confidence"`
	StateName  string  `json:"state_name,omitempty"`
	LGAName    string  `json:"lga_name,omitempty"`
	Address    string  `json:"address,omitempty"`
}

NearbyUnit represents a building unit found near a coordinate.

type PointGeometry

type PointGeometry struct {
	Type        string    `json:"type,omitempty"`
	Coordinates []float64 `json:"coordinates,omitempty"` // [lng, lat]
}

PointGeometry represents GeoJSON point coordinates (L5).

type Postcode

type Postcode struct {
	// contains filtered or unexported fields
}

Postcode represents an immutable, validated 11-byte Nigerian postcode. Backed by a fixed byte array to ensure zero heap escapes.

func Parse

func Parse(raw string) (Postcode, error)

Parse extracts and validates segments with 0 heap allocations.

func (Postcode) AppendText

func (p Postcode) AppendText(b []byte) ([]byte, error)

AppendText implements encoding.TextAppender for zero-alloc formatting. Outputs the spaced format: "AA 99 H77 BB 55".

func (Postcode) Area

func (p Postcode) Area() string

func (Postcode) BuildingUnit

func (p Postcode) BuildingUnit() string

func (Postcode) District

func (p Postcode) District() string

func (Postcode) Formatted

func (p Postcode) Formatted() string

Formatted returns the canonical hyphenated representation (e.g. "EK-01-A03-FK-01").

func (Postcode) IsZero

func (p Postcode) IsZero() bool

func (Postcode) LGA

func (p Postcode) LGA() string

func (Postcode) MarshalText

func (p Postcode) MarshalText() ([]byte, error)

func (Postcode) Raw

func (p Postcode) Raw() string

Raw returns the compact 11-character representation (e.g. "EK01A03FK01").

func (Postcode) State

func (p Postcode) State() string

Safe segment accessors.

func (Postcode) String

func (p Postcode) String() string

String returns the spaced format (e.g. "EK 01 A03 FK 01").

func (*Postcode) UnmarshalText

func (p *Postcode) UnmarshalText(text []byte) error

type RateLimitInfo

type RateLimitInfo struct {
	Limit      int           // Maximum requests allowed in current window (from X-RateLimit-Limit)
	Remaining  int           // Remaining requests in current window (from X-RateLimit-Remaining)
	ResetAt    time.Time     // Timestamp when quota resets (from X-RateLimit-Reset or Retry-After)
	RetryAfter time.Duration // Cooldown duration requested by server (from Retry-After or X-RateLimit-Reset)
}

RateLimitInfo contains the rate-limiting quotas returned by the NIPOST API gateway as documented in https://docs.postcode.gov.ng/authentication.

func ParseRateLimitInfo

func ParseRateLimitInfo(h http.Header) *RateLimitInfo

ParseRateLimitInfo extracts rate limit quotas and cooldown durations from HTTP response headers.

type RecentHouseAddress

type RecentHouseAddress struct {
	Address      string `json:"address,omitempty"`
	Recent       string `json:"recent,omitempty"`
	HouseNumber  string `json:"house_number,omitempty"`
	StreetName   string `json:"street_name,omitempty"`
	LocalityName string `json:"locality_name,omitempty"`
}

RecentHouseAddress contains residential and street metadata (L2+).

type Result

type Result[T any] struct {
	Value T
	Err   error
}

type RetryConfig

type RetryConfig struct {
	MaxRetries        int           // Maximum retry attempts for transient errors (default: 3)
	BaseDelay         time.Duration // Initial exponential backoff delay (default: 100ms)
	MaxDelay          time.Duration // Maximum exponential backoff delay (default: 2s)
	DisableRateLimit  bool          // If true, 429 Too Many Requests will not be retried automatically
	MaxRateLimitDelay time.Duration // Maximum sleep duration client will wait for a 429 cooldown (default: 10s)
}

RetryConfig configures backoff, jitter, and rate-limit handling for HTTP requests.

type RetryTransport

type RetryTransport struct {
	Base   http.RoundTripper
	Config RetryConfig
}

func NewRetryTransport

func NewRetryTransport(base http.RoundTripper, cfg RetryConfig) *RetryTransport

func (*RetryTransport) RoundTrip

func (r *RetryTransport) RoundTrip(req *http.Request) (*http.Response, error)

type ReverseParams

type ReverseParams struct {
	Latitude     float64
	Longitude    float64
	MaxDistanceM float64 // Search radius in meters (default 25, max 250)
}

ReverseParams configures reverse geocoding to snap coordinates to the nearest unit.

type ReverseResponse

type ReverseResponse struct {
	Found      bool        `json:"found"`
	Coordinate []float64   `json:"coordinate"` // [lng, lat]
	Unit       *NearbyUnit `json:"unit,omitempty"`
	Area       string      `json:"area,omitempty"`
	District   string      `json:"district,omitempty"`
	State      string      `json:"state,omitempty"`
	Message    string      `json:"message,omitempty"`
	RadiusM    float64     `json:"radius_m"`
}

ReverseResponse represents the result of reverse geocoding a coordinate.

type Segments

type Segments struct {
	State    string `json:"state"`
	LGA      string `json:"lga"`
	District string `json:"district"`
	Area     string `json:"area"`
	Unit     string `json:"unit"`
}

Segments represents the 5 administrative components of a Nigerian postcode.

type Span

type Span interface {
	End()
	RecordError(err error)
	SetAttribute(key, value string)
	SetStatus(code int, msg string)
}

Span encapsulates active span operations without coupling to any trace vendor.

type Telemetry

type Telemetry struct {
	Tracer  Tracer
	Metrics MetricsRecorder
}

Telemetry bundles tracing and metrics hooks.

func NopTelemetry

func NopTelemetry() Telemetry

NopTelemetry provides zero-overhead, no-op telemetry by default.

type Tracer

type Tracer interface {
	Start(ctx context.Context, operation string) (context.Context, Span)
}

Tracer defines the tracing contract for postcode operations.

Directories

Path Synopsis
cmd
simulator command
postcode module
otelpostcode module

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL