feat: complete API-driven projects with infrastructure and business acceptance
This commit is contained in:
233
scripts/verifyAPI.py
Normal file
233
scripts/verifyAPI.py
Normal file
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bounded local acceptance of API-generated projects, on both Omni transports.
|
||||
|
||||
No remote hosts, cloud credentials or database are used. Child servers and
|
||||
temporary modules are always cleaned up. Run from any directory with Python 3.
|
||||
"""
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
REPORT = ROOT / "performance/apiGenerationLocal.json"
|
||||
ENV = {k: v for k, v in os.environ.items() if not k.startswith("GO_OMNI_")}
|
||||
ENV.update(GOWORK="off", GOFLAGS="")
|
||||
|
||||
|
||||
def run(args, cwd=ROOT, ok=True):
|
||||
result = subprocess.run([str(x) for x in args], cwd=cwd, env=ENV,
|
||||
text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
timeout=90)
|
||||
if ok and result.returncode:
|
||||
raise AssertionError(f"{args}:\n{result.stdout}")
|
||||
if not ok and not result.returncode:
|
||||
raise AssertionError(f"command unexpectedly succeeded: {args}")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def request(port, method, path, body=None, headers=None):
|
||||
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=3)
|
||||
try:
|
||||
if body is not None and len(body) > (1 << 20):
|
||||
# Probe early Content-Length rejection without racing a 1 MiB
|
||||
# client send against the server's intentional connection close.
|
||||
conn.putrequest(method, path)
|
||||
for key, value in (headers or {}).items():
|
||||
conn.putheader(key, value)
|
||||
conn.putheader("Content-Length", str(len(body)))
|
||||
conn.endheaders()
|
||||
else:
|
||||
conn.request(method, path, body=body, headers=headers or {})
|
||||
response = conn.getresponse()
|
||||
return response.status, dict(response.getheaders()), response.read()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def write(path, text):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text)
|
||||
|
||||
|
||||
def exercise(binary, project, transport, starter):
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
environment = dict(ENV, GO_OMNI_HTTP_ADDR=f"127.0.0.1:{port}",
|
||||
GO_OMNI_TRANSPORT=transport, GO_OMNI_LOG_OUTPUT="stderr",
|
||||
GO_OMNI_LOG_FORMAT="json")
|
||||
checks = []
|
||||
log_path = project / f"{transport}.log"
|
||||
with log_path.open("w+") as log:
|
||||
process = subprocess.Popen([str(binary)], cwd=project, env=environment,
|
||||
stdout=log, stderr=log)
|
||||
try:
|
||||
deadline = time.monotonic() + 10
|
||||
while True:
|
||||
if process.poll() is not None:
|
||||
raise AssertionError(log_path.read_text())
|
||||
try:
|
||||
if request(port, "GET", "/health/ready")[0] == 200:
|
||||
break
|
||||
except OSError:
|
||||
pass
|
||||
if time.monotonic() > deadline:
|
||||
raise AssertionError("server startup timed out")
|
||||
time.sleep(0.05)
|
||||
checks.append("readiness")
|
||||
status, headers, document = request(port, "GET", "/openapi.json")
|
||||
assert status == 200 and document == (project / "docs/openapi.json").read_bytes()
|
||||
checks.append("servedOpenApiMatchesFile")
|
||||
if starter:
|
||||
status, headers, data = request(port, "GET", "/api/greet/Alice")
|
||||
assert status == 200 and json.loads(data)["data"]["message"] == "Hello Alice"
|
||||
assert headers.get("X-Request-Id") or headers.get("X-Request-ID")
|
||||
checks.extend(["greeting", "requestId"])
|
||||
else:
|
||||
body = json.dumps({"name": "Alice", "active": False, "age": 0})
|
||||
base = {"Content-Type": "application/json", "X-Request-ID": "api-acceptance-42"}
|
||||
for method, path, payload, expected in [
|
||||
("PUT", "/api/v1/users/7?notify=true", body, 200),
|
||||
("PUT", "/api/v1/users/7", '{"name":"Alice"}', 400),
|
||||
("PUT", "/api/v1/users/7?notify=bad", body, 400),
|
||||
("PUT", "/api/v1/users/7?notify=true¬ify=false", body, 400),
|
||||
("PUT", "/api/v1/users/7", body[:-1] + ',"extra":1}', 400),
|
||||
("PUT", "/api/v1/users/7", body + "{}", 400),
|
||||
("PUT", "/api/v1/users/7", '{"name":"A","active":false,"age":151}', 400),
|
||||
("PUT", "/api/v1/users/0", body, 400),
|
||||
("PUT", "/api/v1/users/7", " " * ((1 << 20) + 1), 413),
|
||||
("GET", "/api/v1/users?page=2&pageSize=10&tag=a&tag=b", None, 200),
|
||||
("GET", "/api/v1/users?page=0", None, 400),
|
||||
("DELETE", "/admin/v1/users/7", None, 401),
|
||||
("POST", "/api/v1/newUsers", body, 201),
|
||||
("POST", "/api/v1/acceptedUsers", body, 202),
|
||||
]:
|
||||
status, headers, data = request(port, method, path, payload, base)
|
||||
assert status == expected, (method, path, status, data)
|
||||
if path.startswith("/api/v1/users/7?") and status == 200:
|
||||
assert json.loads(data)["data"] == {"userId": 7, "name": "Alice", "active": False}
|
||||
assert json.loads(data)["requestId"] == "api-acceptance-42"
|
||||
if path.startswith("/api/v1/users?page=2"):
|
||||
assert json.loads(data)["data"]["page"] == 2
|
||||
checks.extend(["mixedSources", "zeroAndFalse", "pathRange", "defaultAndQuery",
|
||||
"requiredFields", "unknownFields", "duplicateScalar", "trailingJson",
|
||||
"rangeValidation", "bodyLimit413", "auth401", "status201", "status202"])
|
||||
assert request(port, "PUT", "/api/v1/users/7", body,
|
||||
{"Content-Type": "text/plain"})[0] == 415
|
||||
status, headers, data = request(port, "DELETE", "/admin/v1/users/7", headers={"Authorization": "Bearer test-only"})
|
||||
assert status == 204 and data == b"" and headers.get("X-Audit") == "yes"
|
||||
checks.extend(["contentType415", "middlewareComposition", "noContent204"])
|
||||
process.send_signal(signal.SIGTERM)
|
||||
assert process.wait(timeout=12) == 0
|
||||
checks.append("gracefulShutdown")
|
||||
try:
|
||||
request(port, "GET", "/health/live")
|
||||
raise AssertionError("listener survived shutdown")
|
||||
except OSError:
|
||||
checks.append("listenerReleased")
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
if not starter:
|
||||
logs = [json.loads(line) for line in log_path.read_text().splitlines() if line.startswith("{")]
|
||||
assert any(row.get("msg") == "user.updated" and row.get("requestId") == "api-acceptance-42" for row in logs)
|
||||
checks.append("businessLogCorrelation")
|
||||
return {"project": "starter" if starter else "contract", "transport": transport,
|
||||
"checks": checks, "passed": True}
|
||||
|
||||
|
||||
def main():
|
||||
started = time.monotonic()
|
||||
results = []
|
||||
with tempfile.TemporaryDirectory(prefix="goOmniApiAcceptance") as temp:
|
||||
temp = Path(temp)
|
||||
cli = temp / "go-omni"
|
||||
run(["go", "build", "-o", cli, "./cmd/go-omni"])
|
||||
for starter in (True, False):
|
||||
project = temp / ("starter" if starter else "contract")
|
||||
common = ["--module", "example.com/apiAcceptance", "--framework-dir", ROOT]
|
||||
if starter:
|
||||
run([cli, "api", "new", *common, project])
|
||||
entry = project / "api/service.api"
|
||||
else:
|
||||
run([cli, "new", "--api", ROOT / "examples/contracts/user.api", *common, project])
|
||||
entry = project / "api/user.api"
|
||||
business = '''package user
|
||||
import("context";"example.com/apiAcceptance/internal/types")
|
||||
func(s *Service) UpdateUser(ctx context.Context,req *types.UpdateUserReq)(*types.UserResp,error){
|
||||
s.deps.Logger.InfoContext(ctx,"user.updated","userId",req.UserID)
|
||||
return &types.UserResp{UserID:req.UserID,Name:req.Name,Active:req.Active},nil
|
||||
}
|
||||
'''
|
||||
write(project / "internal/service/user/updateUser.go", business)
|
||||
write(project / "internal/service/user/listUsers.go", '''package user
|
||||
import("context";"example.com/apiAcceptance/internal/types")
|
||||
func(s *Service) ListUsers(ctx context.Context,req *types.ListUsersReq)(*types.ListUsersResp,error){return &types.ListUsersResp{Items:[]types.UserResp{},Page:req.Page,Total:0},nil}
|
||||
''')
|
||||
write(project / "internal/service/admin/user/deleteUser.go", '''package user
|
||||
import("context";"example.com/apiAcceptance/internal/types")
|
||||
func(s *Service) DeleteUser(ctx context.Context,req *types.DeleteUserReq)error{return nil}
|
||||
''')
|
||||
write(project / "internal/middleware/auth.go", '''package middleware
|
||||
import("git.yuntuo.hk/k2safe/go-omni/httpx";apierrors "git.yuntuo.hk/k2safe/go-omni/errors")
|
||||
func Auth(next httpx.Handler)httpx.Handler{return func(c *httpx.Context)error{if c.Header("Authorization")!="Bearer test-only"{return apierrors.Unauthorized("UNAUTHORIZED","authentication required")};return next(c)}}
|
||||
''')
|
||||
write(project / "internal/middleware/audit.go", '''package middleware
|
||||
import("git.yuntuo.hk/k2safe/go-omni/httpx")
|
||||
func Audit(next httpx.Handler)httpx.Handler{return func(c *httpx.Context)error{c.SetResponseHeader("X-Audit","yes");return next(c)}}
|
||||
''')
|
||||
source = entry.read_text().replace(" UpdateUserReq {", ' CreateUserReq {\n Name string `json:"name"`\n Active bool `json:"active"`\n Age *int `json:"age,optional"`\n }\n UpdateUserReq {')
|
||||
source += '''
|
||||
@server (
|
||||
group: user
|
||||
prefix: /api/v1
|
||||
)
|
||||
service user-api {
|
||||
@handler createUser
|
||||
@status 201
|
||||
post /newUsers (CreateUserReq) returns (UserResp)
|
||||
@handler acceptUser
|
||||
@status 202
|
||||
post /acceptedUsers (CreateUserReq) returns (UserResp)
|
||||
}
|
||||
'''
|
||||
entry.write_text(source)
|
||||
run([cli, "api", "generate", "--api", entry, "--dir", project, "--check"], ok=False)
|
||||
run([cli, "api", "generate", "--api", entry, "--dir", project])
|
||||
assert (project / "internal/service/user/updateUser.go").read_text() == business
|
||||
for operation in ("CreateUser", "AcceptUser"):
|
||||
name = operation[0].lower() + operation[1:]
|
||||
write(project / f"internal/service/user/{name}.go", f'''package user
|
||||
import("context";"example.com/apiAcceptance/internal/types")
|
||||
func(s *Service) {operation}(ctx context.Context,req *types.CreateUserReq)(*types.UserResp,error){{return &types.UserResp{{UserID:8,Name:req.Name,Active:req.Active}},nil}}
|
||||
''')
|
||||
run([cli, "api", "validate", "--api", entry])
|
||||
run([cli, "api", "generate", "--api", entry, "--dir", project, "--check"])
|
||||
binary = project / "server"
|
||||
run(["go", "build", "-mod=mod", "-o", binary, "./cmd/server"], cwd=project)
|
||||
for transport in ("nethttp", "native"):
|
||||
result = exercise(binary, project, transport, starter)
|
||||
results.append(result)
|
||||
print(f"PASS {result['project']}/{transport}: {len(result['checks'])} checks", flush=True)
|
||||
files = []
|
||||
for folder in ("cmd/go-omni/apigen", "contract", "openapi", "examples/contracts"):
|
||||
files.extend(p for p in (ROOT / folder).rglob("*") if p.is_file())
|
||||
files.extend(ROOT / p for p in ("cmd/go-omni/api.go", "cmd/go-omni/main.go", "scripts/verifyAPI.py",
|
||||
"transport/native/server.go", "transport/native/earlyReject_test.go"))
|
||||
report = {"passed": True, "durationSeconds": round(time.monotonic() - started, 2),
|
||||
"cases": results,
|
||||
"sourceSha256": {str(p.relative_to(ROOT)): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(files)}}
|
||||
REPORT.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
|
||||
print(f"Saved {REPORT}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user