Skip to content

Latest commit

 

History

History
462 lines (362 loc) · 14.3 KB

File metadata and controls

462 lines (362 loc) · 14.3 KB

How a-kit Works — Architecture & Code Walkthrough

This document explains how a-kit is structured internally, how each command works, and how the key subsystems (parser, generator, templating) fit together.


High-Level Overview

main.go
  └── cmd.Execute()               ← prints banner, delegates to Cobra
        ├── a-kit create          ← scaffold a brand-new project
        ├── a-kit generate        ← (re)generate module code from .proto
        ├── a-kit version         ← print version
        └── a-kit help            ← built-in by Cobra

The tool has two phases:

Phase Command Entry point
Scaffold a-kit create internal/scaffold/
Generate a-kit generate internal/proto/

Entry Point — main.go and cmd/root.go

// main.go
package main

import "github.com/jacksonfernando/a-kit/cmd"

func main() {
    cmd.Execute()
}

cmd.Execute() prints the ASCII banner with the current version, then hands control to Cobra:

// cmd/root.go
func Execute() {
    fmt.Printf(bannerFmt, version.Get())  // print banner
    if err := rootCmd.Execute(); err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
}

The version string comes from internal/version/version.go. It is injected at build time via -ldflags and falls back to debug.ReadBuildInfo() for go install installs:

// internal/version/version.go
var Version = "dev"  // overridden by: -ldflags "-X .../version.Version=v1.2.3"

func Get() string {
    if Version != "dev" {
        return Version
    }
    // fallback: read version embedded by go install
    if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" {
        return info.Main.Version
    }
    return Version
}

Command: a-kit create

File: cmd/create.gointernal/scaffold/scaffold.go

What it does

  1. Validates the project directory doesn't already exist.
  2. Renders all project skeleton files from embedded templates.
  3. Creates empty migration directories with .gitkeep files.
  4. Parses the bundled api/example.proto and runs the full generator on it.

Flow

cmd/create.go
  └── scaffold.Generate(opts)
        ├── render project files from embed.FS templates
        │     (main.go, go.mod, Makefile, Dockerfile, config.yaml, …)
        ├── create migration/ directories
        └── generateExampleModule()
              └── proto.ParseProto(example.proto)
              └── proto.GenerateModule(pf, "example", ...)

Template rendering

All scaffold templates live in internal/scaffold/templates/ and are embedded using Go's embed package:

// internal/scaffold/templates.go
//go:embed templates
var scaffoldTmplFS embed.FS

func mustScaffoldTmpl(name string) string {
    b, _ := scaffoldTmplFS.ReadFile("templates/" + name)
    return string(b)
}

projectFiles() returns a map of output path → template string. Each template receives a templateData struct:

type templateData struct {
    ProjectName string  // "my-service"
    ModuleName  string  // "github.com/myorg/my-service"
    PackageName string  // "myservice"  (lowercase, no hyphens)
}

Templates use Go's text/template syntax, e.g. {{.ModuleName}}, {{.PackageName}}.

After writing all files, the example module is generated by calling directly into the proto generator — the same code path as a-kit generate:

func generateExampleModule(projectDir, modulePath string) error {
    content, _ := os.ReadFile(filepath.Join(projectDir, "api", "example.proto"))
    pf, _      := proto.ParseProto(string(content))
    return proto.GenerateModule(pf, "example", modulePath, projectDir)
}

Command: a-kit generate

File: cmd/generate.gointernal/proto/

What it does

Reads .proto files from api/, parses them, and writes Go source files for each module.

Flow

