From e5e0ffedc047f58424fa9580517f425a176b8edf Mon Sep 17 00:00:00 2001 From: Maksim Totmin Date: Tue, 27 Oct 2026 14:35:57 +0700 Subject: [PATCH] feat(pkg): expose a public library facade Another repository can import pkg/addrkit and use the parser, index and resolver without HTTP or SQLite. The package only re-exports domain types and constructors and performs no I/O. --- pkg/addrkit/addrkit.go | 115 ++++++++++++++++++++++++++++++++++++ pkg/addrkit/addrkit_test.go | 46 +++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 pkg/addrkit/addrkit.go create mode 100644 pkg/addrkit/addrkit_test.go diff --git a/pkg/addrkit/addrkit.go b/pkg/addrkit/addrkit.go new file mode 100644 index 0000000..d7080fa --- /dev/null +++ b/pkg/addrkit/addrkit.go @@ -0,0 +1,115 @@ +// Package addrkit is the public library facade of the address normalizer. +// +// The service (cmd/addrkitd) and the CLI use the same domain packages through +// this API. Another project in a different repository can import this package +// directly instead of calling the HTTP service; nothing here does I/O, opens a +// database or depends on the service configuration. +// +// Example: +// +// parser := addrkit.NewParser( +// addrkit.NewDictionary([]addrkit.City{ +// {Name: "Новосибирск", Aliases: []string{"нск"}}, +// }), +// "", +// ) +// parsed := parser.Parse("нск, Ленина 5") +// +// index := addrkit.NewIndex("2026-10-01", entries, addrkit.DefaultOptions()) +// resolver := addrkit.NewResolver(index, nil) +// result := resolver.Resolve(context.Background(), "нск, Ленина 5") +package addrkit + +import ( + "git.totmin.ru/en2zmax/addrkit/internal/domain/address" + "git.totmin.ru/en2zmax/addrkit/internal/domain/match" +) + +// Core value types. +type ( + // City is one entry of the configurable city dictionary. + City = address.City + // ParsedAddress is the structured form of a free-form address. + ParsedAddress = address.ParsedAddress + // Entry is one canonical registry row. + Entry = address.Entry + // Status is the machine-readable outcome of a resolution. + Status = address.Status +) + +// Resolution statuses. +const ( + StatusFound = address.StatusFound + StatusPartial = address.StatusPartial + StatusAmbiguous = address.StatusAmbiguous + StatusNotFound = address.StatusNotFound +) + +// Dictionary is the immutable vocabulary used by the parser. +type Dictionary = address.Dictionary + +// NewDictionary builds a dictionary with the built-in vocabulary and cities. +func NewDictionary(cities []City) *Dictionary { + return address.NewDictionary(cities) +} + +// Parser turns free-form address strings into structured addresses. +type Parser = address.Parser + +// NewParser builds a parser. defaultCity may be empty. +func NewParser(dict *Dictionary, defaultCity string) *Parser { + return address.NewParser(dict, defaultCity) +} + +// Parse uses the shared city-less parser. +func Parse(raw string) ParsedAddress { + return address.Parse(raw) +} + +// Matching and resolution. +type ( + // Options tunes the index. + Options = match.Options + // Candidate is one plausible match. + Candidate = match.Candidate + // Result is the outcome of a resolution. + Result = match.Result + // Rewriter is the optional query rewriter (for example an LLM). + Rewriter = match.Rewriter + // Index is an immutable in-memory registry index. + Index = match.Index + // Resolver resolves parsed addresses against an index. + Resolver = match.Resolver +) + +// ResolverOptions tunes optional resolver behaviour. +type ResolverOptions struct { + // Parser defaults to a city-less parser. + Parser *Parser + // OnRewriteError observes failures of the optional rewriter. The resolver + // always keeps the deterministic result. + OnRewriteError func(error) +} + +// DefaultOptions returns the conservative matcher defaults. +func DefaultOptions() Options { + return match.DefaultOptions() +} + +// NewIndex builds an index from canonical entries. +func NewIndex(version string, entries []Entry, opts Options) *Index { + return match.NewIndex(version, entries, opts) +} + +// NewResolver builds a resolver with the default parser. +func NewResolver(index *Index, rewriter Rewriter) *Resolver { + return match.NewResolver(index, rewriter) +} + +// NewResolverWithOptions builds a resolver with explicit options. +func NewResolverWithOptions(index *Index, rewriter Rewriter, opts ResolverOptions) *Resolver { + return match.NewResolverWithOptions(index, rewriter, match.ResolverOptions{ + Parser: opts.Parser, + OnRewriteError: opts.OnRewriteError, + }) +} diff --git a/pkg/addrkit/addrkit_test.go b/pkg/addrkit/addrkit_test.go new file mode 100644 index 0000000..d432990 --- /dev/null +++ b/pkg/addrkit/addrkit_test.go @@ -0,0 +1,46 @@ +package addrkit_test + +import ( + "context" + "testing" + + "git.totmin.ru/en2zmax/addrkit/pkg/addrkit" +) + +// TestPublicAPI exercises the facade exactly the way another repository would: +// parse a city-aware query and resolve it against an in-memory index. +func TestPublicAPI(t *testing.T) { + dict := addrkit.NewDictionary([]addrkit.City{ + {Name: "Новосибирск", Aliases: []string{"нск"}}, + {Name: "Бердск"}, + }) + parser := addrkit.NewParser(dict, "") + + entries := make([]addrkit.Entry, 0, 2) + for _, city := range []string{"Новосибирск", "Бердск"} { + p := parser.ParseInCity("Ленина улица 5", city) + entries = append(entries, addrkit.Entry{ + ID: city, Address: "Ленина улица 5", + City: p.City, CityKey: p.CityKey, + Ordinal: p.Ordinal, NameKey: p.NameKey, NameDisplay: p.NameDisplay, + StreetType: p.StreetType, StreetKey: p.StreetKey, + House: p.House, + }) + } + + resolver := addrkit.NewResolverWithOptions( + addrkit.NewIndex("2026-10-01", entries, addrkit.DefaultOptions()), + nil, + addrkit.ResolverOptions{Parser: parser}, + ) + + res := resolver.ResolveInCity(context.Background(), "нск, Ленина 5", "") + if res.Status != addrkit.StatusFound || res.City != "Новосибирск" { + t.Fatalf("result = %+v", res) + } + + ambiguous := resolver.Resolve(context.Background(), "Ленина 5") + if ambiguous.Status != addrkit.StatusAmbiguous { + t.Fatalf("cityless result = %+v", ambiguous) + } +}