306 lines
11 KiB
Go
306 lines
11 KiB
Go
package apigen
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
const declaredAPI = `syntax = "v1"
|
|
info (
|
|
title: "Declared API"
|
|
description: "Errors and authentication share one contract"
|
|
)
|
|
error (
|
|
Unauthenticated 401 "UNAUTHENTICATED" "authentication required"
|
|
Forbidden 403 "FORBIDDEN" "permission denied"
|
|
EmailTaken 409 "EMAIL_TAKEN" "email already in use"
|
|
NameTaken 409 "NAME_TAKEN" "name already in use"
|
|
)
|
|
auth UserAuth (
|
|
type: bearer
|
|
middleware: Auth
|
|
description: "Verified application token"
|
|
)
|
|
auth PartnerAuth (
|
|
type: apiKey
|
|
in: header
|
|
name: X-API-Key
|
|
middleware: Partner
|
|
)
|
|
auth SessionAuth (
|
|
type: apiKey
|
|
in: cookie
|
|
name: sessionId
|
|
middleware: Session
|
|
)
|
|
@server (
|
|
group: user
|
|
auth: UserAuth
|
|
errors: Unauthenticated
|
|
middleware: Audit
|
|
)
|
|
service declared-api {
|
|
@doc (
|
|
summary: "Create a user"
|
|
description: "Uniqueness is enforced in a transaction"
|
|
tags: users, administration
|
|
deprecated: true
|
|
)
|
|
@errors Forbidden, EmailTaken, NameTaken
|
|
@handler create
|
|
post /users
|
|
@auth none
|
|
@errors none
|
|
@handler public
|
|
get /public
|
|
@auth PartnerAuth
|
|
@handler partner
|
|
get /partner
|
|
@auth SessionAuth
|
|
@handler session
|
|
get /session
|
|
}
|
|
`
|
|
|
|
func document(t *testing.T, c *Compiled) map[string]any {
|
|
t.Helper()
|
|
var doc map[string]any
|
|
if err := json.Unmarshal(c.Document, &doc); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return doc
|
|
}
|
|
func operation(t *testing.T, doc map[string]any, path, method string) map[string]any {
|
|
t.Helper()
|
|
return doc["paths"].(map[string]any)[path].(map[string]any)[method].(map[string]any)
|
|
}
|
|
func TestDeclaredErrorsAndAuthentication(t *testing.T) {
|
|
spec, err := parseText(t, declaredAPI)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c, err := Compile(spec)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
again, err := Compile(spec)
|
|
if err != nil || string(c.Document) != string(again.Document) {
|
|
t.Fatalf("compile is not repeatable: %v", err)
|
|
}
|
|
if strings.Join(spec.Routes[0].Middleware, ",") != "Audit" {
|
|
t.Fatal("parser output mutated")
|
|
}
|
|
if strings.Join(c.Spec.Routes[0].Middleware, ",") != "Auth,Audit" {
|
|
t.Fatal("authentication must be first")
|
|
}
|
|
if strings.Join(c.Spec.Routes[1].Middleware, ",") != "Audit" {
|
|
t.Fatal("public override retained automatic auth")
|
|
}
|
|
doc := document(t, c)
|
|
op := operation(t, doc, "/users", "post")
|
|
if op["deprecated"] != true || op["description"] == nil || len(op["tags"].([]any)) != 2 {
|
|
t.Fatalf("documentation missing: %v", op)
|
|
}
|
|
security := op["security"].([]any)
|
|
if _, ok := security[0].(map[string]any)["UserAuth"]; !ok {
|
|
t.Fatal("security requirement absent")
|
|
}
|
|
schemes := doc["components"].(map[string]any)["securitySchemes"].(map[string]any)
|
|
if len(schemes) != 3 || schemes["UserAuth"].(map[string]any)["scheme"] != "bearer" || schemes["SessionAuth"].(map[string]any)["in"] != "cookie" {
|
|
t.Fatalf("wrong security schemes: %v", schemes)
|
|
}
|
|
responses := op["responses"].(map[string]any)
|
|
for _, status := range []string{"401", "403", "409"} {
|
|
if responses[status] == nil {
|
|
t.Fatal("missing status " + status)
|
|
}
|
|
}
|
|
media := responses["409"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)
|
|
if len(media["examples"].(map[string]any)) != 2 {
|
|
t.Fatal("same-status errors were overwritten")
|
|
}
|
|
codes := media["schema"].(map[string]any)["properties"].(map[string]any)["code"].(map[string]any)["enum"].([]any)
|
|
if len(codes) != 2 {
|
|
t.Fatal("business code enum absent")
|
|
}
|
|
public := operation(t, doc, "/public", "get")
|
|
if len(public["security"].([]any)) != 0 || public["responses"].(map[string]any)["401"] != nil || public["x-omni-errors"] != nil {
|
|
t.Fatal("public overrides did not clear group declarations")
|
|
}
|
|
}
|
|
func TestInvalidDeclarations(t *testing.T) {
|
|
replacements := [][2]string{
|
|
{`"EMAIL_TAKEN"`, `"UNAUTHENTICATED"`},
|
|
{`EmailTaken 409`, `EmailTaken 204`},
|
|
{`EmailTaken 409`, `emailTaken 409`},
|
|
{`NameTaken 409`, `CodeEmailTaken 409`},
|
|
{`auth: UserAuth`, `auth: Unknown`},
|
|
{`errors: Unauthenticated`, `errors: Unknown`},
|
|
{`middleware: Audit`, `middleware: Auth`},
|
|
{`type: bearer`, `type: oauth2`},
|
|
{`in: header`, `in: query`},
|
|
{`name: X-API-Key`, `name: Authorization`},
|
|
{`deprecated: true`, `deprecated: yes`},
|
|
{`tags: users, administration`, `tags: users, users`},
|
|
{`@errors Forbidden, EmailTaken, NameTaken`, `@errors Forbidden, Forbidden`},
|
|
{`middleware: Auth`, `middleware: Defaults`},
|
|
}
|
|
for _, change := range replacements {
|
|
spec, err := parseText(t, strings.Replace(declaredAPI, change[0], change[1], 1))
|
|
if err == nil {
|
|
_, err = Compile(spec)
|
|
}
|
|
if err == nil {
|
|
t.Fatalf("accepted invalid %q", change[1])
|
|
}
|
|
if !strings.Contains(err.Error(), "service.api:") {
|
|
t.Fatalf("source position missing: %v", err)
|
|
}
|
|
}
|
|
}
|
|
func TestImportedErrorCatalog(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeFile(t, filepath.Join(root, "errors.api"), `error Conflict 409 "CONFLICT" "conflict"`)
|
|
writeFile(t, filepath.Join(root, "service.api"), "import \"errors.api\"\nservice imported-api { @errors Conflict\n @handler create\n post /users\n }")
|
|
spec, err := Parse(filepath.Join(root, "service.api"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err = Compile(spec); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
func TestDryRunAndEntryDiscovery(t *testing.T) {
|
|
root := t.TempDir()
|
|
source := filepath.Join(root, "input/service.api")
|
|
writeFile(t, source, declaredAPI)
|
|
dir := filepath.Join(root, "project")
|
|
o := Options{API: source, Dir: dir, Module: "example.com/dx", FrameworkDir: frameworkRoot(t), DryRun: true}
|
|
out, err := Generate(o)
|
|
if err != nil || len(out.Changes) == 0 {
|
|
t.Fatalf("empty plan: %v", err)
|
|
}
|
|
if _, err = os.Stat(dir); !os.IsNotExist(err) {
|
|
t.Fatal("dry-run created directory")
|
|
}
|
|
o.DryRun = false
|
|
if _, err = Generate(o); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
entry, err := ProjectEntry(dir)
|
|
if err != nil || entry != filepath.Join(dir, "api/service.api") {
|
|
t.Fatalf("entry: %s %v", entry, err)
|
|
}
|
|
o = Options{API: entry, Dir: dir, DryRun: true}
|
|
before := snapshot(t, dir)
|
|
out, err = Generate(o)
|
|
if err != nil || len(out.Changes) != 0 {
|
|
t.Fatalf("unchanged project plan: %v %+v", err, out.Changes)
|
|
}
|
|
writeFile(t, entry, strings.Replace(declaredAPI, "email already in use", "email is already registered", 1))
|
|
before = snapshot(t, dir)
|
|
out, err = Generate(o)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !sameFiles(before, snapshot(t, dir)) {
|
|
t.Fatal("dry-run changed project")
|
|
}
|
|
actions := map[string]string{}
|
|
for _, c := range out.Changes {
|
|
actions[c.Path] = c.Action
|
|
}
|
|
if actions["internal/apierrors/errorsGen.go"] != "update" || actions["docs/openapi.json"] != "update" {
|
|
t.Fatalf("missing plan: %v", actions)
|
|
}
|
|
o.Check = true
|
|
if _, err = Generate(o); err == nil {
|
|
t.Fatal("check and dry-run combined")
|
|
}
|
|
// A malformed manifest cannot redirect the shorthand outside its project.
|
|
writeFile(t, filepath.Join(dir, manifestPath), `{"version":1,"entry":"../input/service.api"}`)
|
|
if _, err = ProjectEntry(dir); err == nil {
|
|
t.Fatal("unsafe manifest entry accepted")
|
|
}
|
|
}
|
|
|
|
func TestDeclaredContractConsumer(t *testing.T) {
|
|
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") == "" {
|
|
t.Skip("consumer integration")
|
|
}
|
|
root := t.TempDir()
|
|
source := filepath.Join(root, "input/service.api")
|
|
writeFile(t, source, declaredAPI)
|
|
dir := filepath.Join(root, "project")
|
|
if _, err := Generate(Options{API: source, Dir: dir, Module: "example.com/dx", FrameworkDir: frameworkRoot(t)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeFile(t, filepath.Join(dir, "contract_test.go"), declaredConsumerTest)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", "-race", "-count=1", "./...")
|
|
cmd.Dir = dir
|
|
cmd.Env = append(os.Environ(), "GOWORK=off", "GOFLAGS=")
|
|
if out, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("consumer: %v\n%s", err, out)
|
|
}
|
|
}
|
|
|
|
const declaredConsumerTest = `package main_test
|
|
import (
|
|
"context";"errors";"io";"net/http/httptest";"strings";"testing"
|
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
|
"example.com/dx/internal/apierrors"
|
|
"example.com/dx/internal/handler"
|
|
"example.com/dx/internal/svc"
|
|
)
|
|
func TestRuntimeAuthAndFreshErrors(t *testing.T){
|
|
first:=apierrors.EmailTaken().WithCause(errors.New("private"));second:=apierrors.EmailTaken()
|
|
if first==second||second.Cause!=nil||second.Code!=apierrors.CodeEmailTaken||second.Status!=409{t.Fatal("shared error instance")}
|
|
cfg:=logx.Default();cfg.Writer=io.Discard;logger,err:=logx.New(cfg);if err!=nil{t.Fatal(err)};defer logger.Close()
|
|
deps,err:=svc.New(context.Background(),logger);if err!=nil{t.Fatal(err)};defer deps.Close()
|
|
app:=omni.New();defer app.Close();if err=handler.RegisterRoutes(app,deps);err!=nil{t.Fatal(err)}
|
|
w:=httptest.NewRecorder();app.Engine().ServeHTTP(w,httptest.NewRequest("POST","/users",nil))
|
|
if w.Code!=501||!strings.Contains(w.Body.String(),"MIDDLEWARE_NOT_IMPLEMENTED"){t.Fatal("unimplemented auth did not fail closed")}
|
|
var called []string
|
|
deps.Middlewares["Auth"]=func(next httpx.Handler)httpx.Handler{return func(c *httpx.Context)error{called=append(called,"auth");if c.Header("Authorization")!="Bearer valid"{return apierrors.Unauthenticated()};return next(c)}}
|
|
deps.Middlewares["Audit"]=func(next httpx.Handler)httpx.Handler{return func(c *httpx.Context)error{called=append(called,"audit");return next(c)}}
|
|
app2:=omni.New();defer app2.Close();if err=handler.RegisterRoutes(app2,deps);err!=nil{t.Fatal(err)}
|
|
w=httptest.NewRecorder();app2.Engine().ServeHTTP(w,httptest.NewRequest("POST","/users",nil))
|
|
if w.Code!=401||strings.Join(called,",")!="auth"{t.Fatal("unauthenticated request reached other middleware")}
|
|
called=nil;req:=httptest.NewRequest("POST","/users",nil);req.Header.Set("Authorization","Bearer valid")
|
|
w=httptest.NewRecorder();app2.Engine().ServeHTTP(w,req)
|
|
if w.Code!=501||strings.Join(called,",")!="auth,audit"{t.Fatal("middleware order changed")}
|
|
called=nil;w=httptest.NewRecorder();app2.Engine().ServeHTTP(w,httptest.NewRequest("GET","/public",nil))
|
|
if w.Code!=501||strings.Join(called,",")!="audit"||strings.Contains(w.Body.String(),"MIDDLEWARE_NOT_IMPLEMENTED"){t.Fatal("public override retained auth")}
|
|
delete(deps.Middlewares,"Auth");app3:=omni.New();defer app3.Close()
|
|
if err=handler.RegisterRoutes(app3,deps);err==nil{t.Fatal("missing auth registration accepted")}
|
|
w=httptest.NewRecorder();app3.Engine().ServeHTTP(w,httptest.NewRequest("GET","/public",nil))
|
|
if w.Code!=404{t.Fatal("failed auth preflight registered partial routes")}
|
|
}
|
|
`
|
|
|
|
func TestAuthMetadataWithParameterNamePrefixes(t *testing.T) {
|
|
source := "type Req {\n ID int `path:\"id\"`\n IDLong int `path:\"idLong\"`\n}\nauth Gate (\n type: bearer\n middleware: Auth\n)\n@server (auth: Gate)\nservice prefix-api {\n @handler read\n get /items/:id/children/:idLong (Req)\n}"
|
|
spec, err := parseText(t, source)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c, err := Compile(spec)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
op := operation(t, document(t, c), "/items/{id}/children/{idLong}", "get")
|
|
if len(op["security"].([]any)) != 1 {
|
|
t.Fatal("auth metadata lost")
|
|
}
|
|
}
|