cmd/generate.go
  ├── resolve which .proto files to process
  │     (all api/*.proto, or a specific one if a module name is given)
  ├── proto.ParseProto(content)       → *ProtoFile
  └── proto.GenerateModule(pf, ...)   → writes Go files to disk

If a specific module name is given (a-kit generate order), only api/order.proto is processed. Otherwise every api/*.proto file is regenerated:

if len(args) == 1 {
    protoFiles = []string{filepath.Join(apiDir, args[0]+".proto")}
}

if len(protoFiles) == 0 {
    entries, _ := os.ReadDir(apiDir)
    for _, e := range entries {
        if !e.IsDir() && strings.HasSuffix(e.Name(), ".proto") {
            protoFiles = append(protoFiles, filepath.Join(apiDir, e.Name()))
        }
    }
}

The Parser — internal/proto/parser.go

Design: frame-based depth tracking

The parser does not use protoc. It reads the .proto text line by line with a stack of frames that track the current nesting context.

type frame struct {
    kind      string // "service" | "message" | "rpc_options" | "http_option"
    svcIdx    int    // index into pf.Services
    msgIdx    int    // index into pf.Messages
    rpcIdx    int    // index into svc.RPCs
    pushDepth int    // brace depth when this frame was opened
}

Depth is tracked by counting { and } characters per line. Frames are popped when depth <= frame.pushDepth.

Processing order per line

1. Count { opens and } closes on the line
2. Capture top-of-stack frame (tf) BEFORE any changes
3. Process content at the CURRENT depth using a switch statement
4. Update depth: depth += opens − closes
5. Pop any frames now closed (depth ≤ frame.pushDepth)

Content is processed before the depth changes. This means when we see:

service ExampleService {    // depth 0 → opens frame, processed at depth 0
    rpc ListExamples(...) { // depth 1 → processed at depth 1 (inside service)
        option (...) = {    // depth 2 → processed at depth 2 (inside rpc_options)
            get: "/v1/..."  // depth 3 → processed at depth 3 (inside http_option)
        }
    }
}

Key switch cases

switch {
// depth 0: top-level declarations
case depth == 0:
    // matches: package, service, message

// depth 1, inside a service block
case depth == 1 && tf.kind == "service":
    // matches: rpc declarations (with optional inline annotation)

// depth 2, inside an rpc { ... } block
case depth == 2 && tf.kind == "rpc_options":
    // matches: option (google.api.http) = { ... }
    // also handles single-line form: { get: "/v1/..." }

// depth 3, inside the google.api.http block
case depth == 3 && tf.kind == "http_option":
    // matches: get/post/put/patch/delete: "path", body: "*"

// depth 1, inside a message block
case depth == 1 && tf.kind == "message":
    // matches: field declarations (repeated, optional, scalar, message types)
}

Single-line option blocks

A common pattern is writing delete/get in a single line:

option (google.api.http) = { delete: "/v1/{name=examples/*}" };

When the parser pushes the http_option frame at depth 2, it also immediately scans the same line for the method and path — because after counting opens/closes the net depth is 0 and the frame will be popped before the next line:

case depth == 2 && tf.kind == "rpc_options":
    if reHTTPOption.MatchString(line) {
        stack = append(stack, frame{kind: "http_option", ...})
        // also scan the same line for inline content
        rpc := &pf.Services[tf.svcIdx].RPCs[tf.rpcIdx]
        if m := reHTTPMethodOpt.FindStringSubmatch(line); m != nil {
            rpc.HTTPMethod = strings.ToUpper(m[1])
            rpc.HTTPPath = m[2]
        }
    }

The Generator — internal/proto/generator.go

Data flow

*ProtoFile  (raw parsed output)
     │
     ▼
buildMessages()          → []MsgInfo     (proto messages → Go struct metadata)
buildGeneratorDataPair() → GeneratorData (one for external RPCs, one for internal)
     │
     ▼
writeFile(tmpl, data)    → renders template → writes .go file

GeneratorData

Every template receives a GeneratorData value:

type GeneratorData struct {
    ModuleName       string    // "order"
    ModulePath       string    // "github.com/myorg/my-service"
    ServiceName      string    // "OrderService"
    PackageName      string    // "order"
    DirPrefix        string    // "" (external) or "internal/" (internal)
    ModuleImportPath string    // full import path for this module's interfaces
    RPCs             []RPCInfo // all RPC methods in this set
    Messages         []MsgInfo // all proto messages
    NeedsEmpty       bool      // true if any RPC response is google.protobuf.Empty
    HasBodyRPCs      bool      // true if any RPC needs a request body
}

External vs. Internal RPCs

The generator splits RPCs into two groups based on the Internal keyword:

target := &extRPCs
if r.Internal {
    target = &intRPCs
}
*target = append(*target, info)
  • External RPCs<module>/handler/http/, <module>/service/, <module>/repository/mysql/, <module>/_mock/
  • Internal RPCs → same structure under internal/<module>/, but no HTTP handler is generated

Route resolution

Routes are resolved in priority order:

if r.HTTPMethod == "" {
    // 1. No annotation → infer from RPC name (Create→POST, List→GET, etc.)
    method, echoPath, pathParams = inferRoute(r.Name, moduleName)
} else if strings.Contains(r.HTTPPath, "{") {
    // 2. Google API style path template → convert to Echo format
    method = r.HTTPMethod
    echoPath, pathParams = googlePathToEcho(r.HTTPPath)
} else {
    // 3. Plain Echo path (e.g. /v1/orders/:id) → extract :params directly
    method = r.HTTPMethod
    echoPath = r.HTTPPath
    for _, seg := range strings.Split(echoPath, "/") {
        if strings.HasPrefix(seg, ":") {
            pathParams = append(pathParams, pathParamFromName(strings.TrimPrefix(seg, ":")))
        }
    }
}

Google API path → Echo path conversion

googlePathToEcho converts path templates like /v1/{name=examples/*} into Echo-compatible paths /v1/examples/:name:

// "/v1/{name=examples/*}"       → "/v1/examples/:name"
// "/v1/{name=examples/*}:search"→ "/v1/examples/:name/search"
// "/v1/{order.name=examples/*}" → "/v1/examples/:name"  (Nested=true)

A custom method suffix (:search, :cancel) outside {} is detected by scanning for : at brace depth 0, then appended as a path segment.

Templates

All Go code templates live in internal/proto/templates/ and are loaded via embed.FS:

//go:embed templates
var protoTmplFS embed.FS

func mustProtoTmpl(name string) string {
    b, _ := protoTmplFS.ReadFile("templates/" + name)
    return string(b)
}
Template file Output
models.tmpl models/<module>_dto.go — request/response structs with json/query/validate tags
interface.tmpl <module>/interface.go — service and repository interfaces
handler.tmpl <module>/handler/http/<module>_handler.go — Echo HTTP handler
handler_test.tmpl <module>/handler/http/<module>_handler_test.go — handler unit tests
service.tmpl <module>/service/<module>_service.go — business logic delegation
service_test.tmpl <module>/service/<module>_service_test.go — service unit tests
repository.tmpl <module>/repository/mysql/<module>_repository.go — GORM stub
mock_service.tmpl <module>/_mock/<module>_service_mock.go — mock for tests
mock_repository.tmpl <module>/_mock/<module>_repository_mock.go — mock for tests

Templates use Go's text/template syntax with a small FuncMap:

funcMap := template.FuncMap{
    "lower":       strings.ToLower,
    "lowerFirst":  lowerFirst,   // "OrderService" → "orderService"
    "methodTitle": ...,          // "GET" → "Get"  (for nethttp.MethodGet)
}

Generated Module Structure

For a proto service OrderService with a mix of public and internal RPCs:

models/
  order_dto.go              ← shared request/response structs

order/                      ← public RPCs
  interface.go
  handler/http/
    order_handler.go
    order_handler_test.go
  service/
    order_service.go
    order_service_test.go
  repository/mysql/
    order_repository.go
  _mock/
    order_service_mock.go
    order_repository_mock.go

internal/order/             ← Internal RPCs (no HTTP handler)
  interface.go
  service/
    order_service.go
    order_service_test.go
  repository/mysql/
    order_repository.go
  _mock/
    order_service_mock.go
    order_repository_mock.go

Adding a New Module

  1. Create api/<module>.proto following the Google API style.
  2. Run a-kit generate <module>.
  3. Wire it up in main.go:
    repo := orderRepository.NewOrderServiceMySQLRepository(db)
    svc  := orderService.NewOrderService(repo)
    orderHTTPHandler.NewOrderServiceHandler(e, svc, mw)
  4. Implement the repository stub and business logic.

Package Map

a-kit/
├── main.go                        entry point
├── cmd/
│   ├── root.go                    Cobra root command, banner, Execute()
│   ├── create.go                  `a-kit create` command
│   ├── generate.go                `a-kit generate` command
│   └── version.go                 `a-kit version` command
├── internal/
│   ├── version/
│   │   └── version.go             ldflags-injectable version string
│   ├── scaffold/
│   │   ├── scaffold.go            project skeleton generator
│   │   ├── templates.go           embed.FS loader for scaffold templates
│   │   └── templates/             22 .tmpl files for project files
│   └── proto/
│       ├── parser.go              .proto text → *ProtoFile (no protoc)
│       ├── generator.go           *ProtoFile → Go source files
│       ├── parser_test.go         table-driven parser unit tests
│       ├── generator_test.go      table-driven generator helper tests
│       └── templates/             9 .tmpl files for module code
└── Makefile                       build / install with ldflags version injection