Files
go-omni/cmd/go-omni/apigen/parse.go

729 lines
19 KiB
Go

// Package apigen implements go-omni's deliberately bounded go-zero-style API
// dialect. It generates Omni handlers, never go-zero or transport dependencies.
package apigen
import (
"errors"
"fmt"
"git.yuntuo.hk/k2safe/go-omni/contract"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"text/scanner"
)
type Spec struct {
DocumentCatalog *apierrors.Catalog
DocumentErrors *DocumentErrors
SharedRoots map[string]SharedRoot
SourcePaths map[string]string
RequestPolicy contract.RequestPolicy
DefaultResponse string
appDeclared bool
Title, Version, Service, Description string
Errors []ErrorDefinition
ErrorCatalogs []string
Auth []AuthDefinition
Responses []ResponseDefinition
Scalars []ScalarDefinition
Types []Type
Routes []Route
Sources map[string][]byte // relative to the entry API's directory
Entry string
}
type Type struct {
Name string
Fields []Field
Position string
}
// An empty Type denotes a named field composition, expanded before compilation.
type Field struct{ Name, Type, Tag, Description string }
type Route struct {
BoundMultipart bool
MaxParts int
MaxFieldBytes, MaxFileBytes int64
RequestPolicy contract.RequestPolicy
Manual, CustomBinding bool
BodyMode, Produces string
MaxBodyBytes int64
Method, Path, Request, Response, Handler, Group, Summary string
Middleware []string
Auth string
ResponseFormat string
ErrorResponseFormat string
Errors []string
Description string
Tags []string
Deprecated bool
Status int
Position string
}
type token struct {
text, position string
comment string
kind rune
space bool
}
type parser struct {
tokens []token
at int
spec *Spec
imports []string
}
func Parse(path string) (*Spec, error) { return parseWithCatalogs(path, nil) }
func parseWithCatalogs(path string, catalogs map[string][]byte) (*Spec, error) {
entry, err := filepath.Abs(path)
if err != nil {
return nil, err
}
entry, err = filepath.EvalSymlinks(entry)
if err != nil {
return nil, err
}
root := filepath.Dir(entry)
shared, err := readSharedConfig(root)
if err != nil {
return nil, err
}
roots := shared.Roots
spec := &Spec{Version: "1.0.0", DocumentErrors: shared.Errors, SharedRoots: roots, SourcePaths: map[string]string{}, Sources: map[string][]byte{}, Entry: filepath.Base(entry)}
visiting, visited := map[string]bool{}, map[string]bool{}
total := 0
var visit func(string, string, int) error
visit = func(name, alias string, depth int) error {
if depth > 32 || len(visited)+len(visiting) >= 64 {
return errors.New("api: import limit exceeded")
}
real, err := filepath.EvalSymlinks(name)
if err != nil {
return err
}
sourceRoot := root
if alias != "" {
sourceRoot = roots[alias].Path
}
rel, err := sourceWithin(sourceRoot, real)
if err != nil {
return err
}
if alias != "" {
rel = "@" + alias + "/" + rel
}
if filepath.Ext(real) != ".api" {
return errors.New("api: expected .api file")
}
if visiting[real] {
return fmt.Errorf("api: circular import %s", rel)
}
if visited[real] {
return nil
}
visiting[real] = true
defer delete(visiting, real)
f, err := os.Open(real)
if err != nil {
return err
}
data, err := io.ReadAll(io.LimitReader(f, (1<<20)+1))
_ = f.Close()
if err != nil {
return err
}
total += len(data)
if len(data) > 1<<20 || total > 8<<20 {
return errors.New("api: source size limit exceeded")
}
tokens, err := lex(rel, string(data))
if err != nil {
return err
}
p := &parser{tokens: tokens, spec: spec}
if err = p.parse(); err != nil {
return err
}
spec.Sources[filepath.ToSlash(rel)] = data
spec.SourcePaths[filepath.ToSlash(rel)] = real
for _, imp := range p.imports {
if filepath.IsAbs(imp) || strings.Contains(imp, "\\") {
return errors.New("api: imports must use relative slash paths")
}
nextAlias := alias
next := filepath.Join(filepath.Dir(real), filepath.FromSlash(imp))
if strings.HasPrefix(imp, "@") {
var sub string
var ok bool
nextAlias, sub, ok = strings.Cut(strings.TrimPrefix(imp, "@"), "/")
declared, found := roots[nextAlias]
if !ok || !found || sub == "" || strings.HasPrefix(sub, "/") || strings.HasPrefix(sub, "../") || filepath.ToSlash(filepath.Clean(sub)) != sub {
return errors.New("api: import needs a declared @root and a clean relative path")
}
next = filepath.Join(declared.Path, filepath.FromSlash(sub))
}
if err = visit(next, nextAlias, depth+1); err != nil {
return err
}
}
visited[real] = true
return nil
}
if err = visit(entry, "", 0); err != nil {
return nil, err
}
if err = readDocumentCatalog(root, spec, catalogs); err != nil {
return nil, err
}
if err = validateSharedPins(spec); err != nil {
return nil, err
}
if spec.Service == "" || len(spec.Routes) == 0 {
return nil, errors.New("api: at least one service route required")
}
if spec.Title == "" {
spec.Title = spec.Service
}
return spec, nil
}
func lex(name, source string) ([]token, error) {
var s scanner.Scanner
s.Init(strings.NewReader(source))
s.Filename = name
s.Mode = scanner.ScanIdents | scanner.ScanInts | scanner.ScanStrings | scanner.ScanRawStrings | scanner.ScanComments
s.Whitespace ^= 1 << '\n'
var scanErr error
s.Error = func(s *scanner.Scanner, message string) {
if scanErr == nil {
scanErr = fmt.Errorf("%s: %s", s.Position, message)
}
}
var out []token
end := 0
pending := ""
lastLine := 0
lastToken := -1
for {
kind := s.Scan()
if kind == scanner.Comment {
comment := cleanComment(s.TokenText())
if lastToken >= 0 && s.Position.Line == lastLine {
out[lastToken].comment = joinComment(out[lastToken].comment, comment)
} else {
pending = joinComment(pending, comment)
}
end = s.Position.Offset + len(s.TokenText())
continue
}
current := token{text: s.TokenText(), position: s.Position.String(), kind: kind, space: s.Position.Offset > end}
if kind != '\n' {
current.comment = pending
pending = ""
lastLine = s.Position.Line
lastToken = len(out)
}
out = append(out, current)
end = s.Position.Offset + len(s.TokenText())
if kind == scanner.EOF {
break
}
}
return out, scanErr
}
func (p *parser) peek() string { return p.tokens[p.at].text }
func (p *parser) next() token {
t := p.tokens[p.at]
if t.kind != scanner.EOF {
p.at++
}
return t
}
func (p *parser) skip() {
for p.peek() == "\n" || p.peek() == ";" {
p.next()
}
}
func (p *parser) err(message string) error {
return fmt.Errorf("%s: %s (got %q)", p.tokens[p.at].position, message, p.peek())
}
func (p *parser) expect(s string) error {
if p.peek() != s {
return p.err("expected " + strconv.Quote(s))
}
p.next()
return nil
}
func (p *parser) ident() (string, error) {
if p.tokens[p.at].kind != scanner.Ident {
return "", p.err("expected identifier")
}
return p.next().text, nil
}
func (p *parser) quoted() (string, error) {
if p.tokens[p.at].kind != scanner.String {
return "", p.err("expected quoted string")
}
return strconv.Unquote(p.next().text)
}
func (p *parser) parse() error {
server := map[string]string{}
syntaxSeen := false
for p.skip(); p.tokens[p.at].kind != scanner.EOF; p.skip() {
switch p.peek() {
case "scalar":
if err := p.parseScalar(); err != nil {
return err
}
case "syntax":
if syntaxSeen {
return p.err("duplicate syntax")
}
syntaxSeen = true
p.next()
if err := p.expect("="); err != nil {
return err
}
v, err := p.quoted()
if err != nil {
return err
}
if v != "v1" {
return p.err("only syntax v1 supported")
}
case "info":
p.next()
m, err := p.metadata()
if err != nil {
return err
}
if _, a := m["desc"]; a {
if _, b := m["description"]; b {
return p.err("use only one of info desc and description")
}
}
for k, v := range m {
switch k {
case "title":
p.spec.Title = v
case "version":
p.spec.Version = v
case "desc", "description":
p.spec.Description = v
case "author", "email":
default:
return p.err("unsupported info property " + k)
}
}
case "import":
p.next()
if p.peek() == "(" {
p.next()
for p.skip(); p.peek() != ")"; p.skip() {
v, err := p.quoted()
if err != nil {
return err
}
p.imports = append(p.imports, v)
}
p.next()
} else {
v, err := p.quoted()
if err != nil {
return err
}
p.imports = append(p.imports, v)
}
case "errorCatalog":
if err := p.parseErrorCatalog(); err != nil {
return err
}
case "error":
if err := p.parseErrors(); err != nil {
return err
}
case "auth":
if err := p.parseAuth(); err != nil {
return err
}
case "response":
if err := p.parseResponse(); err != nil {
return err
}
case "type":
p.next()
if p.peek() == "(" {
p.next()
for p.skip(); p.peek() != ")"; p.skip() {
if err := p.parseType(); err != nil {
return err
}
}
p.next()
} else {
if err := p.parseType(); err != nil {
return err
}
}
case "@":
p.next()
if p.peek() == "app" {
if p.spec.appDeclared {
return p.err("duplicate @app declaration")
}
// Defaults belong to the entry contract, never an imported module.
if !strings.HasPrefix(p.tokens[p.at].position, p.spec.Entry+":") {
return p.err("@app must be declared in the entry API file")
}
p.next()
m, err := p.metadata()
if err != nil {
return err
}
if len(m) == 0 {
return p.err("@app requires defaultResponse or request")
}
for key, value := range m {
switch key {
case "defaultResponse":
case "request":
p.spec.RequestPolicy, err = decodeRequestPolicy(value)
if err != nil {
return err
}
default:
return p.err("unsupported @app property " + key)
}
}
p.spec.DefaultResponse = m["defaultResponse"]
p.spec.appDeclared = true
continue
}
if err := p.expect("server"); err != nil {
return err
}
if len(server) != 0 {
return p.err("duplicate server block")
}
m, err := p.metadata()
if err != nil {
return err
}
server = m
for k := range m {
if k != "prefix" && k != "group" && k != "middleware" && k != "auth" && k != "errors" && k != "response" {
return p.err("unsupported @server option " + k + "; wire authentication through named middleware")
}
}
case "service":
if err := p.parseService(server); err != nil {
return err
}
server = map[string]string{}
default:
return p.err("unsupported declaration")
}
}
if len(server) > 0 {
return p.err("@server must precede a service")
}
return nil
}
func (p *parser) metadata() (map[string]string, error) {
if err := p.expect("("); err != nil {
return nil, err
}
out := map[string]string{}
for p.skip(); p.peek() != ")"; p.skip() {
key, err := p.ident()
if err != nil {
return nil, err
}
if err = p.expect(":"); err != nil {
return nil, err
}
value := ""
if p.tokens[p.at].kind == scanner.String {
value, err = p.quoted()
if err != nil {
return nil, err
}
} else if p.peek() == "{" {
depth := 0
var object strings.Builder
for {
t := p.next()
if t.kind == scanner.EOF {
return nil, p.err("unterminated metadata object")
}
if t.text == "{" {
depth++
}
if t.text == "}" {
depth--
}
if t.space {
object.WriteByte(' ')
}
object.WriteString(t.text)
if depth == 0 {
break
}
}
value = object.String()
} else {
for p.peek() != "\n" && p.peek() != ";" && p.peek() != ")" && p.tokens[p.at].kind != scanner.EOF {
value += p.next().text
}
}
if value == "" {
return nil, p.err("metadata value required")
}
if _, exists := out[key]; exists {
return nil, p.err("duplicate metadata " + key)
}
out[key] = value
}
p.next()
return out, nil
}
func (p *parser) parseType() error {
pos := p.tokens[p.at].position
name, err := p.ident()
if err != nil {
return err
}
p.skip()
if err = p.expect("{"); err != nil {
return err
}
typ := Type{Name: name, Position: pos}
for p.skip(); p.peek() != "}"; p.skip() {
description := p.tokens[p.at].comment
name, err := p.ident()
if err != nil {
return err
}
expression := ""
for p.peek() != "\n" && p.peek() != ";" && p.peek() != "}" && p.tokens[p.at].kind != scanner.RawString && p.tokens[p.at].kind != scanner.EOF {
if p.peek() == "interface" {
p.next()
if p.expect("{") != nil || p.expect("}") != nil {
return p.err("only empty interface{} is supported; use any for dynamic JSON")
}
expression += "any"
} else {
expression += p.next().text
}
}
if expression == "" {
if p.tokens[p.at].kind == scanner.RawString {
return p.err("composed types cannot carry field tags")
}
typ.Fields = append(typ.Fields, Field{Name: name, Description: description})
continue
}
if p.tokens[p.at].kind != scanner.RawString {
return p.err("field source tag required")
}
tagToken := p.next()
description = joinComment(description, tagToken.comment)
tag, err := strconv.Unquote(tagToken.text)
if err != nil {
return err
}
typ.Fields = append(typ.Fields, Field{Name: name, Type: expression, Tag: tag, Description: description})
}
p.next()
p.spec.Types = append(p.spec.Types, typ)
return nil
}
func (p *parser) parseService(server map[string]string) error {
p.next()
name, err := p.ident()
if err != nil {
return err
}
for p.peek() == "-" {
p.next()
part, e := p.ident()
if e != nil {
return e
}
name += "-" + part
}
if p.spec.Service != "" && p.spec.Service != name {
return p.err("one service name per entry API is supported")
}
p.spec.Service = name
p.skip()
if err = p.expect("{"); err != nil {
return err
}
for p.skip(); p.peek() != "}"; p.skip() {
route := Route{ResponseFormat: server["response"], Auth: server["auth"], Errors: commaList(server["errors"]), Group: server["group"], Status: 200, Position: p.tokens[p.at].position}
if route.Group == "" {
route.Group = "api"
}
if server["middleware"] != "" {
route.Middleware = strings.Split(server["middleware"], ",")
}
seen := map[string]bool{}
for p.peek() == "@" {
p.next()
annotation, e := p.ident()
if e != nil {
return e
}
if seen[annotation] {
return p.err("duplicate route annotation")
}
seen[annotation] = true
switch annotation {
case "request":
var props map[string]string
props, e = p.metadata()
if e == nil {
route.RequestPolicy, e = parseRequestPolicy(props)
}
case "handlerMode":
mode, err := p.ident()
if err != nil {
return err
}
if mode != "manual" {
return p.err("handlerMode must be manual")
}
route.Manual = true
case "binding":
mode, err := p.ident()
if err != nil {
return err
}
if mode != "custom" {
return p.err("binding must be custom")
}
route.CustomBinding = true
case "produces":
route.Produces, e = p.quoted()
case "body":
e = p.parseBody(&route)
case "handler":
route.Handler, e = p.ident()
case "doc":
e = p.parseDoc(&route)
case "auth":
route.Auth, e = p.ident()
case "response":
var format string
format, e = p.ident()
if format == "raw" && route.ResponseFormat != "raw" {
route.ErrorResponseFormat = route.ResponseFormat
}
route.ResponseFormat = format
case "errors":
var names []string
names, e = p.names()
if len(names) == 1 && names[0] == "none" {
route.Errors = nil
} else {
route.Errors = mergeNames(route.Errors, names)
}
case "status":
route.Status, e = strconv.Atoi(p.next().text)
default:
return p.err("unsupported route annotation " + annotation)
}
if e != nil {
return e
}
p.skip()
}
if route.Handler == "" {
return p.err("@handler required")
}
route.Method, err = p.ident()
if err != nil {
return err
}
path := ""
for p.peek() != "\n" && p.peek() != ";" && p.peek() != "(" && p.peek() != "returns" && p.peek() != "}" && p.tokens[p.at].kind != scanner.EOF {
path += p.next().text
}
route.Path = server["prefix"] + path
if p.peek() == "(" {
route.Request, err = p.routeType()
if err != nil {
return err
}
}
if p.peek() == "returns" {
p.next()
route.Response, err = p.routeType()
if err != nil {
return err
}
}
if p.peek() != "\n" && p.peek() != ";" && p.peek() != "}" {
return p.err("unexpected route suffix")
}
if route.Response == "" && !seen["status"] && !route.Manual && route.Produces == "" {
route.Status = 204
}
p.spec.Routes = append(p.spec.Routes, route)
}
p.next()
return nil
}
func (p *parser) routeType() (string, error) {
if err := p.expect("("); err != nil {
return "", err
}
prefix := ""
if p.peek() == "[" {
p.next()
if err := p.expect("]"); err != nil {
return "", err
}
prefix = "[]"
} else if p.peek() == "*" {
p.next()
prefix = "*"
}
name, err := p.ident()
if err != nil {
return "", err
}
return prefix + name, p.expect(")")
}
func joinComment(a, b string) string {
if a == "" {
return b
}
if b == "" {
return a
}
return a + "\n" + b
}
func cleanComment(s string) string {
if strings.HasPrefix(s, "//") {
return strings.TrimSpace(strings.TrimPrefix(s, "//"))
}
s = strings.TrimSuffix(strings.TrimPrefix(s, "/*"), "*/")
lines := strings.Split(s, "\n")
for i, line := range lines {
lines[i] = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "*"))
}
return strings.TrimSpace(strings.Join(lines, "\n"))
}
func fieldTag(f Field) string {
tags, err := contract.ParseTags(f.Tag)
if err != nil || f.Description == "" || tags["description"] != "" {
return f.Tag
}
return f.Tag + " description:" + strconv.Quote(f.Description)
}