diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..07eeb51 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: ci +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version-file: 'go.mod' + - name: Build + run: go build ./... + - name: Test + run: go test ./... + - name: Vet + run: go vet ./... diff --git a/README.md b/README.md index a2f54ff..1782fbc 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,15 @@ The Codefly language agent for typed, finite Go operations: generate a handler and harness, compile portable native artifacts, and emit container build recipes. -**Status: repository initialized; implementation is deferred until the Python/native/k3d contract is qualified.** No agent executable or release is available yet. [Implementation issue #1](https://github.com/codefly-dev/runnable-go/issues/1) tracks this work. +**Status: the contract-determined generation slice is implemented; the agent process, harness and qualification are deferred until the Python/native/k3d contract is qualified.** No agent executable or release is available yet. [Implementation issue #1](https://github.com/codefly-dev/runnable-go/issues/1) records the original scope; [issue #2](https://github.com/codefly-dev/runnable-go/issues/2) tracks the remaining execution lifecycle. + +`pkg/generate` turns a runnable's declared contract into the typed Go bindings and the author handler scaffold. The bounded profile maps to `string`, `int64`, `bool`, generated structs and slices, and the contract's independent `optional` (the key may be absent) and `nullable` (the value may be null) declarations map to three distinct Go spellings so neither state can stand for the other. + +The agent gRPC process, the invocation harness, build evidence and native/k3d qualification wait on [core #472](https://github.com/codefly-dev/core/issues/472), which freezes the agent/CLI handoff `runnable.codefly.yaml` names but does not define: carrying a `RunnableIdentity` through `Builder.Load`, returning native launch and build evidence from `Builder.Package`, and the `codefly.runnable/v1` framing a harness must agree with byte for byte. + +Python now has a harness and packaging implementation in [runnable-python #2](https://github.com/codefly-dev/runnable-python/pull/2). Its framing remains a proposal for core #472. Go must consume the shared contract after it is ratified and qualified through the real Codefly/Orchestration path. + +Bindings do not replace payload validation: the future harness must enforce required fields, unknown-key rejection, and non-null arrays before decoding input or accepting output. See [the review milestone](docs/milestone_1_bindings_review.md). ## What belongs here diff --git a/agent.codefly.yaml b/agent.codefly.yaml new file mode 100644 index 0000000..d10607b --- /dev/null +++ b/agent.codefly.yaml @@ -0,0 +1,4 @@ +publisher: codefly.dev +kind: codefly:runnable +name: go +version: 0.0.1 diff --git a/agent_test.go b/agent_test.go new file mode 100644 index 0000000..716ccc1 --- /dev/null +++ b/agent_test.go @@ -0,0 +1,37 @@ +package runnablego_test + +import ( + "context" + "testing" + + "github.com/codefly-dev/core/resources" +) + +// The distribution identity is what the CLI resolves an agent by: a runnable +// agent named go is the runnable-go repository, release asset and executable. +func TestAgentDeclaresRunnableGoIdentity(t *testing.T) { + agent, err := resources.LoadFromPath[resources.Agent](context.Background(), "agent.codefly.yaml") + if err != nil { + t.Fatalf("loading agent.codefly.yaml: %v", err) + } + if agent.Kind != resources.RunnableAgent { + t.Fatalf("kind = %q, want %q", agent.Kind, resources.RunnableAgent) + } + if agent.Name != "go" { + t.Fatalf("name = %q, want go", agent.Name) + } + if !agent.IsRunnable() { + t.Fatal("agent is not recognized as a runnable agent") + } + + registration, err := resources.AgentKindRegistrationFor(agent.Kind) + if err != nil { + t.Fatalf("registration for %q: %v", agent.Kind, err) + } + if got := registration.ExecutableName(agent.Name); got != "runnable-go" { + t.Fatalf("executable = %q, want runnable-go", got) + } + if got := registration.GitHubRepository(agent.Name); got != "runnable-go" { + t.Fatalf("repository = %q, want runnable-go", got) + } +} diff --git a/docs/milestone_1_bindings_review.md b/docs/milestone_1_bindings_review.md new file mode 100644 index 0000000..0454ac9 --- /dev/null +++ b/docs/milestone_1_bindings_review.md @@ -0,0 +1,26 @@ +# Milestone 1 — reviewed Go bindings + +PR #3 establishes the `codefly:runnable` / `go` distribution identity and renders +core's bounded Runnable schema as typed Go input/output bindings and an author +handler scaffold. Core is pinned at `6a40c4bf28ac`. + +The review found no blocking defect in this limited implementation. Go tests +compile the generated package with the real compiler and `encoding/json`, +checking absent/null/set states, signed 64-bit integers, type mismatches and +identifier collisions. The repository's tests, build and vet must pass before +merge. + +This milestone does not include a runnable agent executable, invocation harness, +native package, image recipe, release or durable invocation qualification. +[Issue #2](https://github.com/codefly-dev/runnable-go/issues/2) carries the +remaining acceptance checklist from issue #1. + +Python now has a proposed harness and packaging implementation. Core #472 must +ratify the shared loading, build-evidence and invocation contracts; Go will reuse +that contract after Python qualification. No Go-specific launcher protocol or +language branch belongs in CLI or Orchestration. + +The future Go harness must validate required fields, unknown keys and nullable +arrays against the raw payload. Generated Go types alone do not enforce those +rules. Native and Orchestration-created k3d invocation Jobs, cross-language +agreement, immutable version selection and reconnect remain to qualify. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1a9d768 --- /dev/null +++ b/go.mod @@ -0,0 +1,49 @@ +module github.com/codefly-dev/runnable-go + +go 1.27.0 + +require github.com/codefly-dev/core v0.3.28-0.20260912222100-6a40c4bf28ac + +require ( + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 // indirect + buf.build/go/protovalidate v1.2.0 // indirect + cel.dev/expr v0.25.1 // indirect + github.com/Masterminds/semver v1.5.0 // indirect + github.com/Masterminds/semver/v3 v3.5.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/go-openapi/analysis v0.25.0 // indirect + github.com/go-openapi/errors v0.22.7 // indirect + github.com/go-openapi/jsonpointer v0.23.1 // indirect + github.com/go-openapi/jsonreference v0.21.5 // indirect + github.com/go-openapi/loads v0.23.3 // indirect + github.com/go-openapi/spec v0.22.4 // indirect + github.com/go-openapi/strfmt v0.26.1 // indirect + github.com/go-openapi/swag/conv v0.26.0 // indirect + github.com/go-openapi/swag/jsonname v0.26.0 // indirect + github.com/go-openapi/swag/jsonutils v0.26.0 // indirect + github.com/go-openapi/swag/loading v0.26.0 // indirect + github.com/go-openapi/swag/mangling v0.26.0 // indirect + github.com/go-openapi/swag/stringutils v0.26.0 // indirect + github.com/go-openapi/swag/typeutils v0.26.0 // indirect + github.com/go-openapi/swag/yamlutils v0.26.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/google/cel-go v0.28.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/oklog/ulid/v2 v2.1.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/yoheimuta/go-protoparser/v4 v4.14.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.44.0 // indirect + golang.org/x/text v0.39.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260420184626-e10c466a9529 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect + google.golang.org/grpc v1.82.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1bce0b8 --- /dev/null +++ b/go.sum @@ -0,0 +1,227 @@ +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 h1:s6hzCXtND/ICdGPTMGk7C+/BFlr2Jg5GyH0NKf4XGXg= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= +buf.build/go/protovalidate v1.2.0 h1:DQVrUWkmGTBij+kOYv/x2LLxwcLaGKMdzShj1/6/3H0= +buf.build/go/protovalidate v1.2.0/go.mod h1:7rYiQEhqvAipoazpVNBBH2S2f8bjG4huMVy1V2Yofn4= +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= +github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= +github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= +github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cheggaaa/pb/v3 v3.1.7 h1:2FsIW307kt7A/rz/ZI2lvPO+v3wKazzE4K/0LtTWsOI= +github.com/cheggaaa/pb/v3 v3.1.7/go.mod h1:/Ji89zfVPeC/u5j8ukD0MBPHt2bzTYp74lQ7KlgFWTQ= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/codefly-dev/core v0.3.28-0.20260912222100-6a40c4bf28ac h1:qxVS3biwizSGScOHBfGQFD3nEqkoo7CEYVVq4PaXdSo= +github.com/codefly-dev/core v0.3.28-0.20260912222100-6a40c4bf28ac/go.mod h1:s3e5uut1GseXvtmBanN9J6wcfnTSFTJfAolIMef+41w= +github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= +github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-openapi/analysis v0.25.0 h1:EnjAq1yO8wEO9HbPmY8vLPEIkdZuuFhCAKBPvCB7bCs= +github.com/go-openapi/analysis v0.25.0/go.mod h1:5WFTRE43WLkPG9r9OtlMfqkkvUTYLVVCIxLlEpyF8kE= +github.com/go-openapi/errors v0.22.7 h1:JLFBGC0Apwdzw3484MmBqspjPbwa2SHvpDm0u5aGhUA= +github.com/go-openapi/errors v0.22.7/go.mod h1://QW6SD9OsWtH6gHllUCddOXDL0tk0ZGNYHwsw4sW3w= +github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= +github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= +github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= +github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= +github.com/go-openapi/loads v0.23.3 h1:g5Xap1JfwKkUnZdn+S0L3SzBDpcTIYzZ5Qaag0YDkKQ= +github.com/go-openapi/loads v0.23.3/go.mod h1:NOH07zLajXo8y55hom0omlHWDVVvCwBM/S+csCK8LqA= +github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ= +github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ= +github.com/go-openapi/strfmt v0.26.1 h1:7zGCHji7zSYDC2tCXIusoxYQz/48jAf2q+sF6wXTG+c= +github.com/go-openapi/strfmt v0.26.1/go.mod h1:Zslk5VZPOISLwmWTMBIS7oiVFem1o1EI6zULY8Uer7Y= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= +github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= +github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= +github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= +github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA= +github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y= +github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko= +github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg= +github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ= +github.com/go-openapi/swag/mangling v0.26.0/go.mod h1:jifS7W9vbg+pw63bT+GI53otluMQL3CeemuyCHKwVx0= +github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg= +github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE= +github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4= +github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE= +github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ= +github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE= +github.com/go-openapi/testify/v2 v2.4.2 h1:tiByHpvE9uHrrKjOszax7ZvKB7QOgizBWGBLuq0ePx4= +github.com/go-openapi/testify/v2 v2.4.2/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= +github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/cel-go v0.28.0 h1:KjSWstCpz/MN5t4a8gnGJNIYUsJRpdi/r97xWDphIQc= +github.com/google/cel-go v0.28.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= +github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= +github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v89 v89.0.0 h1:35bEK5XoEcF3PZrlVbl9XN63f5BcJRA/UGkxeC9xPg0= +github.com/google/go-github/v89 v89.0.0/go.mod h1:QLcbU0ipeAqQuR5KSg8c2lql4Qk1EwJ2dWz/0rP4Nho= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= +github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= +github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= +github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= +github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.2.1 h1:yqRB4fvOge2+FyRXFkXqsyMoqPazv14Yyy+iyccT2E4= +github.com/shoenig/go-m1cpu v0.2.1/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= +github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +github.com/yoheimuta/go-protoparser/v4 v4.14.2 h1:/P/LlX1CF9NaTWEltGcIZVvNlPbhABuAnBtAWpb3+74= +github.com/yoheimuta/go-protoparser/v4 v4.14.2/go.mod h1:AHNNnSWnb0UoL4QgHPiOAg2BniQceFscPI5X/BZNHl8= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 h1:0Qx7VGBacMm9ZENQ7TnNObTYI4ShC+lHI16seduaxZo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +google.golang.org/genproto/googleapis/api v0.0.0-20260420184626-e10c466a9529 h1:zUWMZsvo/IJcD1t6MNCPO/azZTwz0TvwCBqr5aifoVY= +google.golang.org/genproto/googleapis/api v0.0.0-20260420184626-e10c466a9529/go.mod h1:a5OGAgyRr4lqco7AG9hQM9Fwh0N2ZV4grR0eXFEsXQg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7 h1:hcha5B1kVACrLujCKLbr8XWMxCxzQx42DY8QKYJrDLg= +k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7/go.mod h1:GewRfANuJ70iYzvn+i4lezLDAFzvjxZYK1gn1lWcfas= +sigs.k8s.io/kustomize/api v0.21.1 h1:lzqbzvz2CSvsjIUZUBNFKtIMsEw7hVLJp0JeSIVmuJs= +sigs.k8s.io/kustomize/api v0.21.1/go.mod h1:f3wkKByTrgpgltLgySCntrYoq5d3q7aaxveSagwTlwI= +sigs.k8s.io/kustomize/kyaml v0.21.1 h1:IVlbmhC076nf6foyL6Taw4BkrLuEsXUXNpsE+ScX7fI= +sigs.k8s.io/kustomize/kyaml v0.21.1/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ= +sigs.k8s.io/yaml v1.5.0 h1:M10b2U7aEUY6hRtU870n2VTPgR5RZiL/I6Lcc2F4NUQ= +sigs.k8s.io/yaml v1.5.0/go.mod h1:wZs27Rbxoai4C0f8/9urLZtZtF3avA3gKvGyPdDqTO4= diff --git a/pkg/generate/bindings.go b/pkg/generate/bindings.go new file mode 100644 index 0000000..02464c9 --- /dev/null +++ b/pkg/generate/bindings.go @@ -0,0 +1,235 @@ +// Package generate turns a Runnable contract into Go source: the typed input +// and output bindings the harness and the author share, and the handler +// scaffold the author owns. +package generate + +import ( + "fmt" + "go/format" + "strings" + + "github.com/codefly-dev/core/resources" +) + +// Type names of a generated binding file: the two payload roots and the +// carrier of a field declared both optional and nullable. +const ( + inputType = "Input" + outputType = "Output" + optionalType = "Optional" +) + +// Bindings renders the typed Go bindings of a contract's input and output. +// +// The bounded profile maps to Go by value type and by the two declarations the +// contract keeps independent: optional means the key may be absent, nullable +// means the value may be null. A field that is only optional is a pointer +// omitted when absent, a field that is only nullable is a pointer written as +// null, and a field that is both is an Optional, which round-trips absent and +// null as the distinct states they are. +// +// A Go nil slice encodes as null whether or not the array field is declared +// nullable, so an array the contract never allows to be null is caught by +// output validation rather than by its type. +func Bindings(pkg string, contract *resources.RunnableContract) ([]byte, error) { + if err := contract.Validate(); err != nil { + return nil, err + } + g := &generator{declared: make(map[string]struct{})} + if err := g.enqueue(inputType, "is the typed invocation input declared by the contract.", contract.Input.Fields); err != nil { + return nil, err + } + if err := g.enqueue(outputType, "is the typed invocation output declared by the contract.", contract.Output.Fields); err != nil { + return nil, err + } + if err := g.run(); err != nil { + return nil, err + } + + var b strings.Builder + b.WriteString("// Code generated by runnable-go. DO NOT EDIT.\n\n") + fmt.Fprintf(&b, "package %s\n", pkg) + if g.usesOptional { + b.WriteString("\nimport \"encoding/json\"\n") + } + for _, decl := range g.decls { + b.WriteString("\n") + b.WriteString(decl) + } + if g.usesOptional { + b.WriteString("\n") + b.WriteString(optionalDeclaration) + } + return format.Source([]byte(b.String())) +} + +type pending struct { + name string + doc string + fields []*resources.RunnableField +} + +type generator struct { + decls []string + queue []pending + declared map[string]struct{} + usesOptional bool +} + +// enqueue schedules one object type. Nested objects are rendered after the type +// that references them, so a binding file reads from the payload root down. +func (g *generator) enqueue(name string, doc string, fields []*resources.RunnableField) error { + if _, exists := g.declared[name]; exists { + return fmt.Errorf("type %q is generated twice: rename the field it comes from", name) + } + g.declared[name] = struct{}{} + g.queue = append(g.queue, pending{name: name, doc: doc, fields: fields}) + return nil +} + +func (g *generator) run() error { + for len(g.queue) > 0 { + next := g.queue[0] + g.queue = g.queue[1:] + decl, err := g.structure(next) + if err != nil { + return err + } + g.decls = append(g.decls, decl) + } + return nil +} + +func (g *generator) structure(p pending) (string, error) { + var b strings.Builder + fmt.Fprintf(&b, "// %s %s\n", p.name, p.doc) + fmt.Fprintf(&b, "type %s struct {\n", p.name) + + taken := make(map[string]string, len(p.fields)) + for _, field := range p.fields { + name := exported(field.Name) + if name == "" { + return "", fmt.Errorf("field %q has no Go identifier", field.Name) + } + if previous, clash := taken[name]; clash { + return "", fmt.Errorf("fields %q and %q both generate Go field %q", previous, field.Name, name) + } + taken[name] = field.Name + + declared, err := g.fieldType(p.name+name, field) + if err != nil { + return "", err + } + if field.Description != "" { + for _, line := range strings.Split(field.Description, "\n") { + fmt.Fprintf(&b, "\t// %s\n", line) + } + } + fmt.Fprintf(&b, "\t%s %s `json:%q`\n", name, declared, jsonTag(field)) + } + b.WriteString("}\n") + return b.String(), nil +} + +// fieldType is the Go type a field is declared with, optionality and +// nullability included. hint names the type a nested object is generated as. +func (g *generator) fieldType(hint string, field *resources.RunnableField) (string, error) { + base, err := g.valueType(hint, field) + if err != nil { + return "", err + } + switch { + case field.Optional && field.Nullable: + g.usesOptional = true + return fmt.Sprintf("%s[%s]", optionalType, base), nil + case field.Optional, field.Nullable: + return "*" + base, nil + default: + return base, nil + } +} + +func (g *generator) valueType(hint string, field *resources.RunnableField) (string, error) { + switch field.Type { + case resources.RunnableFieldString: + return "string", nil + case resources.RunnableFieldInteger: + return "int64", nil + case resources.RunnableFieldBoolean: + return "bool", nil + case resources.RunnableFieldObject: + if err := g.enqueue(hint, "is a nested object of the contract.", field.Fields); err != nil { + return "", err + } + return hint, nil + case resources.RunnableFieldArray: + element, err := g.valueType(hint+"Item", field.Items) + if err != nil { + return "", err + } + if field.Items.Nullable { + element = "*" + element + } + return "[]" + element, nil + } + return "", fmt.Errorf("field %q has type %q outside the bounded profile", field.Name, field.Type) +} + +// jsonTag omits an optional key when it is absent; a nullable-only key is +// always written, as null when it has no value. +func jsonTag(field *resources.RunnableField) string { + if field.Optional { + return field.Name + ",omitzero" + } + return field.Name +} + +// exported turns a contract field name into a Go field name. Contract names are +// identifiers, so the only transformation is underscore-separated capitalization. +func exported(name string) string { + var b strings.Builder + for _, part := range strings.Split(name, "_") { + if part == "" { + continue + } + b.WriteString(strings.ToUpper(part[:1])) + b.WriteString(part[1:]) + } + return b.String() +} + +const optionalDeclaration = `// Optional carries a field the contract declares both optional and nullable. +// Absent and null are separate states there, so neither may stand for the other. +type Optional[T any] struct { + // Present is true when the payload carried the key. + Present bool + // Value is nil when the key was present and null. + Value *T +} + +// IsZero reports an absent key, which is what omits it from an encoded payload. +func (o Optional[T]) IsZero() bool { + return !o.Present +} + +func (o Optional[T]) MarshalJSON() ([]byte, error) { + if o.Value == nil { + return []byte("null"), nil + } + return json.Marshal(o.Value) +} + +func (o *Optional[T]) UnmarshalJSON(data []byte) error { + o.Present = true + if string(data) == "null" { + o.Value = nil + return nil + } + var value T + if err := json.Unmarshal(data, &value); err != nil { + return err + } + o.Value = &value + return nil +} +` diff --git a/pkg/generate/bindings_test.go b/pkg/generate/bindings_test.go new file mode 100644 index 0000000..1ada96f --- /dev/null +++ b/pkg/generate/bindings_test.go @@ -0,0 +1,220 @@ +package generate_test + +import ( + "strings" + "testing" + + "github.com/codefly-dev/core/resources" + "github.com/codefly-dev/runnable-go/pkg/generate" +) + +func field(name string, t resources.RunnableFieldType) *resources.RunnableField { + return &resources.RunnableField{Name: name, Type: t} +} + +func contractOf(input, output []*resources.RunnableField) *resources.RunnableContract { + return &resources.RunnableContract{ + Protocol: resources.RunnableProtocolV1, + Input: &resources.RunnableSchema{Fields: input}, + Output: &resources.RunnableSchema{Fields: output}, + } +} + +func generated(t *testing.T, input, output []*resources.RunnableField) string { + t.Helper() + source, err := generate.Bindings("wordcount", contractOf(input, output)) + if err != nil { + t.Fatalf("Bindings: %v", err) + } + return string(source) +} + +// requireLine matches a declaration regardless of the column padding gofmt +// aligns struct fields with. +func requireLine(t *testing.T, source string, want string) { + t.Helper() + for _, line := range strings.Split(source, "\n") { + if strings.Join(strings.Fields(line), " ") == want { + return + } + } + t.Fatalf("generated source has no line %q:\n%s", want, source) +} + +// Each value type of the bounded profile has exactly one Go spelling, and an +// integer is the signed 64-bit one the contract declares. +func TestValueTypesMapToTheBoundedProfile(t *testing.T) { + source := generated(t, + []*resources.RunnableField{ + field("text", resources.RunnableFieldString), + field("count", resources.RunnableFieldInteger), + field("enabled", resources.RunnableFieldBoolean), + {Name: "tags", Type: resources.RunnableFieldArray, Items: field("", resources.RunnableFieldString)}, + }, + []*resources.RunnableField{field("total", resources.RunnableFieldInteger)}, + ) + + requireLine(t, source, "Text string `json:\"text\"`") + requireLine(t, source, "Count int64 `json:\"count\"`") + requireLine(t, source, "Enabled bool `json:\"enabled\"`") + requireLine(t, source, "Tags []string `json:\"tags\"`") + requireLine(t, source, "Total int64 `json:\"total\"`") +} + +// Optional and nullable are declared independently, so the four combinations +// are four distinct Go spellings. +func TestOptionalAndNullableAreIndependent(t *testing.T) { + source := generated(t, + []*resources.RunnableField{ + field("required", resources.RunnableFieldString), + {Name: "absent_or_set", Type: resources.RunnableFieldString, Optional: true}, + {Name: "null_or_set", Type: resources.RunnableFieldString, Nullable: true}, + {Name: "absent_or_null_or_set", Type: resources.RunnableFieldString, Optional: true, Nullable: true}, + }, + []*resources.RunnableField{field("total", resources.RunnableFieldInteger)}, + ) + + requireLine(t, source, "Required string `json:\"required\"`") + requireLine(t, source, "AbsentOrSet *string `json:\"absent_or_set,omitzero\"`") + requireLine(t, source, "NullOrSet *string `json:\"null_or_set\"`") + requireLine(t, source, "AbsentOrNullOrSet Optional[string] `json:\"absent_or_null_or_set,omitzero\"`") + if !strings.Contains(source, "type Optional[T any] struct") { + t.Fatalf("Optional is used but not declared:\n%s", source) + } +} + +// Optional is only declared when the contract needs it. +func TestOptionalIsNotDeclaredWhenUnused(t *testing.T) { + source := generated(t, + []*resources.RunnableField{{Name: "note", Type: resources.RunnableFieldString, Nullable: true}}, + []*resources.RunnableField{field("total", resources.RunnableFieldInteger)}, + ) + if strings.Contains(source, "Optional") { + t.Fatalf("Optional declared for a contract that has no optional-and-nullable field:\n%s", source) + } + if strings.Contains(source, "encoding/json") { + t.Fatalf("bindings import encoding/json without Optional:\n%s", source) + } +} + +// Nested objects are named after the path that reaches them, so two objects +// with the same field name in different parents stay distinct types. +func TestNestedObjectsAreNamedByPath(t *testing.T) { + source := generated(t, + []*resources.RunnableField{ + {Name: "options", Type: resources.RunnableFieldObject, Optional: true, Fields: []*resources.RunnableField{ + {Name: "stop_words", Type: resources.RunnableFieldArray, Nullable: true, Items: field("", resources.RunnableFieldString)}, + }}, + {Name: "records", Type: resources.RunnableFieldArray, Items: &resources.RunnableField{ + Type: resources.RunnableFieldObject, Fields: []*resources.RunnableField{field("id", resources.RunnableFieldString)}, + }}, + }, + []*resources.RunnableField{ + {Name: "options", Type: resources.RunnableFieldObject, Fields: []*resources.RunnableField{ + field("stop_words", resources.RunnableFieldString), + }}, + }, + ) + + requireLine(t, source, "Options *InputOptions `json:\"options,omitzero\"`") + requireLine(t, source, "StopWords *[]string `json:\"stop_words\"`") + requireLine(t, source, "Records []InputRecordsItem `json:\"records\"`") + requireLine(t, source, "type InputRecordsItem struct {") + requireLine(t, source, "type OutputOptions struct {") +} + +// A nullable array element is a pointer; the contract forbids an optional one. +func TestNullableArrayElements(t *testing.T) { + source := generated(t, + []*resources.RunnableField{ + {Name: "scores", Type: resources.RunnableFieldArray, Items: &resources.RunnableField{Type: resources.RunnableFieldInteger, Nullable: true}}, + }, + []*resources.RunnableField{field("total", resources.RunnableFieldInteger)}, + ) + requireLine(t, source, "Scores []*int64 `json:\"scores\"`") +} + +// A field description reaches the author on the generated field. +func TestDescriptionsAreCarriedToFields(t *testing.T) { + source := generated(t, + []*resources.RunnableField{{Name: "text", Type: resources.RunnableFieldString, Description: "the document to count"}}, + []*resources.RunnableField{field("total", resources.RunnableFieldInteger)}, + ) + requireLine(t, source, "// the document to count") +} + +// Two wire names that are distinct to the contract but identical in Go are a +// generation failure, not a silently dropped field. +func TestCollidingGoFieldNamesAreRejected(t *testing.T) { + _, err := generate.Bindings("wordcount", contractOf( + []*resources.RunnableField{field("stop_words", resources.RunnableFieldString), field("stopWords", resources.RunnableFieldString)}, + []*resources.RunnableField{field("total", resources.RunnableFieldInteger)}, + )) + if err == nil { + t.Fatal("colliding Go field names generated without error") + } + if !strings.Contains(err.Error(), "StopWords") { + t.Fatalf("error does not name the colliding Go field: %v", err) + } +} + +func TestFieldNameWithoutGoIdentifierIsRejected(t *testing.T) { + _, err := generate.Bindings("wordcount", contractOf( + []*resources.RunnableField{field("_", resources.RunnableFieldString)}, + []*resources.RunnableField{field("total", resources.RunnableFieldInteger)}, + )) + if err == nil { + t.Fatal("field name with no Go identifier generated without error") + } +} + +// Generation runs core's contract rules rather than its own, so a declaration +// core rejects never reaches a binding file. +func TestContractIsValidatedByCore(t *testing.T) { + cases := map[string]*resources.RunnableContract{ + "unsupported protocol": { + Protocol: "codefly.runnable/v2", + Input: &resources.RunnableSchema{Fields: []*resources.RunnableField{field("text", resources.RunnableFieldString)}}, + Output: &resources.RunnableSchema{Fields: []*resources.RunnableField{field("total", resources.RunnableFieldInteger)}}, + }, + "type outside the profile": contractOf( + []*resources.RunnableField{field("amount", resources.RunnableFieldType("number"))}, + []*resources.RunnableField{field("total", resources.RunnableFieldInteger)}, + ), + "array without items": contractOf( + []*resources.RunnableField{{Name: "tags", Type: resources.RunnableFieldArray}}, + []*resources.RunnableField{field("total", resources.RunnableFieldInteger)}, + ), + "optional array element": contractOf( + []*resources.RunnableField{{Name: "tags", Type: resources.RunnableFieldArray, Items: &resources.RunnableField{Type: resources.RunnableFieldString, Optional: true}}}, + []*resources.RunnableField{field("total", resources.RunnableFieldInteger)}, + ), + "field declared twice": contractOf( + []*resources.RunnableField{field("text", resources.RunnableFieldString), field("text", resources.RunnableFieldString)}, + []*resources.RunnableField{field("total", resources.RunnableFieldInteger)}, + ), + } + for name, contract := range cases { + t.Run(name, func(t *testing.T) { + if _, err := generate.Bindings("wordcount", contract); err == nil { + t.Fatal("invalid contract generated without error") + } + }) + } +} + +func TestHandlerScaffold(t *testing.T) { + source, err := generate.Handler("wordcount", "word-count") + if err != nil { + t.Fatalf("Handler: %v", err) + } + text := string(source) + requireLine(t, text, "package wordcount") + requireLine(t, text, "func Handle(ctx context.Context, in Input) (Output, error) {") + if !strings.Contains(text, "word-count") { + t.Fatalf("scaffold does not name the runnable:\n%s", text) + } + if strings.Contains(text, "DO NOT EDIT") { + t.Fatalf("author-owned scaffold is marked generated:\n%s", text) + } +} diff --git a/pkg/generate/compile_test.go b/pkg/generate/compile_test.go new file mode 100644 index 0000000..374ae99 --- /dev/null +++ b/pkg/generate/compile_test.go @@ -0,0 +1,169 @@ +package generate_test + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/codefly-dev/core/resources" + "github.com/codefly-dev/runnable-go/pkg/generate" +) + +// coveringContract exercises every value type of the bounded profile and every +// combination of optional and nullable. +func coveringContract() *resources.RunnableContract { + return contractOf( + []*resources.RunnableField{ + field("text", resources.RunnableFieldString), + {Name: "count", Type: resources.RunnableFieldInteger, Optional: true}, + {Name: "note", Type: resources.RunnableFieldString, Nullable: true}, + {Name: "flag", Type: resources.RunnableFieldBoolean, Optional: true, Nullable: true}, + {Name: "options", Type: resources.RunnableFieldObject, Optional: true, Fields: []*resources.RunnableField{ + field("limit", resources.RunnableFieldInteger), + }}, + {Name: "scores", Type: resources.RunnableFieldArray, Items: field("", resources.RunnableFieldInteger)}, + }, + []*resources.RunnableField{field("total", resources.RunnableFieldInteger)}, + ) +} + +// The generated bindings and scaffold are only correct if the Go toolchain and +// encoding/json agree with them, so they are compiled and exercised as the +// author workspace would compile them. +func TestGeneratedPackageCompilesAndKeepsContractSemantics(t *testing.T) { + dir := t.TempDir() + + bindings, err := generate.Bindings("wordcount", coveringContract()) + if err != nil { + t.Fatalf("Bindings: %v", err) + } + handler, err := generate.Handler("wordcount", "word-count") + if err != nil { + t.Fatalf("Handler: %v", err) + } + + for name, content := range map[string][]byte{ + "go.mod": []byte("module wordcount\n\ngo 1.27.0\n"), + "bindings.go": bindings, + "handler.go": handler, + "semantics_test.go": []byte(semanticsTest), + } { + if err := os.WriteFile(filepath.Join(dir, name), content, 0o600); err != nil { + t.Fatalf("writing %s: %v", name, err) + } + } + + cmd := exec.Command("go", "test", "./...") + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOFLAGS=-mod=mod", "GOPROXY=off") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("generated package failed: %v\n%s\n--- bindings ---\n%s", err, out, bindings) + } +} + +const semanticsTest = `package wordcount + +import ( + "encoding/json" + "strings" + "testing" +) + +func decode(t *testing.T, payload string) Input { + t.Helper() + var in Input + if err := json.Unmarshal([]byte(payload), &in); err != nil { + t.Fatalf("decoding %s: %v", payload, err) + } + return in +} + +func TestAbsentNullAndSetAreThreeStates(t *testing.T) { + absent := decode(t, ` + "`" + `{"text":"a","note":null,"scores":[]}` + "`" + `) + if absent.Flag.Present { + t.Error("an absent optional-and-nullable key decoded as present") + } + if absent.Count != nil { + t.Error("an absent optional key decoded as set") + } + if absent.Options != nil { + t.Error("an absent optional object decoded as set") + } + if absent.Note != nil { + t.Error("a null nullable key decoded as a value") + } + + null := decode(t, ` + "`" + `{"text":"a","note":null,"flag":null,"scores":[]}` + "`" + `) + if !null.Flag.Present || null.Flag.Value != nil { + t.Errorf("a null optional-and-nullable key decoded as %+v, want present with no value", null.Flag) + } + + set := decode(t, ` + "`" + `{"text":"a","note":"n","flag":true,"count":7,"options":{"limit":3},"scores":[1,2]}` + "`" + `) + if !set.Flag.Present || set.Flag.Value == nil || !*set.Flag.Value { + t.Errorf("a set optional-and-nullable key decoded as %+v", set.Flag) + } + if set.Count == nil || *set.Count != 7 { + t.Errorf("count decoded as %v", set.Count) + } + if set.Note == nil || *set.Note != "n" { + t.Errorf("note decoded as %v", set.Note) + } + if set.Options == nil || set.Options.Limit != 3 { + t.Errorf("options decoded as %+v", set.Options) + } +} + +func TestEncodingOmitsAbsentKeysAndWritesNull(t *testing.T) { + encoded := func(t *testing.T, in Input) string { + t.Helper() + data, err := json.Marshal(in) + if err != nil { + t.Fatalf("encoding: %v", err) + } + return string(data) + } + + absent := encoded(t, Input{Text: "a", Scores: []int64{}}) + for _, key := range []string{` + "`" + `"flag"` + "`" + `, ` + "`" + `"count"` + "`" + `, ` + "`" + `"options"` + "`" + `} { + if strings.Contains(absent, key) { + t.Errorf("absent key %s was encoded: %s", key, absent) + } + } + if !strings.Contains(absent, ` + "`" + `"note":null` + "`" + `) { + t.Errorf("a nullable key was not encoded as null: %s", absent) + } + + null := encoded(t, Input{Text: "a", Scores: []int64{}, Flag: Optional[bool]{Present: true}}) + if !strings.Contains(null, ` + "`" + `"flag":null` + "`" + `) { + t.Errorf("a present-and-null key was not encoded as null: %s", null) + } +} + +func TestIntegerIsSigned64Bit(t *testing.T) { + in := decode(t, ` + "`" + `{"text":"a","note":null,"scores":[9223372036854775807,-9223372036854775808]}` + "`" + `) + if in.Scores[0] != 9223372036854775807 || in.Scores[1] != -9223372036854775808 { + t.Errorf("64-bit bounds decoded as %v", in.Scores) + } + var overflow Input + if err := json.Unmarshal([]byte(` + "`" + `{"text":"a","scores":[9223372036854775808]}` + "`" + `), &overflow); err == nil { + t.Error("an integer past the 64-bit bound decoded without error") + } +} + +func TestNoImplicitCoercion(t *testing.T) { + for _, payload := range []string{ + ` + "`" + `{"text":"a","count":"7"}` + "`" + `, + ` + "`" + `{"text":"a","count":1.5}` + "`" + `, + ` + "`" + `{"text":5}` + "`" + `, + ` + "`" + `{"text":"a","flag":"true"}` + "`" + `, + ` + "`" + `{"text":"a","scores":[true]}` + "`" + `, + ` + "`" + `{"text":"a","options":[]}` + "`" + `, + } { + var in Input + if err := json.Unmarshal([]byte(payload), &in); err == nil { + t.Errorf("%s decoded without error", payload) + } + } +} +` diff --git a/pkg/generate/handler.go b/pkg/generate/handler.go new file mode 100644 index 0000000..c314542 --- /dev/null +++ b/pkg/generate/handler.go @@ -0,0 +1,21 @@ +package generate + +import ( + "fmt" + "go/format" + "strings" +) + +// Handler renders the author entrypoint of a runnable. The scaffold is written +// once and owned by the author from then on, so it carries no generated marker +// and is never rewritten over their implementation. +func Handler(pkg string, name string) ([]byte, error) { + var b strings.Builder + fmt.Fprintf(&b, "package %s\n\n", pkg) + b.WriteString("import (\n\t\"context\"\n\t\"errors\"\n)\n\n") + fmt.Fprintf(&b, "// Handle runs one invocation of the %s runnable.\n", name) + fmt.Fprintf(&b, "func Handle(ctx context.Context, in %s) (%s, error) {\n", inputType, outputType) + fmt.Fprintf(&b, "\treturn %s{}, errors.New(\"handler is not implemented\")\n", outputType) + b.WriteString("}\n") + return format.Source([]byte(b.String())) +}