README
¶
mapper
kanna-mapper writes the functions between your domain types and the wire types you do not control. You declare their signatures; it writes the bodies, field by field, calling the conversions you list for the types Go cannot convert on its own.
Part of the kanna family of code generators. Requires Go 1.26.
Install
go get -tool github.com/go-kanna/mapper/cmd/kanna-mapper
There is no runtime to import: the generated code calls your types, the functions you list, and fmt.
Use
Declare the functions you want as the methods of an interface marked //kanna:mapper, and list the conversions Go
cannot do by itself in a //kanna:converter list:
package mapper
//go:generate go tool kanna-mapper
import (
"uuid"
"google.golang.org/protobuf/types/known/timestamppb"
"example.com/app/gen/employeev1"
"example.com/app/model"
)
//kanna:mapper
type _ interface {
EmployeeToProto(model.Employee) *employeev1.Employee
EmployeeFromProto(*employeev1.Employee) (model.Employee, error)
}
//kanna:converter
var _ = []any{
uuid.UUID.String,
uuid.Parse,
timestamppb.New,
(*timestamppb.Timestamp).AsTime,
}
With these types on either side:
// package model: yours
type Employee struct {
ID uuid.UUID
FullName string `map:"Name"`
HiredAt time.Time
Address Address
}
// package employeev1: generated by protoc-gen-go
type Employee struct {
Id string
Name string
HiredAt *timestamppb.Timestamp
Address *Address
// ...
}
go generate ./... writes mapper_gen.go next to the declaration:
// Code generated by kanna-mapper. DO NOT EDIT.
// EmployeeToProto maps model.Employee to *employeev1.Employee.
func EmployeeToProto(src model.Employee) *employeev1.Employee {
return &employeev1.Employee{
Id: src.ID.String(),
Name: src.FullName,
HiredAt: timestamppb.New(src.HiredAt),
Address: addressToEmployeev1(src.Address),
}
}
// EmployeeFromProto maps *employeev1.Employee to model.Employee.
func EmployeeFromProto(src *employeev1.Employee) (model.Employee, error) {
if src == nil {
return model.Employee{}, nil
}
v, err := uuid.Parse(src.GetId())
if err != nil {
return model.Employee{}, fmt.Errorf("map model.Employee.ID: %w", err)
}
return model.Employee{
ID: v,
FullName: src.GetName(),
HiredAt: src.GetHiredAt().AsTime(),
Address: addressToModel(src.GetAddress()),
}, nil
}
// addressToEmployeev1 maps model.Address to *employeev1.Address.
func addressToEmployeev1(src model.Address) *employeev1.Address {
// ...
}
The declaration is ordinary Go: the compiler and your editor check every type and function in it, and the imports it
uses need no blank import to stay. The interface is never implemented, which is why it can be called _; any name
works. (uuid is the standard library's, new in Go 1.27; github.com/google/uuid is listed the same way.)
A mapping that cannot be written stops generation, rather than the first request that meets it. Had the wire type
carried a Revision the domain does not have, the domain's Status been an enum of its own, and EmployeeFromProto
been declared without its error:
$ go generate ./...
./mapper.go:15:2: error: EmployeeToProto: no source in model.Employee for employeev1.Employee.Revision
add map:"Revision" to the field of model.Employee that fills it, or leave it zero with //kanna:map Revision=-
./mapper.go:15:2: error: EmployeeToProto: cannot map model.Employee.Status (model.Status) to employeev1.Employee.Status (employeev1.Status)
add a func(model.Status) employeev1.Status to a //kanna:converter list
./mapper.go:16:2: error: EmployeeFromProto has to return an error: ID is converted by uuid.Parse, which can fail
declare it as EmployeeFromProto(*employeev1.Employee) (model.Employee, error)
Declaring functions
Each method of a //kanna:mapper interface is a function kanna-mapper writes, with the name and signature written
there:
- one parameter, the source, and a result that is the destination or
(destination, error); - the source and the destination are struct types or pointers to them, and not generic;
- the parameter's name is used when it has one,
srcotherwise; - the method's doc comment becomes the function's,
//kanna:lines left out; without one, the function gets the sentence above; - a method with an unexported name gives an unexported function, and a name the package already declares is an error.
A package may have several such interfaces; they all go into its one mapper_gen.go. An interface embedding another is
not supported.
Whether a function returns an error is up to its declaration. When a conversion it needs can fail, a declaration without the error stops generation and says which field needs it, so a signature changes only when you change it. A declaration with an error that nothing can produce is fine, and returns nil: an API may expect to fail later.
A struct field whose types make a pair nobody declared, such as Address above, is mapped by an unexported function
kanna-mapper writes for that pair and reuses wherever the pair appears. Declare the pair yourself to call it from
elsewhere or to adjust its fields with //kanna:map. A problem inside such a function is
reported at the declaration that led to it, with the path:
./mapper.go:15:2: error: EmployeeToProto: Address: no source in model.Address for employeev1.Address.Etag
declare AddressToEmployeev1(model.Address) *employeev1.Address in the //kanna:mapper interface and add //kanna:map Etag=-
Conversions
A //kanna:converter list names the functions that convert one type to another when Go cannot:
- a function of any package, by name:
uuid.Parse,timestamppb.New, or one of your own; - a method, as a method expression:
uuid.UUID.String,(*timestamppb.Timestamp).AsTime.
Each takes the source and returns the destination, optionally with an error: func(A) B or func(A) (B, error).
A function is called as such and a method as a method, src.ID.String(). A function literal, a method value, or a
generic function is rejected; give it a name.
A list is a variable of type []any initialized with a composite literal, and its name may be _. Every list in a
package applies to every //kanna:mapper interface of that package. Nothing outside a list is ever called, but a
function of the package that would fit a missing conversion is pointed out in the error.
Two entries of a package's lists for the same pair of types stop generation, used or not, so that adding
uuid.MustParse next to uuid.Parse cannot change which one runs. So does an entry for a pair one of the package's
declared functions already maps.
Sharing a list
A list with a name can be an entry of another list, which then holds everything in it. Write the conversions an application uses everywhere once:
package convert
//kanna:converter
var Defaults = []any{
uuid.UUID.String,
uuid.Parse,
timestamppb.New,
(*timestamppb.Timestamp).AsTime,
}
and use them in each package that maps, such as the handlers of one service:
package billing
//kanna:converter
var _ = []any{
convert.Defaults,
statusToProto,
}
An entry naming a list must name one marked //kanna:converter. A function a shared list brings in has to be callable
from the package using it, so an unexported one is an error there. The same function arriving twice counts once.
When two different functions convert the same pair, the nearer one wins, as with fields promoted through embedding: the
package's own lists and its declared functions first, then the lists they name, then the lists those name. Two at the
same distance are an error. To read a missing timestamp as the zero time.Time in one package, where
(*timestamppb.Timestamp).AsTime would make it the Unix epoch, list a function of your own there; it takes the place of
the AsTime that convert.Defaults brings.
How fields are matched
Each destination field takes the first rule that matches.
| Rule | Example |
|---|---|
the destination field's own tag, or its //kanna:map |
a destination tagged map:"EmployeeName" reads EmployeeName |
| a source field tagged with the destination's name | a source tagged map:"Name" fills Name |
| the same name | Name → Name |
| the same name, any case | ID → Id |
| a promoted field | an embedded struct's field, by exact name |
A destination field with no source is an error, not a silent zero value. A field tagged map:"-" takes no part in
mapping, in either direction. A tag on a promoted field keeps applying: a field excluded or renamed in an embedded
struct is not matched by its name. Neither is a field promoted through an embedded pointer, which would panic to read
when the pointer is nil, or through an embedded field tagged map:"-".
A struct mapped to several types may carry a tag naming a field only some of them have, so a tag naming none of a destination's fields is no error by itself. When a destination field has no source and a source field's tag nearly names it, the error points the tag out.
Where a source declares a getter, as protoc-gen-go writes them, it is read through it, src.GetName(), so that a nil
message reads as empty. A getter that turns a nil pointer into a zero value is passed over for the field itself,
unless its result is already the destination's type, so that a field left unset stays zero rather than being converted
from an empty value.
Fields you cannot tag
The types protoc-gen-go writes are not yours to tag. A //kanna:map line above a method says what its destination
fields read, for that function only:
//kanna:mapper
type _ interface {
// EmployeeToProto leaves Revision and UpdateTime to the server.
//
//kanna:map Revision=- UpdateTime=-
EmployeeToProto(model.Employee) *employeev1.Employee
}
Field=- leaves the destination field zero; Field=Source names the source field it reads, as a tag on the
destination would. An entry naming no field of the destination is an error, with the field it was probably meant to
name. An entry takes the place of the tags for its field, in that function only, so a struct filled from several types
can read each under its own name.
How values are converted
Each field takes the first case that applies.
| Case | Result |
|---|---|
| identical types | assigned as is |
| a declared function or a listed conversion for the pair | that function is called |
| the source is a pointer | dereferenced; nil leaves the destination zero |
| the destination is a pointer | the address of the converted value |
| both are slices | converted element by element; nil stays nil |
| both are maps with the same key type | converted value by value; nil stays nil |
| a lossless Go conversion | dst(v), unless both types are declared with type |
| both are structs, or pointers to them, with exported fields | an unexported function written for the pair |
"Lossless" is meant strictly: int32 → int64 converts, int64 → int32 does not. Neither does int → int32
(int is 64 bits on some platforms), int → uint (sign), int64 → float64 (precision), or a number → string.
Two types declared with type never convert into each other on their own, even when one could be written as the
other. A protobuf enum and a domain enum may both be int32, but on the wire 0 is STATUS_UNSPECIFIED, while in the
domain it is likely a real status; converting the number would quietly change what it means. List a function between
them. A declared type and a predeclared one still convert: model.Email → string is string(src.Email).
A nil source stays nil whenever the destination is a pointer too. That includes a conversion that takes the source
pointer itself, such as (*timestamppb.Timestamp).AsTime filling a *time.Time: it is called only when the source is
set. Filling a time.Time, it is called with whatever the source holds, and decides what nil becomes; AsTime makes it
the Unix epoch. List a function of your own to decide otherwise.
The error of a conversion that fails names the destination field it was for: map model.Employee.ID: invalid uuid.
Protobuf oneofs
A oneof arrives as an interface-typed field whose implementations are single-field wrapper structs, which is how protoc-gen-go writes them. On the domain side it is a set of pointer fields of which at most one is set:
type Change struct { // domain
Version int64
Membership *Membership
Category *Category
}
type Change struct { // wire, as protoc-gen-go wrote it
Version int64
Kind isChange_Kind // *Change_Membership | *Change_Category
}
Each arm is matched to a domain field by the name of its wrapper's field, with the rules above, and converted the way that field would be. Going out, the first set field in the oneof's declaration order fills the arm; none set leaves the oneof nil. Coming back, a type switch on the oneof fills the field for the arm that is there:
if v := src.Membership; v != nil {
dst.Kind = &syncv1.Change_Membership{Membership: membershipToSyncv1(*v)}
} else if v2 := src.Category; v2 != nil {
dst.Kind = &syncv1.Change_Category{Category: categoryToSyncv1(*v2)}
}
The domain field has to be a pointer, slice, or map, because that is what says whether the arm is set. An arm with no
domain field is an error like any other destination field: leave it out with //kanna:map Membership=-, or the whole
oneof with //kanna:map Kind=-.
Types generated with protobuf's Opaque API, which have no exported fields, are not supported.
Flags
kanna-mapper [flags] [packages]
| Flag | Meaning |
|---|---|
-tags <list> |
comma-separated build tags |
-check |
verify the output is up to date instead of writing it, including that no output is left over |
-v |
list the files written, removed, or left unchanged |
-version |
print the version |
The packages default to the one in the current directory, so //go:generate go tool kanna-mapper needs no argument.
A run that succeeds prints nothing; one that finds no //kanna:mapper interface at all warns. A package whose
interfaces have all lost //kanna:mapper has its mapper_gen.go removed; a file of that name without the
generated-code header is never touched.
Example
example maps a domain aggregate onto protobuf-shaped wire types and back, covering each way a field can be
handled: converted with uuid.Parse and (*timestamppb.Timestamp).AsTime listed as they are, through a list shared
between packages, renamed with a tag, excluded with map:"-" and with //kanna:map, mapped through a function written
for a nested pair, and folded into a oneof. CI regenerates it and fails if the output would change.
Development
make test # unit tests
make lint # golangci-lint
make example # regenerate the example, then build and run it
License
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
kanna-mapper
command
Command kanna-mapper generates the functions declared as the methods of interfaces marked //kanna:mapper.
|
Command kanna-mapper generates the functions declared as the methods of interfaces marked //kanna:mapper. |
|
internal
|
|
|
gen
Package gen writes the functions declared as the methods of interfaces marked //kanna:mapper.
|
Package gen writes the functions declared as the methods of interfaces marked //kanna:mapper. |