diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 785a951a7..f723d6b00 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -7,7 +7,7 @@ This document provides guidance for AI agents working within the Telepact codeba Telepact is a multi-language API ecosystem built around a unified schema definition. The core idea is to define your API in `.telepact.json` files, and then use language-specific libraries to implement clients and servers. The project is a monorepo containing several independent but related components: -- **Core Libraries (`/lib`)**: Implementations of the Telepact protocol in `java`, `py` (Python), and `ts` (TypeScript). These provide the `Server` and `Client` classes. +- **Core Libraries (`/lib`)**: Implementations of the Telepact protocol in `go`, `java`, `py` (Python), and `ts` (TypeScript). These provide the `Server` and `Client` classes. - **SDKs (`/sdk`)**: Tools built on top of the core libraries, including a `cli`, a web-based `console`, and a `prettier` plugin for formatting schema files. - **Bindings (`/bind`)**: Language-specific bindings, like for `dart`. - **Schema Definitions**: APIs are defined in `.telepact.json` files (e.g., `common/auth.telepact.json`). These files are the source of truth for all API interactions. @@ -20,7 +20,6 @@ The entire project uses a hierarchical `Makefile` system. The root `Makefile` de ### Building -- To build everything: `make` (though this is often not necessary). - To build a specific library, navigate to its directory or use the root makefile. For example, to build the Java library: ```sh make java @@ -31,33 +30,18 @@ The entire project uses a hierarchical `Makefile` system. The root `Makefile` de ### Testing -The central test runner is located in `/test/runner`, which is a Python project. It is responsible for orchestrating tests across all language libraries to ensure they are interoperable. - -- To run all tests: - ```sh - make test - ``` -- To run tests for a specific language: +- Run tests from the `/test/runner` directory: ```sh - make test-java - make test-py - make test-ts + poetry run python -m pytest -s -vv -k ``` -- When in the `/test/runner` directory, you can run tests directly with `poetry run python -m pytest`. -- To run a single test case (e.g., for debugging), use `pytest`'s `-k` option to select a test by name. The `test/runner/Makefile` has `test-trace-*` targets that are good examples: - ```sh - # From the root directory - make test-trace-java - - # Or, from the test/runner directory - poetry run python -m pytest -k test_client_server_case[java-0] -s -vv - ``` +- NOTE: You need the `-s` flag to show all request/response payloads for debugging. + HOWEVER, avoid using `-s` when `-k` is not specified, as it will produce excessive output. ### Key Files & Directories - `Makefile`: The entry point for all build, test, and deploy operations. -- `lib/{java,py,ts}`: The core libraries. Changes here impact the fundamental behavior of Telepact. +- `lib/{go,java,py,ts}`: The core libraries. Changes here impact the fundamental behavior of Telepact. - `bind/dart`: Language-specific bindings for Dart. - `test/runner`: The cross-language integration test suite. This is the best place to understand how different language implementations are expected to behave and interact. - `common/*.telepact.json`: The common schema files that define the internal APIs used by Telepact itself. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ee44186e3..1104f3ca8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -119,6 +119,22 @@ jobs: path: lib/java/target retention-days: 1 + build-go: + name: Build Go + runs-on: ubuntu-latest + timeout-minutes: 1 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.22" + + - name: Build go + run: make go + build-py: name: Build Python runs-on: ubuntu-latest @@ -382,7 +398,7 @@ jobs: name: Test runs-on: ubuntu-latest timeout-minutes: 5 - needs: [build-ts, build-java, build-py, build-cli] + needs: [build-ts, build-java, build-go, build-py, build-cli] steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bc5bfe218..912a3be29 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -91,6 +91,24 @@ jobs: POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_TOKEN }} run: make deploy-py + publish-go: + runs-on: ubuntu-latest + if: ${{ contains(github.event.release.body, '- go') }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.target_commitish }} + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.22" + + - name: Publish to Go proxy + run: make deploy-go + publish-cli: runs-on: ubuntu-latest if: ${{ contains(github.event.release.body, '- cli') }} diff --git a/Makefile b/Makefile index 845684bc6..564a0f378 100644 --- a/Makefile +++ b/Makefile @@ -64,6 +64,21 @@ test-trace-ts: deploy-ts: $(MAKE) -C lib/ts deploy +go: + $(MAKE) -C lib/go + +clean-go: + $(MAKE) -C lib/go clean + +test-go: + $(MAKE) -C test/runner test-go + +test-trace-go: + $(MAKE) -C test/runner test-trace-go + +deploy-go: + $(MAKE) -C lib/go deploy + .PHONY: test test: $(MAKE) -C test/runner test @@ -72,6 +87,7 @@ clean-test: $(MAKE) -C test/runner clean $(MAKE) -C test/lib/java clean $(MAKE) -C test/lib/py clean + $(MAKE) -C test/lib/go clean $(MAKE) -C test/lib/ts clean dart: diff --git a/README.md b/README.md index eff9b3729..31c3dd41d 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ the library and SDK docs: - [Library: Typescript](./lib/ts/README.md) - [Library: Python](./lib/py/README.md) - [Library: Java](./lib/java/README.md) +- [Library: Go](./lib/go/README.md) - [SDK: CLI](./sdk/cli/README.md) - [SDK: Developer Console](./sdk/console/README.md) diff --git a/lib/go/Client.go b/lib/go/Client.go new file mode 100644 index 000000000..f198ec34c --- /dev/null +++ b/lib/go/Client.go @@ -0,0 +1,133 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package telepact + +import ( + "context" + "errors" + "fmt" + + telepactinternal "github.com/telepact/telepact/lib/go/internal" + "github.com/telepact/telepact/lib/go/internal/binary" +) + +// ClientAdapter is the transport-specific function used to exchange Telepact messages. +type ClientAdapter func(ctx context.Context, request Message, serializer *Serializer) (Message, error) + +// ClientOptions configure a Client instance. +type ClientOptions struct { + UseBinary bool + AlwaysSendJSON bool + TimeoutMSDefault int + SerializationImpl Serialization +} + +// NewClientOptions returns a ClientOptions struct populated with library defaults. +func NewClientOptions() *ClientOptions { + return &ClientOptions{ + UseBinary: false, + AlwaysSendJSON: true, + TimeoutMSDefault: 5000, + SerializationImpl: NewDefaultSerialization(), + } +} + +// Client coordinates request/response interactions with a Telepact service. +type Client struct { + adapter ClientAdapter + useBinaryDefault bool + alwaysSendJSON bool + timeoutMSDefault int + serializer *Serializer +} + +// NewClient constructs a Client using the provided adapter and options. +func NewClient(adapter ClientAdapter, options *ClientOptions) (*Client, error) { + if adapter == nil { + return nil, errors.New("telepact: client adapter must not be nil") + } + + if options == nil { + options = NewClientOptions() + } + + serializationImpl := options.SerializationImpl + if serializationImpl == nil { + serializationImpl = NewDefaultSerialization() + } + + binaryEncodingCache := binary.NewDefaultBinaryEncodingCache() + binaryEncoder := binary.NewClientBinaryEncoder(binaryEncodingCache) + base64Encoder := binary.NewClientBase64Encoder() + serializer := NewSerializer(serializationImpl, binaryEncoder, base64Encoder) + + return &Client{ + adapter: adapter, + useBinaryDefault: options.UseBinary, + alwaysSendJSON: options.AlwaysSendJSON, + timeoutMSDefault: options.TimeoutMSDefault, + serializer: serializer, + }, nil +} + +// RequestWithContext executes the adapter using the supplied context, applying Telepact defaults. +func (c *Client) RequestWithContext(ctx context.Context, request Message) (Message, error) { + if c == nil { + return Message{}, NewTelepactError("telepact: client is nil") + } + + if ctx == nil { + ctx = context.Background() + } + + internalRequest := telepactinternal.NewClientMessage(request.Headers, request.Body) + + adapter := func(ctx context.Context, msg *telepactinternal.ClientMessage) (*telepactinternal.ClientMessage, error) { + resp, err := c.adapter(ctx, Message{Headers: msg.Headers, Body: msg.Body}, c.serializer) + if err != nil { + return nil, err + } + return telepactinternal.NewClientMessage(resp.Headers, resp.Body), nil + } + + internalResponse, err := telepactinternal.ClientHandleMessage( + ctx, + internalRequest, + adapter, + c.timeoutMSDefault, + c.useBinaryDefault, + c.alwaysSendJSON, + ) + if err != nil { + return Message{}, NewTelepactError(fmt.Sprintf("client request failed: %v", err)) + } + + return Message{Headers: internalResponse.Headers, Body: internalResponse.Body}, nil +} + +// Request executes the adapter using a background context. +func (c *Client) Request(request Message) (Message, error) { + return c.RequestWithContext(context.Background(), request) +} + +// Serializer returns the serializer associated with the client. +func (c *Client) Serializer() *Serializer { + if c == nil { + return nil + } + return c.serializer +} diff --git a/lib/go/DefaultSerialization.go b/lib/go/DefaultSerialization.go new file mode 100644 index 000000000..cd4fadcb4 --- /dev/null +++ b/lib/go/DefaultSerialization.go @@ -0,0 +1,225 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package telepact + +import ( + "bytes" + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + + "github.com/vmihailenco/msgpack/v5" +) + +const msgpackJSONNumberExtType = 0x2a + +type msgpackJSONNumber struct { + Value string +} + +func (n *msgpackJSONNumber) MarshalMsgpack() ([]byte, error) { + return []byte(n.Value), nil +} + +func (n *msgpackJSONNumber) UnmarshalMsgpack(data []byte) error { + n.Value = string(data) + return nil +} + +func init() { + msgpack.RegisterExt(msgpackJSONNumberExtType, (*msgpackJSONNumber)(nil)) +} + +// DefaultSerialization implements the Serialization interface using encoding/json and vmihailenco/msgpack. +type DefaultSerialization struct{} + +// NewDefaultSerialization constructs a DefaultSerialization instance. +func NewDefaultSerialization() *DefaultSerialization { + return &DefaultSerialization{} +} + +// ToJSON converts a pseudo-JSON object into its JSON-encoded bytes representation. +func (d *DefaultSerialization) ToJSON(message any) ([]byte, error) { + payload, err := json.Marshal(message) + if err != nil { + return nil, NewSerializationError(err, "encode JSON") + } + return payload, nil +} + +// ToMsgpack converts a pseudo-JSON object into its MessagePack-encoded bytes representation. +func (d *DefaultSerialization) ToMsgpack(message any) ([]byte, error) { + prepared := wrapJSONNumbers(message) + payload, err := msgpack.Marshal(prepared) + if err != nil { + return nil, NewSerializationError(err, "encode msgpack") + } + return payload, nil +} + +// FromJSON decodes JSON bytes into a pseudo-JSON object. +func (d *DefaultSerialization) FromJSON(data []byte) (any, error) { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + + var out any + if err := decoder.Decode(&out); err != nil { + return nil, NewSerializationError(err, "decode JSON") + } + return normalizePseudoJSON(out), nil +} + +// FromMsgpack decodes MessagePack bytes into a pseudo-JSON object. +func (d *DefaultSerialization) FromMsgpack(data []byte) (any, error) { + decoder := msgpack.NewDecoder(bytes.NewReader(data)) + decoder.SetMapDecoder(func(dec *msgpack.Decoder) (any, error) { + length, err := dec.DecodeMapLen() + if err != nil { + return nil, err + } + result := make(map[any]any, length) + for i := 0; i < length; i++ { + key, err := dec.DecodeInterface() + if err != nil { + return nil, err + } + value, err := dec.DecodeInterface() + if err != nil { + return nil, err + } + result[key] = value + } + return result, nil + }) + + value, err := decoder.DecodeInterface() + if err != nil { + return nil, NewSerializationError(err, "decode msgpack") + } + return normalizePseudoJSON(unwrapJSONNumbers(value)), nil +} + +func normalizePseudoJSON(value any) any { + switch v := value.(type) { + case map[string]any: + for key, val := range v { + v[key] = normalizePseudoJSON(val) + } + return v + case map[any]any: + result := make(map[string]any, len(v)) + for key, val := range v { + result[fmt.Sprint(key)] = normalizePseudoJSON(val) + } + return result + case []any: + for i, val := range v { + v[i] = normalizePseudoJSON(val) + } + return v + case float64: + if math.Trunc(v) == v && v <= math.MaxInt64 && v >= math.MinInt64 { + return int64(v) + } + return v + case json.Number: + normalized, ok := normalizeJSONNumber(v) + if ok { + return normalized + } + return v + default: + return v + } +} + +func wrapJSONNumbers(value any) any { + switch v := value.(type) { + case map[string]any: + result := make(map[string]any, len(v)) + for key, val := range v { + result[key] = wrapJSONNumbers(val) + } + return result + case map[any]any: + result := make(map[any]any, len(v)) + for key, val := range v { + result[key] = wrapJSONNumbers(val) + } + return result + case []any: + result := make([]any, len(v)) + for i, val := range v { + result[i] = wrapJSONNumbers(val) + } + return result + case json.Number: + return &msgpackJSONNumber{Value: string(v)} + case *msgpackJSONNumber: + return v + default: + return value + } +} + +func unwrapJSONNumbers(value any) any { + switch v := value.(type) { + case map[string]any: + for key, val := range v { + v[key] = unwrapJSONNumbers(val) + } + return v + case map[any]any: + for key, val := range v { + v[key] = unwrapJSONNumbers(val) + } + return v + case []any: + for i, val := range v { + v[i] = unwrapJSONNumbers(val) + } + return v + case *msgpackJSONNumber: + if v == nil { + return nil + } + return json.Number(v.Value) + case msgpackJSONNumber: + return json.Number(v.Value) + default: + return value + } +} + +func normalizeJSONNumber(value json.Number) (any, bool) { + raw := string(value) + + if !strings.ContainsAny(raw, ".eE") { + if i, err := strconv.ParseInt(raw, 10, 64); err == nil { + return i, true + } + return nil, false + } + + if f, err := strconv.ParseFloat(raw, 64); err == nil && !math.IsNaN(f) && !math.IsInf(f, 0) { + return f, true + } + + return nil, false +} diff --git a/lib/go/Makefile b/lib/go/Makefile new file mode 100644 index 000000000..4c3672822 --- /dev/null +++ b/lib/go/Makefile @@ -0,0 +1,55 @@ +#| +#| Copyright The Telepact Authors +#| +#| Licensed under the Apache License, Version 2.0 (the "License"); +#| you may not use this file except in compliance with the License. +#| You may obtain a copy of the License at +#| +#| https://www.apache.org/licenses/LICENSE-2.0 +#| +#| Unless required by applicable law or agreed to in writing, software +#| distributed under the License is distributed on an "AS IS" BASIS, +#| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +#| See the License for the specific language governing permissions and +#| limitations under the License. +#| + +SHELL := /bin/bash + +VERSION := $(shell cat ../../VERSION.txt) +MODULE_PATH := github.com/telepact/telepact/lib/go + +GO_SOURCES := $(shell find . -type f -name '*.go' -not -path './target/*') + +SCHEMA_SOURCE_DIR := ../../common +SCHEMA_TARGET_DIR := internal/schema/do_not_edit + +.PHONY: all build test fmt tidy clean deploy sync-schema-assets copy-common-assets + +all: build + +copy-common-assets: + @mkdir -p $(SCHEMA_TARGET_DIR) + cp $(SCHEMA_SOURCE_DIR)/*.json $(SCHEMA_TARGET_DIR)/ + +sync-schema-assets: + @$(MAKE) copy-common-assets + @if [ -n "$$(git status --porcelain $(SCHEMA_TARGET_DIR))" ]; then \ + echo "ERROR: Schema assets updated. Please run 'make go' and commit the changes."; \ + exit 1; \ + fi + +build: sync-schema-assets go.mod go.sum $(GO_SOURCES) + GO111MODULE=on go build ./... + +fmt: + gofmt -w $(GO_SOURCES) + +tidy: + GO111MODULE=on go mod tidy + +clean: + GO111MODULE=on go clean ./... + +deploy: build + GO111MODULE=on GOPROXY=proxy.golang.org,direct go list -m $(MODULE_PATH)@v$(VERSION) diff --git a/lib/go/Message.go b/lib/go/Message.go new file mode 100644 index 000000000..eee644eea --- /dev/null +++ b/lib/go/Message.go @@ -0,0 +1,118 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package telepact + +import ( + "fmt" + "sort" + "strings" +) + +// Message models a Telepact message with headers and a body. +type Message struct { + Headers map[string]any + Body map[string]any +} + +// NewMessage creates a Message with defensive copies of the provided maps. +func NewMessage(headers map[string]any, body map[string]any) Message { + return Message{ + Headers: cloneStringMap(headers), + Body: cloneStringMap(body), + } +} + +// BodyTarget returns the first key in the body map, which corresponds to the target. +func (m Message) BodyTarget() (string, error) { + keys := orderedBodyKeys(m.Body) + if len(keys) == 0 { + return "", NewTelepactError("message body missing target") + } + return keys[0], nil +} + +// BodyPayload returns the payload associated with the body's target entry. +func (m Message) BodyPayload() (map[string]any, error) { + keys := orderedBodyKeys(m.Body) + if len(keys) == 0 { + return nil, NewTelepactError("message body missing payload") + } + + value := m.Body[keys[0]] + switch typed := value.(type) { + case map[string]any: + return cloneStringMap(typed), nil + case map[any]any: + converted := make(map[string]any, len(typed)) + for k, v := range typed { + converted[fmt.Sprint(k)] = v + } + return converted, nil + default: + return nil, NewTelepactError("message body payload is not an object") + } +} + +func cloneStringMap(source map[string]any) map[string]any { + if source == nil { + return nil + } + copy := make(map[string]any, len(source)) + for key, value := range source { + copy[key] = value + } + return copy +} + +func orderedBodyKeys(body map[string]any) []string { + if len(body) == 0 { + return nil + } + + keys := make([]string, 0, len(body)) + for key := range body { + keys = append(keys, key) + } + + sort.Slice(keys, func(i, j int) bool { + pi := bodyKeyPriority(keys[i]) + pj := bodyKeyPriority(keys[j]) + if pi != pj { + return pi < pj + } + return keys[i] < keys[j] + }) + + return keys +} + +func bodyKeyPriority(key string) int { + switch { + case strings.HasPrefix(key, "fn."): + return 0 + case key == "Ok_": + return 1 + case key == "Err_": + return 2 + case !strings.HasPrefix(key, "_") && !strings.HasPrefix(key, "@"): + return 3 + case strings.HasPrefix(key, "_"): + return 4 + default: + return 5 + } +} diff --git a/lib/go/MockServer.go b/lib/go/MockServer.go new file mode 100644 index 000000000..e65ddcb7b --- /dev/null +++ b/lib/go/MockServer.go @@ -0,0 +1,140 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package telepact + +import ( + "github.com/telepact/telepact/lib/go/internal/mock" +) + +// MockServerOptions configures behaviour of the mock server. +type MockServerOptions struct { + OnError func(error) + EnableMessageResponseGeneration bool + EnableOptionalFieldGeneration bool + RandomizeOptionalFieldGeneration bool + GeneratedCollectionLengthMin int + GeneratedCollectionLengthMax int +} + +// NewMockServerOptions constructs MockServerOptions populated with defaults. +func NewMockServerOptions() *MockServerOptions { + return &MockServerOptions{ + OnError: func(error) {}, + EnableMessageResponseGeneration: true, + EnableOptionalFieldGeneration: true, + RandomizeOptionalFieldGeneration: true, + GeneratedCollectionLengthMin: 0, + GeneratedCollectionLengthMax: 3, + } +} + +// MockServer provides a mock Telepact server implementation. +type MockServer struct { + random *RandomGenerator + enableGeneratedDefaultStub bool + enableOptionalFieldGeneration bool + randomizeOptionalFieldGeneration bool + stubs []*mock.MockStub + invocations []*mock.MockInvocation + server *Server +} + +// NewMockServer constructs a new MockServer using the supplied schema and options. +func NewMockServer(mockSchema *MockTelepactSchema, options *MockServerOptions) (*MockServer, error) { + if mockSchema == nil { + return nil, NewTelepactError("telepact: mock schema must not be nil") + } + + if options == nil { + options = NewMockServerOptions() + } + + if options.OnError == nil { + options.OnError = func(error) {} + } + + ms := &MockServer{ + random: NewRandomGenerator(options.GeneratedCollectionLengthMin, options.GeneratedCollectionLengthMax), + enableGeneratedDefaultStub: options.EnableMessageResponseGeneration, + enableOptionalFieldGeneration: options.EnableOptionalFieldGeneration, + randomizeOptionalFieldGeneration: options.RandomizeOptionalFieldGeneration, + stubs: []*mock.MockStub{}, + invocations: []*mock.MockInvocation{}, + } + + serverOptions := NewServerOptions() + serverOptions.OnError = options.OnError + serverOptions.AuthRequired = false + + telepactSchema := NewTelepactSchema( + mockSchema.Original, + mockSchema.Parsed, + mockSchema.ParsedRequestHeaders, + mockSchema.ParsedResponseHeaders, + ) + + server, err := NewServer(telepactSchema, ms.handle, serverOptions) + if err != nil { + return nil, err + } + + ms.server = server + + return ms, nil +} + +// Process processes a Telepact request message and returns the serialized response. +func (ms *MockServer) Process(message []byte) (Response, error) { + if ms == nil || ms.server == nil { + return Response{}, NewTelepactError("telepact: mock server is not initialized") + } + return ms.server.Process(message) +} + +func (ms *MockServer) handle(request Message) (Message, error) { + if ms == nil { + return Message{}, NewTelepactError("telepact: mock server is nil") + } + + functionName, err := request.BodyTarget() + if err != nil { + return Message{}, err + } + + argument, err := request.BodyPayload() + if err != nil { + return Message{}, err + } + + responseHeaders, responseBody, handleErr := mock.MockHandle( + request.Headers, + functionName, + argument, + &ms.stubs, + &ms.invocations, + ms.random, + ms.server.telepactSchema.Parsed, + ms.enableGeneratedDefaultStub, + ms.enableOptionalFieldGeneration, + ms.randomizeOptionalFieldGeneration, + ) + if handleErr != nil { + return Message{}, NewTelepactError(handleErr.Error()) + } + + return NewMessage(responseHeaders, responseBody), nil +} diff --git a/lib/go/MockTelepactSchema.go b/lib/go/MockTelepactSchema.go new file mode 100644 index 000000000..07aebedea --- /dev/null +++ b/lib/go/MockTelepactSchema.go @@ -0,0 +1,127 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package telepact + +import ( + "github.com/telepact/telepact/lib/go/internal/schema" + "github.com/telepact/telepact/lib/go/internal/types" +) + +// MockTelepactSchema represents a schema prepared for mock usage. +type MockTelepactSchema struct { + Original []any + Parsed map[string]types.TType + ParsedRequestHeaders map[string]*types.TFieldDeclaration + ParsedResponseHeaders map[string]*types.TFieldDeclaration +} + +// NewMockTelepactSchema constructs a MockTelepactSchema with the supplied values. +func NewMockTelepactSchema( + original []any, + parsed map[string]types.TType, + parsedRequestHeaders map[string]*types.TFieldDeclaration, + parsedResponseHeaders map[string]*types.TFieldDeclaration, +) *MockTelepactSchema { + return &MockTelepactSchema{ + Original: cloneAnySlice(original), + Parsed: parsed, + ParsedRequestHeaders: parsedRequestHeaders, + ParsedResponseHeaders: parsedResponseHeaders, + } +} + +// MockTelepactSchemaFromJSON constructs a MockTelepactSchema from JSON content. +func MockTelepactSchemaFromJSON(json string) (*MockTelepactSchema, error) { + result, err := schema.CreateMockTelepactSchemaFromFileJSONMap(map[string]string{"auto_": json}) + if err != nil { + return nil, wrapParseError(err) + } + return mockTelepactSchemaFromParsed(result), nil +} + +// MockTelepactSchemaFromFileJSONMap constructs a MockTelepactSchema from a map of filenames to JSON content. +func MockTelepactSchemaFromFileJSONMap(fileJSONMap map[string]string) (*MockTelepactSchema, error) { + result, err := schema.CreateMockTelepactSchemaFromFileJSONMap(fileJSONMap) + if err != nil { + return nil, wrapParseError(err) + } + return mockTelepactSchemaFromParsed(result), nil +} + +// MockTelepactSchemaFromDirectory constructs a MockTelepactSchema from the JSON files contained in a directory. +func MockTelepactSchemaFromDirectory(directory string) (*MockTelepactSchema, error) { + schemaFileMap, parseFailures, err := schema.GetSchemaFileMap(directory) + if err != nil { + return nil, err + } + if len(parseFailures) > 0 { + return nil, NewTelepactSchemaParseError(parseFailures, schemaFileMap) + } + result, err := schema.CreateMockTelepactSchemaFromFileJSONMap(schemaFileMap) + if err != nil { + return nil, wrapParseError(err) + } + return mockTelepactSchemaFromParsed(result), nil +} + +// ParsedDefinitions returns the parsed schema definitions keyed by message name. +func (m *MockTelepactSchema) ParsedDefinitions() map[string]types.TType { + if m == nil { + return nil + } + return m.Parsed +} + +// RequestHeaderDeclarations returns the parsed request header declarations keyed by header name. +func (m *MockTelepactSchema) RequestHeaderDeclarations() map[string]*types.TFieldDeclaration { + if m == nil { + return nil + } + return m.ParsedRequestHeaders +} + +// ResponseHeaderDeclarations returns the parsed response header declarations keyed by header name. +func (m *MockTelepactSchema) ResponseHeaderDeclarations() map[string]*types.TFieldDeclaration { + if m == nil { + return nil + } + return m.ParsedResponseHeaders +} + +// OriginalDefinitions returns the original schema definitions slice. +func (m *MockTelepactSchema) OriginalDefinitions() []any { + if m == nil { + return nil + } + return m.Original +} + +func cloneAnySlice(values []any) []any { + if values == nil { + return nil + } + cloned := make([]any, len(values)) + copy(cloned, values) + return cloned +} + +func mockTelepactSchemaFromParsed(result *schema.ParsedSchemaResult) *MockTelepactSchema { + if result == nil { + return nil + } + return NewMockTelepactSchema(result.Original, result.Parsed, result.ParsedRequestHeaders, result.ParsedResponseHeaders) +} diff --git a/lib/go/README.md b/lib/go/README.md new file mode 100644 index 000000000..a6b8ce112 --- /dev/null +++ b/lib/go/README.md @@ -0,0 +1,153 @@ +## Telepact Library for Go + +### Installation + +```bash +go get github.com/telepact/telepact/lib/go +``` + +### Usage + +API: + +```json +[ + { + "fn.greet": { + "subject": "string" + }, + "->": { + "Ok_": { + "message": "string" + } + } + } +] +``` + +Server: + +```go +package main + +import ( + "fmt" + "log" + + telepact "github.com/telepact/telepact/lib/go" +) + +func main() { + files, err := telepact.NewTelepactSchemaFiles("./directory/containing/api/files") + if err != nil { + log.Fatal(err) + } + + schema, err := telepact.TelepactSchemaFromFileJSONMap(files.FilenamesToJSON) + if err != nil { + log.Fatal(err) + } + + handler := func(request telepact.Message) (telepact.Message, error) { + functionName, err := request.BodyTarget() + if err != nil { + return telepact.Message{}, err + } + + arguments, err := request.BodyPayload() + if err != nil { + return telepact.Message{}, err + } + + // Early in the handler, perform any pre-flight "middleware" operations, such as + // authentication, tracing, or logging. + log.Printf("Function started: %s", functionName) + + // At the end the handler, perform any post-flight "middleware" operations + defer log.Printf("Function finished: %s", functionName) + + // Dispatch request to appropriate function handling code. + // (This example uses manual dispatching, but you can also use a more advanced pattern.) + if functionName == "fn.greet" { + subject, _ := arguments["subject"].(string) + return telepact.NewMessage( + map[string]any{}, + map[string]any{ + "Ok_": map[string]any{ + "message": fmt.Sprintf("Hello %s!", subject), + }, + }, + ), nil + } + + return telepact.Message{}, telepact.NewTelepactError("function not found") + } + + serverOptions := telepact.NewServerOptions() + server, err := telepact.NewServer(schema, handler, serverOptions) + if err != nil { + log.Fatal(err) + } + + // Wire up request/response bytes from your transport of choice + transport.Receive(func(requestBytes []byte) ([]byte, error) { + response, err := server.Process(requestBytes) + if err != nil { + return nil, err + } + return response.Bytes, nil + }) +} +``` + +Client: + +```go +package main + +import ( + "context" + "log" + + telepact "github.com/telepact/telepact/lib/go" +) + +func main() { + adapter := func(ctx context.Context, request telepact.Message, serializer *telepact.Serializer) (telepact.Message, error) { + requestBytes, err := serializer.Serialize(request) + if err != nil { + return telepact.Message{}, err + } + + // Wire up request/response bytes to your transport of choice + responseBytes, err := transport.Send(ctx, requestBytes) + if err != nil { + return telepact.Message{}, err + } + + return serializer.Deserialize(responseBytes) + } + + clientOptions := telepact.NewClientOptions() + client, err := telepact.NewClient(adapter, clientOptions) + if err != nil { + log.Fatal(err) + } + + response, err := client.Request( + telepact.NewMessage( + map[string]any{}, + map[string]any{ + "fn.greet": map[string]any{"subject": "Telepact"}, + }, + ), + ) + if err != nil { + log.Fatal(err) + } + + log.Printf("Response: %+v", response) +} +``` + +For more concrete usage examples, [see the tests](https://github.com/Telepact/telepact/tree/main/test/lib/go). diff --git a/lib/go/RandomGenerator.go b/lib/go/RandomGenerator.go new file mode 100644 index 000000000..96408afc1 --- /dev/null +++ b/lib/go/RandomGenerator.go @@ -0,0 +1,106 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package telepact + +import "encoding/binary" + +var randomWords = []string{ + "alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta", + "iota", "kappa", "lambda", "mu", "nu", "xi", "omicron", "pi", + "rho", "sigma", "tau", "upsilon", "phi", "chi", "psi", "omega", +} + +// RandomGenerator produces deterministic pseudo-random values used during mock generation. +type RandomGenerator struct { + seed int32 + collectionLengthMin int + collectionLengthMax int + count int +} + +// NewRandomGenerator constructs a RandomGenerator with the provided collection length bounds. +func NewRandomGenerator(collectionLengthMin, collectionLengthMax int) *RandomGenerator { + rg := &RandomGenerator{ + collectionLengthMin: collectionLengthMin, + collectionLengthMax: collectionLengthMax, + } + rg.SetSeed(0) + return rg +} + +// SetSeed sets the generator seed. A zero value is normalized to one to avoid the stationary state. +func (r *RandomGenerator) SetSeed(seed int32) { + if seed == 0 { + r.seed = 1 + return + } + r.seed = seed +} + +// NextInt returns the next pseudo-random positive 31-bit integer. +func (r *RandomGenerator) NextInt() int { + x := r.seed + x ^= x << 16 + x ^= x >> 11 + x ^= x << 5 + if x == 0 { + x = 1 + } + r.seed = x + r.count++ + return int(x & 0x7fffffff) +} + +// NextIntWithCeiling returns the next pseudo-random integer modulo the provided ceiling. +func (r *RandomGenerator) NextIntWithCeiling(ceiling int) int { + if ceiling == 0 { + return 0 + } + return r.NextInt() % ceiling +} + +// NextBoolean returns a pseudo-random boolean value. +func (r *RandomGenerator) NextBoolean() bool { + return r.NextIntWithCeiling(31) > 15 +} + +// NextBytes returns four pseudo-random bytes using big-endian encoding. +func (r *RandomGenerator) NextBytes() []byte { + bytes := make([]byte, 4) + binary.BigEndian.PutUint32(bytes, uint32(r.NextInt())) + return bytes +} + +// NextString returns a pseudo-random word from a deterministic dictionary. +func (r *RandomGenerator) NextString() string { + index := r.NextIntWithCeiling(len(randomWords)) + return randomWords[index] +} + +// NextDouble returns a pseudo-random floating-point value in the range [0, 1). +func (r *RandomGenerator) NextDouble() float64 { + return float64(r.NextInt()&0x7fffffff) / float64(0x7fffffff) +} + +// NextCollectionLength returns the next pseudo-random collection length within the configured bounds. +func (r *RandomGenerator) NextCollectionLength() int { + offset := r.collectionLengthMax - r.collectionLengthMin + if offset <= 0 { + return r.collectionLengthMin + } + return r.NextIntWithCeiling(offset) + r.collectionLengthMin +} diff --git a/lib/go/Response.go b/lib/go/Response.go new file mode 100644 index 000000000..03d43da8c --- /dev/null +++ b/lib/go/Response.go @@ -0,0 +1,36 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package telepact + +// Response represents a serialized payload and its associated headers. +type Response struct { + Bytes []byte + Headers map[string]any +} + +// NewResponse constructs a Response with defensive copies of the provided values. +func NewResponse(bytes []byte, headers map[string]any) Response { + var clonedBytes []byte + if bytes != nil { + clonedBytes = make([]byte, len(bytes)) + copy(clonedBytes, bytes) + } + return Response{ + Bytes: clonedBytes, + Headers: cloneStringMap(headers), + } +} diff --git a/lib/go/SerializationError.go b/lib/go/SerializationError.go new file mode 100644 index 000000000..030d5bebd --- /dev/null +++ b/lib/go/SerializationError.go @@ -0,0 +1,53 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package telepact + +import "fmt" + +// SerializationError indicates a failure converting between pseudo-JSON objects and serialized bytes. +type SerializationError struct { + Message string + Cause error +} + +// NewSerializationError constructs a SerializationError with the provided cause. +func NewSerializationError(cause error, message string) *SerializationError { + return &SerializationError{Message: message, Cause: cause} +} + +// Error implements the error interface. +func (e *SerializationError) Error() string { + if e == nil { + return "" + } + msg := e.Message + if msg == "" { + msg = "serialization error" + } + if e.Cause != nil { + return fmt.Sprintf("%s: %v", msg, e.Cause) + } + return msg +} + +// Unwrap exposes the wrapped cause for errors.Is/As. +func (e *SerializationError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} diff --git a/lib/go/Serializer.go b/lib/go/Serializer.go new file mode 100644 index 000000000..5cfaa528a --- /dev/null +++ b/lib/go/Serializer.go @@ -0,0 +1,61 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package telepact + +import ( + telepactinternal "github.com/telepact/telepact/lib/go/internal" + "github.com/telepact/telepact/lib/go/internal/binary" +) + +// Serializer converts Telepact messages between pseudo-JSON and serialized bytes. +type Serializer struct { + serializationImpl Serialization + binaryEncoder binary.BinaryEncoder + base64Encoder binary.Base64Encoder +} + +// NewSerializer constructs a Serializer with the provided serialization and encoding strategies. +func NewSerializer(serializationImpl Serialization, binaryEncoder binary.BinaryEncoder, base64Encoder binary.Base64Encoder) *Serializer { + return &Serializer{ + serializationImpl: serializationImpl, + binaryEncoder: binaryEncoder, + base64Encoder: base64Encoder, + } +} + +// Serialize converts a Message into its serialized byte representation. +func (s *Serializer) Serialize(message Message) ([]byte, error) { + return telepactinternal.SerializeInternal( + message.Headers, + message.Body, + s.binaryEncoder, + s.base64Encoder, + s.serializationImpl, + func(err error, context string) error { + return NewSerializationError(err, context) + }, + ) +} + +// Deserialize converts serialized bytes into a Message structure. +func (s *Serializer) Deserialize(messageBytes []byte) (Message, error) { + headers, body, err := telepactinternal.DeserializeInternal(messageBytes, s.serializationImpl, s.binaryEncoder, s.base64Encoder) + if err != nil { + return Message{}, err + } + return NewMessage(headers, body), nil +} diff --git a/lib/go/Server.go b/lib/go/Server.go new file mode 100644 index 000000000..53846d85c --- /dev/null +++ b/lib/go/Server.go @@ -0,0 +1,184 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package telepact + +import ( + telepactinternal "github.com/telepact/telepact/lib/go/internal" + "github.com/telepact/telepact/lib/go/internal/binary" +) + +// ServerHandler processes incoming Telepact messages and returns a response message. +type ServerHandler func(Message) (Message, error) + +// ServerOptions configures server behaviour. +type ServerOptions struct { + OnError func(error) + OnRequest func(Message) + OnResponse func(Message) + AuthRequired bool + Serialization Serialization +} + +// NewServerOptions constructs ServerOptions populated with defaults. +func NewServerOptions() *ServerOptions { + return &ServerOptions{ + OnError: func(error) {}, + OnRequest: func(Message) {}, + OnResponse: func(Message) {}, + AuthRequired: true, + Serialization: NewDefaultSerialization(), + } +} + +// Server represents a Telepact server instance. +type Server struct { + handler ServerHandler + onError func(error) + onRequest func(Message) + onResponse func(Message) + telepactSchema *TelepactSchema + serializer *Serializer +} + +// NewServer constructs a Server using the supplied schema, handler, and options. +func NewServer(telepactSchema *TelepactSchema, handler ServerHandler, options *ServerOptions) (*Server, error) { + if telepactSchema == nil { + return nil, NewTelepactError("telepact: schema must not be nil") + } + if handler == nil { + return nil, NewTelepactError("telepact: handler must not be nil") + } + + if options == nil { + options = NewServerOptions() + } + + if options.OnError == nil { + options.OnError = func(error) {} + } + if options.OnRequest == nil { + options.OnRequest = func(Message) {} + } + if options.OnResponse == nil { + options.OnResponse = func(Message) {} + } + + serializationImpl := options.Serialization + if serializationImpl == nil { + serializationImpl = NewDefaultSerialization() + } + + binaryEncoding, err := binary.ConstructBinaryEncoding(telepactSchema.Parsed) + if err != nil { + return nil, err + } + + binaryEncoder := binary.NewServerBinaryEncoder(binaryEncoding) + base64Encoder := binary.NewServerBase64Encoder() + serializer := NewSerializer(serializationImpl, binaryEncoder, base64Encoder) + + if _, exists := telepactSchema.Parsed["struct.Auth_"]; !exists && options.AuthRequired { + return nil, NewTelepactError("Unauthenticated server. Either define a `struct.Auth_` in your schema or set `options.auth_required` to `false`.") + } + + return &Server{ + handler: handler, + onError: options.OnError, + onRequest: options.OnRequest, + onResponse: options.OnResponse, + telepactSchema: telepactSchema, + serializer: serializer, + }, nil +} + +// ProcessWithHeaders processes a request message with optional override headers. +func (s *Server) ProcessWithHeaders(requestMessageBytes []byte, overrideHeaders map[string]any) (Response, error) { + if s == nil { + return Response{}, NewTelepactError("telepact: server is nil") + } + + if overrideHeaders == nil { + overrideHeaders = map[string]any{} + } + + deserialize := func(bytes []byte) (telepactinternal.ServerMessage, error) { + msg, err := s.serializer.Deserialize(bytes) + if err != nil { + return telepactinternal.ServerMessage{}, err + } + return telepactinternal.ServerMessage{Headers: msg.Headers, Body: msg.Body}, nil + } + + serialize := func(message telepactinternal.ServerMessage) ([]byte, error) { + goMessage := NewMessage(message.Headers, message.Body) + return s.serializer.Serialize(goMessage) + } + + internalOnRequest := func(message telepactinternal.ServerMessage) { + s.onRequest(NewMessage(message.Headers, message.Body)) + } + + internalOnResponse := func(message telepactinternal.ServerMessage) { + s.onResponse(NewMessage(message.Headers, message.Body)) + } + + internalHandler := func(message telepactinternal.ServerMessage) (telepactinternal.ServerMessage, error) { + response, err := s.handler(NewMessage(message.Headers, message.Body)) + if err != nil { + return telepactinternal.ServerMessage{}, err + } + return telepactinternal.ServerMessage{Headers: response.Headers, Body: response.Body}, nil + } + + responseMessage, responseBytes, err := telepactinternal.ProcessBytes( + requestMessageBytes, + overrideHeaders, + deserialize, + serialize, + s.telepactSchema, + s.onError, + internalOnRequest, + internalOnResponse, + internalHandler, + ) + if err != nil { + return Response{}, err + } + + return NewResponse(responseBytes, responseMessage.Headers), nil +} + +// Process processes a request message without any header overrides. +func (s *Server) Process(requestMessageBytes []byte) (Response, error) { + return s.ProcessWithHeaders(requestMessageBytes, nil) +} + +// TelepactSchema returns the schema associated with the server. +func (s *Server) TelepactSchema() *TelepactSchema { + if s == nil { + return nil + } + return s.telepactSchema +} + +// Serializer returns the serializer associated with the server. +func (s *Server) Serializer() *Serializer { + if s == nil { + return nil + } + return s.serializer +} diff --git a/lib/go/TelepactError.go b/lib/go/TelepactError.go new file mode 100644 index 000000000..e993c7196 --- /dev/null +++ b/lib/go/TelepactError.go @@ -0,0 +1,38 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package telepact + +// TelepactError indicates a critical failure in Telepact processing logic. +type TelepactError struct { + message string +} + +// NewTelepactError constructs a new TelepactError with the given message. +func NewTelepactError(message string) *TelepactError { + return &TelepactError{message: message} +} + +// Error implements the error interface. +func (e *TelepactError) Error() string { + if e == nil { + return "" + } + if e.message == "" { + return "telepact error" + } + return e.message +} diff --git a/lib/go/TelepactSchema.go b/lib/go/TelepactSchema.go new file mode 100644 index 000000000..0e542a2f4 --- /dev/null +++ b/lib/go/TelepactSchema.go @@ -0,0 +1,128 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package telepact + +import ( + "github.com/telepact/telepact/lib/go/internal/schema" + "github.com/telepact/telepact/lib/go/internal/types" +) + +// TelepactSchema represents a parsed Telepact schema. +type TelepactSchema struct { + Original []any + Parsed map[string]types.TType + ParsedRequestHeaders map[string]*types.TFieldDeclaration + ParsedResponseHeaders map[string]*types.TFieldDeclaration +} + +// NewTelepactSchema constructs a TelepactSchema with the supplied values. +func NewTelepactSchema( + original []any, + parsed map[string]types.TType, + parsedRequestHeaders map[string]*types.TFieldDeclaration, + parsedResponseHeaders map[string]*types.TFieldDeclaration, +) *TelepactSchema { + return &TelepactSchema{ + Original: cloneAnySlice(original), + Parsed: parsed, + ParsedRequestHeaders: parsedRequestHeaders, + ParsedResponseHeaders: parsedResponseHeaders, + } +} + +// TelepactSchemaFromJSON constructs a TelepactSchema from JSON content. +func TelepactSchemaFromJSON(json string) (*TelepactSchema, error) { + result, err := schema.CreateTelepactSchemaFromFileJSONMap(map[string]string{"auto_": json}) + if err != nil { + return nil, wrapParseError(err) + } + return telepactSchemaFromParsed(result), nil +} + +// TelepactSchemaFromFileJSONMap constructs a TelepactSchema from a map of filenames to JSON content. +func TelepactSchemaFromFileJSONMap(fileJSONMap map[string]string) (*TelepactSchema, error) { + result, err := schema.CreateTelepactSchemaFromFileJSONMap(fileJSONMap) + if err != nil { + return nil, wrapParseError(err) + } + return telepactSchemaFromParsed(result), nil +} + +// TelepactSchemaFromDirectory constructs a TelepactSchema from the JSON files contained in a directory. +func TelepactSchemaFromDirectory(directory string) (*TelepactSchema, error) { + schemaFileMap, parseFailures, err := schema.GetSchemaFileMap(directory) + if err != nil { + return nil, err + } + if len(parseFailures) > 0 { + return nil, NewTelepactSchemaParseError(parseFailures, schemaFileMap) + } + result, err := schema.CreateTelepactSchemaFromFileJSONMap(schemaFileMap) + if err != nil { + return nil, wrapParseError(err) + } + return telepactSchemaFromParsed(result), nil +} + +// ParsedDefinitions returns the parsed schema definitions keyed by message name. +func (t *TelepactSchema) ParsedDefinitions() map[string]types.TType { + if t == nil { + return nil + } + return t.Parsed +} + +// RequestHeaderDeclarations returns the parsed request header declarations keyed by header name. +func (t *TelepactSchema) RequestHeaderDeclarations() map[string]*types.TFieldDeclaration { + if t == nil { + return nil + } + return t.ParsedRequestHeaders +} + +// ResponseHeaderDeclarations returns the parsed response header declarations keyed by header name. +func (t *TelepactSchema) ResponseHeaderDeclarations() map[string]*types.TFieldDeclaration { + if t == nil { + return nil + } + return t.ParsedResponseHeaders +} + +// OriginalDefinitions returns the original schema definitions slice. +func (t *TelepactSchema) OriginalDefinitions() []any { + if t == nil { + return nil + } + return t.Original +} + +func telepactSchemaFromParsed(result *schema.ParsedSchemaResult) *TelepactSchema { + if result == nil { + return nil + } + return NewTelepactSchema(result.Original, result.Parsed, result.ParsedRequestHeaders, result.ParsedResponseHeaders) +} + +func wrapParseError(err error) error { + if err == nil { + return nil + } + if parseErr, ok := err.(*schema.ParseError); ok { + return NewTelepactSchemaParseError(parseErr.Failures, parseErr.DocumentJSON) + } + return err +} diff --git a/lib/go/TelepactSchemaFiles.go b/lib/go/TelepactSchemaFiles.go new file mode 100644 index 000000000..8600adb26 --- /dev/null +++ b/lib/go/TelepactSchemaFiles.go @@ -0,0 +1,39 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package telepact + +import "github.com/telepact/telepact/lib/go/internal/schema" + +// TelepactSchemaFiles holds the raw JSON contents of Telepact schema files keyed by filename. +type TelepactSchemaFiles struct { + FilenamesToJSON map[string]string +} + +// NewTelepactSchemaFiles constructs a TelepactSchemaFiles instance by reading schema files from a directory. +func NewTelepactSchemaFiles(directory string) (*TelepactSchemaFiles, error) { + fileMap, parseFailures, err := schema.GetSchemaFileMap(directory) + if err != nil { + return nil, err + } + if len(parseFailures) > 0 { + return nil, NewTelepactSchemaParseError(parseFailures, fileMap) + } + + return &TelepactSchemaFiles{ + FilenamesToJSON: fileMap, + }, nil +} diff --git a/lib/go/TelepactSchemaParseError.go b/lib/go/TelepactSchemaParseError.go new file mode 100644 index 000000000..d1ed33f86 --- /dev/null +++ b/lib/go/TelepactSchemaParseError.go @@ -0,0 +1,52 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package telepact + +import ( + "fmt" + + "github.com/telepact/telepact/lib/go/internal/schema" +) + +// TelepactSchemaParseError indicates a failure to parse a Telepact schema definition. +type TelepactSchemaParseError struct { + SchemaParseFailures []*schema.SchemaParseFailure + SchemaParseFailuresPseudoJSON any + message string +} + +// NewTelepactSchemaParseError constructs a TelepactSchemaParseError with the supplied failures and schema documents. +func NewTelepactSchemaParseError( + schemaParseFailures []*schema.SchemaParseFailure, + telepactSchemaDocumentNamesToJSON map[string]string, +) *TelepactSchemaParseError { + pseudoJSON := schema.MapSchemaParseFailuresToPseudoJSON(schemaParseFailures, telepactSchemaDocumentNamesToJSON) + + return &TelepactSchemaParseError{ + SchemaParseFailures: schemaParseFailures, + SchemaParseFailuresPseudoJSON: pseudoJSON, + message: fmt.Sprintf("%v", pseudoJSON), + } +} + +// Error implements the error interface. +func (e *TelepactSchemaParseError) Error() string { + if e == nil { + return "" + } + return e.message +} diff --git a/lib/go/TestClient.go b/lib/go/TestClient.go new file mode 100644 index 000000000..0cb9af44b --- /dev/null +++ b/lib/go/TestClient.go @@ -0,0 +1,171 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package telepact + +import ( + "encoding/json" + "fmt" + + "github.com/telepact/telepact/lib/go/internal/mock" + "github.com/telepact/telepact/lib/go/internal/types" +) + +// TestClientOptions configures the pseudo-random generation performed by TestClient. +type TestClientOptions struct { + GeneratedCollectionLengthMin int + GeneratedCollectionLengthMax int +} + +// TestClient exercises a Telepact client, providing helpers for assertion and mock data generation. +type TestClient struct { + client *Client + random *RandomGenerator + schema *TelepactSchema +} + +// NewTestClient constructs a TestClient using the supplied Telepact client and options. +func NewTestClient(client *Client, options TestClientOptions) *TestClient { + generator := NewRandomGenerator(options.GeneratedCollectionLengthMin, options.GeneratedCollectionLengthMax) + return &TestClient{ + client: client, + random: generator, + schema: nil, + } +} + +// AssertRequest issues the provided request and compares the response body against the expected pseudo JSON structure. +func (tc *TestClient) AssertRequest(request Message, expectedPseudoJSONBody map[string]any, expectMatch bool) (Message, error) { + if tc == nil { + return Message{}, fmt.Errorf("test client is nil") + } + + if tc.schema == nil { + schema, err := tc.fetchSchema() + if err != nil { + return Message{}, err + } + tc.schema = schema + } + + responseMessage, err := tc.client.Request(request) + if err != nil { + return Message{}, err + } + + didMatch := mock.IsSubMap(expectedPseudoJSONBody, responseMessage.Body) + + if expectMatch { + if !didMatch { + return Message{}, fmt.Errorf("Expected response body was not a sub map. Expected: %v Actual: %v", expectedPseudoJSONBody, responseMessage.Body) + } + return responseMessage, nil + } + + if didMatch { + return Message{}, fmt.Errorf("Expected response body was a sub map. Expected: %v Actual: %v", expectedPseudoJSONBody, responseMessage.Body) + } + + useBlueprintValue := true + includeOptionalFields := false + alwaysIncludeRequiredFields := true + randomizeOptionalFieldGeneration := false + + functionName, err := request.BodyTarget() + if err != nil { + return Message{}, err + } + + definitionRaw, ok := tc.schema.Parsed[fmt.Sprintf("%s.->", functionName)] + if !ok { + return Message{}, fmt.Errorf("result union type not found for function %s", functionName) + } + + definition, ok := definitionRaw.(*types.TUnion) + if !ok { + return Message{}, fmt.Errorf("result type for %s is not a union", functionName) + } + + ctx := types.NewGenerateContext( + includeOptionalFields, + randomizeOptionalFieldGeneration, + alwaysIncludeRequiredFields, + functionName, + tc.random, + ) + + generatedResult := definition.GenerateRandomValue( + expectedPseudoJSONBody, + useBlueprintValue, + nil, + ctx, + ) + + resultBody, err := toStringAnyMap(generatedResult) + if err != nil { + return Message{}, err + } + + return NewMessage(responseMessage.Headers, resultBody), nil +} + +// SetSeed configures the pseudo-random generator seed. +func (tc *TestClient) SetSeed(seed int32) { + if tc == nil { + return + } + tc.random.SetSeed(seed) +} + +func (tc *TestClient) fetchSchema() (*TelepactSchema, error) { + response, err := tc.client.Request(NewMessage(map[string]any{}, map[string]any{"fn.api_": map[string]any{}})) + if err != nil { + return nil, err + } + + topLevel, ok := response.Body["Ok_"].(map[string]any) + if !ok { + return nil, fmt.Errorf("expected Ok_ response body when fetching schema") + } + + api := topLevel["api"] + encoded, err := json.Marshal(api) + if err != nil { + return nil, err + } + + schema, err := TelepactSchemaFromJSON(string(encoded)) + if err != nil { + return nil, err + } + + return schema, nil +} + +func toStringAnyMap(value any) (map[string]any, error) { + switch typed := value.(type) { + case map[string]any: + return typed, nil + case map[any]any: + converted := make(map[string]any, len(typed)) + for k, v := range typed { + converted[fmt.Sprint(k)] = v + } + return converted, nil + default: + return nil, fmt.Errorf("expected map result, received %T", value) + } +} diff --git a/lib/go/TypedMessage.go b/lib/go/TypedMessage.go new file mode 100644 index 000000000..bb6563afa --- /dev/null +++ b/lib/go/TypedMessage.go @@ -0,0 +1,31 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package telepact + +// TypedMessage represents a Message with a statically typed body. +type TypedMessage[T any] struct { + Headers map[string]any + Body T +} + +// NewTypedMessage constructs a TypedMessage with defensive copies of the provided headers. +func NewTypedMessage[T any](headers map[string]any, body T) TypedMessage[T] { + return TypedMessage[T]{ + Headers: cloneStringMap(headers), + Body: body, + } +} diff --git a/lib/go/go.mod b/lib/go/go.mod new file mode 100644 index 000000000..b07c10fc4 --- /dev/null +++ b/lib/go/go.mod @@ -0,0 +1,7 @@ +module github.com/telepact/telepact/lib/go + +go 1.22 + +require github.com/vmihailenco/msgpack/v5 v5.4.1 + +require github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect diff --git a/lib/go/go.sum b/lib/go/go.sum new file mode 100644 index 000000000..fd15c1b8e --- /dev/null +++ b/lib/go/go.sum @@ -0,0 +1,12 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/lib/go/internal/ClientHandleMessage.go b/lib/go/internal/ClientHandleMessage.go new file mode 100644 index 000000000..35c882a4c --- /dev/null +++ b/lib/go/internal/ClientHandleMessage.go @@ -0,0 +1,201 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package internal + +import ( + "context" + "time" +) + +// ClientMessage represents a Telepact message as seen by the internal client handler. +type ClientMessage struct { + Headers map[string]any + Body map[string]any +} + +// NewClientMessage constructs a ClientMessage instance. +func NewClientMessage(headers map[string]any, body map[string]any) *ClientMessage { + return &ClientMessage{ + Headers: headers, + Body: body, + } +} + +// ClientHandleMessageAdapter delegates request processing to the transport adapter. +type ClientHandleMessageAdapter func(ctx context.Context, request *ClientMessage) (*ClientMessage, error) + +// ClientHandleMessage applies Telepact client defaults before invoking the transport adapter. +func ClientHandleMessage( + ctx context.Context, + request *ClientMessage, + adapter ClientHandleMessageAdapter, + timeoutMSDefault int, + useBinaryDefault bool, + alwaysSendJSON bool, +) (*ClientMessage, error) { + if ctx == nil { + ctx = context.Background() + } + + headers := ensureHeaders(request) + if _, ok := headers["@time_"]; !ok { + headers["@time_"] = timeoutMSDefault + } + + if useBinaryDefault { + headers["@binary_"] = true + } + + if isBinary(headers) && alwaysSendJSON { + headers["_forceSendJson"] = true + } + + timeout := timeoutFromHeader(headers["@time_"], timeoutMSDefault) + response, err := executeWithTimeout(ctx, timeout, adapter, request) + if err != nil { + return nil, err + } + + if isIncompatibleBinaryEncoding(response.Body) { + headers["@binary_"] = true + headers["_forceSendJson"] = true + + return executeWithTimeout(ctx, timeout, adapter, request) + } + + return response, nil +} + +func ensureHeaders(message *ClientMessage) map[string]any { + if message.Headers == nil { + message.Headers = make(map[string]any) + } + return message.Headers +} + +func isBinary(headers map[string]any) bool { + flag, ok := headers["@binary_"].(bool) + return ok && flag +} + +func timeoutFromHeader(value any, defaultMS int) time.Duration { + switch v := value.(type) { + case int: + if v > 0 { + return time.Duration(v) * time.Millisecond + } + case int32: + if v > 0 { + return time.Duration(v) * time.Millisecond + } + case int64: + if v > 0 { + return time.Duration(v) * time.Millisecond + } + case float32: + if v > 0 { + return time.Duration(v) * time.Millisecond + } + case float64: + if v > 0 { + return time.Duration(v) * time.Millisecond + } + case uint: + if v > 0 { + return time.Duration(v) * time.Millisecond + } + case uint32: + if v > 0 { + return time.Duration(v) * time.Millisecond + } + case uint64: + if v > 0 { + return time.Duration(v) * time.Millisecond + } + } + return time.Duration(defaultMS) * time.Millisecond +} + +func executeWithTimeout( + ctx context.Context, + timeout time.Duration, + adapter ClientHandleMessageAdapter, + request *ClientMessage, +) (*ClientMessage, error) { + ctxWithTimeout, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + type adapterResult struct { + response *ClientMessage + err error + } + + resultCh := make(chan adapterResult, 1) + go func() { + resp, err := adapter(ctxWithTimeout, request) + resultCh <- adapterResult{response: resp, err: err} + }() + + select { + case <-ctxWithTimeout.Done(): + return nil, ctxWithTimeout.Err() + case result := <-resultCh: + return result.response, result.err + } +} + +func isIncompatibleBinaryEncoding(body map[string]any) bool { + if body == nil { + return false + } + + payload, ok := body["ErrorParseFailure_"].(map[string]any) + if !ok { + return false + } + + reasonsRaw, ok := payload["reasons"] + if !ok { + return false + } + + reasons, ok := reasonsRaw.([]any) + if !ok || len(reasons) != 1 { + return false + } + + reason, ok := reasons[0].(map[string]any) + if !ok { + return false + } + + value, ok := reason["IncompatibleBinaryEncoding"] + if !ok { + return false + } + + switch typed := value.(type) { + case map[string]any: + return len(typed) == 0 + case map[any]any: + return len(typed) == 0 + case nil: + return true + default: + return false + } +} diff --git a/lib/go/internal/DeserializeInternal.go b/lib/go/internal/DeserializeInternal.go new file mode 100644 index 000000000..26b85f23c --- /dev/null +++ b/lib/go/internal/DeserializeInternal.go @@ -0,0 +1,110 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package internal + +import ( + "fmt" + + "github.com/telepact/telepact/lib/go/internal/binary" + "github.com/telepact/telepact/lib/go/internal/types" +) + +// DeserializeInternal converts serialized bytes back into Telepact headers and body maps. +func DeserializeInternal( + messageBytes []byte, + serializer Serialization, + binaryEncoder binary.BinaryEncoder, + base64Encoder binary.Base64Encoder, +) (map[string]any, map[string]any, error) { + if len(messageBytes) == 0 { + return nil, nil, types.NewInvalidMessage(fmt.Errorf("empty payload")) + } + + var ( + messageAsPseudoJSON any + err error + ) + + isMsgpack := messageBytes[0] == 0x92 + + if isMsgpack { + messageAsPseudoJSON, err = serializer.FromMsgpack(messageBytes) + } else { + messageAsPseudoJSON, err = serializer.FromJSON(messageBytes) + } + if err != nil { + return nil, nil, types.NewInvalidMessage(err) + } + + messageList, ok := messageAsPseudoJSON.([]any) + if !ok || len(messageList) != 2 { + return nil, nil, types.NewInvalidMessage(nil) + } + + var finalList []any + if isMsgpack { + finalList, err = binaryEncoder.Decode(messageList) + } else { + finalList, err = base64Encoder.Decode(messageList) + } + if err != nil { + return nil, nil, types.NewInvalidMessage(err) + } + + if len(finalList) != 2 { + return nil, nil, types.NewInvalidMessage(nil) + } + + headers, ok := toStringMap(finalList[0]) + if !ok { + return nil, nil, types.NewInvalidMessage(nil) + } + + body, ok := toStringMap(finalList[1]) + if !ok { + return nil, nil, types.NewInvalidMessage(nil) + } + + if len(body) != 1 { + return nil, nil, types.NewInvalidMessageBody() + } + + for key, value := range body { + converted, ok := toStringMap(value) + if !ok { + return nil, nil, types.NewInvalidMessageBody() + } + body[key] = converted + } + + return headers, body, nil +} + +func toStringMap(value any) (map[string]any, bool) { + switch typed := value.(type) { + case map[string]any: + return typed, true + case map[any]any: + converted := make(map[string]any, len(typed)) + for k, v := range typed { + converted[fmt.Sprint(k)] = v + } + return converted, true + default: + return nil, false + } +} diff --git a/lib/go/internal/HandleMessage.go b/lib/go/internal/HandleMessage.go new file mode 100644 index 000000000..8c4786107 --- /dev/null +++ b/lib/go/internal/HandleMessage.go @@ -0,0 +1,343 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package internal + +import ( + "fmt" + + "github.com/telepact/telepact/lib/go/internal/binary" + "github.com/telepact/telepact/lib/go/internal/types" +) + +// HandleMessage mirrors the Python server handler orchestration using primitive maps. +func HandleMessage( + requestMessage ServerMessage, + overrideHeaders map[string]any, + schema SchemaAccessor, + handler func(ServerMessage) (ServerMessage, error), + onError func(error), +) (ServerMessage, error) { + if schema == nil { + return ServerMessage{}, fmt.Errorf("telepact: schema must not be nil") + } + if handler == nil { + return ServerMessage{}, fmt.Errorf("telepact: handler must not be nil") + } + + requestHeaders := requestMessage.Headers + if requestHeaders == nil { + requestHeaders = make(map[string]any) + } + requestBody := requestMessage.Body + if requestBody == nil { + requestBody = make(map[string]any) + } + + if overrideHeaders == nil { + overrideHeaders = map[string]any{} + } + + responseHeaders := make(map[string]any) + for key, value := range overrideHeaders { + requestHeaders[key] = value + } + + requestTargetInit, requestPayload, err := firstServerMessageEntry(requestBody) + if err != nil { + return ServerMessage{}, err + } + + parsed := schema.ParsedDefinitions() + if parsed == nil { + return ServerMessage{}, fmt.Errorf("telepact: schema missing parsed definitions") + } + + requestTarget := requestTargetInit + unknownTarget := "" + if _, ok := parsed[requestTargetInit]; !ok { + unknownTarget = requestTargetInit + requestTarget = "fn.ping_" + } + + callUnion, err := lookupUnionType(parsed, requestTarget) + if err != nil { + return ServerMessage{}, err + } + + resultUnionType, err := lookupUnionType(parsed, requestTarget+".->") + if err != nil { + return ServerMessage{}, err + } + + if callID, ok := requestHeaders["@id_"]; ok && callID != nil { + responseHeaders["@id_"] = callID + } + + if parseFailuresRaw, ok := requestHeaders["_parseFailures"]; ok { + parseFailures := convertToAnySlice(parseFailuresRaw) + newErrorResult := map[string]any{ + "ErrorParseFailure_": map[string]any{ + "reasons": parseFailures, + }, + } + if err := validateResult(resultUnionType, newErrorResult); err != nil { + return ServerMessage{}, err + } + return ServerMessage{Headers: cloneStringAnyMap(responseHeaders), Body: newErrorResult}, nil + } + + if unknownTarget != "" { + newErrorResult := map[string]any{ + "ErrorInvalidRequestBody_": map[string]any{ + "cases": []any{ + map[string]any{ + "path": []any{unknownTarget}, + "reason": map[string]any{"FunctionUnknown": map[string]any{}}, + }, + }, + }, + } + if err := validateResult(resultUnionType, newErrorResult); err != nil { + return ServerMessage{}, err + } + return ServerMessage{Headers: cloneStringAnyMap(responseHeaders), Body: newErrorResult}, nil + } + + functionName := requestTarget + + requestHeaderFailures := types.ValidateHeaders(requestHeaders, schema.RequestHeaderDeclarations(), functionName) + if len(requestHeaderFailures) > 0 { + invalidMessage, err := buildInvalidErrorMessage("ErrorInvalidRequestHeaders_", requestHeaderFailures, resultUnionType, responseHeaders) + return invalidMessage, err + } + + if clientKnownRaw, ok := requestHeaders["@bin_"]; ok { + responseHeaders["@binary_"] = true + responseHeaders["@clientKnownBinaryChecksums_"] = convertToAnySlice(clientKnownRaw) + if pac, ok := requestHeaders["@pac_"]; ok { + responseHeaders["@pac_"] = pac + } + } + + selectStructFields := extractSelectStructFields(requestHeaders["@select_"]) + + callValidateCtx := types.NewValidateContext(nil, functionName, false) + callValidationFailures := callUnion.Validate(requestBody, nil, callValidateCtx) + if len(callValidationFailures) > 0 { + invalidMessage, err := buildInvalidErrorMessage("ErrorInvalidRequestBody_", callValidationFailures, resultUnionType, responseHeaders) + return invalidMessage, err + } + + if len(callValidateCtx.BytesCoercions) > 0 { + if err := binary.ServerBase64Decode(requestBody, callValidateCtx.BytesCoercions); err != nil { + return ServerMessage{}, err + } + } + + unsafeResponseEnabled := boolValue(requestHeaders["@unsafe_"]) + + callMessage := ServerMessage{ + Headers: requestHeaders, + Body: map[string]any{requestTarget: requestPayload}, + } + + var resultMessage ServerMessage + switch functionName { + case "fn.ping_": + resultMessage = ServerMessage{Headers: make(map[string]any), Body: map[string]any{"Ok_": map[string]any{}}} + case "fn.api_": + resultMessage = ServerMessage{Headers: make(map[string]any), Body: map[string]any{"Ok_": map[string]any{"api": schema.OriginalDefinitions()}}} + default: + resp, err := handler(callMessage) + if err != nil { + invokeOnError(onError, err) + return ServerMessage{Headers: cloneStringAnyMap(responseHeaders), Body: map[string]any{"ErrorUnknown_": map[string]any{}}}, nil + } + resultMessage = resp + } + + if resultMessage.Headers == nil { + resultMessage.Headers = make(map[string]any) + } + for key, value := range responseHeaders { + resultMessage.Headers[key] = value + } + + finalResponseHeaders := resultMessage.Headers + resultUnion := resultMessage.Body + if resultUnion == nil { + resultUnion = make(map[string]any) + } + + skipResultValidation := unsafeResponseEnabled + coerceBase64 := !boolValue(finalResponseHeaders["@binary_"]) + + resultValidateCtx := types.NewValidateContext(selectStructFields, functionName, coerceBase64) + resultValidationFailures := resultUnionType.Validate(resultUnion, nil, resultValidateCtx) + if len(resultValidationFailures) > 0 && !skipResultValidation { + invalidMessage, err := buildInvalidErrorMessage("ErrorInvalidResponseBody_", resultValidationFailures, resultUnionType, finalResponseHeaders) + if err == nil { + invokeOnError(onError, fmt.Errorf("Response validation failed: %v. Actual response: %v", resultValidationFailures, resultUnion)) + } + return invalidMessage, err + } + + if len(resultValidateCtx.Base64Coercions) > 0 { + finalResponseHeaders["@base64_"] = resultValidateCtx.Base64Coercions + } + if len(resultValidateCtx.BytesCoercions) > 0 { + if err := binary.ServerBase64Decode(resultUnion, resultValidateCtx.BytesCoercions); err != nil { + return ServerMessage{}, err + } + } + + responseHeaderFailures := types.ValidateHeaders(finalResponseHeaders, schema.ResponseHeaderDeclarations(), functionName) + if len(responseHeaderFailures) > 0 { + invalidMessage, err := buildInvalidErrorMessage("ErrorInvalidResponseHeaders_", responseHeaderFailures, resultUnionType, responseHeaders) + return invalidMessage, err + } + + finalResultUnion := resultUnion + if selectStructFields != nil { + selected := SelectStructFields(types.NewTTypeDeclaration(resultUnionType, false, nil), resultUnion, selectStructFields) + if converted := convertToStringAnyMap(selected); converted != nil { + finalResultUnion = converted + } + } + + return ServerMessage{Headers: finalResponseHeaders, Body: finalResultUnion}, nil +} + +func firstServerMessageEntry(body map[string]any) (string, any, error) { + for key, value := range body { + return key, value, nil + } + return "", nil, fmt.Errorf("telepact: message body missing target entry") +} + +func lookupUnionType(parsed map[string]types.TType, key string) (*types.TUnion, error) { + definition, ok := parsed[key] + if !ok || definition == nil { + return nil, fmt.Errorf("telepact: schema missing definition for %s", key) + } + union, ok := definition.(*types.TUnion) + if !ok || union == nil { + return nil, fmt.Errorf("telepact: definition for %s is not a union", key) + } + return union, nil +} + +func extractSelectStructFields(value any) map[string]any { + switch typed := value.(type) { + case map[string]any: + return typed + case map[any]any: + converted := make(map[string]any, len(typed)) + for key, val := range typed { + converted[fmt.Sprint(key)] = val + } + return converted + default: + return nil + } +} + +func boolValue(value any) bool { + if flag, ok := value.(bool); ok { + return flag + } + return false +} + +func invokeOnError(callback func(error), err error) { + if callback == nil || err == nil { + return + } + defer func() { _ = recover() }() + callback(err) +} + +func buildInvalidErrorMessage( + errorKey string, + validationFailures []*types.ValidationFailure, + resultUnionType *types.TUnion, + responseHeaders map[string]any, +) (ServerMessage, error) { + cases := types.MapValidationFailuresToInvalidFieldCases(validationFailures) + newErrorResult := map[string]any{ + errorKey: map[string]any{ + "cases": cases, + }, + } + if err := validateResult(resultUnionType, newErrorResult); err != nil { + return ServerMessage{}, err + } + return ServerMessage{Headers: cloneStringAnyMap(responseHeaders), Body: newErrorResult}, nil +} + +func validateResult(resultUnionType *types.TUnion, errorResult map[string]any) error { + if resultUnionType == nil { + return fmt.Errorf("telepact: result union type is nil") + } + ctx := types.NewValidateContext(nil, "", false) + validationFailures := resultUnionType.Validate(errorResult, nil, ctx) + if len(validationFailures) == 0 { + return nil + } + cases := types.MapValidationFailuresToInvalidFieldCases(validationFailures) + return fmt.Errorf("telepact: failed internal validation: %v", cases) +} + +func convertToAnySlice(value any) []any { + switch typed := value.(type) { + case nil: + return nil + case []any: + return typed + case []string: + result := make([]any, len(typed)) + for i, entry := range typed { + result[i] = entry + } + return result + case []int: + result := make([]any, len(typed)) + for i, entry := range typed { + result[i] = entry + } + return result + default: + return []any{typed} + } +} + +func convertToStringAnyMap(value any) map[string]any { + switch typed := value.(type) { + case nil: + return nil + case map[string]any: + return typed + case map[any]any: + converted := make(map[string]any, len(typed)) + for key, val := range typed { + converted[fmt.Sprint(key)] = val + } + return converted + default: + return nil + } +} diff --git a/lib/go/internal/ParseRequestMessage.go b/lib/go/internal/ParseRequestMessage.go new file mode 100644 index 000000000..61ec4451f --- /dev/null +++ b/lib/go/internal/ParseRequestMessage.go @@ -0,0 +1,71 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package internal + +import ( + "errors" + + "github.com/telepact/telepact/lib/go/internal/binary" + "github.com/telepact/telepact/lib/go/internal/types" +) + +func ParseRequestMessage( + requestMessageBytes []byte, + deserialize func([]byte) (ServerMessage, error), + schema SchemaAccessor, + onError func(error), +) (ServerMessage, error) { + _ = schema + + message, err := deserialize(requestMessageBytes) + if err == nil { + return message, nil + } + + invokeOnError(onError, err) + + reason := "ExpectedJsonArrayOfTwoObjects" + + var unavailableErr binary.BinaryEncoderUnavailableError + if errors.As(err, &unavailableErr) { + reason = "IncompatibleBinaryEncoding" + } else { + var missingErr *binary.BinaryEncodingMissing + if errors.As(err, &missingErr) { + reason = "BinaryDecodeFailure" + } else { + var invalidMessageErr *types.InvalidMessage + if errors.As(err, &invalidMessageErr) { + reason = "ExpectedJsonArrayOfTwoObjects" + } else { + var invalidBodyErr *types.InvalidMessageBody + if errors.As(err, &invalidBodyErr) { + reason = "ExpectedJsonArrayOfAnObjectAndAnObjectOfOneObject" + } + } + } + } + + parseHeaders := map[string]any{ + "_parseFailures": []any{map[string]any{reason: map[string]any{}}}, + } + parseBody := map[string]any{ + "_unknown": map[string]any{}, + } + + return ServerMessage{Headers: parseHeaders, Body: parseBody}, nil +} diff --git a/lib/go/internal/ProcessBytes.go b/lib/go/internal/ProcessBytes.go new file mode 100644 index 000000000..3248a894f --- /dev/null +++ b/lib/go/internal/ProcessBytes.go @@ -0,0 +1,74 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package internal + +func ProcessBytes( + requestMessageBytes []byte, + overrideHeaders map[string]any, + deserialize func([]byte) (ServerMessage, error), + serialize func(ServerMessage) ([]byte, error), + schema SchemaAccessor, + onError func(error), + onRequest func(ServerMessage), + onResponse func(ServerMessage), + handler func(ServerMessage) (ServerMessage, error), +) (ServerMessage, []byte, error) { + requestMessage, err := ParseRequestMessage(requestMessageBytes, deserialize, schema, onError) + if err != nil { + return ServerMessage{}, nil, err + } + + safeInvokeMessage(onRequest, requestMessage) + + responseMessage, err := HandleMessage(requestMessage, overrideHeaders, schema, handler, onError) + if err != nil { + invokeOnError(onError, err) + return buildUnknownResponse(serialize) + } + + safeInvokeMessage(onResponse, responseMessage) + + responseBytes, err := serialize(responseMessage) + if err != nil { + invokeOnError(onError, err) + return buildUnknownResponse(serialize) + } + + return responseMessage, responseBytes, nil +} + +func safeInvokeMessage(callback func(ServerMessage), message ServerMessage) { + if callback == nil { + return + } + defer func() { _ = recover() }() + callback(message) +} + +func buildUnknownResponse(serialize func(ServerMessage) ([]byte, error)) (ServerMessage, []byte, error) { + unknownMessage := ServerMessage{ + Headers: map[string]any{}, + Body: map[string]any{"ErrorUnknown_": map[string]any{}}, + } + + responseBytes, err := serialize(unknownMessage) + if err != nil { + return ServerMessage{}, nil, err + } + + return unknownMessage, responseBytes, nil +} diff --git a/lib/go/internal/SchemaAccessor.go b/lib/go/internal/SchemaAccessor.go new file mode 100644 index 000000000..302af3dea --- /dev/null +++ b/lib/go/internal/SchemaAccessor.go @@ -0,0 +1,27 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package internal + +import "github.com/telepact/telepact/lib/go/internal/types" + +// SchemaAccessor exposes the Telepact schema details required by server helpers. +type SchemaAccessor interface { + ParsedDefinitions() map[string]types.TType + RequestHeaderDeclarations() map[string]*types.TFieldDeclaration + ResponseHeaderDeclarations() map[string]*types.TFieldDeclaration + OriginalDefinitions() []any +} diff --git a/lib/go/internal/SelectStructFields.go b/lib/go/internal/SelectStructFields.go new file mode 100644 index 000000000..d660c04b8 --- /dev/null +++ b/lib/go/internal/SelectStructFields.go @@ -0,0 +1,255 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package internal + +import ( + "fmt" + "sort" + "strings" + + "github.com/telepact/telepact/lib/go/internal/types" +) + +// SelectStructFields filters a value based on the provided type declaration and select structure. +func SelectStructFields(typeDeclaration *types.TTypeDeclaration, value any, selectedStructFields map[string]any) any { + if typeDeclaration == nil { + return value + } + + switch typed := typeDeclaration.Type.(type) { + case *types.TStruct: + valueMap := toStringAnyMap(value) + structName := typed.Name + selectedFields := toStringSlice(getSelectEntry(selectedStructFields, structName)) + + result := make(map[string]any) + fieldNames := sortedStringKeys(valueMap) + for _, fieldName := range fieldNames { + fieldValue := valueMap[fieldName] + if selectedFields != nil && !containsString(selectedFields, fieldName) { + continue + } + + fieldDecl := typed.Fields[fieldName] + if fieldDecl == nil { + continue + } + + result[fieldName] = SelectStructFields(fieldDecl.TypeDeclaration, fieldValue, selectedStructFields) + } + return result + + case *types.TUnion: + valueMap := toStringAnyMap(value) + unionTag := firstKey(valueMap) + unionData := toStringAnyMap(valueMap[unionTag]) + + defaultTagSelections := make(map[string][]string, len(typed.Tags)) + tagKeys := sortedStringKeys(typed.Tags) + for _, tagKey := range tagKeys { + unionStruct := typed.Tags[tagKey] + fieldNames := sortedStringKeys(unionStruct.Fields) + defaultTagSelections[tagKey] = fieldNames + } + + unionSelectedRaw := getSelectEntry(selectedStructFields, typed.Name) + unionSelected := toMapOfStringToInterface(unionSelectedRaw) + if len(unionSelected) == 0 { + for tagKey, fieldNames := range defaultTagSelections { + converted := make([]any, len(fieldNames)) + for i, fieldName := range fieldNames { + converted[i] = fieldName + } + unionSelected[tagKey] = converted + } + } + + selectedFields := defaultTagSelections[unionTag] + if rawSelected, ok := unionSelected[unionTag]; ok { + selectedFields = toStringSlice(rawSelected) + } + + result := make(map[string]any) + unionStruct := typed.Tags[unionTag] + if unionStruct == nil { + return map[string]any{unionTag: result} + } + + unionFieldNames := sortedStringKeys(unionData) + for _, fieldName := range unionFieldNames { + fieldValue := unionData[fieldName] + if selectedFields != nil && !containsString(selectedFields, fieldName) { + continue + } + fieldDecl := unionStruct.Fields[fieldName] + if fieldDecl == nil { + continue + } + result[fieldName] = SelectStructFields(fieldDecl.TypeDeclaration, fieldValue, selectedStructFields) + } + + return map[string]any{unionTag: result} + + case *types.TObject: + valueMap := toStringAnyMap(value) + if len(typeDeclaration.TypeParameters) == 0 { + return valueMap + } + + nestedType := typeDeclaration.TypeParameters[0] + result := make(map[string]any, len(valueMap)) + keys := sortedStringKeys(valueMap) + for _, key := range keys { + entry := valueMap[key] + result[key] = SelectStructFields(nestedType, entry, selectedStructFields) + } + return result + + case *types.TArray: + if len(typeDeclaration.TypeParameters) == 0 { + return value + } + nestedType := typeDeclaration.TypeParameters[0] + valueSlice := toAnySlice(value) + result := make([]any, 0, len(valueSlice)) + for _, entry := range valueSlice { + result = append(result, SelectStructFields(nestedType, entry, selectedStructFields)) + } + return result + + default: + return value + } +} + +func toStringAnyMap(value any) map[string]any { + switch typed := value.(type) { + case map[string]any: + return typed + case map[any]any: + result := make(map[string]any, len(typed)) + for key, val := range typed { + result[toString(key)] = val + } + return result + default: + return map[string]any{} + } +} + +func getSelectEntry(selected map[string]any, key string) any { + if selected == nil { + return nil + } + if value, ok := selected[key]; ok { + return value + } + if strings.HasPrefix(key, "fn.") && strings.HasSuffix(key, ".->") { + if value, ok := selected["->"]; ok { + return value + } + } + return nil +} + +func toAnySlice(value any) []any { + switch typed := value.(type) { + case []any: + return typed + case []string: + result := make([]any, len(typed)) + for i, entry := range typed { + result[i] = entry + } + return result + default: + return []any{} + } +} + +func toStringSlice(value any) []string { + switch typed := value.(type) { + case []string: + return typed + case []any: + result := make([]string, 0, len(typed)) + for _, entry := range typed { + if str, ok := entry.(string); ok { + result = append(result, str) + } + } + return result + default: + return nil + } +} + +func toMapOfStringToInterface(value any) map[string]any { + switch typed := value.(type) { + case map[string]any: + return typed + case map[any]any: + result := make(map[string]any, len(typed)) + for key, val := range typed { + result[toString(key)] = val + } + return result + default: + return map[string]any{} + } +} + +func containsString(slice []string, target string) bool { + for _, entry := range slice { + if entry == target { + return true + } + } + return false +} + +func firstKey(m map[string]any) string { + keys := sortedStringKeys(m) + if len(keys) == 0 { + return "" + } + return keys[0] +} + +func sortedStringKeys[T any](m map[string]T) []string { + if len(m) == 0 { + return nil + } + + keys := make([]string, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func toString(value any) string { + switch v := value.(type) { + case string: + return v + case fmt.Stringer: + return v.String() + default: + return fmt.Sprintf("%v", v) + } +} diff --git a/lib/go/internal/SerializeInternal.go b/lib/go/internal/SerializeInternal.go new file mode 100644 index 000000000..ab7c7a1a3 --- /dev/null +++ b/lib/go/internal/SerializeInternal.go @@ -0,0 +1,94 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package internal + +import ( + "errors" + "fmt" + + "github.com/telepact/telepact/lib/go/internal/binary" +) + +// Serialization provides the conversion hooks needed by SerializeInternal and DeserializeInternal. +type Serialization interface { + ToJSON(message any) ([]byte, error) + ToMsgpack(message any) ([]byte, error) + FromJSON(data []byte) (any, error) + FromMsgpack(data []byte) (any, error) +} + +// SerializeInternal converts the provided Telepact headers and body into serialized bytes. +// The wrap function is used to translate intermediate failures into domain-specific errors. +func SerializeInternal( + headers map[string]any, + body map[string]any, + binaryEncoder binary.BinaryEncoder, + base64Encoder binary.Base64Encoder, + serializer Serialization, + wrap func(error, string) error, +) ([]byte, error) { + if headers == nil || body == nil { + return nil, wrap(fmt.Errorf("message headers or body is nil"), "serialize message") + } + + serializeAsBinary := false + if raw, ok := headers["@binary_"]; ok { + delete(headers, "@binary_") + if flag, ok := raw.(bool); ok && flag { + serializeAsBinary = true + } + } + + message := []any{headers, body} + + if serializeAsBinary { + encoded, err := binaryEncoder.Encode(message) + if err != nil { + var unavailableErr binary.BinaryEncoderUnavailableError + if errors.As(err, &unavailableErr) { + return serializeAsJSON(message, base64Encoder, serializer, wrap) + } + return nil, wrap(err, "encode msgpack") + } + + payload, err := serializer.ToMsgpack(encoded) + if err != nil { + return nil, wrap(err, "encode msgpack") + } + return payload, nil + } + + return serializeAsJSON(message, base64Encoder, serializer, wrap) +} + +func serializeAsJSON( + message []any, + base64Encoder binary.Base64Encoder, + serializer Serialization, + wrap func(error, string) error, +) ([]byte, error) { + encoded, err := base64Encoder.Encode(message) + if err != nil { + return nil, wrap(err, "encode JSON") + } + + payload, err := serializer.ToJSON(encoded) + if err != nil { + return nil, wrap(err, "encode JSON") + } + return payload, nil +} diff --git a/lib/go/internal/ServerMessage.go b/lib/go/internal/ServerMessage.go new file mode 100644 index 000000000..1becbbce7 --- /dev/null +++ b/lib/go/internal/ServerMessage.go @@ -0,0 +1,34 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package internal + +// ServerMessage models the headers and body exchanged with Telepact servers. +type ServerMessage struct { + Headers map[string]any + Body map[string]any +} + +func cloneStringAnyMap(source map[string]any) map[string]any { + if source == nil { + return make(map[string]any) + } + copy := make(map[string]any, len(source)) + for key, value := range source { + copy[key] = value + } + return copy +} diff --git a/lib/go/internal/binary/Base64Encoder.go b/lib/go/internal/binary/Base64Encoder.go new file mode 100644 index 000000000..0b3761684 --- /dev/null +++ b/lib/go/internal/binary/Base64Encoder.go @@ -0,0 +1,23 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +// Base64Encoder defines methods for encoding and decoding pseudo-JSON Telepact messages via Base64. +type Base64Encoder interface { + Decode(message []any) ([]any, error) + Encode(message []any) ([]any, error) +} diff --git a/lib/go/internal/binary/BinaryEncoder.go b/lib/go/internal/binary/BinaryEncoder.go new file mode 100644 index 000000000..fd3517418 --- /dev/null +++ b/lib/go/internal/binary/BinaryEncoder.go @@ -0,0 +1,23 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +// BinaryEncoder defines methods for encoding and decoding pseudo-JSON Telepact messages into a binary representation. +type BinaryEncoder interface { + Encode(message []any) ([]any, error) + Decode(message []any) ([]any, error) +} diff --git a/lib/go/internal/binary/BinaryEncoderUnavailableError.go b/lib/go/internal/binary/BinaryEncoderUnavailableError.go new file mode 100644 index 000000000..74383f823 --- /dev/null +++ b/lib/go/internal/binary/BinaryEncoderUnavailableError.go @@ -0,0 +1,25 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +// BinaryEncoderUnavailableError indicates that a binary encoder implementation is unavailable at runtime. +type BinaryEncoderUnavailableError struct{} + +// Error implements the error interface. +func (BinaryEncoderUnavailableError) Error() string { + return "binary encoder unavailable" +} diff --git a/lib/go/internal/binary/BinaryEncoding.go b/lib/go/internal/binary/BinaryEncoding.go new file mode 100644 index 000000000..9a630e2bd --- /dev/null +++ b/lib/go/internal/binary/BinaryEncoding.go @@ -0,0 +1,44 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +// BinaryEncoding stores lookup tables for translating between string keys and integer codes +// in the binary encoding representation. +type BinaryEncoding struct { + EncodeMap map[string]int + DecodeMap map[int]string + Checksum int +} + +// NewBinaryEncoding constructs a BinaryEncoding from the provided encoding map and checksum. +func NewBinaryEncoding(binaryEncodingMap map[string]int, checksum int) *BinaryEncoding { + encodeMap := make(map[string]int, len(binaryEncodingMap)) + for key, value := range binaryEncodingMap { + encodeMap[key] = value + } + + decodeMap := make(map[int]string, len(binaryEncodingMap)) + for key, value := range encodeMap { + decodeMap[value] = key + } + + return &BinaryEncoding{ + EncodeMap: encodeMap, + DecodeMap: decodeMap, + Checksum: checksum, + } +} diff --git a/lib/go/internal/binary/BinaryEncodingCache.go b/lib/go/internal/binary/BinaryEncodingCache.go new file mode 100644 index 000000000..c62c5fa76 --- /dev/null +++ b/lib/go/internal/binary/BinaryEncodingCache.go @@ -0,0 +1,27 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +// BinaryEncodingCache stores BinaryEncoding instances keyed by checksum values. +type BinaryEncodingCache interface { + // Add registers a BinaryEncoding entry for the provided checksum. + Add(checksum int, binaryEncodingMap map[string]int) + // Get retrieves the BinaryEncoding associated with the checksum. + Get(checksum int) *BinaryEncoding + // Remove deletes the BinaryEncoding associated with the checksum. + Remove(checksum int) +} diff --git a/lib/go/internal/binary/BinaryEncodingMissing.go b/lib/go/internal/binary/BinaryEncodingMissing.go new file mode 100644 index 000000000..9259d18bf --- /dev/null +++ b/lib/go/internal/binary/BinaryEncodingMissing.go @@ -0,0 +1,37 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +import "fmt" + +// BinaryEncodingMissing indicates that an expected key was absent from the binary encoding map. +type BinaryEncodingMissing struct { + Key any +} + +// NewBinaryEncodingMissing constructs a BinaryEncodingMissing error for the provided key. +func NewBinaryEncodingMissing(key any) *BinaryEncodingMissing { + return &BinaryEncodingMissing{Key: key} +} + +// Error implements the error interface. +func (e *BinaryEncodingMissing) Error() string { + if e == nil { + return "missing binary encoding" + } + return fmt.Sprintf("Missing binary encoding for %v", e.Key) +} diff --git a/lib/go/internal/binary/BinaryPackNode.go b/lib/go/internal/binary/BinaryPackNode.go new file mode 100644 index 000000000..17d4a4c01 --- /dev/null +++ b/lib/go/internal/binary/BinaryPackNode.go @@ -0,0 +1,38 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +// BinaryPackNode represents a node in the binary packing trie used to encode field keys. +type BinaryPackNode struct { + Value int + Nested map[int]*BinaryPackNode +} + +// NewBinaryPackNode constructs a BinaryPackNode with defensive copies of the nested map. +func NewBinaryPackNode(value int, nested map[int]*BinaryPackNode) *BinaryPackNode { + var cloned map[int]*BinaryPackNode + if nested != nil { + cloned = make(map[int]*BinaryPackNode, len(nested)) + for key, child := range nested { + cloned[key] = child + } + } + return &BinaryPackNode{ + Value: value, + Nested: cloned, + } +} diff --git a/lib/go/internal/binary/CannotPack.go b/lib/go/internal/binary/CannotPack.go new file mode 100644 index 000000000..50e3a64ab --- /dev/null +++ b/lib/go/internal/binary/CannotPack.go @@ -0,0 +1,25 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +// CannotPack indicates that a value cannot be represented in the binary encoding. +type CannotPack struct{} + +// Error implements the error interface. +func (CannotPack) Error() string { + return "cannot pack value" +} diff --git a/lib/go/internal/binary/ClientBase64Decode.go b/lib/go/internal/binary/ClientBase64Decode.go new file mode 100644 index 000000000..b615ff93b --- /dev/null +++ b/lib/go/internal/binary/ClientBase64Decode.go @@ -0,0 +1,206 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +import ( + "encoding/base64" + "fmt" +) + +// ClientBase64Decode decodes base64-encoded payload fields specified by the pseudo-JSON headers. +func ClientBase64Decode(message []any) error { + if len(message) < 2 { + return fmt.Errorf("invalid message: expected headers and body, got %d elements", len(message)) + } + + var base64Paths any + switch headers := message[0].(type) { + case map[string]any: + base64Paths = headers["@base64_"] + case map[any]any: + base64Paths = headers["@base64_"] + default: + return fmt.Errorf("invalid message headers for base64 decode: %T", message[0]) + } + + if base64Paths == nil { + return nil + } + + _, err := travelBase64Decode(message[1], base64Paths) + return err +} + +func travelBase64Decode(value any, base64Paths any) (any, error) { + switch paths := base64Paths.(type) { + case map[string]any: + for key, descriptor := range paths { + if err := applyBase64Descriptor(value, key, descriptor); err != nil { + return nil, err + } + } + return nil, nil + case map[any]any: + for rawKey, descriptor := range paths { + key := fmt.Sprint(rawKey) + if err := applyBase64Descriptor(value, key, descriptor); err != nil { + return nil, err + } + } + return nil, nil + case bool: + if !paths { + return nil, invalidBase64Path(base64Paths, value) + } + if value == nil { + return nil, nil + } + str, ok := value.(string) + if !ok { + return nil, invalidBase64Path(base64Paths, value) + } + decoded, err := base64.StdEncoding.DecodeString(str) + if err != nil { + return nil, fmt.Errorf("decode base64 value: %w", err) + } + return decoded, nil + default: + return nil, invalidBase64Path(base64Paths, value) + } +} + +func applyBase64Descriptor(container any, key string, descriptor any) error { + if boolDescriptor, ok := descriptor.(bool); ok { + if !boolDescriptor { + return invalidBase64Path(descriptor, container) + } + if key == "*" { + switch typed := container.(type) { + case []any: + for i, item := range typed { + decoded, err := travelBase64Decode(item, true) + if err != nil { + return err + } + typed[i] = decoded + } + return nil + case map[string]any: + for k, item := range typed { + decoded, err := travelBase64Decode(item, true) + if err != nil { + return err + } + typed[k] = decoded + } + return nil + case map[any]any: + for k, item := range typed { + decoded, err := travelBase64Decode(item, true) + if err != nil { + return err + } + typed[k] = decoded + } + return nil + default: + return invalidBase64Path(key, container) + } + } + switch typed := container.(type) { + case map[string]any: + child, ok := typed[key] + if !ok { + return invalidBase64Path(key, container) + } + decoded, err := travelBase64Decode(child, true) + if err != nil { + return err + } + typed[key] = decoded + return nil + case map[any]any: + child, ok := typed[key] + if !ok { + return invalidBase64Path(key, container) + } + decoded, err := travelBase64Decode(child, true) + if err != nil { + return err + } + typed[key] = decoded + return nil + default: + return invalidBase64Path(key, container) + } + } + + if key == "*" { + switch typed := container.(type) { + case []any: + for i := range typed { + if _, err := travelBase64Decode(typed[i], descriptor); err != nil { + return err + } + } + return nil + case map[string]any: + for k := range typed { + if _, err := travelBase64Decode(typed[k], descriptor); err != nil { + return err + } + } + return nil + case map[any]any: + for k := range typed { + if _, err := travelBase64Decode(typed[k], descriptor); err != nil { + return err + } + } + return nil + default: + return invalidBase64Path(key, container) + } + } + + switch typed := container.(type) { + case map[string]any: + child, ok := typed[key] + if !ok { + return invalidBase64Path(key, container) + } + if _, err := travelBase64Decode(child, descriptor); err != nil { + return err + } + return nil + case map[any]any: + child, ok := typed[key] + if !ok { + return invalidBase64Path(key, container) + } + if _, err := travelBase64Decode(child, descriptor); err != nil { + return err + } + return nil + default: + return invalidBase64Path(key, container) + } +} + +func invalidBase64Path(base64Paths any, value any) error { + return fmt.Errorf("invalid base64 path: %v for value: %v", base64Paths, value) +} diff --git a/lib/go/internal/binary/ClientBase64Encode.go b/lib/go/internal/binary/ClientBase64Encode.go new file mode 100644 index 000000000..175a70875 --- /dev/null +++ b/lib/go/internal/binary/ClientBase64Encode.go @@ -0,0 +1,75 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +import ( + "encoding/base64" + "fmt" +) + +// ClientBase64Encode traverses the message body and encodes any raw byte slices into base64 strings. +func ClientBase64Encode(message []any) error { + if len(message) < 2 { + return fmt.Errorf("invalid message: expected headers and body, got %d elements", len(message)) + } + + encoded, err := travelBase64Encode(message[1]) + if err != nil { + return err + } + + message[1] = encoded + return nil +} + +func travelBase64Encode(value any) (any, error) { + switch typed := value.(type) { + case map[string]any: + for key, item := range typed { + encoded, err := travelBase64Encode(item) + if err != nil { + return nil, err + } + typed[key] = encoded + } + return typed, nil + case map[any]any: + for key, item := range typed { + encoded, err := travelBase64Encode(item) + if err != nil { + return nil, err + } + typed[key] = encoded + } + return typed, nil + case []any: + for index, item := range typed { + encoded, err := travelBase64Encode(item) + if err != nil { + return nil, err + } + typed[index] = encoded + } + return typed, nil + case []byte: + return base64.StdEncoding.EncodeToString(typed), nil + case nil: + return nil, nil + default: + return typed, nil + } +} diff --git a/lib/go/internal/binary/ClientBase64Encoder.go b/lib/go/internal/binary/ClientBase64Encoder.go new file mode 100644 index 000000000..5e6172e53 --- /dev/null +++ b/lib/go/internal/binary/ClientBase64Encoder.go @@ -0,0 +1,41 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +// ClientBase64Encoder implements the Base64Encoder interface for Telepact client messages. +type ClientBase64Encoder struct{} + +// NewClientBase64Encoder constructs a new ClientBase64Encoder instance. +func NewClientBase64Encoder() *ClientBase64Encoder { + return &ClientBase64Encoder{} +} + +// Decode decodes base64-encoded payload fields specified by the pseudo-JSON headers. +func (e *ClientBase64Encoder) Decode(message []any) ([]any, error) { + if err := ClientBase64Decode(message); err != nil { + return nil, err + } + return message, nil +} + +// Encode traverses the message body and encodes any raw byte slices into base64 strings. +func (e *ClientBase64Encoder) Encode(message []any) ([]any, error) { + if err := ClientBase64Encode(message); err != nil { + return nil, err + } + return message, nil +} diff --git a/lib/go/internal/binary/ClientBinaryDecode.go b/lib/go/internal/binary/ClientBinaryDecode.go new file mode 100644 index 000000000..359196a08 --- /dev/null +++ b/lib/go/internal/binary/ClientBinaryDecode.go @@ -0,0 +1,190 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +import "fmt" + +// ClientBinaryDecode decodes a Telepact client message using the provided binary encoding cache and strategy. +func ClientBinaryDecode(message []any, cache BinaryEncodingCache, strategy *ClientBinaryStrategy) ([]any, error) { + if len(message) < 2 { + return nil, fmt.Errorf("invalid message: expected headers and body, got %d elements", len(message)) + } + + headers, err := ensureStringMap(message[0]) + if err != nil { + return nil, err + } + + encodedBody, err := ensureAnyMap(message[1]) + if err != nil { + return nil, err + } + + checksums, err := extractIntSlice(headers["@bin_"]) + if err != nil || len(checksums) == 0 { + return nil, BinaryEncoderUnavailableError{} + } + binaryChecksum := checksums[0] + + if encodingRaw, ok := headers["@enc_"]; ok { + encodingMap, castErr := toStringIntMap(encodingRaw) + if castErr != nil { + return nil, castErr + } + cache.Add(binaryChecksum, encodingMap) + } + + strategy.UpdateChecksum(binaryChecksum) + currentChecksums := strategy.GetCurrentChecksums() + if len(currentChecksums) == 0 { + return nil, BinaryEncoderUnavailableError{} + } + + encoder := cache.Get(currentChecksums[0]) + if encoder == nil { + return nil, BinaryEncoderUnavailableError{} + } + + var workingBody map[any]any + if isStrictTrue(headers["@pac_"]) { + packed, packErr := UnpackBody(encodedBody) + if packErr != nil { + return nil, packErr + } + workingBody = packed + } else { + workingBody = encodedBody + } + + decodedBody, decodeErr := DecodeBody(workingBody, encoder) + if decodeErr != nil { + return nil, decodeErr + } + + return []any{headers, decodedBody}, nil +} + +func ensureAnyMap(value any) (map[any]any, error) { + switch typed := value.(type) { + case map[any]any: + return typed, nil + case map[string]any: + result := make(map[any]any, len(typed)) + for key, val := range typed { + result[key] = val + } + return result, nil + case map[int]any: + result := make(map[any]any, len(typed)) + for key, val := range typed { + result[key] = val + } + return result, nil + default: + return nil, fmt.Errorf("expected map for encoded body, got %T", value) + } +} + +func extractIntSlice(value any) ([]int, error) { + if value == nil { + return []int{}, nil + } + + switch typed := value.(type) { + case []int: + return append([]int{}, typed...), nil + case []any: + result := make([]int, 0, len(typed)) + for _, element := range typed { + intValue, ok := toInt(element) + if !ok { + return nil, fmt.Errorf("invalid checksum value: %v", element) + } + result = append(result, intValue) + } + return result, nil + case []float64: + result := make([]int, len(typed)) + for i, element := range typed { + result[i] = int(element) + } + return result, nil + default: + return nil, fmt.Errorf("invalid checksum list type: %T", value) + } +} + +func toStringIntMap(value any) (map[string]int, error) { + switch typed := value.(type) { + case map[string]int: + return typed, nil + case map[string]any: + result := make(map[string]int, len(typed)) + for key, raw := range typed { + intValue, ok := toInt(raw) + if !ok { + return nil, fmt.Errorf("invalid binary encoding value %v for key %s", raw, key) + } + result[key] = intValue + } + return result, nil + case map[any]any: + result := make(map[string]int, len(typed)) + for rawKey, raw := range typed { + key := fmt.Sprint(rawKey) + intValue, ok := toInt(raw) + if !ok { + return nil, fmt.Errorf("invalid binary encoding value %v for key %s", raw, key) + } + result[key] = intValue + } + return result, nil + default: + return nil, fmt.Errorf("invalid binary encoding map type: %T", value) + } +} + +func toInt(value any) (int, bool) { + switch v := value.(type) { + case int: + return v, true + case int8: + return int(v), true + case int16: + return int(v), true + case int32: + return int(v), true + case int64: + return int(v), true + case uint: + return int(v), true + case uint8: + return int(v), true + case uint16: + return int(v), true + case uint32: + return int(v), true + case uint64: + return int(v), true + case float32: + return int(v), true + case float64: + return int(v), true + default: + return 0, false + } +} diff --git a/lib/go/internal/binary/ClientBinaryEncode.go b/lib/go/internal/binary/ClientBinaryEncode.go new file mode 100644 index 000000000..7f7f3c66f --- /dev/null +++ b/lib/go/internal/binary/ClientBinaryEncode.go @@ -0,0 +1,97 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +import "fmt" + +// ClientBinaryEncode encodes a Telepact client message using the provided binary encoding cache and strategy. +func ClientBinaryEncode(message []any, cache BinaryEncodingCache, strategy *ClientBinaryStrategy) ([]any, error) { + if len(message) < 2 { + return nil, fmt.Errorf("invalid message: expected headers and body, got %d elements", len(message)) + } + + headers, err := ensureStringMap(message[0]) + if err != nil { + return nil, err + } + + messageBody, err := ensureStringMap(message[1]) + if err != nil { + return nil, err + } + + forceSendJSON := isStrictTrue(headers["_forceSendJson"]) + delete(headers, "_forceSendJson") + + checksums := strategy.GetCurrentChecksums() + headers["@bin_"] = checksums + + if forceSendJSON { + return nil, BinaryEncoderUnavailableError{} + } + + if len(checksums) > 1 { + return nil, BinaryEncoderUnavailableError{} + } + + if len(checksums) == 0 { + return nil, BinaryEncoderUnavailableError{} + } + + encoding := cache.Get(checksums[0]) + if encoding == nil { + return nil, BinaryEncoderUnavailableError{} + } + + encodedBody, err := EncodeBody(messageBody, encoding) + if err != nil { + return nil, err + } + + var finalBody map[any]any + if isStrictTrue(headers["@pac_"]) { + packedBody, packErr := PackBody(encodedBody) + if packErr != nil { + return nil, packErr + } + finalBody = packedBody + } else { + finalBody = encodedBody + } + + return []any{headers, finalBody}, nil +} + +func ensureStringMap(value any) (map[string]any, error) { + switch typed := value.(type) { + case map[string]any: + return typed, nil + case map[any]any: + result := make(map[string]any, len(typed)) + for key, val := range typed { + result[fmt.Sprint(key)] = val + } + return result, nil + default: + return nil, fmt.Errorf("expected map[string]any, got %T", value) + } +} + +func isStrictTrue(value any) bool { + boolVal, ok := value.(bool) + return ok && boolVal +} diff --git a/lib/go/internal/binary/ClientBinaryEncoder.go b/lib/go/internal/binary/ClientBinaryEncoder.go new file mode 100644 index 000000000..4536ca303 --- /dev/null +++ b/lib/go/internal/binary/ClientBinaryEncoder.go @@ -0,0 +1,41 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +// ClientBinaryEncoder implements BinaryEncoder for Telepact client messages. +type ClientBinaryEncoder struct { + cache BinaryEncodingCache + strategy *ClientBinaryStrategy +} + +// NewClientBinaryEncoder constructs a ClientBinaryEncoder with the provided cache. +func NewClientBinaryEncoder(cache BinaryEncodingCache) *ClientBinaryEncoder { + return &ClientBinaryEncoder{ + cache: cache, + strategy: NewClientBinaryStrategy(cache), + } +} + +// Encode encodes a pseudo-JSON client message body into its binary representation. +func (e *ClientBinaryEncoder) Encode(message []any) ([]any, error) { + return ClientBinaryEncode(message, e.cache, e.strategy) +} + +// Decode decodes a binary client message body back into pseudo-JSON. +func (e *ClientBinaryEncoder) Decode(message []any) ([]any, error) { + return ClientBinaryDecode(message, e.cache, e.strategy) +} diff --git a/lib/go/internal/binary/ClientBinaryStrategy.go b/lib/go/internal/binary/ClientBinaryStrategy.go new file mode 100644 index 000000000..6502874e9 --- /dev/null +++ b/lib/go/internal/binary/ClientBinaryStrategy.go @@ -0,0 +1,102 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +import ( + "sync" + "time" +) + +type checksumRecord struct { + value int + expiration int +} + +// ClientBinaryStrategy tracks checksum rotation for client binary encoding. +type ClientBinaryStrategy struct { + cache BinaryEncodingCache + primary *checksumRecord + secondary *checksumRecord + lastUpdate time.Time + mu sync.Mutex +} + +// NewClientBinaryStrategy constructs a ClientBinaryStrategy instance. +func NewClientBinaryStrategy(cache BinaryEncodingCache) *ClientBinaryStrategy { + return &ClientBinaryStrategy{ + cache: cache, + lastUpdate: time.Now(), + } +} + +// UpdateChecksum records a newly observed checksum, rotating the primary/secondary slots when necessary. +func (s *ClientBinaryStrategy) UpdateChecksum(newChecksum int) { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now() + + if s.primary == nil { + s.primary = &checksumRecord{value: newChecksum} + s.lastUpdate = now + return + } + + if s.primary.value != newChecksum { + expired := s.secondary + s.secondary = s.primary + s.primary = &checksumRecord{value: newChecksum} + if s.secondary != nil { + s.secondary.expiration++ + } + if expired != nil { + s.cache.Remove(expired.value) + } + s.lastUpdate = now + return + } + + s.lastUpdate = now +} + +// GetCurrentChecksums returns the current checksum order, retiring the secondary checksum after inactivity. +func (s *ClientBinaryStrategy) GetCurrentChecksums() []int { + s.mu.Lock() + defer s.mu.Unlock() + + if s.primary == nil { + return []int{} + } + + if s.secondary == nil { + return []int{s.primary.value} + } + + minutesSinceUpdate := time.Since(s.lastUpdate).Minutes() + penalty := int(minutesSinceUpdate/10.0) + 1 + if penalty < 1 { + penalty = 1 + } + + s.secondary.expiration += penalty + if s.secondary.expiration > 5 { + s.secondary = nil + return []int{s.primary.value} + } + + return []int{s.primary.value, s.secondary.value} +} diff --git a/lib/go/internal/binary/ConstructBinaryEncoding.go b/lib/go/internal/binary/ConstructBinaryEncoding.go new file mode 100644 index 000000000..ad422abe5 --- /dev/null +++ b/lib/go/internal/binary/ConstructBinaryEncoding.go @@ -0,0 +1,112 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +import ( + "sort" + "strings" + + "github.com/telepact/telepact/lib/go/internal/types" +) + +// ConstructBinaryEncoding builds a BinaryEncoding from the supplied parsed Telepact schema types. +func ConstructBinaryEncoding(parsed map[string]types.TType) (*BinaryEncoding, error) { + allKeys := make(map[string]struct{}) + + for key, value := range parsed { + unionType, ok := value.(*types.TUnion) + if !ok { + continue + } + + if strings.HasSuffix(key, ".->") { + resultStruct, ok := unionType.Tags["Ok_"] + if !ok { + continue + } + + allKeys["Ok_"] = struct{}{} + appendStructKeys(resultStruct.Fields, allKeys) + } else if strings.HasPrefix(key, "fn.") { + allKeys[key] = struct{}{} + + argsStruct, ok := unionType.Tags[key] + if !ok { + continue + } + + appendStructKeys(argsStruct.Fields, allKeys) + } + } + + sortedKeys := make([]string, 0, len(allKeys)) + for key := range allKeys { + sortedKeys = append(sortedKeys, key) + } + sort.Strings(sortedKeys) + + encodingMap := make(map[string]int, len(sortedKeys)) + for index, key := range sortedKeys { + encodingMap[key] = index + } + + checksum := CreateChecksum(strings.Join(sortedKeys, "\n")) + return NewBinaryEncoding(encodingMap, checksum), nil +} + +func appendStructKeys(fields map[string]*types.TFieldDeclaration, allKeys map[string]struct{}) { + for fieldKey, field := range fields { + allKeys[fieldKey] = struct{}{} + for _, nestedKey := range traceType(field.TypeDeclaration) { + allKeys[nestedKey] = struct{}{} + } + } +} + +func traceType(typeDeclaration *types.TTypeDeclaration) []string { + if typeDeclaration == nil { + return nil + } + + var keys []string + + switch typed := typeDeclaration.Type.(type) { + case *types.TArray: + if len(typeDeclaration.TypeParameters) > 0 { + keys = append(keys, traceType(typeDeclaration.TypeParameters[0])...) + } + case *types.TObject: + if len(typeDeclaration.TypeParameters) > 0 { + keys = append(keys, traceType(typeDeclaration.TypeParameters[0])...) + } + case *types.TStruct: + for structFieldKey, structField := range typed.Fields { + keys = append(keys, structFieldKey) + keys = append(keys, traceType(structField.TypeDeclaration)...) + } + case *types.TUnion: + for tagKey, tagStruct := range typed.Tags { + keys = append(keys, tagKey) + for structFieldKey, structField := range tagStruct.Fields { + keys = append(keys, structFieldKey) + keys = append(keys, traceType(structField.TypeDeclaration)...) + } + } + } + + return keys +} diff --git a/lib/go/internal/binary/CreateChecksum.go b/lib/go/internal/binary/CreateChecksum.go new file mode 100644 index 000000000..cbf4814f7 --- /dev/null +++ b/lib/go/internal/binary/CreateChecksum.go @@ -0,0 +1,25 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +import "hash/crc32" + +// CreateChecksum produces a signed 32-bit CRC checksum for the provided string value. +func CreateChecksum(value string) int { + checksum := crc32.ChecksumIEEE([]byte(value)) + return int(int32(checksum)) +} diff --git a/lib/go/internal/binary/DecodeBody.go b/lib/go/internal/binary/DecodeBody.go new file mode 100644 index 000000000..cafcf9e38 --- /dev/null +++ b/lib/go/internal/binary/DecodeBody.go @@ -0,0 +1,34 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +import "fmt" + +// DecodeBody decodes a binary-encoded message body into pseudo-JSON using the provided BinaryEncoding. +func DecodeBody(encodedBody map[any]any, encoding *BinaryEncoding) (map[string]any, error) { + decoded, err := DecodeKeys(encodedBody, encoding) + if err != nil { + return nil, err + } + + result, ok := decoded.(map[string]any) + if !ok { + return nil, fmt.Errorf("expected decoded body to be map[string]any, got %T", decoded) + } + + return result, nil +} diff --git a/lib/go/internal/binary/DecodeKeys.go b/lib/go/internal/binary/DecodeKeys.go new file mode 100644 index 000000000..65ee71dc8 --- /dev/null +++ b/lib/go/internal/binary/DecodeKeys.go @@ -0,0 +1,118 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +import ( + "fmt" + "strconv" +) + +// DecodeKeys recursively decodes map keys using the provided BinaryEncoding lookup tables. +func DecodeKeys(given any, encoding *BinaryEncoding) (any, error) { + switch typed := given.(type) { + case map[string]any: + result := make(map[string]any, len(typed)) + for key, value := range typed { + decodedValue, err := DecodeKeys(value, encoding) + if err != nil { + return nil, err + } + if decodedKey, ok, convErr := decodeNumericStringKey(key, encoding); convErr != nil { + return nil, convErr + } else if ok { + result[decodedKey] = decodedValue + continue + } + result[key] = decodedValue + } + return result, nil + case map[int]any: + result := make(map[string]any, len(typed)) + for key, value := range typed { + decodedValue, err := DecodeKeys(value, encoding) + if err != nil { + return nil, err + } + newKey, ok := encoding.DecodeMap[key] + if !ok { + return nil, NewBinaryEncodingMissing(key) + } + result[newKey] = decodedValue + } + return result, nil + case map[any]any: + result := make(map[string]any, len(typed)) + for rawKey, value := range typed { + decodedValue, err := DecodeKeys(value, encoding) + if err != nil { + return nil, err + } + + switch key := rawKey.(type) { + case string: + if decodedKey, ok, convErr := decodeNumericStringKey(key, encoding); convErr != nil { + return nil, convErr + } else if ok { + result[decodedKey] = decodedValue + break + } + result[key] = decodedValue + default: + intKey, ok := toInt(key) + if !ok { + return nil, fmt.Errorf("invalid key type for decode: %T", rawKey) + } + newKey, exists := encoding.DecodeMap[intKey] + if !exists { + return nil, NewBinaryEncodingMissing(intKey) + } + result[newKey] = decodedValue + } + } + return result, nil + case []any: + result := make([]any, len(typed)) + for i, item := range typed { + decodedValue, err := DecodeKeys(item, encoding) + if err != nil { + return nil, err + } + result[i] = decodedValue + } + return result, nil + default: + return given, nil + } +} + +func decodeNumericStringKey(key string, encoding *BinaryEncoding) (string, bool, error) { + if encoding == nil { + return "", false, nil + } + if len(key) == 0 { + return "", false, nil + } + if key[0] == '-' || (key[0] >= '0' && key[0] <= '9') { + if intKey, err := strconv.Atoi(key); err == nil { + if decoded, ok := encoding.DecodeMap[intKey]; ok { + return decoded, true, nil + } + return "", false, NewBinaryEncodingMissing(intKey) + } + } + return "", false, nil +} diff --git a/lib/go/internal/binary/DefaultBinaryEncodingCache.go b/lib/go/internal/binary/DefaultBinaryEncodingCache.go new file mode 100644 index 000000000..1a7002259 --- /dev/null +++ b/lib/go/internal/binary/DefaultBinaryEncodingCache.go @@ -0,0 +1,67 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +import "sync" + +// DefaultBinaryEncodingCache stores recently used binary encoders in-memory. +type DefaultBinaryEncodingCache struct { + mu sync.RWMutex + cache map[int]*BinaryEncoding +} + +// NewDefaultBinaryEncodingCache constructs a DefaultBinaryEncodingCache instance. +func NewDefaultBinaryEncodingCache() *DefaultBinaryEncodingCache { + return &DefaultBinaryEncodingCache{ + cache: make(map[int]*BinaryEncoding), + } +} + +// Add registers a BinaryEncoding for the provided checksum. +func (c *DefaultBinaryEncodingCache) Add(checksum int, binaryEncodingMap map[string]int) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.cache == nil { + c.cache = make(map[int]*BinaryEncoding) + } + + encoding := NewBinaryEncoding(binaryEncodingMap, checksum) + c.cache[checksum] = encoding +} + +// Get retrieves the BinaryEncoding for the checksum, returning nil when absent. +func (c *DefaultBinaryEncodingCache) Get(checksum int) *BinaryEncoding { + c.mu.RLock() + defer c.mu.RUnlock() + + if c.cache == nil { + return nil + } + return c.cache[checksum] +} + +// Remove deletes the BinaryEncoding for the checksum. +func (c *DefaultBinaryEncodingCache) Remove(checksum int) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.cache == nil { + return + } + delete(c.cache, checksum) +} diff --git a/lib/go/internal/binary/EncodeBody.go b/lib/go/internal/binary/EncodeBody.go new file mode 100644 index 000000000..c29b74285 --- /dev/null +++ b/lib/go/internal/binary/EncodeBody.go @@ -0,0 +1,34 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +import "fmt" + +// EncodeBody encodes a pseudo-JSON message body with the provided BinaryEncoding lookup tables. +func EncodeBody(messageBody map[string]any, encoding *BinaryEncoding) (map[any]any, error) { + encoded, err := EncodeKeys(messageBody, encoding) + if err != nil { + return nil, err + } + + result, ok := encoded.(map[any]any) + if !ok { + return nil, fmt.Errorf("expected encoded body to be map[any]any, got %T", encoded) + } + + return result, nil +} diff --git a/lib/go/internal/binary/EncodeKeys.go b/lib/go/internal/binary/EncodeKeys.go new file mode 100644 index 000000000..c8cf4984b --- /dev/null +++ b/lib/go/internal/binary/EncodeKeys.go @@ -0,0 +1,80 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +import "fmt" + +// EncodeKeys recursively encodes map keys using the provided BinaryEncoding lookup tables. +func EncodeKeys(given any, encoding *BinaryEncoding) (any, error) { + switch typed := given.(type) { + case nil: + return nil, nil + case map[string]any: + result := make(map[any]any, len(typed)) + for key, value := range typed { + encodedValue, err := EncodeKeys(value, encoding) + if err != nil { + return nil, err + } + + if mapped, ok := encoding.EncodeMap[key]; ok { + result[mapped] = encodedValue + } else { + result[key] = encodedValue + } + } + return result, nil + case map[int]any: + result := make(map[int]any, len(typed)) + for key, value := range typed { + encodedValue, err := EncodeKeys(value, encoding) + if err != nil { + return nil, err + } + result[key] = encodedValue + } + return result, nil + case map[any]any: + result := make(map[any]any, len(typed)) + for key, value := range typed { + encodedValue, err := EncodeKeys(value, encoding) + if err != nil { + return nil, err + } + + keyString := fmt.Sprint(key) + if mapped, ok := encoding.EncodeMap[keyString]; ok { + result[mapped] = encodedValue + } else { + result[key] = encodedValue + } + } + return result, nil + case []any: + result := make([]any, len(typed)) + for i, item := range typed { + encodedValue, err := EncodeKeys(item, encoding) + if err != nil { + return nil, err + } + result[i] = encodedValue + } + return result, nil + default: + return given, nil + } +} diff --git a/lib/go/internal/binary/Pack.go b/lib/go/internal/binary/Pack.go new file mode 100644 index 000000000..f7c234a9f --- /dev/null +++ b/lib/go/internal/binary/Pack.go @@ -0,0 +1,57 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +// Pack attempts to pack nested maps/lists into the compact binary representation used by Telepact. +func Pack(value any) (any, error) { + switch typed := value.(type) { + case []any: + return PackList(typed) + case map[string]any: + result := make(map[string]any, len(typed)) + for key, val := range typed { + packed, err := Pack(val) + if err != nil { + return nil, err + } + result[key] = packed + } + return result, nil + case map[int]any: + result := make(map[int]any, len(typed)) + for key, val := range typed { + packed, err := Pack(val) + if err != nil { + return nil, err + } + result[key] = packed + } + return result, nil + case map[any]any: + result := make(map[any]any, len(typed)) + for key, val := range typed { + packed, err := Pack(val) + if err != nil { + return nil, err + } + result[key] = packed + } + return result, nil + default: + return value, nil + } +} diff --git a/lib/go/internal/binary/PackBody.go b/lib/go/internal/binary/PackBody.go new file mode 100644 index 000000000..f1961ba3c --- /dev/null +++ b/lib/go/internal/binary/PackBody.go @@ -0,0 +1,30 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +// PackBody packs the message body map into the compact binary representation when possible. +func PackBody(body map[any]any) (map[any]any, error) { + result := make(map[any]any, len(body)) + for key, value := range body { + packedValue, err := Pack(value) + if err != nil { + return nil, err + } + result[key] = packedValue + } + return result, nil +} diff --git a/lib/go/internal/binary/PackList.go b/lib/go/internal/binary/PackList.go new file mode 100644 index 000000000..8ef681bae --- /dev/null +++ b/lib/go/internal/binary/PackList.go @@ -0,0 +1,135 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +import ( + "errors" + + "github.com/vmihailenco/msgpack/v5" +) + +const ( + // PackedByte is the msgpack extension type used to denote a packed list header. + PackedByte byte = 17 + // UndefinedByte is the msgpack extension type used for undefined cells in packed rows. + UndefinedByte byte = 18 +) + +var ( + packedListMarker = &packedListExt{} + undefinedMarker = &undefinedExt{} +) + +type packedListExt struct{} + +func (p *packedListExt) MarshalMsgpack() ([]byte, error) { + return nil, nil +} + +func (p *packedListExt) UnmarshalMsgpack([]byte) error { + return nil +} + +type undefinedExt struct{} + +func (u *undefinedExt) MarshalMsgpack() ([]byte, error) { + return nil, nil +} + +func (u *undefinedExt) UnmarshalMsgpack([]byte) error { + return nil +} + +func init() { + msgpack.RegisterExt(int8(PackedByte), (*packedListExt)(nil)) + msgpack.RegisterExt(int8(UndefinedByte), (*undefinedExt)(nil)) +} + +// PackList attempts to pack a list of maps into the compact representation used by the Telepact binary encoder. +func PackList(lst []any) ([]any, error) { + if len(lst) == 0 { + return lst, nil + } + + header := []any{nil} + packedList := []any{packedListMarker, header} + keyIndexMap := make(map[int]*BinaryPackNode) + + for _, item := range lst { + mapValue, ok := toIntKeyMap(item) + if !ok { + return fallbackPackList(lst) + } + + row, err := PackMap(mapValue, &header, keyIndexMap) + if err != nil { + var cannotPack CannotPack + if errors.As(err, &cannotPack) { + return fallbackPackList(lst) + } + return nil, err + } + + packedList = append(packedList, row) + } + + packedList[1] = header + return packedList, nil +} + +func fallbackPackList(lst []any) ([]any, error) { + result := make([]any, len(lst)) + for i, item := range lst { + packed, err := Pack(item) + if err != nil { + return nil, err + } + result[i] = packed + } + return result, nil +} + +func toIntKeyMap(value any) (map[int]any, bool) { + switch typed := value.(type) { + case map[int]any: + return typed, true + case map[int64]any: + result := make(map[int]any, len(typed)) + for key, val := range typed { + result[int(key)] = val + } + return result, true + case map[int32]any: + result := make(map[int]any, len(typed)) + for key, val := range typed { + result[int(key)] = val + } + return result, true + case map[any]any: + result := make(map[int]any, len(typed)) + for key, val := range typed { + intKey, ok := toInt(key) + if !ok { + return nil, false + } + result[intKey] = val + } + return result, true + default: + return nil, false + } +} diff --git a/lib/go/internal/binary/PackMap.go b/lib/go/internal/binary/PackMap.go new file mode 100644 index 000000000..aa4967d5c --- /dev/null +++ b/lib/go/internal/binary/PackMap.go @@ -0,0 +1,101 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +import "sort" + +// PackMap attempts to pack a dictionary with integer keys into a compact row representation. +func PackMap(m map[int]any, header *[]any, keyIndexMap map[int]*BinaryPackNode) ([]any, error) { + row := make([]any, 0, len(m)) + + keys := make([]int, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + sort.Ints(keys) + + for _, key := range keys { + rawValue := m[key] + node, exists := keyIndexMap[key] + if !exists { + node = NewBinaryPackNode(len(*header)-1, make(map[int]*BinaryPackNode)) + + if _, ok := toIntKeyMap(rawValue); ok { + *header = append(*header, []any{key}) + } else { + *header = append(*header, key) + } + + keyIndexMap[key] = node + } + + index := node.Value + + if mapValue, ok := toIntKeyMap(rawValue); ok { + nestedHeaderAny := (*header)[index+1] + nestedHeader, ok := nestedHeaderAny.([]any) + if !ok { + return nil, CannotPack{} + } + + nestedMap := node.Nested + if nestedMap == nil { + nestedMap = make(map[int]*BinaryPackNode) + node.Nested = nestedMap + } + + nestedHeaderCopy := nestedHeader + nestedRow, err := PackMap(mapValue, &nestedHeaderCopy, nestedMap) + if err != nil { + return nil, err + } + (*header)[index+1] = nestedHeaderCopy + + row = ensureRowCapacity(row, index) + if len(row) == index { + row = append(row, nestedRow) + } else { + row[index] = nestedRow + } + } else { + if _, ok := (*header)[index+1].([]any); ok { + return nil, CannotPack{} + } + + packedValue, err := Pack(rawValue) + if err != nil { + return nil, err + } + + row = ensureRowCapacity(row, index) + if len(row) == index { + row = append(row, packedValue) + } else { + row[index] = packedValue + } + } + } + + return row, nil +} + +func ensureRowCapacity(row []any, index int) []any { + for len(row) < index { + row = append(row, undefinedMarker) + } + return row +} diff --git a/lib/go/internal/binary/ServerBase64Decode.go b/lib/go/internal/binary/ServerBase64Decode.go new file mode 100644 index 000000000..4a33b2ea3 --- /dev/null +++ b/lib/go/internal/binary/ServerBase64Decode.go @@ -0,0 +1,197 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +import ( + "encoding/base64" + "fmt" +) + +// ServerBase64Decode traverses the message body and decodes base64 strings into byte arrays based on the provided paths. +func ServerBase64Decode(body map[string]any, bytesPaths map[string]any) error { + _, err := travelServerBase64Decode(body, bytesPaths) + return err +} + +func travelServerBase64Decode(value any, bytesPaths any) (any, error) { + switch paths := bytesPaths.(type) { + case map[string]any: + for key, descriptor := range paths { + if err := applyBase64DecodeDescriptor(value, key, descriptor); err != nil { + return nil, err + } + } + return value, nil + case map[any]any: + for rawKey, descriptor := range paths { + key := fmt.Sprint(rawKey) + if err := applyBase64DecodeDescriptor(value, key, descriptor); err != nil { + return nil, err + } + } + return value, nil + case bool: + if !paths { + return nil, fmt.Errorf("invalid bytes path: %v for value: %v", bytesPaths, value) + } + if value == nil { + return nil, nil + } + str, ok := value.(string) + if !ok { + return nil, fmt.Errorf("invalid bytes path: %v for value: %T", bytesPaths, value) + } + decoded, err := base64.StdEncoding.DecodeString(str) + if err != nil { + return nil, err + } + return decoded, nil + default: + return nil, fmt.Errorf("invalid bytes path: %v for value: %v", bytesPaths, value) + } +} + +func applyBase64DecodeDescriptor(container any, key string, descriptor any) error { + if boolDescriptor, ok := descriptor.(bool); ok { + if !boolDescriptor { + return fmt.Errorf("invalid bytes path: %v for value: %v", key, container) + } + return applyBase64DecodeLeaf(container, key) + } + + return applyBase64DecodeNested(container, key, descriptor) +} + +func applyBase64DecodeLeaf(container any, key string) error { + switch typed := container.(type) { + case []any: + if key != "*" { + return fmt.Errorf("invalid bytes path: %s for value: %v", key, container) + } + for i, item := range typed { + decoded, err := travelServerBase64Decode(item, true) + if err != nil { + return err + } + typed[i] = decoded + } + return nil + case map[string]any: + if key == "*" { + for childKey, item := range typed { + decoded, err := travelServerBase64Decode(item, true) + if err != nil { + return err + } + typed[childKey] = decoded + } + return nil + } + child, ok := typed[key] + if !ok { + return fmt.Errorf("invalid bytes path: %s for value: %v", key, container) + } + decoded, err := travelServerBase64Decode(child, true) + if err != nil { + return err + } + typed[key] = decoded + return nil + case map[any]any: + if key == "*" { + for childKey, item := range typed { + decoded, err := travelServerBase64Decode(item, true) + if err != nil { + return err + } + typed[childKey] = decoded + } + return nil + } + child, ok := typed[key] + if !ok { + return fmt.Errorf("invalid bytes path: %s for value: %v", key, container) + } + decoded, err := travelServerBase64Decode(child, true) + if err != nil { + return err + } + typed[key] = decoded + return nil + default: + return fmt.Errorf("invalid bytes path: %s for value: %v", key, container) + } +} + +func applyBase64DecodeNested(container any, key string, descriptor any) error { + switch typed := container.(type) { + case []any: + if key != "*" { + return fmt.Errorf("invalid bytes path: %s for value: %v", key, container) + } + for _, item := range typed { + if _, err := travelServerBase64Decode(item, descriptor); err != nil { + return err + } + } + return nil + case map[string]any: + if key == "*" { + for childKey, item := range typed { + if updated, err := travelServerBase64Decode(item, descriptor); err != nil { + return err + } else if updated != nil { + typed[childKey] = updated + } + } + return nil + } + child, ok := typed[key] + if !ok { + return fmt.Errorf("invalid bytes path: %s for value: %v", key, container) + } + if updated, err := travelServerBase64Decode(child, descriptor); err != nil { + return err + } else if updated != nil { + typed[key] = updated + } + return nil + case map[any]any: + if key == "*" { + for childKey, item := range typed { + if updated, err := travelServerBase64Decode(item, descriptor); err != nil { + return err + } else if updated != nil { + typed[childKey] = updated + } + } + return nil + } + child, ok := typed[key] + if !ok { + return fmt.Errorf("invalid bytes path: %s for value: %v", key, container) + } + if updated, err := travelServerBase64Decode(child, descriptor); err != nil { + return err + } else if updated != nil { + typed[key] = updated + } + return nil + default: + return fmt.Errorf("invalid bytes path: %s for value: %v", key, container) + } +} diff --git a/lib/go/internal/binary/ServerBase64Encode.go b/lib/go/internal/binary/ServerBase64Encode.go new file mode 100644 index 000000000..68d5f05c2 --- /dev/null +++ b/lib/go/internal/binary/ServerBase64Encode.go @@ -0,0 +1,227 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +import ( + "encoding/base64" + "fmt" +) + +// ServerBase64Encode traverses the message body and encodes byte arrays into base64 strings based on the provided paths. +func ServerBase64Encode(message []any) error { + if len(message) < 2 { + return fmt.Errorf("invalid message: expected headers and body, got %d elements", len(message)) + } + + headers, err := ensureStringMap(message[0]) + if err != nil { + return err + } + + body, err := ensureStringKeyedMap(message[1]) + if err != nil { + return err + } + + base64Paths := headers["@base64_"] + if base64Paths == nil { + return nil + } + + _, err = travelServerBase64Encode(body, base64Paths) + return err +} + +func travelServerBase64Encode(value any, base64Paths any) (any, error) { + switch paths := base64Paths.(type) { + case map[string]any: + for key, descriptor := range paths { + if err := applyBase64EncodeDescriptor(value, key, descriptor); err != nil { + return nil, err + } + } + return value, nil + case map[any]any: + for rawKey, descriptor := range paths { + key := fmt.Sprint(rawKey) + if err := applyBase64EncodeDescriptor(value, key, descriptor); err != nil { + return nil, err + } + } + return value, nil + case bool: + if !paths { + return nil, fmt.Errorf("invalid base64 path: %v for value: %v", base64Paths, value) + } + if value == nil { + return nil, nil + } + bytesValue, ok := value.([]byte) + if !ok { + return nil, fmt.Errorf("invalid base64 path: %v for value: %T", base64Paths, value) + } + return base64.StdEncoding.EncodeToString(bytesValue), nil + default: + return nil, fmt.Errorf("invalid base64 path: %v for value: %v", base64Paths, value) + } +} + +func applyBase64EncodeDescriptor(container any, key string, descriptor any) error { + if boolDescriptor, ok := descriptor.(bool); ok { + if !boolDescriptor { + return fmt.Errorf("invalid base64 path: %v for value: %v", key, container) + } + return applyBase64EncodeLeaf(container, key) + } + + return applyBase64EncodeNested(container, key, descriptor) +} + +func applyBase64EncodeLeaf(container any, key string) error { + switch typed := container.(type) { + case []any: + if key != "*" { + return fmt.Errorf("invalid base64 path: %s for value: %v", key, container) + } + for i, item := range typed { + encoded, err := travelServerBase64Encode(item, true) + if err != nil { + return err + } + typed[i] = encoded + } + return nil + case map[string]any: + if key == "*" { + for childKey, item := range typed { + encoded, err := travelServerBase64Encode(item, true) + if err != nil { + return err + } + typed[childKey] = encoded + } + return nil + } + child, ok := typed[key] + if !ok { + return fmt.Errorf("invalid base64 path: %s for value: %v", key, container) + } + encoded, err := travelServerBase64Encode(child, true) + if err != nil { + return err + } + typed[key] = encoded + return nil + case map[any]any: + if key == "*" { + for childKey, item := range typed { + encoded, err := travelServerBase64Encode(item, true) + if err != nil { + return err + } + typed[childKey] = encoded + } + return nil + } + child, ok := typed[key] + if !ok { + return fmt.Errorf("invalid base64 path: %s for value: %v", key, container) + } + encoded, err := travelServerBase64Encode(child, true) + if err != nil { + return err + } + typed[key] = encoded + return nil + default: + return fmt.Errorf("invalid base64 path: %s for value: %v", key, container) + } +} + +func applyBase64EncodeNested(container any, key string, descriptor any) error { + switch typed := container.(type) { + case []any: + if key != "*" { + return fmt.Errorf("invalid base64 path: %s for value: %v", key, container) + } + for _, item := range typed { + if _, err := travelServerBase64Encode(item, descriptor); err != nil { + return err + } + } + return nil + case map[string]any: + if key == "*" { + for childKey, item := range typed { + if updated, err := travelServerBase64Encode(item, descriptor); err != nil { + return err + } else if updated != nil { + typed[childKey] = updated + } + } + return nil + } + child, ok := typed[key] + if !ok { + return fmt.Errorf("invalid base64 path: %s for value: %v", key, container) + } + if updated, err := travelServerBase64Encode(child, descriptor); err != nil { + return err + } else if updated != nil { + typed[key] = updated + } + return nil + case map[any]any: + if key == "*" { + for childKey, item := range typed { + if updated, err := travelServerBase64Encode(item, descriptor); err != nil { + return err + } else if updated != nil { + typed[childKey] = updated + } + } + return nil + } + child, ok := typed[key] + if !ok { + return fmt.Errorf("invalid base64 path: %s for value: %v", key, container) + } + if updated, err := travelServerBase64Encode(child, descriptor); err != nil { + return err + } else if updated != nil { + typed[key] = updated + } + return nil + default: + return fmt.Errorf("invalid base64 path: %s for value: %v", key, container) + } +} + +func ensureStringKeyedMap(value any) (map[string]any, error) { + switch typed := value.(type) { + case map[string]any: + return typed, nil + case map[any]any: + result := make(map[string]any, len(typed)) + for key, val := range typed { + result[fmt.Sprint(key)] = val + } + return result, nil + default: + return nil, fmt.Errorf("expected map[string]any, got %T", value) + } +} diff --git a/lib/go/internal/binary/ServerBase64Encoder.go b/lib/go/internal/binary/ServerBase64Encoder.go new file mode 100644 index 000000000..1cda933c3 --- /dev/null +++ b/lib/go/internal/binary/ServerBase64Encoder.go @@ -0,0 +1,38 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +// ServerBase64Encoder implements Base64Encoder for server-side processing. +type ServerBase64Encoder struct{} + +// NewServerBase64Encoder constructs a ServerBase64Encoder instance. +func NewServerBase64Encoder() *ServerBase64Encoder { + return &ServerBase64Encoder{} +} + +// Decode is a no-op on the server side; decoding occurs after validation. +func (e *ServerBase64Encoder) Decode(message []any) ([]any, error) { + return message, nil +} + +// Encode applies server-side base64 encoding to the provided message. +func (e *ServerBase64Encoder) Encode(message []any) ([]any, error) { + if err := ServerBase64Encode(message); err != nil { + return nil, err + } + return message, nil +} diff --git a/lib/go/internal/binary/ServerBinaryDecode.go b/lib/go/internal/binary/ServerBinaryDecode.go new file mode 100644 index 000000000..3c652a052 --- /dev/null +++ b/lib/go/internal/binary/ServerBinaryDecode.go @@ -0,0 +1,65 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +import "fmt" + +// ServerBinaryDecode transforms an encoded message into its pseudo-JSON representation. +func ServerBinaryDecode(message []any, binaryEncoding *BinaryEncoding) ([]any, error) { + if len(message) != 2 { + return nil, fmt.Errorf("invalid message: expected two elements, got %d", len(message)) + } + + headers, err := ensureStringMap(message[0]) + if err != nil { + return nil, err + } + + encodedBody, err := ensureAnyMap(message[1]) + if err != nil { + return nil, err + } + + clientChecksums, err := extractIntSlice(headers["@bin_"]) + if err != nil { + return nil, err + } + if len(clientChecksums) == 0 { + return nil, BinaryEncoderUnavailableError{} + } + + checksumUsed := clientChecksums[0] + if binaryEncoding == nil || checksumUsed != binaryEncoding.Checksum { + return nil, BinaryEncoderUnavailableError{} + } + + finalEncodedBody := encodedBody + if isStrictTrue(headers["@pac_"]) { + unpacked, err := UnpackBody(encodedBody) + if err != nil { + return nil, err + } + finalEncodedBody = unpacked + } + + messageBody, err := DecodeBody(finalEncodedBody, binaryEncoding) + if err != nil { + return nil, err + } + + return []any{headers, messageBody}, nil +} diff --git a/lib/go/internal/binary/ServerBinaryEncode.go b/lib/go/internal/binary/ServerBinaryEncode.go new file mode 100644 index 000000000..f4fd724db --- /dev/null +++ b/lib/go/internal/binary/ServerBinaryEncode.go @@ -0,0 +1,104 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +import ( + "fmt" + "sort" +) + +// ServerBinaryEncode encodes a pseudo-JSON response message into its binary representation for the client. +func ServerBinaryEncode(message []any, binaryEncoding *BinaryEncoding) ([]any, error) { + if binaryEncoding == nil { + return nil, BinaryEncoderUnavailableError{} + } + if len(message) != 2 { + return nil, fmt.Errorf("invalid message: expected two elements, got %d", len(message)) + } + + headers, err := ensureStringMap(message[0]) + if err != nil { + return nil, err + } + + messageBody, err := ensureStringMap(message[1]) + if err != nil { + return nil, err + } + + clientKnownRaw, hasClientKnown := headers["@clientKnownBinaryChecksums_"] + if hasClientKnown { + delete(headers, "@clientKnownBinaryChecksums_") + } + + clientKnown, err := extractIntSlice(clientKnownRaw) + if err != nil { + return nil, err + } + + resultTag := firstKey(messageBody) + if resultTag != "Ok_" { + return nil, BinaryEncoderUnavailableError{} + } + + checksumKnown := false + for _, checksum := range clientKnown { + if binaryEncoding != nil && checksum == binaryEncoding.Checksum { + checksumKnown = true + break + } + } + + if !checksumKnown { + encodeMapCopy := make(map[string]int, len(binaryEncoding.EncodeMap)) + for key, value := range binaryEncoding.EncodeMap { + encodeMapCopy[key] = value + } + headers["@enc_"] = encodeMapCopy + } + + headers["@bin_"] = []int{binaryEncoding.Checksum} + + encodedBody, err := EncodeBody(messageBody, binaryEncoding) + if err != nil { + return nil, err + } + + finalEncodedBody := encodedBody + if isStrictTrue(headers["@pac_"]) { + packedBody, err := PackBody(encodedBody) + if err != nil { + return nil, err + } + finalEncodedBody = packedBody + } + + return []any{headers, finalEncodedBody}, nil +} + +func firstKey(m map[string]any) string { + if len(m) == 0 { + return "" + } + + keys := make([]string, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + sort.Strings(keys) + return keys[0] +} diff --git a/lib/go/internal/binary/ServerBinaryEncoder.go b/lib/go/internal/binary/ServerBinaryEncoder.go new file mode 100644 index 000000000..26451c04d --- /dev/null +++ b/lib/go/internal/binary/ServerBinaryEncoder.go @@ -0,0 +1,37 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +// ServerBinaryEncoder adapts ServerBinaryEncode and ServerBinaryDecode to the BinaryEncoder interface. +type ServerBinaryEncoder struct { + binaryEncoding *BinaryEncoding +} + +// NewServerBinaryEncoder constructs a ServerBinaryEncoder. +func NewServerBinaryEncoder(binaryEncoding *BinaryEncoding) *ServerBinaryEncoder { + return &ServerBinaryEncoder{binaryEncoding: binaryEncoding} +} + +// Encode encodes the provided message using the underlying binary encoding. +func (e *ServerBinaryEncoder) Encode(message []any) ([]any, error) { + return ServerBinaryEncode(message, e.binaryEncoding) +} + +// Decode decodes the provided message using the underlying binary encoding. +func (e *ServerBinaryEncoder) Decode(message []any) ([]any, error) { + return ServerBinaryDecode(message, e.binaryEncoding) +} diff --git a/lib/go/internal/binary/Unpack.go b/lib/go/internal/binary/Unpack.go new file mode 100644 index 000000000..c7c089d0e --- /dev/null +++ b/lib/go/internal/binary/Unpack.go @@ -0,0 +1,57 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +// Unpack recursively unpacks packed lists and maps back into pseudo-JSON structures. +func Unpack(value any) (any, error) { + switch typed := value.(type) { + case []any: + return UnpackList(typed) + case map[any]any: + result := make(map[any]any, len(typed)) + for key, val := range typed { + unpacked, err := Unpack(val) + if err != nil { + return nil, err + } + result[key] = unpacked + } + return result, nil + case map[string]any: + result := make(map[string]any, len(typed)) + for key, val := range typed { + unpacked, err := Unpack(val) + if err != nil { + return nil, err + } + result[key] = unpacked + } + return result, nil + case map[int]any: + result := make(map[int]any, len(typed)) + for key, val := range typed { + unpacked, err := Unpack(val) + if err != nil { + return nil, err + } + result[key] = unpacked + } + return result, nil + default: + return value, nil + } +} diff --git a/lib/go/internal/binary/UnpackBody.go b/lib/go/internal/binary/UnpackBody.go new file mode 100644 index 000000000..7988a140c --- /dev/null +++ b/lib/go/internal/binary/UnpackBody.go @@ -0,0 +1,30 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +// UnpackBody unpacks each entry within the message body map. +func UnpackBody(body map[any]any) (map[any]any, error) { + result := make(map[any]any, len(body)) + for key, value := range body { + unpacked, err := Unpack(value) + if err != nil { + return nil, err + } + result[key] = unpacked + } + return result, nil +} diff --git a/lib/go/internal/binary/UnpackList.go b/lib/go/internal/binary/UnpackList.go new file mode 100644 index 000000000..65b734baf --- /dev/null +++ b/lib/go/internal/binary/UnpackList.go @@ -0,0 +1,64 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +import "fmt" + +// UnpackList converts packed list representation back into standard pseudo-JSON arrays. +func UnpackList(lst []any) ([]any, error) { + if len(lst) == 0 { + return lst, nil + } + + switch lst[0].(type) { + case *packedListExt: + if len(lst) < 2 { + return nil, fmt.Errorf("invalid packed list: missing header") + } + + header, ok := lst[1].([]any) + if !ok { + return nil, fmt.Errorf("invalid packed list header type: %T", lst[1]) + } + + unpacked := make([]any, 0, len(lst)-2) + for i := 2; i < len(lst); i++ { + row, ok := lst[i].([]any) + if !ok { + return nil, fmt.Errorf("invalid packed row type: %T", lst[i]) + } + + unpackedMap, err := UnpackMap(row, header) + if err != nil { + return nil, err + } + unpacked = append(unpacked, unpackedMap) + } + + return unpacked, nil + default: + result := make([]any, len(lst)) + for i, item := range lst { + unpacked, err := Unpack(item) + if err != nil { + return nil, err + } + result[i] = unpacked + } + return result, nil + } +} diff --git a/lib/go/internal/binary/UnpackMap.go b/lib/go/internal/binary/UnpackMap.go new file mode 100644 index 000000000..560f3168f --- /dev/null +++ b/lib/go/internal/binary/UnpackMap.go @@ -0,0 +1,77 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package binary + +import "fmt" + +// UnpackMap converts a packed row into a map keyed by integers. +func UnpackMap(row []any, header []any) (map[int]any, error) { + result := make(map[int]any) + + for idx := 0; idx < len(row); idx++ { + keyIndex := idx + 1 + if keyIndex >= len(header) { + return nil, fmt.Errorf("packed map header out of range for index %d", idx) + } + + headerValue := header[keyIndex] + value := row[idx] + + if _, skip := value.(*undefinedExt); skip { + continue + } + + if nestedHeader, ok := headerValue.([]any); ok { + nestedRow, ok := value.([]any) + if !ok { + return nil, fmt.Errorf("expected nested packed row, got %T", value) + } + + if len(nestedHeader) == 0 { + return nil, fmt.Errorf("nested header missing key reference") + } + + nestedKeyRaw := nestedHeader[0] + nestedKey, ok := toInt(nestedKeyRaw) + if !ok { + return nil, fmt.Errorf("invalid nested key type: %T", nestedKeyRaw) + } + + unpackedNested, err := UnpackMap(nestedRow, nestedHeader) + if err != nil { + return nil, err + } + + result[nestedKey] = unpackedNested + continue + } + + key, ok := toInt(headerValue) + if !ok { + return nil, fmt.Errorf("invalid header key type: %T", headerValue) + } + + unpackedValue, err := Unpack(value) + if err != nil { + return nil, err + } + + result[key] = unpackedValue + } + + return result, nil +} diff --git a/lib/go/internal/mock/IsSubMap.go b/lib/go/internal/mock/IsSubMap.go new file mode 100644 index 000000000..db0f2f36e --- /dev/null +++ b/lib/go/internal/mock/IsSubMap.go @@ -0,0 +1,37 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package mock + +// IsSubMap verifies that every entry in part matches the corresponding entry in whole. +func IsSubMap(part map[string]any, whole map[string]any) bool { + if len(part) == 0 { + return true + } + + for key, partValue := range part { + wholeValue, ok := whole[key] + if !ok { + return false + } + + if !IsSubMapEntryEqual(partValue, wholeValue) { + return false + } + } + + return true +} diff --git a/lib/go/internal/mock/IsSubMapEntryEqual.go b/lib/go/internal/mock/IsSubMapEntryEqual.go new file mode 100644 index 000000000..81753df91 --- /dev/null +++ b/lib/go/internal/mock/IsSubMapEntryEqual.go @@ -0,0 +1,102 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package mock + +import ( + "fmt" + "reflect" +) + +// IsSubMapEntryEqual determines whether a value from the expected map matches the value in the actual map. +func IsSubMapEntryEqual(partValue any, wholeValue any) bool { + partMap, partIsMap := toStringAnyMap(partValue) + wholeMap, wholeIsMap := toStringAnyMap(wholeValue) + if partIsMap && wholeIsMap { + return IsSubMap(partMap, wholeMap) + } + + partSlice, partIsSlice := toAnySlice(partValue) + wholeSlice, wholeIsSlice := toAnySlice(wholeValue) + if partIsSlice && wholeIsSlice { + for _, element := range partSlice { + if !PartiallyMatches(wholeSlice, element) { + return false + } + } + return true + } + + return reflect.DeepEqual(partValue, wholeValue) +} + +func toStringAnyMap(value any) (map[string]any, bool) { + switch typed := value.(type) { + case map[string]any: + return typed, true + case map[any]any: + converted := make(map[string]any, len(typed)) + for key, entry := range typed { + converted[toString(key)] = entry + } + return converted, true + default: + return nil, false + } +} + +func toAnySlice(value any) ([]any, bool) { + switch typed := value.(type) { + case []any: + return typed, true + case []string: + converted := make([]any, len(typed)) + for i, entry := range typed { + converted[i] = entry + } + return converted, true + case []int: + converted := make([]any, len(typed)) + for i, entry := range typed { + converted[i] = entry + } + return converted, true + default: + val := reflect.ValueOf(value) + if !val.IsValid() || val.Kind() != reflect.Slice { + return nil, false + } + + converted := make([]any, val.Len()) + for i := 0; i < val.Len(); i++ { + converted[i] = val.Index(i).Interface() + } + return converted, true + } +} + +func toString(value any) string { + switch v := value.(type) { + case nil: + return "" + case string: + return v + case fmt.Stringer: + return v.String() + default: + return fmt.Sprintf("%v", v) + } +} diff --git a/lib/go/internal/mock/MockHandle.go b/lib/go/internal/mock/MockHandle.go new file mode 100644 index 000000000..d1a5afe41 --- /dev/null +++ b/lib/go/internal/mock/MockHandle.go @@ -0,0 +1,294 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package mock + +import ( + "fmt" + "reflect" + "strings" + + "github.com/telepact/telepact/lib/go/internal/types" +) + +type seedableRandomGenerator interface { + types.RandomGenerator + SetSeed(int32) +} + +// MockHandle processes a Telepact mock request and produces response headers and body content. +func MockHandle( + headers map[string]any, + functionName string, + argument map[string]any, + stubs *[]*MockStub, + invocations *[]*MockInvocation, + random seedableRandomGenerator, + parsed map[string]types.TType, + enableGeneratedDefaultStub bool, + enableOptionalFieldGeneration bool, + randomizeOptionalFieldGeneration bool, +) (map[string]any, map[string]any, error) { + if stubs == nil || invocations == nil { + return nil, nil, fmt.Errorf("telepact: mock handle requires stub and invocation storage") + } + + enableGenerationStub := boolValue(headers["@gen_"]) + + switch functionName { + case "fn.createStub_": + return handleCreateStub(argument, stubs) + case "fn.verify_": + return map[string]any{}, handleVerify(argument, *invocations), nil + case "fn.verifyNoMoreInteractions_": + return map[string]any{}, VerifyNoMoreInteractions(*invocations), nil + case "fn.clearCalls_": + *invocations = (*invocations)[:0] + return map[string]any{}, map[string]any{"Ok_": map[string]any{}}, nil + case "fn.clearStubs_": + *stubs = (*stubs)[:0] + return map[string]any{}, map[string]any{"Ok_": map[string]any{}}, nil + case "fn.setRandomSeed_": + if random == nil { + return nil, nil, fmt.Errorf("telepact: random generator unavailable") + } + seedValue, ok := argument["seed"] + if !ok { + return nil, nil, fmt.Errorf("telepact: setRandomSeed request missing seed") + } + random.SetSeed(int32(toInt(seedValue))) + return map[string]any{}, map[string]any{"Ok_": map[string]any{}}, nil + } + + *invocations = append(*invocations, NewMockInvocation(functionName, cloneStringAnyMap(argument))) + + definition := lookupUnionDefinition(parsed, functionName) + + if definition != nil { + for _, stub := range *stubs { + if stub == nil || stub.Count == 0 || stub.WhenFunction != functionName { + continue + } + + matches := false + if stub.AllowArgumentPartialMatch { + matches = IsSubMap(stub.WhenArgument, argument) + } else { + matches = reflect.DeepEqual(stub.WhenArgument, argument) + } + + if matches { + resultBody, err := generateStubResult(definition, stub.ThenResult, functionName, random, randomizeOptionalFieldGeneration) + if err != nil { + return nil, nil, err + } + if stub.Count > 0 { + stub.Count-- + } + return map[string]any{}, resultBody, nil + } + } + } + + if !enableGeneratedDefaultStub && !enableGenerationStub { + return map[string]any{}, map[string]any{"ErrorNoMatchingStub_": map[string]any{}}, nil + } + + if definition == nil { + return nil, nil, fmt.Errorf("unexpected unknown function: %s", functionName) + } + + okStructRef, ok := definition.Tags["Ok_"] + if !ok || okStructRef == nil { + return nil, nil, fmt.Errorf("telepact: union type missing Ok_ tag") + } + + // The Python implementation always enables optional field generation for the + // auto-generated Ok_ stub response, regardless of the server option. We + // mirror that behavior here to keep the port faithful. + includeOptionalFields := true + _ = enableOptionalFieldGeneration + + ctx := types.NewGenerateContext( + includeOptionalFields, + randomizeOptionalFieldGeneration, + true, + functionName, + random, + ) + + randomOkStruct := okStructRef.GenerateRandomValue(map[string]any{}, true, nil, ctx) + okBody, ok := toStringAnyMap(randomOkStruct) + if !ok { + return nil, nil, fmt.Errorf("telepact: generated Ok_ struct was not an object") + } + + return map[string]any{}, map[string]any{"Ok_": okBody}, nil +} + +func handleCreateStub(argument map[string]any, stubs *[]*MockStub) (map[string]any, map[string]any, error) { + if argument == nil { + return nil, nil, fmt.Errorf("telepact: createStub request missing stub argument") + } + + stubValue, ok := argument["stub"] + if !ok { + return nil, nil, fmt.Errorf("telepact: createStub request missing stub definition") + } + + givenStub, ok := toStringAnyMap(stubValue) + if !ok { + return nil, nil, fmt.Errorf("telepact: stub definition must be an object") + } + + stubFunctionName := "" + stubArgument := map[string]any{} + for key, value := range givenStub { + if strings.HasPrefix(key, "fn.") { + stubFunctionName = key + if converted, ok := toStringAnyMap(value); ok { + stubArgument = cloneStringAnyMap(converted) + } + break + } + } + + if stubFunctionName == "" { + return nil, nil, fmt.Errorf("telepact: stub definition missing function call") + } + + stubResult := map[string]any{} + if stubResultRaw, ok := givenStub["->"]; ok { + if converted, ok := toStringAnyMap(stubResultRaw); ok { + stubResult = cloneStringAnyMap(converted) + } + } + + allowArgumentPartialMatch := !boolValue(argument["strictMatch!"]) + stubCount := -1 + if countRaw, ok := argument["count!"]; ok { + stubCount = toInt(countRaw) + } + + stub := NewMockStub(stubFunctionName, stubArgument, stubResult, allowArgumentPartialMatch, stubCount) + *stubs = append([]*MockStub{stub}, *stubs...) + + return map[string]any{}, map[string]any{"Ok_": map[string]any{}}, nil +} + +func handleVerify(argument map[string]any, invocations []*MockInvocation) map[string]any { + if argument == nil { + return map[string]any{"ErrorVerificationFailure": map[string]any{"reason": "missing call argument"}} + } + + givenCallRaw, ok := argument["call"] + if !ok { + return map[string]any{"ErrorVerificationFailure": map[string]any{"reason": "missing call definition"}} + } + + givenCall, ok := toStringAnyMap(givenCallRaw) + if !ok { + return map[string]any{"ErrorVerificationFailure": map[string]any{"reason": "call definition must be an object"}} + } + + callFunctionName := "" + callArgument := map[string]any{} + for key, value := range givenCall { + if strings.HasPrefix(key, "fn.") { + callFunctionName = key + if converted, ok := toStringAnyMap(value); ok { + callArgument = cloneStringAnyMap(converted) + } + break + } + } + + if callFunctionName == "" { + return map[string]any{"ErrorVerificationFailure": map[string]any{"reason": "missing function call"}} + } + + verifyTimes := map[string]any{"AtLeast": map[string]any{"times": 1}} + if verifyTimesRaw, ok := argument["count!"]; ok { + if converted, ok := toStringAnyMap(verifyTimesRaw); ok { + verifyTimes = cloneStringAnyMap(converted) + } + } + + strictMatch := boolValue(argument["strictMatch!"]) + + return Verify(callFunctionName, callArgument, strictMatch, verifyTimes, invocations) +} + +func lookupUnionDefinition(parsed map[string]types.TType, functionName string) *types.TUnion { + if parsed == nil { + return nil + } + + definition, ok := parsed[functionName+".->"] + if !ok || definition == nil { + return nil + } + + if union, ok := definition.(*types.TUnion); ok { + return union + } + return nil +} + +func generateStubResult( + definition *types.TUnion, + blueprint map[string]any, + functionName string, + random types.RandomGenerator, + randomizeOptionalFieldGeneration bool, +) (map[string]any, error) { + if definition == nil { + return nil, fmt.Errorf("telepact: missing result union definition") + } + + ctx := types.NewGenerateContext( + false, + randomizeOptionalFieldGeneration, + true, + functionName, + random, + ) + + generated := definition.GenerateRandomValue(blueprint, true, nil, ctx) + resultMap, ok := toStringAnyMap(generated) + if !ok { + return nil, fmt.Errorf("telepact: generated stub result was not an object") + } + return resultMap, nil +} + +func boolValue(value any) bool { + if flag, ok := value.(bool); ok { + return flag + } + return false +} + +func cloneStringAnyMap(source map[string]any) map[string]any { + if source == nil { + return map[string]any{} + } + clone := make(map[string]any, len(source)) + for key, value := range source { + clone[key] = value + } + return clone +} diff --git a/lib/go/internal/mock/MockInvocation.go b/lib/go/internal/mock/MockInvocation.go new file mode 100644 index 000000000..93d963812 --- /dev/null +++ b/lib/go/internal/mock/MockInvocation.go @@ -0,0 +1,33 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package mock + +// MockInvocation captures an invocation observed by the Telepact mock server implementation. +type MockInvocation struct { + FunctionName string + FunctionArgument map[string]any + Verified bool +} + +// NewMockInvocation constructs a MockInvocation with the supplied function name and argument. +func NewMockInvocation(functionName string, functionArgument map[string]any) *MockInvocation { + return &MockInvocation{ + FunctionName: functionName, + FunctionArgument: functionArgument, + Verified: false, + } +} diff --git a/lib/go/internal/mock/MockStub.go b/lib/go/internal/mock/MockStub.go new file mode 100644 index 000000000..9b06cbd40 --- /dev/null +++ b/lib/go/internal/mock/MockStub.go @@ -0,0 +1,43 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package mock + +// MockStub represents a Telepact mock stub definition. +type MockStub struct { + WhenFunction string + WhenArgument map[string]any + ThenResult map[string]any + AllowArgumentPartialMatch bool + Count int +} + +// NewMockStub constructs a MockStub using the supplied configuration. +func NewMockStub( + whenFunction string, + whenArgument map[string]any, + thenResult map[string]any, + allowArgumentPartialMatch bool, + count int, +) *MockStub { + return &MockStub{ + WhenFunction: whenFunction, + WhenArgument: whenArgument, + ThenResult: thenResult, + AllowArgumentPartialMatch: allowArgumentPartialMatch, + Count: count, + } +} diff --git a/lib/go/internal/mock/PartiallyMatches.go b/lib/go/internal/mock/PartiallyMatches.go new file mode 100644 index 000000000..565a68aa3 --- /dev/null +++ b/lib/go/internal/mock/PartiallyMatches.go @@ -0,0 +1,27 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package mock + +// PartiallyMatches determines if any element in wholeList matches partElement using sub-map comparison semantics. +func PartiallyMatches(wholeList []any, partElement any) bool { + for _, wholeElement := range wholeList { + if IsSubMapEntryEqual(partElement, wholeElement) { + return true + } + } + return false +} diff --git a/lib/go/internal/mock/Verify.go b/lib/go/internal/mock/Verify.go new file mode 100644 index 000000000..60e05d4b7 --- /dev/null +++ b/lib/go/internal/mock/Verify.go @@ -0,0 +1,140 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package mock + +import "reflect" + +// Verify inspects recorded mock invocations and produces verification metadata mirroring Telepact's Python implementation. +func Verify( + functionName string, + argument map[string]any, + exactMatch bool, + verificationTimes map[string]any, + invocations []*MockInvocation, +) map[string]any { + matchesFound := 0 + + for _, invocation := range invocations { + if invocation == nil || invocation.FunctionName != functionName { + continue + } + + if exactMatch { + if reflect.DeepEqual(invocation.FunctionArgument, argument) { + invocation.Verified = true + matchesFound++ + } + continue + } + + if IsSubMap(argument, invocation.FunctionArgument) { + invocation.Verified = true + matchesFound++ + } + } + + allCallsPseudoJSON := make([]any, 0, len(invocations)) + for _, invocation := range invocations { + if invocation == nil { + continue + } + allCallsPseudoJSON = append(allCallsPseudoJSON, map[string]any{ + invocation.FunctionName: invocation.FunctionArgument, + }) + } + + var verifyKey string + var verifyTimesStruct map[string]any + for key, raw := range verificationTimes { + verifyKey = key + verifyTimesStruct, _ = toStringAnyMap(raw) + break + } + + var verificationFailure map[string]any + + switch verifyKey { + case "Exact": + times := toInt(verifyTimesStruct["times"]) + if matchesFound > times { + verificationFailure = map[string]any{ + "TooManyMatchingCalls": map[string]any{ + "wanted": map[string]any{"Exact": map[string]any{"times": times}}, + "found": matchesFound, + "allCalls": allCallsPseudoJSON, + }, + } + } else if matchesFound < times { + verificationFailure = map[string]any{ + "TooFewMatchingCalls": map[string]any{ + "wanted": map[string]any{"Exact": map[string]any{"times": times}}, + "found": matchesFound, + "allCalls": allCallsPseudoJSON, + }, + } + } + case "AtMost": + times := toInt(verifyTimesStruct["times"]) + if matchesFound > times { + verificationFailure = map[string]any{ + "TooManyMatchingCalls": map[string]any{ + "wanted": map[string]any{"AtMost": map[string]any{"times": times}}, + "found": matchesFound, + "allCalls": allCallsPseudoJSON, + }, + } + } + case "AtLeast": + times := toInt(verifyTimesStruct["times"]) + if matchesFound < times { + verificationFailure = map[string]any{ + "TooFewMatchingCalls": map[string]any{ + "wanted": map[string]any{"AtLeast": map[string]any{"times": times}}, + "found": matchesFound, + "allCalls": allCallsPseudoJSON, + }, + } + } + } + + if verificationFailure == nil { + return map[string]any{"Ok_": map[string]any{}} + } + + return map[string]any{ + "ErrorVerificationFailure": map[string]any{ + "reason": verificationFailure, + }, + } +} + +func toInt(value any) int { + switch typed := value.(type) { + case int: + return typed + case int32: + return int(typed) + case int64: + return int(typed) + case float32: + return int(typed) + case float64: + return int(typed) + default: + return 0 + } +} diff --git a/lib/go/internal/mock/VerifyNoMoreInteractions.go b/lib/go/internal/mock/VerifyNoMoreInteractions.go new file mode 100644 index 000000000..edcdecc06 --- /dev/null +++ b/lib/go/internal/mock/VerifyNoMoreInteractions.go @@ -0,0 +1,41 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package mock + +// VerifyNoMoreInteractions ensures that all recorded mock invocations have been verified. +func VerifyNoMoreInteractions(invocations []*MockInvocation) map[string]any { + unverified := make([]map[string]any, 0) + for _, invocation := range invocations { + if invocation == nil || invocation.Verified { + continue + } + + unverified = append(unverified, map[string]any{ + invocation.FunctionName: invocation.FunctionArgument, + }) + } + + if len(unverified) == 0 { + return map[string]any{"Ok_": map[string]any{}} + } + + return map[string]any{ + "ErrorVerificationFailure": map[string]any{ + "additionalUnverifiedCalls": unverified, + }, + } +} diff --git a/lib/go/internal/schema/ApplyErrorToParsedTypes.go b/lib/go/internal/schema/ApplyErrorToParsedTypes.go new file mode 100644 index 000000000..b3cae4548 --- /dev/null +++ b/lib/go/internal/schema/ApplyErrorToParsedTypes.go @@ -0,0 +1,104 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package schema + +import ( + "regexp" + "strings" + + "github.com/telepact/telepact/lib/go/internal/types" +) + +// ApplyErrorToParsedTypes attaches the supplied error struct to parsed function result unions. +// It returns any schema parse failures encountered so the caller can surface them appropriately. +func ApplyErrorToParsedTypes( + errorType *types.TError, + parsedTypes map[string]types.TType, + schemaKeysToDocumentNames map[string]string, + schemaKeysToIndex map[string]int, + documentNamesToJSON map[string]string, + fnErrorRegexes map[string]string, +) ([]*SchemaParseFailure, error) { + if errorType == nil || errorType.Errors == nil { + return nil, nil + } + + parseFailures := make([]*SchemaParseFailure, 0) + + errorKey := errorType.Name + errorIndex := schemaKeysToIndex[errorKey] + documentName := schemaKeysToDocumentNames[errorKey] + + for parsedTypeName := range parsedTypes { + if !strings.HasPrefix(parsedTypeName, "fn.") || strings.HasSuffix(parsedTypeName, ".->") { + continue + } + + fnName := parsedTypeName + resultTypeKey := fnName + ".->" + parsedResultType, ok := parsedTypes[resultTypeKey] + if !ok { + continue + } + + fnUnion, ok := parsedResultType.(*types.TUnion) + if !ok || fnUnion == nil { + continue + } + + fnErrorRegex, hasRegex := fnErrorRegexes[fnName] + if !hasRegex || fnErrorRegex == "" { + continue + } + + regex := regexp.MustCompile(fnErrorRegex) + if !regex.MatchString(errorKey) { + continue + } + + fnResultTags := fnUnion.Tags + errorTags := errorType.Errors.Tags + + for errorTagName, errorTag := range errorTags { + if _, exists := fnResultTags[errorTagName]; exists { + otherPathIndex := schemaKeysToIndex[fnName] + errorTagIndex := errorType.Errors.TagIndices[errorTagName] + otherDocumentName := schemaKeysToDocumentNames[fnName] + fnErrorTagIndex := fnUnion.TagIndices[errorTagName] + otherFinalPath := []any{otherPathIndex, "->", fnErrorTagIndex, errorTagName} + otherDocumentJSON := documentNamesToJSON[otherDocumentName] + otherLocation := GetPathDocumentCoordinatesPseudoJSON(otherFinalPath, otherDocumentJSON) + + failurePath := []any{errorIndex, errorKey, errorTagIndex, errorTagName} + parseFailures = append(parseFailures, NewSchemaParseFailure( + documentName, + failurePath, + "PathCollision", + map[string]any{ + "document": otherDocumentName, + "path": otherFinalPath, + "location": otherLocation, + }, + )) + } + + fnResultTags[errorTagName] = errorTag + } + } + + return parseFailures, nil +} diff --git a/lib/go/internal/schema/CatchErrorCollisions.go b/lib/go/internal/schema/CatchErrorCollisions.go new file mode 100644 index 000000000..2de242124 --- /dev/null +++ b/lib/go/internal/schema/CatchErrorCollisions.go @@ -0,0 +1,176 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package schema + +import "sort" + +// CatchErrorCollisions detects duplicate error paths across definitions and returns parse failures when collisions are found. +func CatchErrorCollisions( + telepactSchemaNameToPseudoJSON map[string][]any, + errorKeys map[string]struct{}, + keysToIndex map[string]int, + schemaKeysToDocumentName map[string]string, + documentNamesToJSON map[string]string, +) ([]*SchemaParseFailure, error) { + if len(errorKeys) == 0 { + return nil, nil + } + + keys := make([]string, 0, len(errorKeys)) + for key := range errorKeys { + keys = append(keys, key) + } + + sort.Slice(keys, func(i, j int) bool { + docI := schemaKeysToDocumentName[keys[i]] + docJ := schemaKeysToDocumentName[keys[j]] + if docI == docJ { + return keysToIndex[keys[i]] < keysToIndex[keys[j]] + } + return docI < docJ + }) + + parseFailures := make([]*SchemaParseFailure, 0) + + for i := 0; i < len(keys); i++ { + for j := i + 1; j < len(keys); j++ { + defKey := keys[i] + otherDefKey := keys[j] + + index := keysToIndex[defKey] + otherIndex := keysToIndex[otherDefKey] + + documentName := schemaKeysToDocumentName[defKey] + otherDocumentName := schemaKeysToDocumentName[otherDefKey] + + pseudoList := telepactSchemaNameToPseudoJSON[documentName] + otherPseudoList := telepactSchemaNameToPseudoJSON[otherDocumentName] + if index < 0 || index >= len(pseudoList) || otherIndex < 0 || otherIndex >= len(otherPseudoList) { + continue + } + + defMap, ok := pseudoList[index].(map[string]any) + if !ok { + continue + } + otherDefMap, ok := otherPseudoList[otherIndex].(map[string]any) + if !ok { + continue + } + + errDefList, ok := toAnySlice(defMap[defKey]) + if !ok { + continue + } + otherErrDefList, ok := toAnySlice(otherDefMap[otherDefKey]) + if !ok { + continue + } + + for k := 0; k < len(errDefList); k++ { + thisErrDef, ok := errDefList[k].(map[string]any) + if !ok { + continue + } + thisKeys := keySetWithoutMeta(thisErrDef) + if len(thisKeys) == 0 { + continue + } + + for l := 0; l < len(otherErrDefList); l++ { + otherErrDef, ok := otherErrDefList[l].(map[string]any) + if !ok { + continue + } + otherKeys := keySetWithoutMeta(otherErrDef) + if len(otherKeys) == 0 { + continue + } + + if keySetsEqual(thisKeys, otherKeys) { + thisErrorKey := firstKey(thisKeys) + otherErrorKey := firstKey(otherKeys) + + finalPath := []any{index, defKey, k, thisErrorKey} + otherPath := []any{otherIndex, otherDefKey, l, otherErrorKey} + + otherDocumentJSON := documentNamesToJSON[documentName] + otherLocation := GetPathDocumentCoordinatesPseudoJSON(finalPath, otherDocumentJSON) + + parseFailures = append(parseFailures, NewSchemaParseFailure( + otherDocumentName, + otherPath, + "PathCollision", + map[string]any{ + "document": documentName, + "path": finalPath, + "location": otherLocation, + }, + )) + } + } + } + } + } + + return parseFailures, nil +} + +func toAnySlice(value any) ([]any, bool) { + switch v := value.(type) { + case []any: + return v, true + default: + return nil, false + } +} + +func keySetWithoutMeta(m map[string]any) map[string]struct{} { + result := make(map[string]struct{}, len(m)) + for key := range m { + if key == "///" { + continue + } + result[key] = struct{}{} + } + return result +} + +func keySetsEqual(a, b map[string]struct{}) bool { + if len(a) != len(b) { + return false + } + for key := range a { + if _, ok := b[key]; !ok { + return false + } + } + return true +} + +func firstKey(m map[string]struct{}) string { + if len(m) == 0 { + return "" + } + + keys := make([]string, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + sort.Strings(keys) + return keys[0] +} diff --git a/lib/go/internal/schema/CatchHeaderCollisions.go b/lib/go/internal/schema/CatchHeaderCollisions.go new file mode 100644 index 000000000..c12544d1f --- /dev/null +++ b/lib/go/internal/schema/CatchHeaderCollisions.go @@ -0,0 +1,140 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package schema + +import "sort" + +// CatchHeaderCollisions reports duplicate header definitions across Telepact schema documents. +func CatchHeaderCollisions( + telepactSchemaNameToPseudoJSON map[string][]any, + headerKeys map[string]struct{}, + keysToIndex map[string]int, + schemaKeysToDocumentNames map[string]string, + documentNamesToJSON map[string]string, +) ([]*SchemaParseFailure, error) { + if len(headerKeys) == 0 { + return nil, nil + } + + headers := make([]string, 0, len(headerKeys)) + for key := range headerKeys { + headers = append(headers, key) + } + + sort.Slice(headers, func(i, j int) bool { + docI := schemaKeysToDocumentNames[headers[i]] + docJ := schemaKeysToDocumentNames[headers[j]] + if docI == docJ { + return keysToIndex[headers[i]] < keysToIndex[headers[j]] + } + return docI < docJ + }) + + parseFailures := make([]*SchemaParseFailure, 0) + + for i := 0; i < len(headers); i++ { + for j := i + 1; j < len(headers); j++ { + defKey := headers[i] + otherDefKey := headers[j] + + index := keysToIndex[defKey] + otherIndex := keysToIndex[otherDefKey] + + documentName := schemaKeysToDocumentNames[defKey] + otherDocumentName := schemaKeysToDocumentNames[otherDefKey] + + pseudoList := telepactSchemaNameToPseudoJSON[documentName] + otherPseudoList := telepactSchemaNameToPseudoJSON[otherDocumentName] + if index < 0 || index >= len(pseudoList) || otherIndex < 0 || otherIndex >= len(otherPseudoList) { + continue + } + + defMap, ok := pseudoList[index].(map[string]any) + if !ok { + continue + } + otherDefMap, ok := otherPseudoList[otherIndex].(map[string]any) + if !ok { + continue + } + + headerDef, ok := defMap[defKey].(map[string]any) + if !ok { + continue + } + otherHeaderDef, ok := otherDefMap[otherDefKey].(map[string]any) + if !ok { + continue + } + + headerCollisions := intersectKeys(headerDef, otherHeaderDef) + for _, collision := range headerCollisions { + thisPath := []any{index, defKey, collision} + thisDocJSON := documentNamesToJSON[documentName] + location := GetPathDocumentCoordinatesPseudoJSON(thisPath, thisDocJSON) + parseFailures = append(parseFailures, NewSchemaParseFailure( + otherDocumentName, + []any{otherIndex, otherDefKey, collision}, + "PathCollision", + map[string]any{ + "document": documentName, + "path": thisPath, + "location": location, + }, + )) + } + + resHeaderDef, ok := defMap["->"].(map[string]any) + if !ok { + continue + } + otherResHeaderDef, ok := otherDefMap["->"].(map[string]any) + if !ok { + continue + } + + resCollisions := intersectKeys(resHeaderDef, otherResHeaderDef) + for _, collision := range resCollisions { + thisPath := []any{index, "->", collision} + thisDocJSON := documentNamesToJSON[documentName] + location := GetPathDocumentCoordinatesPseudoJSON(thisPath, thisDocJSON) + parseFailures = append(parseFailures, NewSchemaParseFailure( + otherDocumentName, + []any{otherIndex, "->", collision}, + "PathCollision", + map[string]any{ + "document": documentName, + "path": thisPath, + "location": location, + }, + )) + } + } + } + + return parseFailures, nil +} + +func intersectKeys(a, b map[string]any) []string { + result := make([]string, 0) + for key := range a { + if _, exists := b[key]; exists { + result = append(result, key) + } + } + return result +} diff --git a/lib/go/internal/schema/CreateMockTelepactSchemaFromFileJsonMap.go b/lib/go/internal/schema/CreateMockTelepactSchemaFromFileJsonMap.go new file mode 100644 index 000000000..02ca6b55d --- /dev/null +++ b/lib/go/internal/schema/CreateMockTelepactSchemaFromFileJsonMap.go @@ -0,0 +1,29 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package schema + +// CreateMockTelepactSchemaFromFileJSONMap constructs a mock Telepact schema from the supplied JSON documents map. +func CreateMockTelepactSchemaFromFileJSONMap(jsonDocuments map[string]string) (*ParsedSchemaResult, error) { + finalDocuments := make(map[string]string, len(jsonDocuments)+1) + for key, value := range jsonDocuments { + finalDocuments[key] = value + } + + finalDocuments["mock_"] = GetMockTelepactJSON() + + return CreateTelepactSchemaFromFileJSONMap(finalDocuments) +} diff --git a/lib/go/internal/schema/CreateTelepactSchemaFromFileJsonMap.go b/lib/go/internal/schema/CreateTelepactSchemaFromFileJsonMap.go new file mode 100644 index 000000000..a12b39d24 --- /dev/null +++ b/lib/go/internal/schema/CreateTelepactSchemaFromFileJsonMap.go @@ -0,0 +1,39 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package schema + +import "regexp" + +// CreateTelepactSchemaFromFileJSONMap constructs a Telepact schema from the supplied JSON documents map. +func CreateTelepactSchemaFromFileJSONMap(jsonDocuments map[string]string) (*ParsedSchemaResult, error) { + finalDocuments := make(map[string]string, len(jsonDocuments)+1) + for key, value := range jsonDocuments { + finalDocuments[key] = value + } + + finalDocuments["internal_"] = GetInternalTelepactJSON() + + authPattern := regexp.MustCompile(`"struct\.Auth_"\s*:`) + for _, document := range jsonDocuments { + if authPattern.MatchString(document) { + finalDocuments["auth_"] = GetAuthTelepactJSON() + break + } + } + + return ParseTelepactSchema(finalDocuments) +} diff --git a/lib/go/internal/schema/DerivePossibleSelects.go b/lib/go/internal/schema/DerivePossibleSelects.go new file mode 100644 index 000000000..bd1d53a67 --- /dev/null +++ b/lib/go/internal/schema/DerivePossibleSelects.go @@ -0,0 +1,129 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package schema + +import ( + "sort" + "strings" + + "github.com/telepact/telepact/lib/go/internal/types" +) + +// DerivePossibleSelects computes the set of selectable fields for a function result union. +func DerivePossibleSelects(fnName string, result *types.TUnion) map[string]any { + if result == nil { + return map[string]any{} + } + + nestedTypes := make(map[string]types.TType) + okStruct := result.Tags["Ok_"] + if okStruct == nil { + return map[string]any{} + } + + okFieldNames := make([]string, 0, len(okStruct.Fields)) + for name, fieldDecl := range okStruct.Fields { + okFieldNames = append(okFieldNames, name) + findNestedTypes(fieldDecl.TypeDeclaration, nestedTypes) + } + sort.Strings(okFieldNames) + + possibleSelect := map[string]any{ + "->": map[string]any{"Ok_": okFieldNames}, + } + + sortedTypeKeys := make([]string, 0, len(nestedTypes)) + for key := range nestedTypes { + sortedTypeKeys = append(sortedTypeKeys, key) + } + sort.Strings(sortedTypeKeys) + + for _, key := range sortedTypeKeys { + if strings.HasPrefix(key, "fn.") { + continue + } + + typ := nestedTypes[key] + switch typed := typ.(type) { + case *types.TUnion: + unionSelect := make(map[string][]string) + tagKeys := make([]string, 0, len(typed.Tags)) + for tagKey := range typed.Tags { + tagKeys = append(tagKeys, tagKey) + } + sort.Strings(tagKeys) + + for _, tagKey := range tagKeys { + tagStruct := typed.Tags[tagKey] + if tagStruct == nil { + continue + } + fieldNames := make([]string, 0, len(tagStruct.Fields)) + for fieldName := range tagStruct.Fields { + fieldNames = append(fieldNames, fieldName) + } + sort.Strings(fieldNames) + if len(fieldNames) > 0 { + unionSelect[tagKey] = fieldNames + } + } + + if len(unionSelect) > 0 { + possibleSelect[key] = unionSelect + } + case *types.TStruct: + fieldNames := make([]string, 0, len(typed.Fields)) + for fieldName := range typed.Fields { + fieldNames = append(fieldNames, fieldName) + } + sort.Strings(fieldNames) + if len(fieldNames) > 0 { + possibleSelect[key] = fieldNames + } + } + } + + return possibleSelect +} + +func findNestedTypes(typeDeclaration *types.TTypeDeclaration, nestedTypes map[string]types.TType) { + if typeDeclaration == nil || typeDeclaration.Type == nil { + return + } + + switch typed := typeDeclaration.Type.(type) { + case *types.TUnion: + nestedTypes[typed.Name] = typed + for _, tag := range typed.Tags { + if tag == nil { + continue + } + for _, fieldDecl := range tag.Fields { + findNestedTypes(fieldDecl.TypeDeclaration, nestedTypes) + } + } + case *types.TStruct: + nestedTypes[typed.Name] = typed + for _, fieldDecl := range typed.Fields { + findNestedTypes(fieldDecl.TypeDeclaration, nestedTypes) + } + case *types.TArray, *types.TObject: + if len(typeDeclaration.TypeParameters) > 0 { + findNestedTypes(typeDeclaration.TypeParameters[0], nestedTypes) + } + } +} diff --git a/lib/go/internal/schema/FindMatchingSchemaKey.go b/lib/go/internal/schema/FindMatchingSchemaKey.go new file mode 100644 index 000000000..589ce97bd --- /dev/null +++ b/lib/go/internal/schema/FindMatchingSchemaKey.go @@ -0,0 +1,32 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package schema + +import "strings" + +// FindMatchingSchemaKey finds an existing schema key equivalent to the supplied key, accounting for info keys. +func FindMatchingSchemaKey(schemaKeys map[string]struct{}, schemaKey string) string { + for key := range schemaKeys { + if strings.HasPrefix(schemaKey, "info.") && strings.HasPrefix(key, "info.") { + return key + } + if key == schemaKey { + return key + } + } + return "" +} diff --git a/lib/go/internal/schema/FindSchemaKey.go b/lib/go/internal/schema/FindSchemaKey.go new file mode 100644 index 000000000..ee6562cbb --- /dev/null +++ b/lib/go/internal/schema/FindSchemaKey.go @@ -0,0 +1,61 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package schema + +import ( + "regexp" + "sort" +) + +// FindSchemaKey identifies the canonical schema key for a definition within a document. +func FindSchemaKey(documentName string, definition map[string]any, index int, documentNamesToJSON map[string]string) (string, error) { + regex := regexp.MustCompile(`^(((fn|errors|headers|info)|((struct|union|_ext)(<[0-2]>)?))\..*)`) + matches := make([]string, 0) + + keys := make([]string, 0, len(definition)) + for key := range definition { + keys = append(keys, key) + } + sort.Strings(keys) + + for _, key := range keys { + if regex.MatchString(key) { + matches = append(matches, key) + } + } + + if len(matches) == 1 { + return matches[0], nil + } + + failure := NewSchemaParseFailure( + documentName, + []any{index}, + "ObjectKeyRegexMatchCountUnexpected", + map[string]any{ + "regex": regex.String(), + "actual": len(matches), + "expected": 1, + "keys": keys, + }, + ) + + return "", &ParseError{ + Failures: []*SchemaParseFailure{failure}, + DocumentJSON: documentNamesToJSON, + } +} diff --git a/lib/go/internal/schema/GetAuthTelepactJSON.go b/lib/go/internal/schema/GetAuthTelepactJSON.go new file mode 100644 index 000000000..cf3f1ce31 --- /dev/null +++ b/lib/go/internal/schema/GetAuthTelepactJSON.go @@ -0,0 +1,36 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package schema + +import "sync" + +var ( + authTelepactJSON string + authTelepactJSONOnce sync.Once +) + +// GetAuthTelepactJSON returns the bundled Telepact auth schema JSON content. +func GetAuthTelepactJSON() string { + authTelepactJSONOnce.Do(func() { + content, err := loadBundledSchema("auth.telepact.json") + if err != nil { + panic(err) + } + authTelepactJSON = content + }) + return authTelepactJSON +} diff --git a/lib/go/internal/schema/GetInternalTelepactJSON.go b/lib/go/internal/schema/GetInternalTelepactJSON.go new file mode 100644 index 000000000..da6e2d876 --- /dev/null +++ b/lib/go/internal/schema/GetInternalTelepactJSON.go @@ -0,0 +1,36 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package schema + +import "sync" + +var ( + internalTelepactJSON string + internalTelepactJSONOnce sync.Once +) + +// GetInternalTelepactJSON returns the bundled Telepact internal schema JSON content. +func GetInternalTelepactJSON() string { + internalTelepactJSONOnce.Do(func() { + content, err := loadBundledSchema("internal.telepact.json") + if err != nil { + panic(err) + } + internalTelepactJSON = content + }) + return internalTelepactJSON +} diff --git a/lib/go/internal/schema/GetMockTelepactJSON.go b/lib/go/internal/schema/GetMockTelepactJSON.go new file mode 100644 index 000000000..62c6de16e --- /dev/null +++ b/lib/go/internal/schema/GetMockTelepactJSON.go @@ -0,0 +1,36 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package schema + +import "sync" + +var ( + mockTelepactJSON string + mockTelepactJSONOnce sync.Once +) + +// GetMockTelepactJSON returns the bundled Telepact mock schema JSON content. +func GetMockTelepactJSON() string { + mockTelepactJSONOnce.Do(func() { + content, err := loadBundledSchema("mock-internal.telepact.json") + if err != nil { + panic(err) + } + mockTelepactJSON = content + }) + return mockTelepactJSON +} diff --git a/lib/go/internal/schema/GetOrParseType.go b/lib/go/internal/schema/GetOrParseType.go new file mode 100644 index 000000000..82f50e7ef --- /dev/null +++ b/lib/go/internal/schema/GetOrParseType.go @@ -0,0 +1,204 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package schema + +import ( + "regexp" + "strings" + + "github.com/telepact/telepact/lib/go/internal/types" +) + +var typeNameRegex = regexp.MustCompile(`^(boolean|integer|number|string|any|bytes)|((fn|(union|struct|_ext))\.([a-zA-Z_]\w*))$`) + +// GetOrParseType returns an existing parsed type or parses and stores it if necessary. +func GetOrParseType(path []any, typeName string, ctx *ParseContext) (types.TType, error) { + if ctx == nil { + return nil, nil + } + + if ctx.FailedTypes != nil { + if _, failed := ctx.FailedTypes[typeName]; failed { + return nil, &ParseError{Failures: []*SchemaParseFailure{}, DocumentJSON: ctx.TelepactSchemaDocumentNamesToJSON} + } + } + + if ctx.ParsedTypes != nil { + if existing, ok := ctx.ParsedTypes[typeName]; ok && existing != nil { + return existing, nil + } + } + + matcher := typeNameRegex.FindStringSubmatch(typeName) + if matcher == nil { + failure := NewSchemaParseFailure( + ctx.DocumentName, + append([]any{}, path...), + "StringRegexMatchFailed", + map[string]any{"regex": typeNameRegex.String()}, + ) + return nil, &ParseError{Failures: []*SchemaParseFailure{failure}, DocumentJSON: ctx.TelepactSchemaDocumentNamesToJSON} + } + + standardTypeName := matcher[1] + if standardTypeName != "" { + var typ types.TType + switch standardTypeName { + case "boolean": + typ = types.NewTBoolean() + case "integer": + typ = types.NewTInteger() + case "number": + typ = types.NewTNumber() + case "string": + typ = types.NewTString() + case "bytes": + typ = types.NewTBytes() + default: + typ = types.NewTAny() + } + return storeParsedType(ctx, typeName, typ), nil + } + + customTypeName := matcher[2] + thisIndex, ok := ctx.SchemaKeysToIndex[customTypeName] + if !ok { + failure := NewSchemaParseFailure( + ctx.DocumentName, + append([]any{}, path...), + "TypeUnknown", + map[string]any{"name": customTypeName}, + ) + return nil, &ParseError{Failures: []*SchemaParseFailure{failure}, DocumentJSON: ctx.TelepactSchemaDocumentNamesToJSON} + } + + thisDocumentName := ctx.SchemaKeysToDocumentName[customTypeName] + documentDefinitions := ctx.TelepactSchemaDocumentsToPseudoJSON[thisDocumentName] + if thisIndex < 0 || thisIndex >= len(documentDefinitions) { + failure := NewSchemaParseFailure( + ctx.DocumentName, + append([]any{}, path...), + "TypeDefinitionMissing", + map[string]any{"name": customTypeName}, + ) + return nil, &ParseError{Failures: []*SchemaParseFailure{failure}, DocumentJSON: ctx.TelepactSchemaDocumentNamesToJSON} + } + + definition, ok := documentDefinitions[thisIndex].(map[string]any) + if !ok { + failure := NewSchemaParseFailure( + thisDocumentName, + []any{thisIndex}, + "TypeDefinitionUnexpected", + map[string]any{"name": customTypeName}, + ) + return nil, &ParseError{Failures: []*SchemaParseFailure{failure}, DocumentJSON: ctx.TelepactSchemaDocumentNamesToJSON} + } + + thisPath := []any{thisIndex} + childCtx := ctx.Copy(thisDocumentName) + + var parsedType types.TType + var err error + + switch { + case strings.HasPrefix(customTypeName, "struct"): + parsedType, err = ParseStructType(thisPath, definition, customTypeName, nil, childCtx) + case strings.HasPrefix(customTypeName, "union"): + parsedType, err = ParseUnionType(thisPath, definition, customTypeName, nil, nil, childCtx) + case strings.HasPrefix(customTypeName, "fn"): + argStruct, argErr := ParseStructType(path, definition, customTypeName, []string{"->", "_errors"}, ctx) + if argErr != nil { + err = argErr + break + } + + // Functions are represented as a single-variant union containing their call struct. + unionTags := map[string]*types.TStruct{} + unionIndices := map[string]int{} + if argStruct != nil { + unionTags[customTypeName] = argStruct + unionIndices[customTypeName] = 0 + } + storeParsedType(ctx, customTypeName, types.NewTUnion(customTypeName, unionTags, unionIndices)) + + resultType, resultErr := ParseFunctionResultType(thisPath, definition, customTypeName, childCtx) + if resultErr != nil { + err = resultErr + break + } + storeParsedType(ctx, customTypeName+".->", resultType) + + errorsRegex, regexErr := ParseFunctionErrorsRegex(thisPath, definition, customTypeName, childCtx) + if regexErr != nil { + err = regexErr + break + } + if ctx.FnErrorRegexes != nil { + ctx.FnErrorRegexes[customTypeName] = errorsRegex + } + return ctx.ParsedTypes[customTypeName], nil + default: + parsedType, err = resolveTypeExtension(customTypeName, path, ctx) + } + + if err != nil { + if parseErr, ok := err.(*ParseError); ok { + if ctx.AllParseFailures != nil { + *ctx.AllParseFailures = append(*ctx.AllParseFailures, parseErr.Failures...) + } + if ctx.FailedTypes == nil { + ctx.FailedTypes = make(map[string]struct{}) + } + ctx.FailedTypes[customTypeName] = struct{}{} + return nil, &ParseError{Failures: []*SchemaParseFailure{}, DocumentJSON: ctx.TelepactSchemaDocumentNamesToJSON} + } + return nil, err + } + + return storeParsedType(ctx, customTypeName, parsedType), nil +} + +func storeParsedType(ctx *ParseContext, name string, typ types.TType) types.TType { + if ctx.ParsedTypes == nil { + ctx.ParsedTypes = make(map[string]types.TType) + } + if typ == nil { + return nil + } + ctx.ParsedTypes[name] = typ + return typ +} + +func resolveTypeExtension(customTypeName string, path []any, ctx *ParseContext) (types.TType, error) { + switch customTypeName { + case "_ext.Select_": + return types.NewTSelect(map[string]any{}), nil + case "_ext.Call_": + return types.NewTMockCall(ctx.ParsedTypes), nil + case "_ext.Stub_": + return types.NewTMockStub(ctx.ParsedTypes), nil + default: + failure := NewSchemaParseFailure( + ctx.DocumentName, + append([]any{}, path...), + "TypeExtensionImplementationMissing", + map[string]any{"name": customTypeName}, + ) + return nil, &ParseError{Failures: []*SchemaParseFailure{failure}, DocumentJSON: ctx.TelepactSchemaDocumentNamesToJSON} + } +} diff --git a/lib/go/internal/schema/GetPathDocumentCoordinatesPseudoJSON.go b/lib/go/internal/schema/GetPathDocumentCoordinatesPseudoJSON.go new file mode 100644 index 000000000..d489d22e1 --- /dev/null +++ b/lib/go/internal/schema/GetPathDocumentCoordinatesPseudoJSON.go @@ -0,0 +1,306 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package schema + +import "strings" + +// GetPathDocumentCoordinatesPseudoJSON locates the row/column coordinates in the document for the given path. +func GetPathDocumentCoordinatesPseudoJSON(path []any, document string) map[string]any { + reader := newRuneReader(document) + return findCoordinates(path, reader, nil, nil) +} + +type runeReader struct { + runes []rune + index int + row int + col int +} + +func newRuneReader(document string) *runeReader { + return &runeReader{ + runes: []rune(document), + row: 1, + col: 0, + } +} + +func (r *runeReader) next() (rune, int, int, bool) { + if r.index >= len(r.runes) { + return 0, r.row, r.col, false + } + + ch := r.runes[r.index] + r.index++ + + if ch == '\n' { + r.row++ + r.col = 0 + } else { + r.col++ + } + + return ch, r.row, r.col, true +} + +func findCoordinates(path []any, reader *runeReader, overrideRow, overrideCol *int) map[string]any { + for { + ch, row, col, ok := reader.next() + if !ok { + break + } + + if len(path) == 0 { + targetRow := row + if overrideRow != nil { + targetRow = *overrideRow + } + targetCol := col + if overrideCol != nil { + targetCol = *overrideCol + } + return map[string]any{"row": targetRow, "col": targetCol} + } + + switch ch { + case '{': + if result := findCoordinatesObject(path, reader); result != nil { + return result + } + case '[': + if result := findCoordinatesArray(path, reader); result != nil { + return result + } + } + } + + panic("path not found in document") +} + +func findCoordinatesObject(path []any, reader *runeReader) map[string]any { + var workingKey string + var keyRow, keyCol int + var hasKey bool + + for { + ch, row, col, ok := reader.next() + if !ok { + break + } + + switch ch { + case '}': + return nil + case ' ': // skip whitespace + continue + case '\n', '\r', '\t': + continue + case ',': + hasKey = false + case '"': + keyRow = row + keyCol = col + workingKey = findString(reader) + hasKey = true + case ':': + if !hasKey || len(path) == 0 { + continue + } + key, ok := path[0].(string) + if !ok { + continue + } + if key == workingKey { + return findCoordinates(path[1:], reader, &keyRow, &keyCol) + } + findValue(reader) + hasKey = false + default: + continue + } + } + + panic("object path not found") +} + +func findCoordinatesArray(path []any, reader *runeReader) map[string]any { + if len(path) == 0 { + return findCoordinates(path, reader, nil, nil) + } + + targetIndex, ok := toInt(path[0]) + if !ok { + return nil + } + + workingIndex := 0 + if workingIndex == targetIndex { + return findCoordinates(path[1:], reader, nil, nil) + } + findValue(reader) + + for { + ch, _, _, ok := reader.next() + if !ok { + break + } + switch ch { + case ']': + return nil + case ',': + workingIndex++ + if workingIndex == targetIndex { + return findCoordinates(path[1:], reader, nil, nil) + } + findValue(reader) + case ' ', '\n', '\r', '\t': + continue + default: + continue + } + } + + panic("array path not found") +} + +func findValue(reader *runeReader) bool { + for { + ch, _, _, ok := reader.next() + if !ok { + break + } + switch ch { + case '{': + findObject(reader) + return false + case '[': + findArray(reader) + return false + case '"': + findString(reader) + return false + case '}': + return true + case ']': + return true + case ',': + return false + case ' ', '\n', '\r', '\t': + continue + default: + // continue scanning primitive tokens + continue + } + } + + panic("value not found in document") +} + +func findObject(reader *runeReader) { + for { + ch, _, _, ok := reader.next() + if !ok { + break + } + switch ch { + case '}': + return + case '"': + findString(reader) + case ':': + if findValue(reader) { + return + } + } + } + + panic("object not terminated in document") +} + +func findArray(reader *runeReader) { + if findValue(reader) { + return + } + + for { + ch, _, _, ok := reader.next() + if !ok { + break + } + switch ch { + case ']': + return + case ',': + if findValue(reader) { + return + } + case ' ', '\n', '\r', '\t': + continue + default: + continue + } + } + + panic("array not terminated in document") +} + +func findString(reader *runeReader) string { + var builder strings.Builder + for { + ch, _, _, ok := reader.next() + if !ok { + break + } + if ch == '"' { + return builder.String() + } + builder.WriteRune(ch) + } + + panic("string not closed in document") +} + +func toInt(value any) (int, bool) { + switch v := value.(type) { + case int: + return v, true + case int8: + return int(v), true + case int16: + return int(v), true + case int32: + return int(v), true + case int64: + return int(v), true + case uint: + return int(v), true + case uint8: + return int(v), true + case uint16: + return int(v), true + case uint32: + return int(v), true + case uint64: + return int(v), true + case float64: + return int(v), true + case float32: + return int(v), true + default: + return 0, false + } +} diff --git a/lib/go/internal/schema/GetSchemaFileMap.go b/lib/go/internal/schema/GetSchemaFileMap.go new file mode 100644 index 000000000..22b194439 --- /dev/null +++ b/lib/go/internal/schema/GetSchemaFileMap.go @@ -0,0 +1,68 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package schema + +import ( + "io/fs" + "os" + "path/filepath" + "strings" +) + +// GetSchemaFileMap reads Telepact schema resources beneath directory and returns their contents, along with any schema parse failures. +func GetSchemaFileMap(directory string) (map[string]string, []*SchemaParseFailure, error) { + finalJSONDocuments := make(map[string]string) + parseFailures := make([]*SchemaParseFailure, 0) + + err := filepath.WalkDir(directory, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + + relativePath, err := filepath.Rel(directory, path) + if err != nil { + return err + } + + if d.IsDir() { + if relativePath == "." { + return nil + } + parseFailures = append(parseFailures, NewSchemaParseFailure(relativePath, nil, "DirectoryDisallowed", map[string]any{})) + finalJSONDocuments[relativePath] = "[]" + return nil + } + + content, err := os.ReadFile(path) + if err != nil { + return err + } + + if !strings.HasSuffix(relativePath, ".telepact.json") { + parseFailures = append(parseFailures, NewSchemaParseFailure(relativePath, nil, "FileNamePatternInvalid", map[string]any{"expected": "*.telepact.json"})) + } + + finalJSONDocuments[relativePath] = string(content) + return nil + }) + + if err != nil { + return nil, nil, err + } + + return finalJSONDocuments, parseFailures, nil +} diff --git a/lib/go/internal/schema/GetTypeUnexpectedParseFailure.go b/lib/go/internal/schema/GetTypeUnexpectedParseFailure.go new file mode 100644 index 000000000..fa79be6ca --- /dev/null +++ b/lib/go/internal/schema/GetTypeUnexpectedParseFailure.go @@ -0,0 +1,32 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package schema + +import "github.com/telepact/telepact/lib/go/internal/types" + +// GetTypeUnexpectedParseFailure constructs a parse failure describing an unexpected type. +func GetTypeUnexpectedParseFailure(documentName string, path []any, value any, expectedType string) []*SchemaParseFailure { + actualType := types.GetType(value) + data := map[string]any{ + "actual": map[string]any{actualType: map[string]any{}}, + "expected": map[string]any{expectedType: map[string]any{}}, + } + + return []*SchemaParseFailure{ + NewSchemaParseFailure(documentName, path, "TypeUnexpected", data), + } +} diff --git a/lib/go/internal/schema/MapSchemaParseFailuresToPseudoJSON.go b/lib/go/internal/schema/MapSchemaParseFailuresToPseudoJSON.go new file mode 100644 index 000000000..23c799b62 --- /dev/null +++ b/lib/go/internal/schema/MapSchemaParseFailuresToPseudoJSON.go @@ -0,0 +1,48 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package schema + +// MapSchemaParseFailuresToPseudoJSON converts schema parse failures into pseudo JSON diagnostics. +func MapSchemaParseFailuresToPseudoJSON(schemaParseFailures []*SchemaParseFailure, telepactDocumentNameToJSON map[string]string) []map[string]any { + pseudoJSONList := make([]map[string]any, 0, len(schemaParseFailures)) + for _, failure := range schemaParseFailures { + if failure == nil { + continue + } + document := telepactDocumentNameToJSON[failure.DocumentName] + location := GetPathDocumentCoordinatesPseudoJSON(append([]any{}, failure.Path...), document) + pseudoJSON := map[string]any{ + "document": failure.DocumentName, + "location": location, + "path": append([]any{}, failure.Path...), + "reason": map[string]any{failure.Reason: cloneStringInterfaceMap(failure.Data)}, + } + pseudoJSONList = append(pseudoJSONList, pseudoJSON) + } + return pseudoJSONList +} + +func cloneStringInterfaceMap(src map[string]any) map[string]any { + if src == nil { + return nil + } + cloned := make(map[string]any, len(src)) + for k, v := range src { + cloned[k] = v + } + return cloned +} diff --git a/lib/go/internal/schema/ParseContext.go b/lib/go/internal/schema/ParseContext.go new file mode 100644 index 000000000..26717cfec --- /dev/null +++ b/lib/go/internal/schema/ParseContext.go @@ -0,0 +1,45 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package schema + +import "github.com/telepact/telepact/lib/go/internal/types" + +// ParseContext carries contextual information shared across schema parsing routines. +type ParseContext struct { + DocumentName string + TelepactSchemaDocumentsToPseudoJSON map[string][]any + TelepactSchemaDocumentNamesToJSON map[string]string + SchemaKeysToDocumentName map[string]string + SchemaKeysToIndex map[string]int + ParsedTypes map[string]types.TType + FnErrorRegexes map[string]string + AllParseFailures *[]*SchemaParseFailure + FailedTypes map[string]struct{} +} + +// Copy returns a shallow copy of the parse context with an optional document name override. +func (ctx *ParseContext) Copy(documentName string) *ParseContext { + if ctx == nil { + return nil + } + + copy := *ctx + if documentName != "" { + copy.DocumentName = documentName + } + return © +} diff --git a/lib/go/internal/schema/ParseError.go b/lib/go/internal/schema/ParseError.go new file mode 100644 index 000000000..25b9dca04 --- /dev/null +++ b/lib/go/internal/schema/ParseError.go @@ -0,0 +1,31 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package schema + +// ParseError represents a collection of schema parse failures. +type ParseError struct { + Failures []*SchemaParseFailure + DocumentJSON map[string]string +} + +// Error implements the error interface. +func (e *ParseError) Error() string { + if e == nil { + return "" + } + return "telepact: schema parse failure" +} diff --git a/lib/go/internal/schema/ParseErrorType.go b/lib/go/internal/schema/ParseErrorType.go new file mode 100644 index 000000000..bb1dacf2e --- /dev/null +++ b/lib/go/internal/schema/ParseErrorType.go @@ -0,0 +1,51 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package schema + +import "github.com/telepact/telepact/lib/go/internal/types" + +// ParseErrorType parses an error type definition into a TError value. +func ParseErrorType(path []any, errorDefinition map[string]any, schemaKey string, ctx *ParseContext) (*types.TError, error) { + if ctx == nil { + return nil, nil + } + + parseFailures := make([]*SchemaParseFailure, 0) + + otherKeys := make(map[string]struct{}, len(errorDefinition)) + for key := range errorDefinition { + otherKeys[key] = struct{}{} + } + delete(otherKeys, schemaKey) + delete(otherKeys, "///") + + for key := range otherKeys { + failurePath := append(append([]any{}, path...), key) + parseFailures = append(parseFailures, NewSchemaParseFailure(ctx.DocumentName, failurePath, "ObjectKeyDisallowed", map[string]any{})) + } + + if len(parseFailures) > 0 { + return nil, &ParseError{Failures: parseFailures, DocumentJSON: ctx.TelepactSchemaDocumentNamesToJSON} + } + + union, err := ParseUnionType(path, errorDefinition, schemaKey, nil, nil, ctx) + if err != nil { + return nil, err + } + + return types.NewTError(schemaKey, union), nil +} diff --git a/lib/go/internal/schema/ParseField.go b/lib/go/internal/schema/ParseField.go new file mode 100644 index 000000000..28b8acba0 --- /dev/null +++ b/lib/go/internal/schema/ParseField.go @@ -0,0 +1,61 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package schema + +import ( + "regexp" + + "github.com/telepact/telepact/lib/go/internal/types" +) + +// ParseField parses a single struct or header field declaration. +func ParseField(path []any, fieldDeclaration string, typeDeclarationValue any, isHeader bool, ctx *ParseContext) (*types.TFieldDeclaration, error) { + if ctx == nil { + return nil, nil + } + + headerRegex := "^@[a-z][a-zA-Z0-9_]*$" + fieldRegex := "^([a-z][a-zA-Z0-9_]*)(!)?$" + + regexToUse := fieldRegex + if isHeader { + regexToUse = headerRegex + } + + regex := regexp.MustCompile(regexToUse) + matches := regex.FindStringSubmatch(fieldDeclaration) + if matches == nil { + failurePath := append(append([]any{}, path...), fieldDeclaration) + failure := NewSchemaParseFailure(ctx.DocumentName, failurePath, "KeyRegexMatchFailed", map[string]any{"regex": regexToUse}) + return nil, &ParseError{Failures: []*SchemaParseFailure{failure}, DocumentJSON: ctx.TelepactSchemaDocumentNamesToJSON} + } + + fieldName := fieldDeclaration + optional := true + if !isHeader { + fieldName = matches[0] + optional = matches[2] != "" + } + + typePath := append(append([]any{}, path...), fieldName) + typeDeclaration, err := ParseTypeDeclaration(typePath, typeDeclarationValue, ctx) + if err != nil { + return nil, err + } + + return types.NewTFieldDeclaration(fieldName, typeDeclaration, optional), nil +} diff --git a/lib/go/internal/schema/ParseFunctionType.go b/lib/go/internal/schema/ParseFunctionType.go new file mode 100644 index 000000000..02273b313 --- /dev/null +++ b/lib/go/internal/schema/ParseFunctionType.go @@ -0,0 +1,107 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package schema + +import ( + "strings" + + "github.com/telepact/telepact/lib/go/internal/types" +) + +// ParseFunctionResultType parses the result union for a function definition. +func ParseFunctionResultType(path []any, functionDefinition map[string]any, schemaKey string, ctx *ParseContext) (*types.TUnion, error) { + if ctx == nil { + return nil, nil + } + + parseFailures := make([]*SchemaParseFailure, 0) + resultSchemaKey := "->" + + var resultType *types.TUnion + if _, exists := functionDefinition[resultSchemaKey]; !exists { + missingKeyPath := append([]any{}, path...) + parseFailures = append(parseFailures, NewSchemaParseFailure(ctx.DocumentName, missingKeyPath, "RequiredObjectKeyMissing", map[string]any{"key": resultSchemaKey})) + } else { + ignoreKeys := make([]string, 0, len(functionDefinition)) + for key := range functionDefinition { + ignoreKeys = append(ignoreKeys, key) + } + + parsedResult, err := ParseUnionType(path, functionDefinition, resultSchemaKey, ignoreKeys, []string{"Ok_"}, ctx) + if err != nil { + if parseErr, ok := err.(*ParseError); ok { + parseFailures = append(parseFailures, parseErr.Failures...) + } else { + return nil, err + } + } else { + resultType = parsedResult + } + } + + if len(parseFailures) > 0 { + return nil, &ParseError{Failures: parseFailures, DocumentJSON: ctx.TelepactSchemaDocumentNamesToJSON} + } + + fnSelectType := DerivePossibleSelects(schemaKey, resultType) + selectTypeAny, err := GetOrParseType([]any{}, "_ext.Select_", ctx) + if err != nil { + return nil, err + } + + selectType, ok := selectTypeAny.(*types.TSelect) + if !ok { + return nil, &ParseError{Failures: []*SchemaParseFailure{NewSchemaParseFailure(ctx.DocumentName, []any{}, "TypeExtensionImplementationMissing", map[string]any{"name": "_ext.Select_"})}, DocumentJSON: ctx.TelepactSchemaDocumentNamesToJSON} + } + if selectType.PossibleSelects == nil { + selectType.PossibleSelects = make(map[string]any) + } + selectType.PossibleSelects[schemaKey] = fnSelectType + + return resultType, nil +} + +// ParseFunctionErrorsRegex parses the optional errors regex for a function definition. +func ParseFunctionErrorsRegex(path []any, functionDefinition map[string]any, schemaKey string, ctx *ParseContext) (string, error) { + if ctx == nil { + return "", nil + } + + parseFailures := make([]*SchemaParseFailure, 0) + errorsRegexKey := "_errors" + regexPath := append(append([]any{}, path...), errorsRegexKey) + + errorsRegex := "^errors\\..*$" + + if value, exists := functionDefinition[errorsRegexKey]; exists { + if !strings.HasSuffix(schemaKey, "_") { + parseFailures = append(parseFailures, NewSchemaParseFailure(ctx.DocumentName, regexPath, "ObjectKeyDisallowed", map[string]any{})) + } + + if stringValue, ok := value.(string); ok { + errorsRegex = stringValue + } else { + parseFailures = append(parseFailures, GetTypeUnexpectedParseFailure(ctx.DocumentName, regexPath, value, "String")...) + } + } + + if len(parseFailures) > 0 { + return "", &ParseError{Failures: parseFailures, DocumentJSON: ctx.TelepactSchemaDocumentNamesToJSON} + } + + return errorsRegex, nil +} diff --git a/lib/go/internal/schema/ParseHeadersType.go b/lib/go/internal/schema/ParseHeadersType.go new file mode 100644 index 000000000..17e464aa1 --- /dev/null +++ b/lib/go/internal/schema/ParseHeadersType.go @@ -0,0 +1,94 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package schema + +import "github.com/telepact/telepact/lib/go/internal/types" + +// ParseHeadersType parses a headers definition into a THeaders value. +func ParseHeadersType(path []any, headersDefinition map[string]any, schemaKey string, ctx *ParseContext) (*types.THeaders, error) { + if ctx == nil { + return nil, nil + } + + parseFailures := make([]*SchemaParseFailure, 0) + requestHeaders := make(map[string]*types.TFieldDeclaration) + responseHeaders := make(map[string]*types.TFieldDeclaration) + + thisPath := append(append([]any{}, path...), schemaKey) + requestHeadersDef, hasRequest := headersDefinition[schemaKey] + if !hasRequest { + requestHeadersDef = nil + } + + requestHeadersMap, ok := requestHeadersDef.(map[string]any) + if !ok { + parseFailures = append(parseFailures, GetTypeUnexpectedParseFailure(ctx.DocumentName, thisPath, requestHeadersDef, "Object")...) + } else { + fields, err := ParseStructFields(thisPath, requestHeadersMap, true, ctx) + if err != nil { + if parseErr, ok := err.(*ParseError); ok { + parseFailures = append(parseFailures, parseErr.Failures...) + } else { + return nil, err + } + } else { + for key, field := range fields { + if field != nil { + field.Optional = true + } + requestHeaders[key] = field + } + } + } + + responseKey := "->" + responsePath := append(append([]any{}, path...), responseKey) + + responseHeadersDef, hasResponse := headersDefinition[responseKey] + if !hasResponse { + parseFailures = append(parseFailures, NewSchemaParseFailure(ctx.DocumentName, responsePath, "RequiredObjectKeyMissing", map[string]any{"key": responseKey})) + } + + responseHeadersMap, ok := responseHeadersDef.(map[string]any) + if !ok { + if hasResponse { + parseFailures = append(parseFailures, GetTypeUnexpectedParseFailure(ctx.DocumentName, responsePath, responseHeadersDef, "Object")...) + } + } else { + fields, err := ParseStructFields(responsePath, responseHeadersMap, true, ctx) + if err != nil { + if parseErr, ok := err.(*ParseError); ok { + parseFailures = append(parseFailures, parseErr.Failures...) + } else { + return nil, err + } + } else { + for key, field := range fields { + if field != nil { + field.Optional = true + } + responseHeaders[key] = field + } + } + } + + if len(parseFailures) > 0 { + return nil, &ParseError{Failures: parseFailures, DocumentJSON: ctx.TelepactSchemaDocumentNamesToJSON} + } + + return types.NewTHeaders(schemaKey, requestHeaders, responseHeaders), nil +} diff --git a/lib/go/internal/schema/ParseStructFields.go b/lib/go/internal/schema/ParseStructFields.go new file mode 100644 index 000000000..45bf094bf --- /dev/null +++ b/lib/go/internal/schema/ParseStructFields.go @@ -0,0 +1,71 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package schema + +import ( + "strings" + + "github.com/telepact/telepact/lib/go/internal/types" +) + +// ParseStructFields parses field declarations for structs and headers. +func ParseStructFields(path []any, referenceStruct map[string]any, isHeader bool, ctx *ParseContext) (map[string]*types.TFieldDeclaration, error) { + if ctx == nil { + return nil, nil + } + + parseFailures := make([]*SchemaParseFailure, 0) + fields := make(map[string]*types.TFieldDeclaration) + + for fieldDeclaration, typeDeclarationValue := range referenceStruct { + for existingField := range fields { + existingFieldNoOpt := strings.Split(existingField, "!")[0] + fieldNoOpt := strings.Split(fieldDeclaration, "!")[0] + if existingFieldNoOpt == fieldNoOpt { + structPath := append([]any{}, path...) + parseFailures = append(parseFailures, NewSchemaParseFailure( + ctx.DocumentName, + structPath, + "DuplicateField", + map[string]any{ + "field": fieldNoOpt, + }, + )) + } + } + + parsedField, err := ParseField(path, fieldDeclaration, typeDeclarationValue, isHeader, ctx) + if err != nil { + if parseErr, ok := err.(*ParseError); ok { + parseFailures = append(parseFailures, parseErr.Failures...) + } else { + return nil, err + } + continue + } + if parsedField == nil { + continue + } + fields[parsedField.FieldName] = parsedField + } + + if len(parseFailures) > 0 { + return nil, &ParseError{Failures: parseFailures, DocumentJSON: ctx.TelepactSchemaDocumentNamesToJSON} + } + + return fields, nil +} diff --git a/lib/go/internal/schema/ParseStructType.go b/lib/go/internal/schema/ParseStructType.go new file mode 100644 index 000000000..b8d88c5a2 --- /dev/null +++ b/lib/go/internal/schema/ParseStructType.go @@ -0,0 +1,67 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package schema + +import "github.com/telepact/telepact/lib/go/internal/types" + +// ParseStructType parses a struct type definition from pseudo JSON. +func ParseStructType(path []any, structDefinition map[string]any, schemaKey string, ignoreKeys []string, ctx *ParseContext) (*types.TStruct, error) { + if ctx == nil { + return nil, nil + } + + parseFailures := make([]*SchemaParseFailure, 0) + + otherKeys := make(map[string]struct{}, len(structDefinition)) + for key := range structDefinition { + otherKeys[key] = struct{}{} + } + + delete(otherKeys, schemaKey) + delete(otherKeys, "///") + delete(otherKeys, "_ignoreIfDuplicate") + for _, key := range ignoreKeys { + delete(otherKeys, key) + } + + for key := range otherKeys { + failurePath := append(append([]any{}, path...), key) + parseFailures = append(parseFailures, NewSchemaParseFailure(ctx.DocumentName, failurePath, "ObjectKeyDisallowed", map[string]any{})) + } + + thisPath := append(append([]any{}, path...), schemaKey) + defInit, ok := structDefinition[schemaKey] + if !ok { + defInit = nil + } + + definition, ok := defInit.(map[string]any) + if !ok { + parseFailures = append(parseFailures, GetTypeUnexpectedParseFailure(ctx.DocumentName, thisPath, defInit, "Object")...) + } + + if len(parseFailures) > 0 { + return nil, &ParseError{Failures: parseFailures, DocumentJSON: ctx.TelepactSchemaDocumentNamesToJSON} + } + + fields, err := ParseStructFields(thisPath, definition, false, ctx) + if err != nil { + return nil, err + } + + return types.NewTStruct(schemaKey, fields), nil +} diff --git a/lib/go/internal/schema/ParseTelepactSchema.go b/lib/go/internal/schema/ParseTelepactSchema.go new file mode 100644 index 000000000..4b77e92c1 --- /dev/null +++ b/lib/go/internal/schema/ParseTelepactSchema.go @@ -0,0 +1,338 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package schema + +import ( + "encoding/json" + "sort" + "strings" + + "github.com/telepact/telepact/lib/go/internal/types" +) + +// ParseTelepactSchema parses the provided Telepact schema documents into their structured representation. +func ParseTelepactSchema(telepactSchemaDocumentNamesToJSON map[string]string) (*ParsedSchemaResult, error) { + if telepactSchemaDocumentNamesToJSON == nil { + return nil, nil + } + + originalSchema := make(map[string]any) + parsedTypes := make(map[string]types.TType) + fnErrorRegexes := make(map[string]string) + parseFailures := make([]*SchemaParseFailure, 0) + failedTypes := make(map[string]struct{}) + schemaKeysToDocumentNames := make(map[string]string) + schemaKeysToIndex := make(map[string]int) + schemaKeys := make(map[string]struct{}) + + orderedDocumentNames := make([]string, 0, len(telepactSchemaDocumentNamesToJSON)) + for documentName := range telepactSchemaDocumentNamesToJSON { + orderedDocumentNames = append(orderedDocumentNames, documentName) + } + sort.Strings(orderedDocumentNames) + + documentNameToPseudoJSON := make(map[string][]any, len(telepactSchemaDocumentNamesToJSON)) + + for documentName, schemaJSON := range telepactSchemaDocumentNamesToJSON { + var parsed any + if err := json.Unmarshal([]byte(schemaJSON), &parsed); err != nil { + failure := NewSchemaParseFailure(documentName, []any{}, "JsonInvalid", map[string]any{}) + return nil, &ParseError{Failures: []*SchemaParseFailure{failure}, DocumentJSON: telepactSchemaDocumentNamesToJSON} + } + + pseudoJSON, ok := parsed.([]any) + if !ok { + failures := GetTypeUnexpectedParseFailure(documentName, []any{}, parsed, "Array") + return nil, &ParseError{Failures: failures, DocumentJSON: telepactSchemaDocumentNamesToJSON} + } + + documentNameToPseudoJSON[documentName] = pseudoJSON + } + + for _, documentName := range orderedDocumentNames { + pseudoJSON := documentNameToPseudoJSON[documentName] + + for index, definitionRaw := range pseudoJSON { + loopPath := []any{index} + definition, ok := definitionRaw.(map[string]any) + if !ok { + failures := GetTypeUnexpectedParseFailure(documentName, loopPath, definitionRaw, "Object") + parseFailures = append(parseFailures, failures...) + continue + } + + schemaKey, err := FindSchemaKey(documentName, definition, index, telepactSchemaDocumentNamesToJSON) + if err != nil { + if parseErr, ok := err.(*ParseError); ok { + parseFailures = append(parseFailures, parseErr.Failures...) + } else { + return nil, err + } + continue + } + + matchingKey := FindMatchingSchemaKey(schemaKeys, schemaKey) + if matchingKey != "" { + otherIndex := schemaKeysToIndex[matchingKey] + otherDocumentName := schemaKeysToDocumentNames[matchingKey] + finalPath := append(append([]any{}, loopPath...), schemaKey) + otherFinalPath := []any{otherIndex, matchingKey} + otherDocumentJSON := telepactSchemaDocumentNamesToJSON[otherDocumentName] + otherLocation := GetPathDocumentCoordinatesPseudoJSON(otherFinalPath, otherDocumentJSON) + + parseFailures = append(parseFailures, NewSchemaParseFailure( + documentName, + finalPath, + "PathCollision", + map[string]any{ + "document": otherDocumentName, + "path": otherFinalPath, + "location": otherLocation, + }, + )) + continue + } + + schemaKeys[schemaKey] = struct{}{} + schemaKeysToIndex[schemaKey] = index + schemaKeysToDocumentNames[schemaKey] = documentName + + if documentName == "auto_" || documentName == "auth_" || !strings.HasSuffix(documentName, "_") { + originalSchema[schemaKey] = definition + } + } + } + + if len(parseFailures) > 0 { + return nil, &ParseError{Failures: parseFailures, DocumentJSON: telepactSchemaDocumentNamesToJSON} + } + + headerKeys := make(map[string]struct{}) + errorKeys := make(map[string]struct{}) + + for schemaKey := range schemaKeys { + switch { + case strings.HasPrefix(schemaKey, "info."): + continue + case strings.HasPrefix(schemaKey, "headers."): + headerKeys[schemaKey] = struct{}{} + case strings.HasPrefix(schemaKey, "errors."): + errorKeys[schemaKey] = struct{}{} + default: + index := schemaKeysToIndex[schemaKey] + documentName := schemaKeysToDocumentNames[schemaKey] + + ctx := &ParseContext{ + DocumentName: documentName, + TelepactSchemaDocumentsToPseudoJSON: documentNameToPseudoJSON, + TelepactSchemaDocumentNamesToJSON: telepactSchemaDocumentNamesToJSON, + SchemaKeysToDocumentName: schemaKeysToDocumentNames, + SchemaKeysToIndex: schemaKeysToIndex, + ParsedTypes: parsedTypes, + FnErrorRegexes: fnErrorRegexes, + AllParseFailures: &parseFailures, + FailedTypes: failedTypes, + } + + if _, err := GetOrParseType([]any{index}, schemaKey, ctx); err != nil { + if parseErr, ok := err.(*ParseError); ok { + parseFailures = append(parseFailures, parseErr.Failures...) + } else { + return nil, err + } + } + } + } + + if len(parseFailures) > 0 { + return nil, &ParseError{Failures: parseFailures, DocumentJSON: telepactSchemaDocumentNamesToJSON} + } + + errorsList := make([]*types.TError, 0, len(errorKeys)) + + for errorKey := range errorKeys { + index := schemaKeysToIndex[errorKey] + documentName := schemaKeysToDocumentNames[errorKey] + pseudoJSON := documentNameToPseudoJSON[documentName] + if index < 0 || index >= len(pseudoJSON) { + continue + } + + definitionRaw := pseudoJSON[index] + definition, ok := definitionRaw.(map[string]any) + if !ok { + failures := GetTypeUnexpectedParseFailure(documentName, []any{index}, definitionRaw, "Object") + parseFailures = append(parseFailures, failures...) + continue + } + + ctx := &ParseContext{ + DocumentName: documentName, + TelepactSchemaDocumentsToPseudoJSON: documentNameToPseudoJSON, + TelepactSchemaDocumentNamesToJSON: telepactSchemaDocumentNamesToJSON, + SchemaKeysToDocumentName: schemaKeysToDocumentNames, + SchemaKeysToIndex: schemaKeysToIndex, + ParsedTypes: parsedTypes, + FnErrorRegexes: fnErrorRegexes, + AllParseFailures: &parseFailures, + FailedTypes: failedTypes, + } + + errorType, err := ParseErrorType([]any{index}, definition, errorKey, ctx) + if err != nil { + if parseErr, ok := err.(*ParseError); ok { + parseFailures = append(parseFailures, parseErr.Failures...) + } else { + return nil, err + } + continue + } + if errorType != nil { + errorsList = append(errorsList, errorType) + } + } + + if len(parseFailures) > 0 { + return nil, &ParseError{Failures: parseFailures, DocumentJSON: telepactSchemaDocumentNamesToJSON} + } + + errorCollisions, err := CatchErrorCollisions(documentNameToPseudoJSON, errorKeys, schemaKeysToIndex, schemaKeysToDocumentNames, telepactSchemaDocumentNamesToJSON) + if err != nil { + return nil, err + } + if len(errorCollisions) > 0 { + parseFailures = append(parseFailures, errorCollisions...) + } + + if len(parseFailures) > 0 { + return nil, &ParseError{Failures: parseFailures, DocumentJSON: telepactSchemaDocumentNamesToJSON} + } + + for _, errorType := range errorsList { + failures, err := ApplyErrorToParsedTypes(errorType, parsedTypes, schemaKeysToDocumentNames, schemaKeysToIndex, telepactSchemaDocumentNamesToJSON, fnErrorRegexes) + if err != nil { + return nil, err + } + if len(failures) > 0 { + parseFailures = append(parseFailures, failures...) + } + } + + if len(parseFailures) > 0 { + return nil, &ParseError{Failures: parseFailures, DocumentJSON: telepactSchemaDocumentNamesToJSON} + } + + headersList := make([]*types.THeaders, 0, len(headerKeys)) + + for headerKey := range headerKeys { + index := schemaKeysToIndex[headerKey] + documentName := schemaKeysToDocumentNames[headerKey] + pseudoJSON := documentNameToPseudoJSON[documentName] + if index < 0 || index >= len(pseudoJSON) { + continue + } + + definitionRaw := pseudoJSON[index] + definition, ok := definitionRaw.(map[string]any) + if !ok { + failures := GetTypeUnexpectedParseFailure(documentName, []any{index}, definitionRaw, "Object") + parseFailures = append(parseFailures, failures...) + continue + } + + ctx := &ParseContext{ + DocumentName: documentName, + TelepactSchemaDocumentsToPseudoJSON: documentNameToPseudoJSON, + TelepactSchemaDocumentNamesToJSON: telepactSchemaDocumentNamesToJSON, + SchemaKeysToDocumentName: schemaKeysToDocumentNames, + SchemaKeysToIndex: schemaKeysToIndex, + ParsedTypes: parsedTypes, + FnErrorRegexes: fnErrorRegexes, + AllParseFailures: &parseFailures, + FailedTypes: failedTypes, + } + + headerType, err := ParseHeadersType([]any{index}, definition, headerKey, ctx) + if err != nil { + if parseErr, ok := err.(*ParseError); ok { + parseFailures = append(parseFailures, parseErr.Failures...) + } else { + return nil, err + } + continue + } + if headerType != nil { + headersList = append(headersList, headerType) + } + } + + if len(parseFailures) > 0 { + return nil, &ParseError{Failures: parseFailures, DocumentJSON: telepactSchemaDocumentNamesToJSON} + } + + headerCollisions, err := CatchHeaderCollisions(documentNameToPseudoJSON, headerKeys, schemaKeysToIndex, schemaKeysToDocumentNames, telepactSchemaDocumentNamesToJSON) + if err != nil { + return nil, err + } + if len(headerCollisions) > 0 { + parseFailures = append(parseFailures, headerCollisions...) + } + + if len(parseFailures) > 0 { + return nil, &ParseError{Failures: parseFailures, DocumentJSON: telepactSchemaDocumentNamesToJSON} + } + + requestHeaders := make(map[string]*types.TFieldDeclaration) + responseHeaders := make(map[string]*types.TFieldDeclaration) + + for _, header := range headersList { + if header == nil { + continue + } + for key, field := range header.RequestHeaders { + requestHeaders[key] = field + } + for key, field := range header.ResponseHeaders { + responseHeaders[key] = field + } + } + + originalKeys := make([]string, 0, len(originalSchema)) + for key := range originalSchema { + originalKeys = append(originalKeys, key) + } + sort.Slice(originalKeys, func(i, j int) bool { + iInfo := strings.HasPrefix(originalKeys[i], "info.") + jInfo := strings.HasPrefix(originalKeys[j], "info.") + if iInfo != jInfo { + return iInfo && !jInfo + } + return originalKeys[i] < originalKeys[j] + }) + + finalOriginal := make([]any, 0, len(originalKeys)) + for _, key := range originalKeys { + finalOriginal = append(finalOriginal, originalSchema[key]) + } + + return &ParsedSchemaResult{ + Original: finalOriginal, + Parsed: parsedTypes, + ParsedRequestHeaders: requestHeaders, + ParsedResponseHeaders: responseHeaders, + }, nil +} diff --git a/lib/go/internal/schema/ParseTypeDeclaration.go b/lib/go/internal/schema/ParseTypeDeclaration.go new file mode 100644 index 000000000..0338f34cc --- /dev/null +++ b/lib/go/internal/schema/ParseTypeDeclaration.go @@ -0,0 +1,114 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package schema + +import ( + "regexp" + + "github.com/telepact/telepact/lib/go/internal/types" +) + +// ParseTypeDeclaration converts a JSON-serialised type declaration into a Telepact declaration structure. +func ParseTypeDeclaration(path []any, typeDeclarationObject any, ctx *ParseContext) (*types.TTypeDeclaration, error) { + if ctx == nil { + return nil, nil + } + + switch typed := typeDeclarationObject.(type) { + case string: + regex := regexp.MustCompile(`^(.*?)(\?)?$`) + matches := regex.FindStringSubmatch(typed) + if matches == nil { + failure := NewSchemaParseFailure(ctx.DocumentName, append([]any{}, path...), "StringRegexMatchFailed", map[string]any{"regex": regex.String()}) + return nil, &ParseError{Failures: []*SchemaParseFailure{failure}, DocumentJSON: ctx.TelepactSchemaDocumentNamesToJSON} + } + + typeName := matches[1] + nullable := matches[2] != "" + + resolvedType, err := GetOrParseType(append([]any{}, path...), typeName, ctx) + if err != nil { + return nil, err + } + if resolvedType == nil { + return nil, nil + } + + if resolvedType.GetTypeParameterCount() != 0 { + failure := NewSchemaParseFailure(ctx.DocumentName, append([]any{}, path...), "ArrayLengthUnexpected", map[string]any{ + "actual": 1, + "expected": resolvedType.GetTypeParameterCount() + 1, + }) + return nil, &ParseError{Failures: []*SchemaParseFailure{failure}, DocumentJSON: ctx.TelepactSchemaDocumentNamesToJSON} + } + + return types.NewTTypeDeclaration(resolvedType, nullable, nil), nil + + case []any: + if len(typed) != 1 { + failure := NewSchemaParseFailure(ctx.DocumentName, append([]any{}, path...), "ArrayLengthUnexpected", map[string]any{ + "actual": len(typed), + "expected": 1, + }) + return nil, &ParseError{Failures: []*SchemaParseFailure{failure}, DocumentJSON: ctx.TelepactSchemaDocumentNamesToJSON} + } + + elementPath := append(append([]any{}, path...), 0) + elementDeclaration, err := ParseTypeDeclaration(elementPath, typed[0], ctx) + if err != nil { + return nil, err + } + + return types.NewTTypeDeclaration(types.NewTArray(), false, []*types.TTypeDeclaration{elementDeclaration}), nil + + case map[string]any: + if len(typed) != 1 { + failure := NewSchemaParseFailure(ctx.DocumentName, append([]any{}, path...), "ObjectSizeUnexpected", map[string]any{ + "actual": len(typed), + "expected": 1, + }) + return nil, &ParseError{Failures: []*SchemaParseFailure{failure}, DocumentJSON: ctx.TelepactSchemaDocumentNamesToJSON} + } + + var key string + var value any + for k, v := range typed { + key = k + value = v + break + } + + if key != "string" { + failureMissing := NewSchemaParseFailure(ctx.DocumentName, append([]any{}, path...), "RequiredObjectKeyMissing", map[string]any{"key": "string"}) + keyPath := append(append([]any{}, path...), key) + failureDisallowed := NewSchemaParseFailure(ctx.DocumentName, keyPath, "ObjectKeyDisallowed", map[string]any{}) + return nil, &ParseError{Failures: []*SchemaParseFailure{failureMissing, failureDisallowed}, DocumentJSON: ctx.TelepactSchemaDocumentNamesToJSON} + } + + valuePath := append(append([]any{}, path...), key) + valueDeclaration, err := ParseTypeDeclaration(valuePath, value, ctx) + if err != nil { + return nil, err + } + + return types.NewTTypeDeclaration(types.NewTObject(), false, []*types.TTypeDeclaration{valueDeclaration}), nil + + default: + failures := GetTypeUnexpectedParseFailure(ctx.DocumentName, append([]any{}, path...), typeDeclarationObject, "StringOrArrayOrObject") + return nil, &ParseError{Failures: failures, DocumentJSON: ctx.TelepactSchemaDocumentNamesToJSON} + } +} diff --git a/lib/go/internal/schema/ParseUnionType.go b/lib/go/internal/schema/ParseUnionType.go new file mode 100644 index 000000000..ae95f4a00 --- /dev/null +++ b/lib/go/internal/schema/ParseUnionType.go @@ -0,0 +1,180 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package schema + +import ( + "regexp" + "sort" + + "github.com/telepact/telepact/lib/go/internal/types" +) + +// ParseUnionType parses a union type definition from pseudo JSON. +func ParseUnionType(path []any, unionDefinition map[string]any, schemaKey string, ignoreKeys []string, requiredKeys []string, ctx *ParseContext) (*types.TUnion, error) { + if ctx == nil { + return nil, nil + } + + parseFailures := make([]*SchemaParseFailure, 0) + + otherKeys := make(map[string]struct{}, len(unionDefinition)) + for key := range unionDefinition { + otherKeys[key] = struct{}{} + } + + delete(otherKeys, schemaKey) + delete(otherKeys, "///") + for _, key := range ignoreKeys { + delete(otherKeys, key) + } + + for key := range otherKeys { + failurePath := append(append([]any{}, path...), key) + parseFailures = append(parseFailures, NewSchemaParseFailure(ctx.DocumentName, failurePath, "ObjectKeyDisallowed", map[string]any{})) + } + + thisPath := append(append([]any{}, path...), schemaKey) + defInit, ok := unionDefinition[schemaKey] + if !ok { + defInit = nil + } + + defSlice, ok := defInit.([]any) + if !ok { + parseFailures = append(parseFailures, GetTypeUnexpectedParseFailure(ctx.DocumentName, thisPath, defInit, "Array")...) + return nil, &ParseError{Failures: parseFailures, DocumentJSON: ctx.TelepactSchemaDocumentNamesToJSON} + } + + definition := make([]map[string]any, 0, len(defSlice)) + for index, element := range defSlice { + loopPath := append(append([]any{}, thisPath...), index) + elementMap, ok := element.(map[string]any) + if !ok { + parseFailures = append(parseFailures, GetTypeUnexpectedParseFailure(ctx.DocumentName, loopPath, element, "Object")...) + continue + } + definition = append(definition, elementMap) + } + + if len(parseFailures) > 0 { + return nil, &ParseError{Failures: parseFailures, DocumentJSON: ctx.TelepactSchemaDocumentNamesToJSON} + } + + if len(definition) == 0 { + parseFailures = append(parseFailures, NewSchemaParseFailure(ctx.DocumentName, thisPath, "EmptyArrayDisallowed", map[string]any{})) + } else { + for _, requiredKey := range requiredKeys { + found := false + for _, element := range definition { + tagKeys := make(map[string]struct{}, len(element)) + for key := range element { + if key == "///" { + continue + } + tagKeys[key] = struct{}{} + } + if _, ok := tagKeys[requiredKey]; ok { + found = true + break + } + } + if !found { + branchPath := append(append([]any{}, thisPath...), 0) + parseFailures = append(parseFailures, NewSchemaParseFailure(ctx.DocumentName, branchPath, "RequiredObjectKeyMissing", map[string]any{"key": requiredKey})) + } + } + } + + if len(parseFailures) > 0 { + return nil, &ParseError{Failures: parseFailures, DocumentJSON: ctx.TelepactSchemaDocumentNamesToJSON} + } + + tags := make(map[string]*types.TStruct, len(definition)) + tagIndices := make(map[string]int, len(definition)) + regex := regexp.MustCompile(`^([A-Z][a-zA-Z0-9_]*)$`) + + for index, element := range definition { + loopPath := append(append([]any{}, thisPath...), index) + elementCopy := make(map[string]any, len(element)) + for k, v := range element { + if k == "///" { + continue + } + elementCopy[k] = v + } + + keys := make([]string, 0, len(elementCopy)) + for key := range elementCopy { + keys = append(keys, key) + } + sort.Strings(keys) + + matches := make([]string, 0) + for _, key := range keys { + if regex.MatchString(key) { + matches = append(matches, key) + } + } + + if len(matches) != 1 { + parseFailures = append(parseFailures, NewSchemaParseFailure(ctx.DocumentName, loopPath, "ObjectKeyRegexMatchCountUnexpected", map[string]any{ + "regex": regex.String(), + "actual": len(matches), + "expected": 1, + "keys": keys, + })) + continue + } + + if len(elementCopy) != 1 { + parseFailures = append(parseFailures, NewSchemaParseFailure(ctx.DocumentName, loopPath, "ObjectSizeUnexpected", map[string]any{ + "expected": 1, + "actual": len(elementCopy), + })) + continue + } + + unionTag := matches[0] + entryValue := elementCopy[unionTag] + unionKeyPath := append(append([]any{}, loopPath...), unionTag) + + unionTagStruct, ok := entryValue.(map[string]any) + if !ok { + parseFailures = append(parseFailures, GetTypeUnexpectedParseFailure(ctx.DocumentName, unionKeyPath, entryValue, "Object")...) + continue + } + + fields, err := ParseStructFields(unionKeyPath, unionTagStruct, false, ctx) + if err != nil { + if parseErr, ok := err.(*ParseError); ok { + parseFailures = append(parseFailures, parseErr.Failures...) + } else { + return nil, err + } + continue + } + + tags[unionTag] = types.NewTStruct(schemaKey+"."+unionTag, fields) + tagIndices[unionTag] = index + } + + if len(parseFailures) > 0 { + return nil, &ParseError{Failures: parseFailures, DocumentJSON: ctx.TelepactSchemaDocumentNamesToJSON} + } + + return types.NewTUnion(schemaKey, tags, tagIndices), nil +} diff --git a/lib/go/internal/schema/ParsedSchemaResult.go b/lib/go/internal/schema/ParsedSchemaResult.go new file mode 100644 index 000000000..a1b06cd09 --- /dev/null +++ b/lib/go/internal/schema/ParsedSchemaResult.go @@ -0,0 +1,27 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package schema + +import "github.com/telepact/telepact/lib/go/internal/types" + +// ParsedSchemaResult contains the parsed schema artifacts required to construct a Telepact schema. +type ParsedSchemaResult struct { + Original []any + Parsed map[string]types.TType + ParsedRequestHeaders map[string]*types.TFieldDeclaration + ParsedResponseHeaders map[string]*types.TFieldDeclaration +} diff --git a/lib/go/internal/schema/SchemaParseFailure.go b/lib/go/internal/schema/SchemaParseFailure.go new file mode 100644 index 000000000..c3b788a39 --- /dev/null +++ b/lib/go/internal/schema/SchemaParseFailure.go @@ -0,0 +1,40 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package schema + +// SchemaParseFailure captures the context of a schema parsing error. +type SchemaParseFailure struct { + DocumentName string + Path []any + Reason string + Data map[string]any +} + +// NewSchemaParseFailure constructs a SchemaParseFailure. +func NewSchemaParseFailure(documentName string, path []any, reason string, data map[string]any) *SchemaParseFailure { + copiedPath := append([]any(nil), path...) + copiedData := make(map[string]any, len(data)) + for k, v := range data { + copiedData[k] = v + } + return &SchemaParseFailure{ + DocumentName: documentName, + Path: copiedPath, + Reason: reason, + Data: copiedData, + } +} diff --git a/lib/go/internal/schema/SchemaResources.go b/lib/go/internal/schema/SchemaResources.go new file mode 100644 index 000000000..27936895a --- /dev/null +++ b/lib/go/internal/schema/SchemaResources.go @@ -0,0 +1,35 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package schema + +import ( + "embed" + "fmt" + "path" +) + +//go:embed do_not_edit/*.json +var embeddedSchemas embed.FS + +func loadBundledSchema(filename string) (string, error) { + bytes, err := embeddedSchemas.ReadFile(path.Join("do_not_edit", filename)) + if err != nil { + return "", fmt.Errorf("telepact: failed to read bundled schema %s: %w", filename, err) + } + + return string(bytes), nil +} diff --git a/lib/go/internal/schema/do_not_edit/auth.telepact.json b/lib/go/internal/schema/do_not_edit/auth.telepact.json new file mode 100644 index 000000000..e2ed6d976 --- /dev/null +++ b/lib/go/internal/schema/do_not_edit/auth.telepact.json @@ -0,0 +1,29 @@ +[ + { + "///": [ + " The `@auth_` header is the conventional location for sending credentials to ", + " the server for the purpose of authentication and authorization. " + ], + "headers.Auth_": { + "@auth_": "struct.Auth_" + }, + "->": {} + }, + { + "///": " A standard error. ", + "errors.Auth_": [ + { + "///": " The credentials in the `_auth` header were missing or invalid. ", + "ErrorUnauthenticated_": { + "message!": "string" + } + }, + { + "///": " The credentials in the `_auth` header were insufficient to run the function. ", + "ErrorUnauthorized_": { + "message!": "string" + } + } + ] + } +] \ No newline at end of file diff --git a/lib/go/internal/schema/do_not_edit/internal.telepact.json b/lib/go/internal/schema/do_not_edit/internal.telepact.json new file mode 100644 index 000000000..7ddff3e75 --- /dev/null +++ b/lib/go/internal/schema/do_not_edit/internal.telepact.json @@ -0,0 +1,258 @@ +[ + { + "///": " Ping the server. ", + "fn.ping_": {}, + "->": [ + { + "Ok_": {} + } + ], + "_errors": "^errors\\.Validation_$" + }, + { + "///": " Get the telepact `schema` of this server. ", + "fn.api_": {}, + "->": [ + { + "Ok_": { + "api": [{"string": "any"}] + } + } + ], + "_errors": "^errors\\.Validation_$" + }, + { + "_ext.Select_": {} + }, + { + "///": " The `@time_` header indicates the request timeout honored by the client. ", + "headers.Time_": { + "@time_": "integer" + }, + "->": {} + }, + { + "///": [ + " If `@unsafe_` is set to `true`, response validation by the server will be ", + " disabled. The server will the client-provided the value of `@unsafe_` header ", + " in the response. " + ], + "headers.Unsafe_": { + "@unsafe_": "boolean" + }, + "->": { + "@unsafe_": "boolean" + } + }, + { + "///": " The `@select_` header is used to select fields from structs. ", + "headers.Select_": { + "@select_": "_ext.Select_" + }, + "->": {} + }, + { + "///": [ + " The `@bin_` header indicates the valid checksums of binary encodings ", + " negotiated between the client and server. If the client sends a `@bin_` header ", + " with any value, the server will respond with a `@bin_` header with an array ", + " containing the currently supported binary encoding checksum. If te client's ", + " provided checksum does not match the server's checksum, the server will also ", + " send an `@enc_` header containing the binary encoding, which is a map of field ", + " names to field ids. The response body may also be encoded in binary. ", + " ", + " The `@pac_` header can also be used to indicate usage of 'packed' binary ", + " encoding strategy. If the client submits a `@pac_` header with a `true` value, ", + " the server will respond with a `@pac_` header with a `true` value. " + ], + "headers.Binary_": { + "@bin_": ["integer"], + "@pac_": "boolean" + }, + "->": { + "@bin_": ["integer"], + "@enc_": {"string": "integer"}, + "@pac_": "boolean" + } + }, + { + "///": " The `@warn_` header is used to send warnings to the client. ", + "headers.Warning_": {}, + "->": { + "@warn_": ["any"] + } + }, + { + "///": [ + " The `@id_` header is used to correlate requests and responses. The server will ", + " reflect the client-provided `@id_` header as-is. " + ], + "headers.Id_": { + "@id_": "any" + }, + "->": { + "@id_": "any" + } + }, + { + "///": " A type. ", + "union.Type_": [ + { + "Null": {} + }, + { + "Boolean": {} + }, + { + "Integer": {} + }, + { + "Number": {} + }, + { + "String": {} + }, + { + "Array": {} + }, + { + "Object": {} + }, + { + "Any": {} + }, + { + "Base64String": {} + }, + { + "Bytes": {} + }, + { + "Unknown": {} + } + ] + }, + { + "///": " A reason for the validation failure in the body. ", + "union.ValidationFailureReason_": [ + { + "TypeUnexpected": { + "expected": "union.Type_", + "actual": "union.Type_" + } + }, + { + "NullDisallowed": {} + }, + { + "ObjectKeyDisallowed": {} + }, + { + "RequiredObjectKeyPrefixMissing": { + "prefix": "string" + } + }, + { + "ArrayElementDisallowed": {} + }, + { + "NumberOutOfRange": {} + }, + { + "ObjectSizeUnexpected": { + "expected": "integer", + "actual": "integer" + } + }, + { + "ExtensionValidationFailed": { + "reason": "string", + "data!": {"string": "any"} + } + }, + { + "ObjectKeyRegexMatchCountUnexpected": { + "regex": "string", + "expected": "integer", + "actual": "integer", + "keys": ["string"] + } + }, + { + "RequiredObjectKeyMissing": { + "key": "string" + } + }, + { + "FunctionUnknown": {} + } + ] + }, + { + "///": " A parse failure. ", + "union.ParseFailure_": [ + { + "IncompatibleBinaryEncoding": {} + }, + { + "///": " The binary decoder encountered a field id that could not be mapped to a key. ", + "BinaryDecodeFailure": {} + }, + { + "JsonInvalid": {} + }, + { + "ExpectedJsonArrayOfAnObjectAndAnObjectOfOneObject": {} + }, + { + "ExpectedJsonArrayOfTwoObjects": {} + } + ] + }, + { + "///": " A validation failure located at a `path` explained by a `reason`. ", + "struct.ValidationFailure_": { + "path": ["any"], + "reason": "union.ValidationFailureReason_" + } + }, + { + "///": " A standard error. ", + "errors.Validation_": [ + { + "///": " The server implementation raised an unknown error. ", + "ErrorUnknown_": {} + }, + { + "///": " The headers on the request are invalid. ", + "ErrorInvalidRequestHeaders_": { + "cases": ["struct.ValidationFailure_"] + } + }, + { + "///": " The body on the request is invalid. ", + "ErrorInvalidRequestBody_": { + "cases": ["struct.ValidationFailure_"] + } + }, + { + "///": " The headers on the response are invalid. ", + "ErrorInvalidResponseHeaders_": { + "cases": ["struct.ValidationFailure_"] + } + }, + { + "///": " The body that the server attempted to put on the response is invalid. ", + "ErrorInvalidResponseBody_": { + "cases": ["struct.ValidationFailure_"] + } + }, + { + "///": " The request could not be parsed as a telepact Message. ", + "ErrorParseFailure_": { + "reasons": ["union.ParseFailure_"] + } + } + ] + } +] \ No newline at end of file diff --git a/lib/go/internal/schema/do_not_edit/json-schema.json b/lib/go/internal/schema/do_not_edit/json-schema.json new file mode 100644 index 000000000..43735fc6f --- /dev/null +++ b/lib/go/internal/schema/do_not_edit/json-schema.json @@ -0,0 +1,245 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "patternProperties": { + "^info\\.[a-zA-Z_]\\w*": { + "type": "object", + "description": "Information about the API.", + "additionalProperties": false + } + }, + "properties": { + "///": { + "anyOf": [{ "type": "string" }, { "type": "array", "items": { "type": "string" } }] + } + } + }, + { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "patternProperties": { + "^struct\\.[a-zA-Z_]\\w*?": { + "type": "object", + "description": "A struct with 0 or more fields.", + "additionalProperties": false, + "patternProperties": { + "^[a-z]\\w*!?$": { + "$ref": "#/$defs/typeDeclaration" + } + } + } + }, + "properties": { + "///": { + "anyOf": [{ "type": "string" }, { "type": "array", "items": { "type": "string" } }] + } + } + }, + { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "patternProperties": { + "union\\.[a-zA-Z_]\\w*$": { + "type": "array", + "items": { + "type": "object", + "description": "An union with 1 or more fields huzzah.", + "minProperties": 1, + "patternProperties": { + "^[A-Z]\\w*?$": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^[a-z]\\w*!?$": { + "$ref": "#/$defs/typeDeclaration" + } + } + } + }, + "properties": { + "///": { + "anyOf": [{ "type": "string" }, { "type": "array", "items": { "type": "string" } }] + } + } + } + } + }, + "properties": { + "///": { + "anyOf": [{ "type": "string" }, { "type": "array", "items": { "type": "string" } }] + } + } + }, + { + "type": "object", + "additionalProperties": false, + "minProperties": 2, + "patternProperties": { + "^fn\\.[a-zA-Z_]\\w*": { + "description": "A function that accepts an argument struct and returns a result union that is either an `Ok_` struct or an error struct.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^[a-z]\\w*!?$": { + "$ref": "#/$defs/typeDeclaration" + } + } + } + }, + "required": ["->"], + "properties": { + "->": { + "type": "array", + "prefixItems": [ + { + "type": "object", + "required": ["Ok_"], + "properties": { + "Ok_": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^[a-z]\\w*!?$": { + "$ref": "#/$defs/typeDeclaration" + } + } + } + }, + "additionalProperties": false + } + ], + "items": { + "type": "object", + "patternProperties": { + "^[A-Z]\\w*?$": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^[a-z]\\w*!?$": { + "$ref": "#/$defs/typeDeclaration" + } + }, + "properties": { + "///": { + "anyOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ] + } + } + } + } + } + }, + "///": { + "anyOf": [{ "type": "string" }, { "type": "array", "items": { "type": "string" } }] + } + } + }, + { + "type": "object", + "additionalProperties": false, + "minProperties": 2, + "patternProperties": { + "^headers\\.[a-zA-Z_]\\w*": { + "description": "A function that accepts an argument struct and returns a result union that is either an `Ok_` struct or an error struct.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^@[a-z]\\w*!?$": { + "$ref": "#/$defs/typeDeclaration" + } + } + } + }, + "required": ["->"], + "properties": { + "->": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^@[a-z]\\w*!?$": { + "$ref": "#/$defs/typeDeclaration" + } + } + }, + "///": { + "anyOf": [{ "type": "string" }, { "type": "array", "items": { "type": "string" } }] + } + } + }, + { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "patternProperties": { + "^errors\\.[a-zA-Z_]\\w*": { + "type": "array", + "items": { + "type": "object", + "description": "An union with 1 or more fields huzzah.", + "minProperties": 1, + "patternProperties": { + "^[A-Z]\\w*?$": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^[a-z]\\w*!?$": { + "$ref": "#/$defs/typeDeclaration" + } + } + } + }, + "properties": { + "///": { + "anyOf": [{ "type": "string" }, { "type": "array", "items": { "type": "string" } }] + } + } + } + } + }, + "properties": { + "///": { + "anyOf": [{ "type": "string" }, { "type": "array", "items": { "type": "string" } }] + } + } + } + ] + }, + "$defs": { + "typeDeclaration": { + "oneOf": [ + { + "type": "string", + "pattern": "^((boolean|integer|number|string|any|bytes)|(fn|union|struct)\\.([a-zA-Z_]\\w*))(\\?)?$" + }, + { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { + "$ref": "#/$defs/typeDeclaration" + } + }, + { + "type": "object", + "properties": { + "string": { + "$ref": "#/$defs/typeDeclaration" + } + }, + "required": ["string"], + "additionalProperties": false + } + ] + } + } +} diff --git a/lib/go/internal/schema/do_not_edit/mock-internal.telepact.json b/lib/go/internal/schema/do_not_edit/mock-internal.telepact.json new file mode 100644 index 000000000..40efe3053 --- /dev/null +++ b/lib/go/internal/schema/do_not_edit/mock-internal.telepact.json @@ -0,0 +1,157 @@ +[ + { + "///": " A stubbed result for matching input. ", + "_ext.Stub_": {} + }, + { + "///": " A call of a function. ", + "_ext.Call_": {} + }, + { + "///": " The number of times a function is allowed to be called. ", + "union.CallCountCriteria_": [ + { + "Exact": { + "times": "integer" + } + }, + { + "AtMost": { + "times": "integer" + } + }, + { + "AtLeast": { + "times": "integer" + } + } + ] + }, + { + "///": " Possible causes for a mock verification to fail. ", + "union.VerificationFailure_": [ + { + "TooFewMatchingCalls": { + "wanted": "union.CallCountCriteria_", + "found": "integer", + "allCalls": ["_ext.Call_"] + } + }, + { + "TooManyMatchingCalls": { + "wanted": "union.CallCountCriteria_", + "found": "integer", + "allCalls": ["_ext.Call_"] + } + } + ] + }, + { + "///": [ + " Create a function stub that will cause the server to return the `stub` result ", + " when the `stub` argument matches the function argument on a request. ", + " ", + " If `ignoreMissingArgFields` is `true`, then the server will skip field ", + " omission validation on the `stub` argument, and the stub will match calls ", + " where the given `stub` argument is Exactly a json sub-structure of the request ", + " function argument. ", + " ", + " If `generateMissingResultFields` is `true`, then the server will skip field ", + " omission validation on the `stub` result, and the server will generate the ", + " necessary data required to make the `result` pass on response validation. " + ], + "fn.createStub_": { + "stub": "_ext.Stub_", + "strictMatch!": "boolean", + "count!": "integer" + }, + "->": [ + { + "Ok_": {} + } + ], + "_errors": "^errors\\.Validation_$" + }, + { + "///": [ + " Verify a call was made with this mock that matches the given `call` and ", + " `multiplicity` criteria. If `allowPartialArgMatch` is supplied as `true`, then ", + " the server will skip field omission validation, and match calls where the ", + " given `call` argument is Exactly a json sub-structure of the actual argument. " + ], + "fn.verify_": { + "call": "_ext.Call_", + "strictMatch!": "boolean", + "count!": "union.CallCountCriteria_" + }, + "->": [ + { + "Ok_": {} + }, + { + "ErrorVerificationFailure": { + "reason": "union.VerificationFailure_" + } + } + ], + "_errors": "^errors\\.Validation_$" + }, + { + "///": [ + " Verify that no interactions have occurred with this mock or that all ", + " interactions have been verified. " + ], + "fn.verifyNoMoreInteractions_": {}, + "->": [ + { + "Ok_": {} + }, + { + "ErrorVerificationFailure": { + "additionalUnverifiedCalls": ["_ext.Call_"] + } + } + ], + "_errors": "^errors\\.Validation_$" + }, + { + "///": " Clear all stub conditions. ", + "fn.clearStubs_": {}, + "->": [ + { + "Ok_": {} + } + ], + "_errors": "^errors\\.Validation_$" + }, + { + "///": " Clear all call data. ", + "fn.clearCalls_": {}, + "->": [ + { + "Ok_": {} + } + ], + "_errors": "^errors\\.Validation_$" + }, + { + "///": " Set the seed of the random generator. ", + "fn.setRandomSeed_": { + "seed": "integer" + }, + "->": [ + { + "Ok_": {} + } + ], + "_errors": "^errors\\.Validation_$" + }, + { + "errors.Mock_": [ + { + "///": " The mock could not return a result due to no matching stub being available. ", + "ErrorNoMatchingStub_": {} + } + ] + } +] \ No newline at end of file diff --git a/lib/go/internal/types/GenerateContext.go b/lib/go/internal/types/GenerateContext.go new file mode 100644 index 000000000..a7bda76cf --- /dev/null +++ b/lib/go/internal/types/GenerateContext.go @@ -0,0 +1,87 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +// RandomGenerator captures the pseudo-random behaviour required during value generation. +type RandomGenerator interface { + NextIntWithCeiling(int) int + NextBoolean() bool + NextInt() int + NextString() string + NextCollectionLength() int + NextBytes() []byte + NextDouble() float64 +} + +// GenerateContext carries contextual information used during mock value generation. +type GenerateContext struct { + IncludeOptionalFields bool + RandomizeOptionalFields bool + AlwaysIncludeRequiredFields bool + FnScope string + RandomGenerator RandomGenerator +} + +// NewGenerateContext constructs a GenerateContext with the supplied values. +func NewGenerateContext( + includeOptionalFields bool, + randomizeOptionalFields bool, + alwaysIncludeRequiredFields bool, + fnScope string, + randomGenerator RandomGenerator, +) *GenerateContext { + return &GenerateContext{ + IncludeOptionalFields: includeOptionalFields, + RandomizeOptionalFields: randomizeOptionalFields, + AlwaysIncludeRequiredFields: alwaysIncludeRequiredFields, + FnScope: fnScope, + RandomGenerator: randomGenerator, + } +} + +// Copy produces a new GenerateContext derived from the receiver, applying optional overrides where provided. +func (ctx *GenerateContext) Copy( + includeOptionalFields *bool, + randomizeOptionalFields *bool, + alwaysIncludeRequiredFields *bool, + fnScope *string, + randomGenerator RandomGenerator, +) *GenerateContext { + if ctx == nil { + return nil + } + + copy := *ctx + + if includeOptionalFields != nil { + copy.IncludeOptionalFields = *includeOptionalFields + } + if randomizeOptionalFields != nil { + copy.RandomizeOptionalFields = *randomizeOptionalFields + } + if alwaysIncludeRequiredFields != nil { + copy.AlwaysIncludeRequiredFields = *alwaysIncludeRequiredFields + } + if fnScope != nil { + copy.FnScope = *fnScope + } + if randomGenerator != nil { + copy.RandomGenerator = randomGenerator + } + + return © +} diff --git a/lib/go/internal/types/GenerateRandomAny.go b/lib/go/internal/types/GenerateRandomAny.go new file mode 100644 index 000000000..ca82b3835 --- /dev/null +++ b/lib/go/internal/types/GenerateRandomAny.go @@ -0,0 +1,34 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +// GenerateRandomAny produces a pseudo-random primitive value. +func GenerateRandomAny(ctx *GenerateContext) any { + if ctx == nil || ctx.RandomGenerator == nil { + return nil + } + + selectType := ctx.RandomGenerator.NextIntWithCeiling(3) + switch selectType { + case 0: + return ctx.RandomGenerator.NextBoolean() + case 1: + return ctx.RandomGenerator.NextInt() + default: + return ctx.RandomGenerator.NextString() + } +} diff --git a/lib/go/internal/types/GenerateRandomArray.go b/lib/go/internal/types/GenerateRandomArray.go new file mode 100644 index 000000000..f65b40741 --- /dev/null +++ b/lib/go/internal/types/GenerateRandomArray.go @@ -0,0 +1,61 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +// GenerateRandomArray produces an array value compliant with the supplied type declaration. +func GenerateRandomArray(blueprintValue any, useBlueprintValue bool, typeParameters []*TTypeDeclaration, ctx *GenerateContext) []any { + if len(typeParameters) == 0 { + return []any{} + } + + nestedTypeDeclaration := typeParameters[0] + + if useBlueprintValue { + startingArray := toAnySlice(blueprintValue) + array := make([]any, 0, len(startingArray)) + for _, startingValue := range startingArray { + value := nestedTypeDeclaration.GenerateRandomValue(startingValue, true, ctx) + array = append(array, value) + } + return array + } + + length := 0 + if ctx != nil && ctx.RandomGenerator != nil { + length = ctx.RandomGenerator.NextCollectionLength() + } + + array := make([]any, 0, length) + for i := 0; i < length; i++ { + value := nestedTypeDeclaration.GenerateRandomValue(nil, false, ctx) + array = append(array, value) + } + + return array +} + +func toAnySlice(value any) []any { + if value == nil { + return nil + } + + if coerced, ok := coerceToInterfaceSlice(value); ok { + return coerced + } + + return nil +} diff --git a/lib/go/internal/types/GenerateRandomBoolean.go b/lib/go/internal/types/GenerateRandomBoolean.go new file mode 100644 index 000000000..d384b9e5f --- /dev/null +++ b/lib/go/internal/types/GenerateRandomBoolean.go @@ -0,0 +1,28 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +// GenerateRandomBoolean returns a pseudo-random boolean, optionally reusing a blueprint value. +func GenerateRandomBoolean(blueprintValue any, useBlueprintValue bool, ctx *GenerateContext) any { + if useBlueprintValue { + return blueprintValue + } + if ctx == nil || ctx.RandomGenerator == nil { + return nil + } + return ctx.RandomGenerator.NextBoolean() +} diff --git a/lib/go/internal/types/GenerateRandomBytes.go b/lib/go/internal/types/GenerateRandomBytes.go new file mode 100644 index 000000000..2683c0fca --- /dev/null +++ b/lib/go/internal/types/GenerateRandomBytes.go @@ -0,0 +1,28 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +// GenerateRandomBytes returns a pseudo-random byte slice, optionally reusing a blueprint value. +func GenerateRandomBytes(blueprintValue any, useBlueprintValue bool, ctx *GenerateContext) any { + if useBlueprintValue { + return blueprintValue + } + if ctx == nil || ctx.RandomGenerator == nil { + return nil + } + return ctx.RandomGenerator.NextBytes() +} diff --git a/lib/go/internal/types/GenerateRandomFn.go b/lib/go/internal/types/GenerateRandomFn.go new file mode 100644 index 000000000..80708b9db --- /dev/null +++ b/lib/go/internal/types/GenerateRandomFn.go @@ -0,0 +1,22 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +// GenerateRandomFn delegates to GenerateRandomUnion for function unions. +func GenerateRandomFn(blueprintValue any, useBlueprintValue bool, callTags map[string]*TStruct, ctx *GenerateContext) any { + return GenerateRandomUnion(blueprintValue, useBlueprintValue, callTags, ctx) +} diff --git a/lib/go/internal/types/GenerateRandomInteger.go b/lib/go/internal/types/GenerateRandomInteger.go new file mode 100644 index 000000000..a3624fc6b --- /dev/null +++ b/lib/go/internal/types/GenerateRandomInteger.go @@ -0,0 +1,28 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +// GenerateRandomInteger returns a pseudo-random integer, optionally reusing a blueprint value. +func GenerateRandomInteger(blueprintValue any, useBlueprintValue bool, ctx *GenerateContext) any { + if useBlueprintValue { + return blueprintValue + } + if ctx == nil || ctx.RandomGenerator == nil { + return 0 + } + return ctx.RandomGenerator.NextInt() +} diff --git a/lib/go/internal/types/GenerateRandomMockCall.go b/lib/go/internal/types/GenerateRandomMockCall.go new file mode 100644 index 000000000..8e9e9ef47 --- /dev/null +++ b/lib/go/internal/types/GenerateRandomMockCall.go @@ -0,0 +1,52 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +import ( + "sort" + "strings" +) + +// GenerateRandomMockCall produces a pseudo-random mock call structure based on the supplied schema types. +func GenerateRandomMockCall(typesMap map[string]TType, ctx *GenerateContext) any { + if ctx == nil || ctx.RandomGenerator == nil { + return nil + } + + functionNames := make([]string, 0) + for key := range typesMap { + if strings.HasPrefix(key, "fn.") && !strings.HasSuffix(key, ".->") && !strings.HasSuffix(key, "_") { + functionNames = append(functionNames, key) + } + } + + sort.Strings(functionNames) + if len(functionNames) == 0 { + return nil + } + + selectedFnName := functionNames[ctx.RandomGenerator.NextIntWithCeiling(len(functionNames))] + + typeEntry := typesMap[selectedFnName] + unionType, ok := typeEntry.(*TUnion) + if !ok { + return nil + } + + alwaysIncludeRequired := false + return GenerateRandomUnion(nil, false, unionType.Tags, ctx.Copy(nil, nil, &alwaysIncludeRequired, nil, nil)) +} diff --git a/lib/go/internal/types/GenerateRandomMockStub.go b/lib/go/internal/types/GenerateRandomMockStub.go new file mode 100644 index 000000000..a5fe1fd7d --- /dev/null +++ b/lib/go/internal/types/GenerateRandomMockStub.go @@ -0,0 +1,68 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +import ( + "sort" + "strings" +) + +// GenerateRandomMockStub creates a pseudo-random mock stub based on Telepact schema types. +func GenerateRandomMockStub(typesMap map[string]TType, ctx *GenerateContext) any { + if ctx == nil || ctx.RandomGenerator == nil { + return nil + } + + functions := make([]string, 0) + for key := range typesMap { + if strings.HasPrefix(key, "fn.") && !strings.HasSuffix(key, ".->") && !strings.HasSuffix(key, "_") { + functions = append(functions, key) + } + } + + sort.Strings(functions) + if len(functions) == 0 { + return nil + } + + index := ctx.RandomGenerator.NextIntWithCeiling(len(functions)) + selectedFnName := functions[index] + + selectedFn, ok := typesMap[selectedFnName].(*TUnion) + if !ok { + return nil + } + + selectedResult, ok := typesMap[selectedFnName+".->"].(*TUnion) + if !ok { + return nil + } + + argFields := selectedFn.Tags[selectedFnName].Fields + okFields := selectedResult.Tags["Ok_"].Fields + + alwaysIncludeRequired := false + arg := GenerateRandomStruct(nil, false, argFields, ctx.Copy(nil, nil, &alwaysIncludeRequired, nil, nil)) + okResult := GenerateRandomStruct(nil, false, okFields, ctx.Copy(nil, nil, &alwaysIncludeRequired, nil, nil)) + + return map[string]any{ + selectedFnName: arg, + "->": map[string]any{ + "Ok_": okResult, + }, + } +} diff --git a/lib/go/internal/types/GenerateRandomNumber.go b/lib/go/internal/types/GenerateRandomNumber.go new file mode 100644 index 000000000..c007cddfb --- /dev/null +++ b/lib/go/internal/types/GenerateRandomNumber.go @@ -0,0 +1,28 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +// GenerateRandomNumber returns a pseudo-random floating-point number, optionally reusing a blueprint value. +func GenerateRandomNumber(blueprintValue any, useBlueprintValue bool, ctx *GenerateContext) any { + if useBlueprintValue { + return blueprintValue + } + if ctx == nil || ctx.RandomGenerator == nil { + return 0.0 + } + return ctx.RandomGenerator.NextDouble() +} diff --git a/lib/go/internal/types/GenerateRandomObject.go b/lib/go/internal/types/GenerateRandomObject.go new file mode 100644 index 000000000..5d0771a41 --- /dev/null +++ b/lib/go/internal/types/GenerateRandomObject.go @@ -0,0 +1,53 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +// GenerateRandomObject produces a pseudo-random object adhering to the provided type declaration. +func GenerateRandomObject(blueprintValue any, useBlueprintValue bool, typeParameters []*TTypeDeclaration, ctx *GenerateContext) map[string]any { + if len(typeParameters) == 0 { + return map[string]any{} + } + + nestedTypeDeclaration := typeParameters[0] + + if useBlueprintValue { + startingObj, _ := coerceToStringAnyMap(blueprintValue) + obj := make(map[string]any, len(startingObj)) + for key, startingValue := range startingObj { + value := nestedTypeDeclaration.GenerateRandomValue(startingValue, true, ctx) + obj[key] = value + } + return obj + } + + length := 0 + if ctx != nil && ctx.RandomGenerator != nil { + length = ctx.RandomGenerator.NextCollectionLength() + } + + obj := make(map[string]any, length) + for i := 0; i < length; i++ { + key := "" + if ctx != nil && ctx.RandomGenerator != nil { + key = ctx.RandomGenerator.NextString() + } + value := nestedTypeDeclaration.GenerateRandomValue(nil, false, ctx) + obj[key] = value + } + + return obj +} diff --git a/lib/go/internal/types/GenerateRandomSelect.go b/lib/go/internal/types/GenerateRandomSelect.go new file mode 100644 index 000000000..d1da856da --- /dev/null +++ b/lib/go/internal/types/GenerateRandomSelect.go @@ -0,0 +1,96 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +import ( + "fmt" + "sort" +) + +// GenerateRandomSelect builds a pseudo-random select projection using the provided context. +func GenerateRandomSelect(possibleSelects map[string]any, ctx *GenerateContext) any { + if ctx == nil { + return nil + } + + possibleSelect := possibleSelects[ctx.FnScope] + return subSelect(possibleSelect, ctx) +} + +func subSelect(possibleSelectSection any, ctx *GenerateContext) any { + switch typed := possibleSelectSection.(type) { + case []any: + selectedFieldNames := make([]string, 0) + for _, entry := range typed { + fieldName, ok := entry.(string) + if !ok { + continue + } + + if ctx.RandomGenerator == nil || ctx.RandomGenerator.NextBoolean() { + selectedFieldNames = append(selectedFieldNames, fieldName) + } + } + sort.Strings(selectedFieldNames) + return selectedFieldNames + case []string: + selectedFieldNames := make([]string, 0, len(typed)) + for _, fieldName := range typed { + if ctx.RandomGenerator == nil || ctx.RandomGenerator.NextBoolean() { + selectedFieldNames = append(selectedFieldNames, fieldName) + } + } + sort.Strings(selectedFieldNames) + return selectedFieldNames + case map[string]any: + selectedSection := make(map[string]any) + keys := make([]string, 0, len(typed)) + for key := range typed { + keys = append(keys, key) + } + sort.Strings(keys) + + for _, key := range keys { + value := typed[key] + if ctx.RandomGenerator != nil && !ctx.RandomGenerator.NextBoolean() { + continue + } + + result := subSelect(value, ctx) + if nestedMap, ok := result.(map[string]any); ok && len(nestedMap) == 0 { + continue + } + selectedSection[key] = result + } + + return selectedSection + case map[string][]string: + converted := make(map[string]any, len(typed)) + for key, value := range typed { + converted[key] = value + } + return subSelect(converted, ctx) + case map[any]any: + converted := make(map[string]any, len(typed)) + for key, value := range typed { + converted[fmt.Sprintf("%v", key)] = value + } + return subSelect(converted, ctx) + default: + panic(fmt.Errorf("invalid possible_select_section: %T", possibleSelectSection)) + } +} diff --git a/lib/go/internal/types/GenerateRandomString.go b/lib/go/internal/types/GenerateRandomString.go new file mode 100644 index 000000000..954471699 --- /dev/null +++ b/lib/go/internal/types/GenerateRandomString.go @@ -0,0 +1,28 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +// GenerateRandomString returns a pseudo-random string, optionally reusing a blueprint value. +func GenerateRandomString(blueprintValue any, useBlueprintValue bool, ctx *GenerateContext) any { + if useBlueprintValue { + return blueprintValue + } + if ctx == nil || ctx.RandomGenerator == nil { + return "" + } + return ctx.RandomGenerator.NextString() +} diff --git a/lib/go/internal/types/GenerateRandomStruct.go b/lib/go/internal/types/GenerateRandomStruct.go new file mode 100644 index 000000000..741b1d124 --- /dev/null +++ b/lib/go/internal/types/GenerateRandomStruct.go @@ -0,0 +1,79 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +import "sort" + +// GenerateRandomStruct produces a pseudo-random struct object that respects the provided field declarations. +func GenerateRandomStruct(blueprintValue any, useBlueprintValue bool, referenceStruct map[string]*TFieldDeclaration, ctx *GenerateContext) map[string]any { + if ctx == nil { + return map[string]any{} + } + sortedKeys := make([]string, 0, len(referenceStruct)) + for key := range referenceStruct { + sortedKeys = append(sortedKeys, key) + } + sort.Strings(sortedKeys) + + startingStruct := map[string]any{} + if useBlueprintValue { + startingStruct = toStringAnyMap(blueprintValue) + } + + obj := make(map[string]any) + for _, fieldName := range sortedKeys { + fieldDeclaration := referenceStruct[fieldName] + if fieldDeclaration == nil { + continue + } + + thisUseBlueprintValue := false + var blueprintEntry any + if useBlueprintValue { + blueprintEntry, thisUseBlueprintValue = startingStruct[fieldName] + } + + typeDeclaration := fieldDeclaration.TypeDeclaration + + if thisUseBlueprintValue { + value := typeDeclaration.GenerateRandomValue(blueprintEntry, true, ctx) + obj[fieldName] = value + continue + } + + if !fieldDeclaration.Optional { + if !ctx.AlwaysIncludeRequiredFields && ctx.RandomGenerator != nil && ctx.RandomGenerator.NextBoolean() { + continue + } + value := typeDeclaration.GenerateRandomValue(nil, false, ctx) + obj[fieldName] = value + continue + } + + if !ctx.IncludeOptionalFields { + continue + } + if ctx.RandomizeOptionalFields && ctx.RandomGenerator != nil && ctx.RandomGenerator.NextBoolean() { + continue + } + + value := typeDeclaration.GenerateRandomValue(nil, false, ctx) + obj[fieldName] = value + } + + return obj +} diff --git a/lib/go/internal/types/GenerateRandomUnion.go b/lib/go/internal/types/GenerateRandomUnion.go new file mode 100644 index 000000000..17948e48d --- /dev/null +++ b/lib/go/internal/types/GenerateRandomUnion.go @@ -0,0 +1,58 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +import "sort" + +// GenerateRandomUnion produces a pseudo-random union value respecting the supplied union tag definitions. +func GenerateRandomUnion(blueprintValue any, useBlueprintValue bool, unionTagsReference map[string]*TStruct, ctx *GenerateContext) map[string]any { + if ctx == nil { + return map[string]any{} + } + + if !useBlueprintValue { + sortedUnionTags := make([]string, 0, len(unionTagsReference)) + for key := range unionTagsReference { + sortedUnionTags = append(sortedUnionTags, key) + } + sort.Strings(sortedUnionTags) + + if len(sortedUnionTags) == 0 { + return map[string]any{} + } + + randomIndex := 0 + if ctx.RandomGenerator != nil { + randomIndex = ctx.RandomGenerator.NextIntWithCeiling(len(sortedUnionTags)) + } + + unionTag := sortedUnionTags[randomIndex] + unionStruct := unionTagsReference[unionTag] + structValue := GenerateRandomStruct(nil, false, unionStruct.Fields, ctx) + return map[string]any{unionTag: structValue} + } + + startingUnion := toStringAnyMap(blueprintValue) + for unionTag, unionValue := range startingUnion { + unionStruct := unionTagsReference[unionTag] + startingStruct := toStringAnyMap(unionValue) + structValue := GenerateRandomStruct(startingStruct, true, unionStruct.Fields, ctx) + return map[string]any{unionTag: structValue} + } + + return map[string]any{} +} diff --git a/lib/go/internal/types/GenerateRandomValueOfType.go b/lib/go/internal/types/GenerateRandomValueOfType.go new file mode 100644 index 000000000..86cc8deb8 --- /dev/null +++ b/lib/go/internal/types/GenerateRandomValueOfType.go @@ -0,0 +1,33 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +// GenerateRandomValueOfType produces a pseudo-random value for the provided type declaration. +func GenerateRandomValueOfType( + blueprintValue any, + useBlueprintValue bool, + thisType TType, + nullable bool, + typeParameters []*TTypeDeclaration, + ctx *GenerateContext, +) any { + if nullable && !useBlueprintValue && ctx != nil && ctx.RandomGenerator != nil && ctx.RandomGenerator.NextBoolean() { + return nil + } + + return thisType.GenerateRandomValue(blueprintValue, useBlueprintValue, typeParameters, ctx) +} diff --git a/lib/go/internal/types/GetType.go b/lib/go/internal/types/GetType.go new file mode 100644 index 000000000..f29ebdc73 --- /dev/null +++ b/lib/go/internal/types/GetType.go @@ -0,0 +1,54 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +import "reflect" + +// GetType mirrors the Python helper by mapping runtime values to schema type names. +func GetType(value any) string { + if value == nil { + return nullName + } + + switch value.(type) { + case bool: + return booleanName + case int, int8, int16, int32, int64: + return numberName + case uint, uint8, uint16, uint32, uint64: + return numberName + case float32, float64: + return numberName + case string: + return stringName + case []byte: + return stringName + case map[string]any: + return objectName + case []any: + return arrayName + } + + typeOfValue := reflect.TypeOf(value) + if typeOfValue.Kind() == reflect.Map { + return objectName + } + if typeOfValue.Kind() == reflect.Slice || typeOfValue.Kind() == reflect.Array { + return arrayName + } + return "Unknown" +} diff --git a/lib/go/internal/types/InvalidMessage.go b/lib/go/internal/types/InvalidMessage.go new file mode 100644 index 000000000..6bd271274 --- /dev/null +++ b/lib/go/internal/types/InvalidMessage.go @@ -0,0 +1,48 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +import "fmt" + +// InvalidMessage indicates that a serialized Telepact message failed validation. +type InvalidMessage struct { + Cause error +} + +// NewInvalidMessage constructs an InvalidMessage with the provided cause. +func NewInvalidMessage(cause error) *InvalidMessage { + return &InvalidMessage{Cause: cause} +} + +// Error implements the error interface. +func (e *InvalidMessage) Error() string { + if e == nil { + return "" + } + if e.Cause != nil { + return fmt.Sprintf("invalid message: %v", e.Cause) + } + return "invalid message" +} + +// Unwrap exposes the underlying cause for errors.Is/As. +func (e *InvalidMessage) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} diff --git a/lib/go/internal/types/InvalidMessageBody.go b/lib/go/internal/types/InvalidMessageBody.go new file mode 100644 index 000000000..b831558d4 --- /dev/null +++ b/lib/go/internal/types/InvalidMessageBody.go @@ -0,0 +1,30 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +// InvalidMessageBody indicates that a Telepact message body failed structural validation. +type InvalidMessageBody struct{} + +// NewInvalidMessageBody constructs a new InvalidMessageBody error. +func NewInvalidMessageBody() *InvalidMessageBody { + return &InvalidMessageBody{} +} + +// Error implements the error interface. +func (e *InvalidMessageBody) Error() string { + return "invalid message body" +} diff --git a/lib/go/internal/types/StringAnyMap.go b/lib/go/internal/types/StringAnyMap.go new file mode 100644 index 000000000..68f7dbe27 --- /dev/null +++ b/lib/go/internal/types/StringAnyMap.go @@ -0,0 +1,30 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +// toStringAnyMap normalises arbitrary map representations into map[string]any, returning an empty map when coercion fails. +func toStringAnyMap(value any) map[string]any { + if value == nil { + return map[string]any{} + } + + if coerced, ok := coerceToStringAnyMap(value); ok { + return coerced + } + + return map[string]any{} +} diff --git a/lib/go/internal/types/TAny.go b/lib/go/internal/types/TAny.go new file mode 100644 index 000000000..a482f8ec3 --- /dev/null +++ b/lib/go/internal/types/TAny.go @@ -0,0 +1,47 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +const anyName = "Any" + +// TAny represents Telepact's wildcard type. +type TAny struct{} + +// NewTAny constructs a TAny instance. +func NewTAny() *TAny { + return &TAny{} +} + +// GetTypeParameterCount implements TType. +func (t *TAny) GetTypeParameterCount() int { + return 0 +} + +// Validate implements TType by accepting all values. +func (t *TAny) Validate(value any, typeParameters []*TTypeDeclaration, ctx *ValidateContext) []*ValidationFailure { + return nil +} + +// GenerateRandomValue implements TType by delegating to GenerateRandomAny. +func (t *TAny) GenerateRandomValue(blueprintValue any, useBlueprintValue bool, typeParameters []*TTypeDeclaration, ctx *GenerateContext) any { + return GenerateRandomAny(ctx) +} + +// GetName implements TType. +func (t *TAny) GetName(ctx *ValidateContext) string { + return anyName +} diff --git a/lib/go/internal/types/TArray.go b/lib/go/internal/types/TArray.go new file mode 100644 index 000000000..c04e0e44a --- /dev/null +++ b/lib/go/internal/types/TArray.go @@ -0,0 +1,47 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +const arrayName = "Array" + +// TArray models an array type declaration. +type TArray struct{} + +// NewTArray constructs a TArray instance. +func NewTArray() *TArray { + return &TArray{} +} + +// GetTypeParameterCount implements TType. +func (t *TArray) GetTypeParameterCount() int { + return 1 +} + +// Validate implements TType. +func (t *TArray) Validate(value any, typeParameters []*TTypeDeclaration, ctx *ValidateContext) []*ValidationFailure { + return ValidateArray(value, typeParameters, ctx) +} + +// GenerateRandomValue implements TType. +func (t *TArray) GenerateRandomValue(blueprintValue any, useBlueprintValue bool, typeParameters []*TTypeDeclaration, ctx *GenerateContext) any { + return GenerateRandomArray(blueprintValue, useBlueprintValue, typeParameters, ctx) +} + +// GetName implements TType. +func (t *TArray) GetName(ctx *ValidateContext) string { + return arrayName +} diff --git a/lib/go/internal/types/TBoolean.go b/lib/go/internal/types/TBoolean.go new file mode 100644 index 000000000..154ecaee6 --- /dev/null +++ b/lib/go/internal/types/TBoolean.go @@ -0,0 +1,47 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +const booleanName = "Boolean" + +// TBoolean represents the Telepact boolean type. +type TBoolean struct{} + +// NewTBoolean constructs a TBoolean instance. +func NewTBoolean() *TBoolean { + return &TBoolean{} +} + +// GetTypeParameterCount implements TType. +func (t *TBoolean) GetTypeParameterCount() int { + return 0 +} + +// Validate implements TType. +func (t *TBoolean) Validate(value any, typeParameters []*TTypeDeclaration, ctx *ValidateContext) []*ValidationFailure { + return ValidateBoolean(value) +} + +// GenerateRandomValue implements TType. +func (t *TBoolean) GenerateRandomValue(blueprintValue any, useBlueprintValue bool, typeParameters []*TTypeDeclaration, ctx *GenerateContext) any { + return GenerateRandomBoolean(blueprintValue, useBlueprintValue, ctx) +} + +// GetName implements TType. +func (t *TBoolean) GetName(ctx *ValidateContext) string { + return booleanName +} diff --git a/lib/go/internal/types/TBytes.go b/lib/go/internal/types/TBytes.go new file mode 100644 index 000000000..f5d007f37 --- /dev/null +++ b/lib/go/internal/types/TBytes.go @@ -0,0 +1,47 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +const bytesName = "Bytes" + +// TBytes represents the Telepact bytes type. +type TBytes struct{} + +// NewTBytes constructs a TBytes instance. +func NewTBytes() *TBytes { + return &TBytes{} +} + +// GetTypeParameterCount implements TType. +func (t *TBytes) GetTypeParameterCount() int { + return 0 +} + +// Validate implements TType. +func (t *TBytes) Validate(value any, typeParameters []*TTypeDeclaration, ctx *ValidateContext) []*ValidationFailure { + return ValidateBytes(value, ctx) +} + +// GenerateRandomValue implements TType. +func (t *TBytes) GenerateRandomValue(blueprintValue any, useBlueprintValue bool, typeParameters []*TTypeDeclaration, ctx *GenerateContext) any { + return GenerateRandomBytes(blueprintValue, useBlueprintValue, ctx) +} + +// GetName implements TType. +func (t *TBytes) GetName(ctx *ValidateContext) string { + return bytesName +} diff --git a/lib/go/internal/types/TError.go b/lib/go/internal/types/TError.go new file mode 100644 index 000000000..44142cd8e --- /dev/null +++ b/lib/go/internal/types/TError.go @@ -0,0 +1,28 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +// TError represents an error type wrapping an underlying union. +type TError struct { + Name string + Errors *TUnion +} + +// NewTError constructs a TError instance. +func NewTError(name string, errors *TUnion) *TError { + return &TError{Name: name, Errors: errors} +} diff --git a/lib/go/internal/types/TFieldDeclaration.go b/lib/go/internal/types/TFieldDeclaration.go new file mode 100644 index 000000000..a33a3674f --- /dev/null +++ b/lib/go/internal/types/TFieldDeclaration.go @@ -0,0 +1,33 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +// TFieldDeclaration describes a struct field in a Telepact schema. +type TFieldDeclaration struct { + FieldName string + TypeDeclaration *TTypeDeclaration + Optional bool +} + +// NewTFieldDeclaration constructs a TFieldDeclaration instance. +func NewTFieldDeclaration(fieldName string, typeDeclaration *TTypeDeclaration, optional bool) *TFieldDeclaration { + return &TFieldDeclaration{ + FieldName: fieldName, + TypeDeclaration: typeDeclaration, + Optional: optional, + } +} diff --git a/lib/go/internal/types/THeaders.go b/lib/go/internal/types/THeaders.go new file mode 100644 index 000000000..8ec27ba00 --- /dev/null +++ b/lib/go/internal/types/THeaders.go @@ -0,0 +1,33 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +// THeaders captures request/response header declarations for a function. +type THeaders struct { + Name string + RequestHeaders map[string]*TFieldDeclaration + ResponseHeaders map[string]*TFieldDeclaration +} + +// NewTHeaders constructs a THeaders instance. +func NewTHeaders(name string, requestHeaders, responseHeaders map[string]*TFieldDeclaration) *THeaders { + return &THeaders{ + Name: name, + RequestHeaders: requestHeaders, + ResponseHeaders: responseHeaders, + } +} diff --git a/lib/go/internal/types/TInteger.go b/lib/go/internal/types/TInteger.go new file mode 100644 index 000000000..c5c4d3ed6 --- /dev/null +++ b/lib/go/internal/types/TInteger.go @@ -0,0 +1,47 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +const integerName = "Integer" + +// TInteger represents the Telepact integer type. +type TInteger struct{} + +// NewTInteger constructs a TInteger instance. +func NewTInteger() *TInteger { + return &TInteger{} +} + +// GetTypeParameterCount implements TType. +func (t *TInteger) GetTypeParameterCount() int { + return 0 +} + +// Validate implements TType. +func (t *TInteger) Validate(value any, typeParameters []*TTypeDeclaration, ctx *ValidateContext) []*ValidationFailure { + return ValidateInteger(value) +} + +// GenerateRandomValue implements TType. +func (t *TInteger) GenerateRandomValue(blueprintValue any, useBlueprintValue bool, typeParameters []*TTypeDeclaration, ctx *GenerateContext) any { + return GenerateRandomInteger(blueprintValue, useBlueprintValue, ctx) +} + +// GetName implements TType. +func (t *TInteger) GetName(ctx *ValidateContext) string { + return integerName +} diff --git a/lib/go/internal/types/TMockCall.go b/lib/go/internal/types/TMockCall.go new file mode 100644 index 000000000..39b404e38 --- /dev/null +++ b/lib/go/internal/types/TMockCall.go @@ -0,0 +1,49 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +const mockCallName = "_ext.Call_" + +// TMockCall represents an external mock call type. +type TMockCall struct { + Types map[string]TType +} + +// NewTMockCall constructs a TMockCall instance. +func NewTMockCall(types map[string]TType) *TMockCall { + return &TMockCall{Types: types} +} + +// GetTypeParameterCount implements TType. +func (t *TMockCall) GetTypeParameterCount() int { + return 0 +} + +// Validate implements TType. +func (t *TMockCall) Validate(value any, typeParameters []*TTypeDeclaration, ctx *ValidateContext) []*ValidationFailure { + return ValidateMockCall(value, t.Types, ctx) +} + +// GenerateRandomValue implements TType. +func (t *TMockCall) GenerateRandomValue(blueprintValue any, useBlueprintValue bool, typeParameters []*TTypeDeclaration, ctx *GenerateContext) any { + return GenerateRandomMockCall(t.Types, ctx) +} + +// GetName implements TType. +func (t *TMockCall) GetName(ctx *ValidateContext) string { + return mockCallName +} diff --git a/lib/go/internal/types/TMockStub.go b/lib/go/internal/types/TMockStub.go new file mode 100644 index 000000000..b4152b2b7 --- /dev/null +++ b/lib/go/internal/types/TMockStub.go @@ -0,0 +1,49 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +const mockStubName = "_ext.Stub_" + +// TMockStub represents an external mock stub type. +type TMockStub struct { + Types map[string]TType +} + +// NewTMockStub constructs a TMockStub instance. +func NewTMockStub(types map[string]TType) *TMockStub { + return &TMockStub{Types: types} +} + +// GetTypeParameterCount implements TType. +func (t *TMockStub) GetTypeParameterCount() int { + return 0 +} + +// Validate implements TType. +func (t *TMockStub) Validate(value any, typeParameters []*TTypeDeclaration, ctx *ValidateContext) []*ValidationFailure { + return ValidateMockStub(value, t.Types, ctx) +} + +// GenerateRandomValue implements TType. +func (t *TMockStub) GenerateRandomValue(blueprintValue any, useBlueprintValue bool, typeParameters []*TTypeDeclaration, ctx *GenerateContext) any { + return GenerateRandomMockStub(t.Types, ctx) +} + +// GetName implements TType. +func (t *TMockStub) GetName(ctx *ValidateContext) string { + return mockStubName +} diff --git a/lib/go/internal/types/TNull.go b/lib/go/internal/types/TNull.go new file mode 100644 index 000000000..78108d771 --- /dev/null +++ b/lib/go/internal/types/TNull.go @@ -0,0 +1,49 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +const nullName = "Null" + +// TNull represents the Telepact Null type. +type TNull struct{} + +// NewTNull constructs a TNull instance. +func NewTNull() *TNull { + return &TNull{} +} + +// GetTypeParameterCount implements TType. +func (t *TNull) GetTypeParameterCount() int { + return 0 +} + +// Validate implements TType. +func (t *TNull) Validate(value any, typeParameters []*TTypeDeclaration, ctx *ValidateContext) []*ValidationFailure { + return ValidateNull(value, ctx) +} + +// GenerateRandomValue implements TType. +func (t *TNull) GenerateRandomValue(blueprintValue any, useBlueprintValue bool, typeParameters []*TTypeDeclaration, ctx *GenerateContext) any { + return nil +} + +// GetName implements TType. +func (t *TNull) GetName(ctx *ValidateContext) string { + return nullName +} diff --git a/lib/go/internal/types/TNumber.go b/lib/go/internal/types/TNumber.go new file mode 100644 index 000000000..19824bf1f --- /dev/null +++ b/lib/go/internal/types/TNumber.go @@ -0,0 +1,50 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| +//| you may not use this file except in compliance with the License. +//| +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +const numberName = "Number" + +// TNumber represents the Telepact Number type. +type TNumber struct{} + +// NewTNumber constructs a TNumber instance. +func NewTNumber() *TNumber { + return &TNumber{} +} + +// GetTypeParameterCount implements TType. +func (t *TNumber) GetTypeParameterCount() int { + return 0 +} + +// Validate implements TType. +func (t *TNumber) Validate(value any, typeParameters []*TTypeDeclaration, ctx *ValidateContext) []*ValidationFailure { + return ValidateNumber(value, ctx) +} + +// GenerateRandomValue implements TType. +func (t *TNumber) GenerateRandomValue(blueprintValue any, useBlueprintValue bool, typeParameters []*TTypeDeclaration, ctx *GenerateContext) any { + return GenerateRandomNumber(blueprintValue, useBlueprintValue, ctx) +} + +// GetName implements TType. +func (t *TNumber) GetName(ctx *ValidateContext) string { + return numberName +} diff --git a/lib/go/internal/types/TObject.go b/lib/go/internal/types/TObject.go new file mode 100644 index 000000000..c7ea52890 --- /dev/null +++ b/lib/go/internal/types/TObject.go @@ -0,0 +1,49 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +const objectName = "Object" + +// TObject represents the Telepact Object type. +type TObject struct{} + +// NewTObject constructs a TObject instance. +func NewTObject() *TObject { + return &TObject{} +} + +// GetTypeParameterCount implements TType. +func (t *TObject) GetTypeParameterCount() int { + return 1 +} + +// Validate implements TType. +func (t *TObject) Validate(value any, typeParameters []*TTypeDeclaration, ctx *ValidateContext) []*ValidationFailure { + return ValidateObject(value, typeParameters, ctx) +} + +// GenerateRandomValue implements TType. +func (t *TObject) GenerateRandomValue(blueprintValue any, useBlueprintValue bool, typeParameters []*TTypeDeclaration, ctx *GenerateContext) any { + return GenerateRandomObject(blueprintValue, useBlueprintValue, typeParameters, ctx) +} + +// GetName implements TType. +func (t *TObject) GetName(ctx *ValidateContext) string { + return objectName +} diff --git a/lib/go/internal/types/TOptional.go b/lib/go/internal/types/TOptional.go new file mode 100644 index 000000000..d92188a5e --- /dev/null +++ b/lib/go/internal/types/TOptional.go @@ -0,0 +1,63 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +const optionalName = "Optional" + +// TOptional represents an optional Telepact type. +type TOptional struct { + Type TType +} + +// NewTOptional constructs a TOptional instance. +func NewTOptional(t TType) *TOptional { + return &TOptional{Type: t} +} + +// GetTypeParameterCount implements TType. +func (t *TOptional) GetTypeParameterCount() int { + return 0 +} + +// Validate implements TType. +func (t *TOptional) Validate(value any, typeParameters []*TTypeDeclaration, ctx *ValidateContext) []*ValidationFailure { + return ValidateOptional(value, t.Type, ctx) +} + +// GenerateRandomValue implements TType. +func (t *TOptional) GenerateRandomValue(blueprintValue any, useBlueprintValue bool, typeParameters []*TTypeDeclaration, ctx *GenerateContext) any { + if useBlueprintValue { + if blueprintValue == nil { + return nil + } + return t.Type.GenerateRandomValue(blueprintValue, true, typeParameters, ctx) + } + + if ctx != nil && ctx.RandomGenerator != nil { + if ctx.RandomGenerator.NextBoolean() { + return nil + } + } + return t.Type.GenerateRandomValue(nil, false, typeParameters, ctx) +} + +// GetName implements TType. +func (t *TOptional) GetName(ctx *ValidateContext) string { + return optionalName +} diff --git a/lib/go/internal/types/TSelect.go b/lib/go/internal/types/TSelect.go new file mode 100644 index 000000000..c03c5c43e --- /dev/null +++ b/lib/go/internal/types/TSelect.go @@ -0,0 +1,49 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +const selectName = "Object" + +// TSelect models a select type and holds the permissible selects map. +type TSelect struct { + PossibleSelects map[string]any +} + +// NewTSelect constructs a TSelect with the provided possible selects map. +func NewTSelect(possibleSelects map[string]any) *TSelect { + return &TSelect{PossibleSelects: possibleSelects} +} + +// GetTypeParameterCount implements TType. +func (t *TSelect) GetTypeParameterCount() int { + return 0 +} + +// Validate implements TType by delegating to ValidateSelect. +func (t *TSelect) Validate(value any, typeParameters []*TTypeDeclaration, ctx *ValidateContext) []*ValidationFailure { + return ValidateSelect(value, t.PossibleSelects, ctx) +} + +// GenerateRandomValue implements TType. +func (t *TSelect) GenerateRandomValue(blueprintValue any, useBlueprintValue bool, typeParameters []*TTypeDeclaration, ctx *GenerateContext) any { + return GenerateRandomSelect(t.PossibleSelects, ctx) +} + +// GetName implements TType. +func (t *TSelect) GetName(ctx *ValidateContext) string { + return selectName +} diff --git a/lib/go/internal/types/TString.go b/lib/go/internal/types/TString.go new file mode 100644 index 000000000..e5f008889 --- /dev/null +++ b/lib/go/internal/types/TString.go @@ -0,0 +1,47 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +const stringName = "String" + +// TString represents the Telepact string type. +type TString struct{} + +// NewTString constructs a TString instance. +func NewTString() *TString { + return &TString{} +} + +// GetTypeParameterCount implements TType. +func (t *TString) GetTypeParameterCount() int { + return 0 +} + +// Validate implements TType. +func (t *TString) Validate(value any, typeParameters []*TTypeDeclaration, ctx *ValidateContext) []*ValidationFailure { + return ValidateString(value) +} + +// GenerateRandomValue implements TType. +func (t *TString) GenerateRandomValue(blueprintValue any, useBlueprintValue bool, typeParameters []*TTypeDeclaration, ctx *GenerateContext) any { + return GenerateRandomString(blueprintValue, useBlueprintValue, ctx) +} + +// GetName implements TType. +func (t *TString) GetName(ctx *ValidateContext) string { + return stringName +} diff --git a/lib/go/internal/types/TStruct.go b/lib/go/internal/types/TStruct.go new file mode 100644 index 000000000..c0cc970a4 --- /dev/null +++ b/lib/go/internal/types/TStruct.go @@ -0,0 +1,50 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +const structName = "Object" + +// TStruct represents a struct type definition in Telepact. +type TStruct struct { + Name string + Fields map[string]*TFieldDeclaration +} + +// NewTStruct constructs a TStruct instance. +func NewTStruct(name string, fields map[string]*TFieldDeclaration) *TStruct { + return &TStruct{Name: name, Fields: fields} +} + +// GetTypeParameterCount implements TType. +func (t *TStruct) GetTypeParameterCount() int { + return 0 +} + +// Validate implements TType. +func (t *TStruct) Validate(value any, typeParameters []*TTypeDeclaration, ctx *ValidateContext) []*ValidationFailure { + return ValidateStruct(value, t.Name, t.Fields, ctx) +} + +// GenerateRandomValue implements TType. +func (t *TStruct) GenerateRandomValue(blueprintValue any, useBlueprintValue bool, typeParameters []*TTypeDeclaration, ctx *GenerateContext) any { + return GenerateRandomStruct(blueprintValue, useBlueprintValue, t.Fields, ctx) +} + +// GetName implements TType. +func (t *TStruct) GetName(ctx *ValidateContext) string { + return structName +} diff --git a/lib/go/internal/types/TType.go b/lib/go/internal/types/TType.go new file mode 100644 index 000000000..8fb2ce259 --- /dev/null +++ b/lib/go/internal/types/TType.go @@ -0,0 +1,25 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +// TType represents a Telepact schema type node. +type TType interface { + GetTypeParameterCount() int + Validate(value any, typeParameters []*TTypeDeclaration, ctx *ValidateContext) []*ValidationFailure + GenerateRandomValue(blueprintValue any, useBlueprintValue bool, typeParameters []*TTypeDeclaration, ctx *GenerateContext) any + GetName(ctx *ValidateContext) string +} diff --git a/lib/go/internal/types/TTypeDeclaration.go b/lib/go/internal/types/TTypeDeclaration.go new file mode 100644 index 000000000..8754df703 --- /dev/null +++ b/lib/go/internal/types/TTypeDeclaration.go @@ -0,0 +1,49 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +// TTypeDeclaration encapsulates a type reference along with nullability and nested type parameters. +type TTypeDeclaration struct { + Type TType + Nullable bool + TypeParameters []*TTypeDeclaration +} + +// NewTTypeDeclaration constructs a TTypeDeclaration mirroring the Python constructor. +func NewTTypeDeclaration(t TType, nullable bool, typeParameters []*TTypeDeclaration) *TTypeDeclaration { + return &TTypeDeclaration{ + Type: t, + Nullable: nullable, + TypeParameters: typeParameters, + } +} + +// Validate ensures the given value conforms to the declaration's type rules. +func (d *TTypeDeclaration) Validate(value any, ctx *ValidateContext) []*ValidationFailure { + if d == nil || d.Type == nil { + return nil + } + return ValidateValueOfType(value, d.Type, d.Nullable, d.TypeParameters, ctx) +} + +// GenerateRandomValue produces a pseudo-random value that conforms to the declaration. +func (d *TTypeDeclaration) GenerateRandomValue(blueprintValue any, useBlueprintValue bool, ctx *GenerateContext) any { + if d == nil || d.Type == nil { + return nil + } + return GenerateRandomValueOfType(blueprintValue, useBlueprintValue, d.Type, d.Nullable, d.TypeParameters, ctx) +} diff --git a/lib/go/internal/types/TUnion.go b/lib/go/internal/types/TUnion.go new file mode 100644 index 000000000..b0b650976 --- /dev/null +++ b/lib/go/internal/types/TUnion.go @@ -0,0 +1,51 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +const unionName = "Object" + +// TUnion represents a union type definition in Telepact. +type TUnion struct { + Name string + Tags map[string]*TStruct + TagIndices map[string]int +} + +// NewTUnion constructs a TUnion instance. +func NewTUnion(name string, tags map[string]*TStruct, tagIndices map[string]int) *TUnion { + return &TUnion{Name: name, Tags: tags, TagIndices: tagIndices} +} + +// GetTypeParameterCount implements TType. +func (t *TUnion) GetTypeParameterCount() int { + return 0 +} + +// Validate implements TType. +func (t *TUnion) Validate(value any, typeParameters []*TTypeDeclaration, ctx *ValidateContext) []*ValidationFailure { + return ValidateUnion(value, t.Name, t.Tags, ctx) +} + +// GenerateRandomValue implements TType. +func (t *TUnion) GenerateRandomValue(blueprintValue any, useBlueprintValue bool, typeParameters []*TTypeDeclaration, ctx *GenerateContext) any { + return GenerateRandomUnion(blueprintValue, useBlueprintValue, t.Tags, ctx) +} + +// GetName implements TType. +func (t *TUnion) GetName(ctx *ValidateContext) string { + return unionName +} diff --git a/lib/go/internal/types/ValidateContext.go b/lib/go/internal/types/ValidateContext.go new file mode 100644 index 000000000..ebd4192e6 --- /dev/null +++ b/lib/go/internal/types/ValidateContext.go @@ -0,0 +1,73 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +// ValidateContext carries contextual state during validation traversal. +type ValidateContext struct { + Path []string + Select map[string]any + Fn string + CoerceBase64 bool + Base64Coercions map[string]any + BytesCoercions map[string]any +} + +// NewValidateContext constructs a ValidateContext mirroring the Python implementation. +func NewValidateContext(selectStruct map[string]any, fn string, coerceBase64 bool) *ValidateContext { + ctx := &ValidateContext{ + Path: make([]string, 0), + Select: nil, + Fn: fn, + CoerceBase64: coerceBase64, + Base64Coercions: make(map[string]any), + BytesCoercions: make(map[string]any), + } + + if selectStruct != nil { + ctx.Select = selectStruct + } + + return ctx +} + +// PushPath appends a path element to the validation context. +func (ctx *ValidateContext) PushPath(segment string) { + if ctx == nil { + return + } + ctx.Path = append(ctx.Path, segment) +} + +// PopPath removes the most recent path element, if any. +func (ctx *ValidateContext) PopPath() { + if ctx == nil || len(ctx.Path) == 0 { + return + } + ctx.Path = ctx.Path[:len(ctx.Path)-1] +} + +// PathString returns the current path as a dotted identifier, aiding debugging and error reporting. +func (ctx *ValidateContext) PathString() string { + if ctx == nil || len(ctx.Path) == 0 { + return "" + } + joined := ctx.Path[0] + for i := 1; i < len(ctx.Path); i++ { + joined += "." + ctx.Path[i] + } + return joined +} diff --git a/lib/go/internal/types/ValidationFailure.go b/lib/go/internal/types/ValidationFailure.go new file mode 100644 index 000000000..b5b5bd1fb --- /dev/null +++ b/lib/go/internal/types/ValidationFailure.go @@ -0,0 +1,44 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +// ValidationFailure mirrors the Python structure capturing validation errors. +type ValidationFailure struct { + Path []any + Reason string + Data map[string]any +} + +// NewValidationFailure constructs a ValidationFailure using the supplied components. +func NewValidationFailure(path []any, reason string, data map[string]any) *ValidationFailure { + return &ValidationFailure{ + Path: append([]any{}, path...), + Reason: reason, + Data: cloneStringInterfaceMap(data), + } +} + +func cloneStringInterfaceMap(source map[string]any) map[string]any { + if source == nil { + return map[string]any{} + } + clone := make(map[string]any, len(source)) + for key, value := range source { + clone[key] = value + } + return clone +} diff --git a/lib/go/internal/types/ValidationHeaders.go b/lib/go/internal/types/ValidationHeaders.go new file mode 100644 index 000000000..7dc074089 --- /dev/null +++ b/lib/go/internal/types/ValidationHeaders.go @@ -0,0 +1,41 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +import "strings" + +// ValidateHeaders validates request headers against parsed field declarations. +func ValidateHeaders(headers map[string]any, parsedRequestHeaders map[string]*TFieldDeclaration, functionName string) []*ValidationFailure { + failures := make([]*ValidationFailure, 0) + + for header, value := range headers { + if !strings.HasPrefix(header, "@") { + failures = append(failures, NewValidationFailure([]any{header}, "RequiredObjectKeyPrefixMissing", map[string]any{"prefix": "@"})) + } + + field := parsedRequestHeaders[header] + if field == nil { + continue + } + + ctx := NewValidateContext(nil, functionName, false) + nestedFailures := field.TypeDeclaration.Validate(value, ctx) + failures = append(failures, prependPathToFailures(nestedFailures, header)...) + } + + return failures +} diff --git a/lib/go/internal/types/ValidationHelpers.go b/lib/go/internal/types/ValidationHelpers.go new file mode 100644 index 000000000..a22d9f01b --- /dev/null +++ b/lib/go/internal/types/ValidationHelpers.go @@ -0,0 +1,120 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +import ( + "fmt" + "math" + "sort" + "strings" +) + +// GetTypeUnexpectedValidationFailure mirrors the Python helper producing a standardized type mismatch failure. +func GetTypeUnexpectedValidationFailure(path []any, value any, expectedType string) []*ValidationFailure { + data := map[string]any{ + "actual": map[string]any{GetType(value): map[string]any{}}, + "expected": map[string]any{expectedType: map[string]any{}}, + } + return []*ValidationFailure{NewValidationFailure(path, "TypeUnexpected", data)} +} + +// prependPath prepends segments to an existing validation failure path. +func prependPath(failure *ValidationFailure, prefix ...any) *ValidationFailure { + if failure == nil { + return nil + } + newPath := make([]any, 0, len(prefix)+len(failure.Path)) + newPath = append(newPath, prefix...) + newPath = append(newPath, failure.Path...) + return &ValidationFailure{ + Path: newPath, + Reason: failure.Reason, + Data: cloneStringInterfaceMap(failure.Data), + } +} + +// prependPathToFailures applies prependPath to each failure in the slice. +func prependPathToFailures(failures []*ValidationFailure, prefix ...any) []*ValidationFailure { + if len(failures) == 0 { + return failures + } + result := make([]*ValidationFailure, 0, len(failures)) + for _, failure := range failures { + result = append(result, prependPath(failure, prefix...)) + } + return result +} + +func sortValidationFailures(failures []*ValidationFailure) { + sort.SliceStable(failures, func(i, j int) bool { + cmp := comparePaths(failures[i].Path, failures[j].Path) + if cmp != 0 { + return cmp < 0 + } + return failures[i].Reason < failures[j].Reason + }) +} + +func comparePaths(a, b []any) int { + if len(a) == 0 && len(b) == 0 { + return 0 + } + if len(a) == 0 { + return -1 + } + if len(b) == 0 { + return 1 + } + limit := len(a) + if len(b) < limit { + limit = len(b) + } + for i := 0; i < limit; i++ { + segA := segmentSortableString(a[i]) + segB := segmentSortableString(b[i]) + if cmp := strings.Compare(segA, segB); cmp != 0 { + return cmp + } + } + if len(a) < len(b) { + return -1 + } + if len(a) > len(b) { + return 1 + } + return 0 +} + +func segmentSortableString(value any) string { + switch v := value.(type) { + case string: + return "0:" + v + case int: + return fmt.Sprintf("1:%020d", v) + case int64: + return fmt.Sprintf("1:%020d", v) + case float64: + return fmt.Sprintf("2:%f", v) + default: + return fmt.Sprintf("3:%v", v) + } +} + +// withinSigned64Bit checks whether v fits within signed 64-bit range. +func withinSigned64Bit(v float64) bool { + return v <= math.MaxInt64 && v >= math.MinInt64 +} diff --git a/lib/go/internal/types/ValidationMapping.go b/lib/go/internal/types/ValidationMapping.go new file mode 100644 index 000000000..929a12b1d --- /dev/null +++ b/lib/go/internal/types/ValidationMapping.go @@ -0,0 +1,33 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +// MapValidationFailuresToInvalidFieldCases mirrors the Python helper for serialising validation failures. +func MapValidationFailuresToInvalidFieldCases(argumentValidationFailures []*ValidationFailure) []map[string]any { + cases := make([]map[string]any, 0, len(argumentValidationFailures)) + for _, failure := range argumentValidationFailures { + if failure == nil { + continue + } + cases = append(cases, map[string]any{ + "path": append([]any{}, failure.Path...), + "reason": map[string]any{failure.Reason: cloneStringInterfaceMap(failure.Data)}, + }) + } + return cases +} diff --git a/lib/go/internal/types/ValidationMock.go b/lib/go/internal/types/ValidationMock.go new file mode 100644 index 000000000..440c3f3b7 --- /dev/null +++ b/lib/go/internal/types/ValidationMock.go @@ -0,0 +1,174 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +import ( + "regexp" + "sort" +) + +const ( + mockFunctionRegex = "^fn\\..*$" + resultKey = "->" +) + +// ValidateMockCall mirrors the Python implementation for validating mock call payloads. +func ValidateMockCall(givenObj any, typesMap map[string]TType, ctx *ValidateContext) []*ValidationFailure { + givenMap, ok := coerceToStringAnyMap(givenObj) + if !ok { + return GetTypeUnexpectedValidationFailure(nil, givenObj, objectName) + } + + keys := make([]string, 0, len(givenMap)) + for key := range givenMap { + keys = append(keys, key) + } + sort.Strings(keys) + + re := regexp.MustCompile(mockFunctionRegex) + matches := make([]string, 0) + for _, key := range keys { + if re.MatchString(key) { + matches = append(matches, key) + } + } + + if len(matches) != 1 { + return []*ValidationFailure{NewValidationFailure(nil, "ObjectKeyRegexMatchCountUnexpected", map[string]any{ + "regex": mockFunctionRegex, + "actual": len(matches), + "expected": 1, + "keys": keys, + })} + } + + functionName := matches[0] + functionType, _ := typesMap[functionName].(*TUnion) + if functionType == nil { + return []*ValidationFailure{NewValidationFailure([]any{functionName}, "ObjectKeyDisallowed", map[string]any{})} + } + + input := givenMap[functionName] + structType := functionType.Tags[functionName] + if structType == nil { + return []*ValidationFailure{NewValidationFailure([]any{functionName}, "ObjectKeyDisallowed", map[string]any{})} + } + + inputFailures := structType.Validate(input, nil, ctx) + inputFailuresWithPath := prependPathToFailures(inputFailures, functionName) + + filtered := make([]*ValidationFailure, 0, len(inputFailuresWithPath)) + for _, failure := range inputFailuresWithPath { + if failure.Reason == "RequiredObjectKeyMissing" { + continue + } + filtered = append(filtered, failure) + } + + sortValidationFailures(filtered) + return filtered +} + +// ValidateMockStub mirrors the Python implementation for validating mock stub payloads. +func ValidateMockStub(givenObj any, typesMap map[string]TType, ctx *ValidateContext) []*ValidationFailure { + givenMap, ok := coerceToStringAnyMap(givenObj) + if !ok { + return GetTypeUnexpectedValidationFailure(nil, givenObj, objectName) + } + + keys := make([]string, 0, len(givenMap)) + for key := range givenMap { + keys = append(keys, key) + } + sort.Strings(keys) + + re := regexp.MustCompile(mockFunctionRegex) + matches := make([]string, 0) + for _, key := range keys { + if re.MatchString(key) { + matches = append(matches, key) + } + } + + if len(matches) != 1 { + return []*ValidationFailure{NewValidationFailure(nil, "ObjectKeyRegexMatchCountUnexpected", map[string]any{ + "regex": mockFunctionRegex, + "actual": len(matches), + "expected": 1, + "keys": keys, + })} + } + + functionName := matches[0] + input := givenMap[functionName] + + functionCallUnion, _ := typesMap[functionName].(*TUnion) + if functionCallUnion == nil { + return []*ValidationFailure{NewValidationFailure([]any{functionName}, "ObjectKeyDisallowed", map[string]any{})} + } + + callStruct := functionCallUnion.Tags[functionName] + if callStruct == nil { + return []*ValidationFailure{NewValidationFailure([]any{functionName}, "ObjectKeyDisallowed", map[string]any{})} + } + + inputFailures := callStruct.Validate(input, nil, ctx) + inputFailures = prependPathToFailures(inputFailures, functionName) + filtered := make([]*ValidationFailure, 0, len(inputFailures)) + for _, failure := range inputFailures { + if failure.Reason == "RequiredObjectKeyMissing" { + continue + } + filtered = append(filtered, failure) + } + + resultUnionKey := functionName + ".->" + functionResultUnion, _ := typesMap[resultUnionKey].(*TUnion) + failures := make([]*ValidationFailure, 0) + failures = append(failures, filtered...) + + if functionResultUnion == nil { + failures = append(failures, NewValidationFailure([]any{resultKey}, "ObjectKeyDisallowed", map[string]any{})) + } else { + output, hasOutput := givenMap[resultKey] + if !hasOutput { + failures = append(failures, NewValidationFailure(nil, "RequiredObjectKeyMissing", map[string]any{"key": resultKey})) + } else { + outputFailures := functionResultUnion.Validate(output, nil, ctx) + outputFailures = prependPathToFailures(outputFailures, resultKey) + for _, failure := range outputFailures { + if failure.Reason == "RequiredObjectKeyMissing" { + continue + } + failures = append(failures, failure) + } + } + } + + for _, key := range keys { + if key == functionName || key == resultKey { + continue + } + if re.MatchString(key) { + continue + } + failures = append(failures, NewValidationFailure([]any{key}, "ObjectKeyDisallowed", map[string]any{})) + } + + sortValidationFailures(failures) + return failures +} diff --git a/lib/go/internal/types/ValidationPrimitives.go b/lib/go/internal/types/ValidationPrimitives.go new file mode 100644 index 000000000..9f7f4b907 --- /dev/null +++ b/lib/go/internal/types/ValidationPrimitives.go @@ -0,0 +1,316 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +import ( + "encoding/base64" + "encoding/json" + "math" + "reflect" + "strconv" + "strings" +) + +// ValidateBoolean validates that the value is a boolean. +func ValidateBoolean(value any) []*ValidationFailure { + if _, ok := value.(bool); ok { + return nil + } + return GetTypeUnexpectedValidationFailure(nil, value, booleanName) +} + +// ValidateNumber ensures the value is an integer or floating point number. +func ValidateNumber(value any, ctx *ValidateContext) []*ValidationFailure { + switch v := value.(type) { + case int: + return validateSignedInt(int64(v)) + case int8: + return validateSignedInt(int64(v)) + case int16: + return validateSignedInt(int64(v)) + case int32: + return validateSignedInt(int64(v)) + case int64: + return validateSignedInt(v) + case uint: + if v > math.MaxInt64 { + return numberOutOfRangeFailure() + } + return nil + case uint8: + return nil + case uint16: + return nil + case uint32: + if uint64(v) > uint64(math.MaxInt64) { + return numberOutOfRangeFailure() + } + return nil + case uint64: + if v > uint64(math.MaxInt64) { + return numberOutOfRangeFailure() + } + return nil + case float32: + return validateFloat(float64(v)) + case float64: + return validateFloat(v) + case json.Number: + raw := string(v) + + if !strings.ContainsAny(raw, ".eE") { + if _, err := strconv.ParseInt(raw, 10, 64); err == nil { + return nil + } else if numErr, ok := err.(*strconv.NumError); ok && numErr.Err == strconv.ErrRange { + return numberOutOfRangeFailure() + } + } + + num, err := strconv.ParseFloat(raw, 64) + if err != nil { + if numErr, ok := err.(*strconv.NumError); ok && numErr.Err == strconv.ErrRange { + return numberOutOfRangeFailure() + } + return GetTypeUnexpectedValidationFailure(nil, value, numberName) + } + + return validateFloat(num) + default: + return GetTypeUnexpectedValidationFailure(nil, value, numberName) + } +} + +// ValidateInteger ensures the value is an integer within 64-bit range. +func ValidateInteger(value any) []*ValidationFailure { + switch v := value.(type) { + case int: + return validateSignedInt(int64(v)) + case int8: + return validateSignedInt(int64(v)) + case int16: + return validateSignedInt(int64(v)) + case int32: + return validateSignedInt(int64(v)) + case int64: + return validateSignedInt(v) + case uint: + if v > math.MaxInt64 { + return numberOutOfRangeFailure() + } + return nil + case uint8: + return nil + case uint16: + return nil + case uint32: + if uint64(v) > uint64(math.MaxInt64) { + return numberOutOfRangeFailure() + } + return nil + case uint64: + if v > uint64(math.MaxInt64) { + return numberOutOfRangeFailure() + } + return nil + case json.Number: + if i, err := v.Int64(); err == nil { + return validateSignedInt(i) + } + if _, err := v.Float64(); err == nil { + return numberOutOfRangeFailure() + } + return GetTypeUnexpectedValidationFailure(nil, value, integerName) + default: + return GetTypeUnexpectedValidationFailure(nil, value, integerName) + } +} + +func validateSignedInt(v int64) []*ValidationFailure { + return validateFloat(float64(v)) +} + +func validateFloat(v float64) []*ValidationFailure { + if math.IsNaN(v) || math.IsInf(v, 0) { + return numberOutOfRangeFailure() + } + + return nil +} + +func numberOutOfRangeFailure() []*ValidationFailure { + return []*ValidationFailure{NewValidationFailure(nil, "NumberOutOfRange", map[string]any{})} +} + +// ValidateString ensures the value is a string. +func ValidateString(value any) []*ValidationFailure { + if _, ok := value.(string); ok { + return nil + } + return GetTypeUnexpectedValidationFailure(nil, value, stringName) +} + +// ValidateNull ensures the value is nil. +func ValidateNull(value any, ctx *ValidateContext) []*ValidationFailure { + if value == nil { + return nil + } + return GetTypeUnexpectedValidationFailure(nil, value, "Null") +} + +// ValidateBytes validates base64 strings or raw byte slices depending on context. +func ValidateBytes(value any, ctx *ValidateContext) []*ValidationFailure { + if _, ok := value.([]byte); ok { + if ctx != nil && ctx.CoerceBase64 { + setCoercedPath(ctx.Path, ctx.Base64Coercions) + } + return nil + } + + if str, ok := value.(string); ok { + _, err := base64.StdEncoding.DecodeString(str) + if err != nil { + return GetTypeUnexpectedValidationFailure(nil, value, "Base64String") + } + if ctx != nil && !ctx.CoerceBase64 { + setCoercedPath(ctx.Path, ctx.BytesCoercions) + } + return nil + } + + return GetTypeUnexpectedValidationFailure(nil, value, bytesName) +} + +func setCoercedPath(path []string, coercedPath map[string]any) { + if len(path) == 0 { + return + } + + part := path[0] + if len(path) == 1 { + coercedPath[part] = true + return + } + + next, ok := coercedPath[part] + if !ok { + next = map[string]any{} + coercedPath[part] = next + } + + nextMap, ok := next.(map[string]any) + if !ok { + nextMap = map[string]any{} + coercedPath[part] = nextMap + } + setCoercedPath(path[1:], nextMap) +} + +// ValidateArray validates array values by delegating to the nested type declaration. +func ValidateArray(value any, typeParameters []*TTypeDeclaration, ctx *ValidateContext) []*ValidationFailure { + if value == nil { + return GetTypeUnexpectedValidationFailure(nil, value, arrayName) + } + + rv := reflect.ValueOf(value) + kind := rv.Kind() + + if kind != reflect.Array && kind != reflect.Slice { + return GetTypeUnexpectedValidationFailure(nil, value, arrayName) + } + + // Prevent treating []byte as array because that's handled by ValidateBytes. + if rv.Type().Elem().Kind() == reflect.Uint8 { + return GetTypeUnexpectedValidationFailure(nil, value, arrayName) + } + + nested := typeParameters[0] + failures := make([]*ValidationFailure, 0) + + for i := 0; i < rv.Len(); i++ { + element := rv.Index(i).Interface() + if ctx != nil { + ctx.Path = append(ctx.Path, "*") + } + nestedFailures := nested.Validate(element, ctx) + if ctx != nil && len(ctx.Path) > 0 { + ctx.Path = ctx.Path[:len(ctx.Path)-1] + } + failures = append(failures, prependPathToFailures(nestedFailures, i)...) + } + + return failures +} + +// ValidateObject validates object/map values by delegating to the nested type. +func ValidateObject(value any, typeParameters []*TTypeDeclaration, ctx *ValidateContext) []*ValidationFailure { + if value == nil { + return GetTypeUnexpectedValidationFailure(nil, value, objectName) + } + + rv := reflect.ValueOf(value) + if rv.Kind() != reflect.Map { + return GetTypeUnexpectedValidationFailure(nil, value, objectName) + } + + nested := typeParameters[0] + failures := make([]*ValidationFailure, 0) + + for _, key := range rv.MapKeys() { + entryValue := rv.MapIndex(key).Interface() + if ctx != nil { + ctx.Path = append(ctx.Path, "*") + } + nestedFailures := nested.Validate(entryValue, ctx) + if ctx != nil && len(ctx.Path) > 0 { + ctx.Path = ctx.Path[:len(ctx.Path)-1] + } + failures = append(failures, prependPathToFailures(nestedFailures, key.Interface())...) + } + + return failures +} + +// ValidateOptional validates optional values; nil is always accepted. +func ValidateOptional(value any, t TType, ctx *ValidateContext) []*ValidationFailure { + if value == nil { + return nil + } + if t == nil { + return nil + } + return t.Validate(value, nil, ctx) +} + +// ValidateValueOfType validates the supplied value against a type declaration, respecting nullability. +func ValidateValueOfType(value any, thisType TType, nullable bool, typeParameters []*TTypeDeclaration, ctx *ValidateContext) []*ValidationFailure { + if value == nil { + if !nullable { + expected := "Unknown" + if thisType != nil { + expected = thisType.GetName(ctx) + } + return GetTypeUnexpectedValidationFailure(nil, value, expected) + } + return nil + } + + if thisType == nil { + return nil + } + + return thisType.Validate(value, typeParameters, ctx) +} diff --git a/lib/go/internal/types/ValidationStruct.go b/lib/go/internal/types/ValidationStruct.go new file mode 100644 index 000000000..6f8f9ba00 --- /dev/null +++ b/lib/go/internal/types/ValidationStruct.go @@ -0,0 +1,341 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package types + +import ( + "reflect" + "sort" + "strings" +) + +// ValidateStruct mirrors the Python behaviour for struct validation. +func ValidateStruct(value any, name string, fields map[string]*TFieldDeclaration, ctx *ValidateContext) []*ValidationFailure { + actualMap, ok := coerceToStringAnyMap(value) + if !ok { + return GetTypeUnexpectedValidationFailure(nil, value, structName) + } + + var selectedFields []string + if ctx != nil && ctx.Select != nil { + if rawSelected, found := ctx.Select[name]; found { + selectedFields = toStringSlice(rawSelected) + } + } + + return validateStructFields(fields, selectedFields, actualMap, ctx) +} + +func validateStructFields(fields map[string]*TFieldDeclaration, selectedFields []string, actual map[string]any, ctx *ValidateContext) []*ValidationFailure { + failures := make([]*ValidationFailure, 0) + + selectedSet := make(map[string]struct{}, len(selectedFields)) + for _, field := range selectedFields { + selectedSet[field] = struct{}{} + } + + missingFields := make([]string, 0) + for fieldName, fieldDecl := range fields { + _, hasValue := actual[fieldName] + _, selected := selectedSet[fieldName] + isOmittedBySelect := selectedFields != nil && !selected + if !hasValue && !fieldDecl.Optional && !isOmittedBySelect { + missingFields = append(missingFields, fieldName) + } + } + + sort.Strings(missingFields) + for _, missing := range missingFields { + failures = append(failures, NewValidationFailure(nil, "RequiredObjectKeyMissing", map[string]any{"key": missing})) + } + + actualKeys := make([]string, 0, len(actual)) + for fieldName := range actual { + actualKeys = append(actualKeys, fieldName) + } + sort.Strings(actualKeys) + + for _, fieldName := range actualKeys { + fieldValue := actual[fieldName] + referenceField, exists := fields[fieldName] + if !exists { + failures = append(failures, NewValidationFailure([]any{fieldName}, "ObjectKeyDisallowed", map[string]any{})) + continue + } + + if ctx != nil { + ctx.Path = append(ctx.Path, fieldName) + } + nestedFailures := referenceField.TypeDeclaration.Validate(fieldValue, ctx) + if ctx != nil && len(ctx.Path) > 0 { + ctx.Path = ctx.Path[:len(ctx.Path)-1] + } + failures = append(failures, prependPathToFailures(nestedFailures, fieldName)...) + } + + sortValidationFailures(failures) + return failures +} + +// ValidateUnion mirrors the Python implementation for union validation. +func ValidateUnion(value any, name string, tags map[string]*TStruct, ctx *ValidateContext) []*ValidationFailure { + actualMap, ok := coerceToStringAnyMap(value) + if !ok { + return GetTypeUnexpectedValidationFailure(nil, value, unionName) + } + + var selectedTags map[string]any + if ctx != nil && ctx.Select != nil { + if strings.HasPrefix(name, "fn.") { + selected := any(nil) + if ctx.Select != nil { + selected = ctx.Select[name] + } + selectedTags = map[string]any{name: selected} + } else { + if raw, found := ctx.Select[name]; found { + if castMap, ok := raw.(map[string]any); ok { + selectedTags = castMap + } + } + } + } + + return validateUnionTags(tags, selectedTags, actualMap, ctx) +} + +func validateUnionTags(referenceTags map[string]*TStruct, selectedTags map[string]any, actual map[string]any, ctx *ValidateContext) []*ValidationFailure { + if len(actual) != 1 { + return []*ValidationFailure{NewValidationFailure(nil, "ObjectSizeUnexpected", map[string]any{"actual": len(actual), "expected": 1})} + } + + var unionTarget string + var unionPayload any + for key, val := range actual { + unionTarget = key + unionPayload = val + break + } + + referenceStruct := referenceTags[unionTarget] + if referenceStruct == nil { + return []*ValidationFailure{NewValidationFailure([]any{unionTarget}, "ObjectKeyDisallowed", map[string]any{})} + } + + payloadMap, ok := coerceToStringAnyMap(unionPayload) + if !ok { + return GetTypeUnexpectedValidationFailure([]any{unionTarget}, unionPayload, objectName) + } + + var structSelectedFields []string + if selectedTags != nil { + if raw, found := selectedTags[unionTarget]; found { + structSelectedFields = toStringSlice(raw) + } + } + + if ctx != nil { + ctx.Path = append(ctx.Path, unionTarget) + } + nestedFailures := validateStructFields(referenceStruct.Fields, structSelectedFields, payloadMap, ctx) + if ctx != nil && len(ctx.Path) > 0 { + ctx.Path = ctx.Path[:len(ctx.Path)-1] + } + + return prependPathToFailures(nestedFailures, unionTarget) +} + +// ValidateSelect mirrors the Python select validation behaviour. +func ValidateSelect(givenObj any, possibleSelects map[string]any, ctx *ValidateContext) []*ValidationFailure { + actualMap, ok := coerceToStringAnyMap(givenObj) + if !ok { + return GetTypeUnexpectedValidationFailure(nil, givenObj, objectName) + } + + if ctx == nil || ctx.Fn == "" { + return nil + } + + possibleSelect := possibleSelects[ctx.Fn] + return isSubSelect([]any{}, actualMap, possibleSelect) +} + +func isSubSelect(path []any, givenObj any, possibleSection any) []*ValidationFailure { + if allowedSlice, ok := coerceToInterfaceSlice(possibleSection); ok { + givenSlice, ok := coerceToInterfaceSlice(givenObj) + if !ok { + return GetTypeUnexpectedValidationFailure(path, givenObj, arrayName) + } + + normalizedAllowed := make([]any, len(allowedSlice)) + for i, value := range allowedSlice { + normalizedAllowed[i] = normalizeSelectValue(value) + } + + failures := make([]*ValidationFailure, 0) + for index, element := range givenSlice { + if !sliceContains(normalizedAllowed, normalizeSelectValue(element)) { + failures = append(failures, NewValidationFailure(append(clonePath(path), index), "ArrayElementDisallowed", map[string]any{})) + } + } + return failures + } + + if allowedMap, ok := coerceToStringAnyMap(possibleSection); ok { + givenMap, ok := coerceToStringAnyMap(givenObj) + if !ok { + return GetTypeUnexpectedValidationFailure(path, givenObj, objectName) + } + + failures := make([]*ValidationFailure, 0) + for key, value := range givenMap { + allowedSub, exists := allowedMap[key] + if !exists { + failures = append(failures, NewValidationFailure(append(clonePath(path), key), "ObjectKeyDisallowed", map[string]any{})) + continue + } + childFailures := isSubSelect(append(clonePath(path), key), value, allowedSub) + failures = append(failures, childFailures...) + } + return failures + } + + if reflect.DeepEqual(normalizeSelectValue(possibleSection), normalizeSelectValue(givenObj)) { + return nil + } + + return []*ValidationFailure{NewValidationFailure(clonePath(path), "ValueDisallowed", map[string]any{"actual": givenObj})} +} + +func normalizeSelectValue(value any) any { + if slice, ok := coerceToInterfaceSlice(value); ok { + normalized := make([]any, len(slice)) + for i, element := range slice { + normalized[i] = normalizeSelectValue(element) + } + return normalized + } + + if m, ok := coerceToStringAnyMap(value); ok { + normalized := make(map[string]any, len(m)) + for key, element := range m { + normalized[key] = normalizeSelectValue(element) + } + return normalized + } + + return value +} + +func sliceContains(slice []any, target any) bool { + for _, candidate := range slice { + if reflect.DeepEqual(candidate, target) { + return true + } + } + return false +} + +// toStringAnyMap attempts to coerce common map representations to map[string]any. +func coerceToStringAnyMap(value any) (map[string]any, bool) { + switch typed := value.(type) { + case map[string]any: + return typed, true + case map[string]string: + converted := make(map[string]any, len(typed)) + for k, v := range typed { + converted[k] = v + } + return converted, true + case map[string]int: + converted := make(map[string]any, len(typed)) + for k, v := range typed { + converted[k] = v + } + return converted, true + default: + rv := reflect.ValueOf(value) + if rv.IsValid() && rv.Kind() == reflect.Map && rv.Type().Key().Kind() == reflect.String { + converted := make(map[string]any, rv.Len()) + for _, key := range rv.MapKeys() { + converted[key.String()] = rv.MapIndex(key).Interface() + } + return converted, true + } + return nil, false + } +} + +func coerceToInterfaceSlice(value any) ([]any, bool) { + switch typed := value.(type) { + case []any: + return typed, true + case []string: + result := make([]any, len(typed)) + for i, v := range typed { + result[i] = v + } + return result, true + case []int: + result := make([]any, len(typed)) + for i, v := range typed { + result[i] = v + } + return result, true + } + + rv := reflect.ValueOf(value) + if rv.IsValid() && (rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array) { + if rv.Type().Elem().Kind() == reflect.Uint8 { + return nil, false + } + result := make([]any, rv.Len()) + for i := 0; i < rv.Len(); i++ { + result[i] = rv.Index(i).Interface() + } + return result, true + } + return nil, false +} + +func toStringSlice(value any) []string { + if value == nil { + return nil + } + switch typed := value.(type) { + case []string: + return append([]string{}, typed...) + case []any: + result := make([]string, 0, len(typed)) + for _, v := range typed { + if str, ok := v.(string); ok { + result = append(result, str) + } + } + return result + default: + return nil + } +} + +func clonePath(path []any) []any { + if len(path) == 0 { + return nil + } + out := make([]any, len(path)) + copy(out, path) + return out +} diff --git a/lib/go/internal/validation/GetInvalidErrorMessage.go b/lib/go/internal/validation/GetInvalidErrorMessage.go new file mode 100644 index 000000000..f947a486f --- /dev/null +++ b/lib/go/internal/validation/GetInvalidErrorMessage.go @@ -0,0 +1,39 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package validation + +import ( + telepact "github.com/telepact/telepact/lib/go" + "github.com/telepact/telepact/lib/go/internal/types" +) + +// GetInvalidErrorMessage mirrors the Python helper for constructing an error message from validation failures. +func GetInvalidErrorMessage(errorKey string, validationFailures []*types.ValidationFailure, resultUnionType *types.TUnion, responseHeaders map[string]any) (telepact.Message, error) { + validationFailureCases := types.MapValidationFailuresToInvalidFieldCases(validationFailures) + + newErrorResult := map[string]any{ + errorKey: map[string]any{ + "cases": validationFailureCases, + }, + } + + if err := ValidateResult(resultUnionType, newErrorResult); err != nil { + return telepact.Message{}, err + } + + return telepact.NewMessage(responseHeaders, newErrorResult), nil +} diff --git a/lib/go/internal/validation/ValidateResult.go b/lib/go/internal/validation/ValidateResult.go new file mode 100644 index 000000000..5b3ce7fc2 --- /dev/null +++ b/lib/go/internal/validation/ValidateResult.go @@ -0,0 +1,40 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package validation + +import ( + "fmt" + + telepact "github.com/telepact/telepact/lib/go" + "github.com/telepact/telepact/lib/go/internal/types" +) + +// ValidateResult ensures the provided error result conforms to the union type definition. +func ValidateResult(resultUnionType *types.TUnion, errorResult map[string]any) error { + if resultUnionType == nil { + return telepact.NewTelepactError("result union type is nil") + } + + validateCtx := types.NewValidateContext(nil, "", false) + validationFailures := resultUnionType.Validate(errorResult, nil, validateCtx) + if len(validationFailures) == 0 { + return nil + } + + cases := types.MapValidationFailuresToInvalidFieldCases(validationFailures) + return telepact.NewTelepactError(fmt.Sprintf("Failed internal telepact validation: %v", cases)) +} diff --git a/lib/go/serialization.go b/lib/go/serialization.go new file mode 100644 index 000000000..b0e60b3c9 --- /dev/null +++ b/lib/go/serialization.go @@ -0,0 +1,25 @@ +//| +//| Copyright The Telepact Authors +//| +//| Licensed under the Apache License, Version 2.0 (the "License"); +//| you may not use this file except in compliance with the License. +//| You may obtain a copy of the License at +//| +//| https://www.apache.org/licenses/LICENSE-2.0 +//| +//| Unless required by applicable law or agreed to in writing, software +//| distributed under the License is distributed on an "AS IS" BASIS, +//| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//| See the License for the specific language governing permissions and +//| limitations under the License. +//| + +package telepact + +// Serialization converts between pseudo-JSON objects and serialized byte payloads. +type Serialization interface { + ToJSON(message any) ([]byte, error) + ToMsgpack(message any) ([]byte, error) + FromJSON(data []byte) (any, error) + FromMsgpack(data []byte) (any, error) +} diff --git a/lib/java/src/main/java/io/github/telepact/internal/schema/ParseStructFields.java b/lib/java/src/main/java/io/github/telepact/internal/schema/ParseStructFields.java index a4ef16fcd..772f9b671 100644 --- a/lib/java/src/main/java/io/github/telepact/internal/schema/ParseStructFields.java +++ b/lib/java/src/main/java/io/github/telepact/internal/schema/ParseStructFields.java @@ -37,26 +37,16 @@ static Map parseStructFields( for (final var structEntry : referenceStruct.entrySet()) { final var fieldDeclaration = structEntry.getKey(); - for (final var existingField : fields.keySet()) { - final var existingFieldNoOpt = existingField.split("!")[0]; - final var fieldNoOpt = fieldDeclaration.split("!")[0]; - if (fieldNoOpt.equals(existingFieldNoOpt)) { - final List finalPath = new ArrayList<>(path); - finalPath.add(fieldDeclaration); - - final List finalOtherPath = new ArrayList<>(path); - finalOtherPath.add(existingField); - - final var finalOtherDocumentJson = ctx.telepactSchemaDocumentNamesToJson.get(ctx.documentName); - final var finalOtherLocationPseudoJson = GetPathDocumentCoordinatesPseudoJson - .getPathDocumentCoordinatesPseudoJson(finalOtherPath, finalOtherDocumentJson); - - parseFailures - .add(new SchemaParseFailure(ctx.documentName, finalPath, "PathCollision", - Map.of("document", ctx.documentName, "path", finalOtherPath, "location", - finalOtherLocationPseudoJson))); - } - } + for (final var existingField : fields.keySet()) { + final var existingFieldNoOpt = existingField.split("!")[0]; + final var fieldNoOpt = fieldDeclaration.split("!")[0]; + if (fieldNoOpt.equals(existingFieldNoOpt)) { + final List structPath = new ArrayList<>(path); + parseFailures + .add(new SchemaParseFailure(ctx.documentName, structPath, "DuplicateField", + Map.of("field", fieldNoOpt))); + } + } final var typeDeclarationValue = structEntry.getValue(); diff --git a/lib/py/telepact/internal/schema/ParseStructFields.py b/lib/py/telepact/internal/schema/ParseStructFields.py index a270626a1..5d02e8dd6 100644 --- a/lib/py/telepact/internal/schema/ParseStructFields.py +++ b/lib/py/telepact/internal/schema/ParseStructFields.py @@ -26,7 +26,6 @@ def parse_struct_fields(path: list[object], reference_struct: dict[str, object], is_header: bool, ctx: 'ParseContext') -> dict[str, 'TFieldDeclaration']: from ...TelepactSchemaParseError import TelepactSchemaParseError from ...internal.schema.ParseField import parse_field - from ...internal.schema.GetPathDocumentCoordinatesPseudoJson import get_path_document_coordinates_pseudo_json parse_failures = [] fields: dict[str, 'TFieldDeclaration'] = {} @@ -36,17 +35,10 @@ def parse_struct_fields(path: list[object], reference_struct: dict[str, object], existing_field_no_opt = existing_field.split("!")[0] field_no_opt = field_declaration.split("!")[0] if field_no_opt == existing_field_no_opt: - final_path = path + [field_declaration] - final_other_path = path + [existing_field] - final_other_document_json = ctx.telepact_schema_document_names_to_json[ - ctx.document_name] - final_other_location_pseudo_json = get_path_document_coordinates_pseudo_json( - final_other_path, final_other_document_json) + struct_path = list(path) parse_failures.append(SchemaParseFailure( - ctx.document_name, final_path, "PathCollision", { - "document": ctx.document_name, - "path": final_other_path, - "location": final_other_location_pseudo_json})) + ctx.document_name, struct_path, "DuplicateField", { + "field": field_no_opt})) try: parsed_field = parse_field(path, diff --git a/lib/ts/src/internal/schema/ParseStructFields.ts b/lib/ts/src/internal/schema/ParseStructFields.ts index bc6864137..f4b0af805 100644 --- a/lib/ts/src/internal/schema/ParseStructFields.ts +++ b/lib/ts/src/internal/schema/ParseStructFields.ts @@ -19,7 +19,6 @@ import { SchemaParseFailure } from '../../internal/schema/SchemaParseFailure'; import { TelepactSchemaParseError } from '../../TelepactSchemaParseError'; import { parseField } from '../../internal/schema/ParseField'; import { ParseContext } from '../../internal/schema/ParseContext'; -import { getPathDocumentCoordinatesPseudoJson } from '../../internal/schema/GetPathDocumentCoordinatesPseudoJson'; export function parseStructFields( path: any[], @@ -35,15 +34,10 @@ export function parseStructFields( const existingFieldNoOpt = existingField.split('!')[0]; const fieldNoOpt = fieldDeclaration.split('!')[0]; if (fieldNoOpt === existingFieldNoOpt) { - const finalPath = [...path, fieldDeclaration]; - const finalOtherPath = [...path, existingField]; - const finalOtherDocumentJson = ctx.telepactSchemaDocumentNamesToJson[ctx.documentName]; - const finalOtherLocation = getPathDocumentCoordinatesPseudoJson(finalOtherPath, finalOtherDocumentJson); + const structPath = [...path]; parseFailures.push( - new SchemaParseFailure(ctx.documentName, finalPath, 'PathCollision', { - document: ctx.documentName, - location: finalOtherLocation, - path: finalOtherPath, + new SchemaParseFailure(ctx.documentName, structPath, 'DuplicateField', { + field: fieldNoOpt, }), ); } diff --git a/sdk/cli/telepact_cli/cli.py b/sdk/cli/telepact_cli/cli.py index adbe46372..f7aed6cee 100644 --- a/sdk/cli/telepact_cli/cli.py +++ b/sdk/cli/telepact_cli/cli.py @@ -54,9 +54,9 @@ def bump_version(version: str) -> str: def _validate_package(ctx: click.Context, param: click.Parameter, value: str) -> str: lang = ctx.params.get('lang') - if lang == 'java' and not value: + if lang in ('java', 'go') and not value: raise click.BadParameter( - '--package is required when --lang is java') + '--package is required when --lang is {}'.format(lang)) return value @@ -68,7 +68,7 @@ def main() -> None: @click.command() @click.option('--schema-http-url', help='telepact schema directory', required=False) @click.option('--schema-dir', help='telepact schema directory', required=False) -@click.option('--lang', help='Language target (one of "java", "py", or "ts")', required=True) +@click.option('--lang', help='Language target (one of "java", "py", "ts", or "go")', required=True) @click.option('--out', help='Output directory', required=True) @click.option('--package', help='Java package (use if --lang is "java")', callback=_validate_package) def codegen(schema_http_url: str, schema_dir: str, lang: str, out: str, package: str) -> None: @@ -92,7 +92,7 @@ def codegen(schema_http_url: str, schema_dir: str, lang: str, out: str, package: print('Language target:', lang) print('Output directory:', out) if package: - print('Java package:', package) + print('Package:', package) target = lang @@ -134,7 +134,78 @@ def _raise_error(message: str) -> None: raise Exception(message) -def _generate_internal(schema_data: list[dict[str, object]], possible_fn_selects: dict[str, object], target: str, output_dir: str, java_package: str) -> None: +def _to_pascal_case(name: str) -> str: + tokens = re.split(r'[^0-9A-Za-z]+', name) + result = ''.join(token[:1].upper() + token[1:] for token in tokens if token) + if not result: + result = name.title() + if result and result[0].isdigit(): + result = f'Fn{result}' + return result + + +_GO_RESERVED_KEYWORDS: set[str] = { + 'break', 'default', 'func', 'interface', 'select', 'case', 'defer', 'go', + 'map', 'struct', 'chan', 'else', 'goto', 'package', 'switch', 'const', + 'fallthrough', 'if', 'range', 'type', 'continue', 'for', 'import', 'return', + 'var', 'bool', 'int', 'string', 'float64', 'error', 'byte', 'rune', 'any', +} + + +def _to_camel_case(name: str) -> str: + pascal = _to_pascal_case(name) + if not pascal: + return name + return pascal[:1].lower() + pascal[1:] + + +def _sanitize_go_identifier(name: str) -> str: + sanitized = re.sub(r'[^0-9A-Za-z_]', '', name) + if not sanitized: + sanitized = 'Field' + if sanitized[0].isdigit(): + sanitized = f'Field{sanitized}' + lower = sanitized.lower() + if lower in _GO_RESERVED_KEYWORDS: + sanitized = f'{sanitized}_' + return sanitized + + +def _process_go_fields(fields: dict[str, object]) -> list[dict[str, object]]: + processed: list[dict[str, object]] = [] + for field_name, field_type in fields.items(): + base_name = field_name.replace('!', '') + sanitized = _sanitize_go_identifier(base_name) + method_name = _to_pascal_case(sanitized) + param_name = _to_camel_case(sanitized) + if sanitized.endswith('_'): + param_name = sanitized + processed.append({ + 'json_name': field_name, + 'method_name': method_name, + 'param_name': param_name, + 'type': field_type, + 'optional': '!' in field_name, + 'nullable': isinstance(field_type, str) and '?' in field_type, + }) + return processed + + +def _process_go_tags(tag_entries: list[dict[str, object]]) -> list[dict[str, object]]: + processed_tags: list[dict[str, object]] = [] + for tag_entry in tag_entries: + tag_key = _find_tag_key(tag_entry) + tag_fields = cast(dict[str, object], tag_entry[tag_key]) + processed_tags.append({ + 'json_name': tag_key, + 'name': _to_pascal_case(tag_key), + 'doc': tag_entry.get('///'), + 'fields': _process_go_fields(tag_fields), + }) + return processed_tags + + +def _generate_internal(schema_data: list[dict[str, object]], possible_fn_selects: dict[str, object], target: str, output_dir: str, package_name: str) -> None: # Load jinja template from file # Adjust the path to your template directory if necessary @@ -146,6 +217,8 @@ def _generate_internal(schema_data: list[dict[str, object]], possible_fn_selects template_env.filters['regex_replace'] = _regex_replace template_env.filters['find_schema_key'] = _find_schema_key template_env.filters['find_tag_key'] = _find_tag_key + template_env.filters['to_pascal_case'] = _to_pascal_case + template_env.filters['to_camel_case'] = _to_camel_case template_env.globals['raise_error'] = _raise_error # Find all errors. definitions, and append to function results @@ -197,18 +270,18 @@ def _write_java_file(jinja_file: str, input: dict, output_file: str) -> None: functions.append(schema_key) _write_java_file('java_type_2.j2', { - 'package': java_package, 'data': schema_entry, 'possible_fn_selects': possible_fn_selects}, f"{schema_key.split('.')[1]}.java") + 'package': package_name, 'data': schema_entry, 'possible_fn_selects': possible_fn_selects}, f"{schema_key.split('.')[1]}.java") _write_java_file('java_server.j2', { - 'package': java_package, 'functions': functions, 'possible_fn_selects': possible_fn_selects}, f"TypedServerHandler.java") + 'package': package_name, 'functions': functions, 'possible_fn_selects': possible_fn_selects}, f"TypedServerHandler.java") _write_java_file('java_client.j2', { - 'package': java_package, 'functions': functions, 'possible_fn_selects': possible_fn_selects}, f"TypedClient.java") + 'package': package_name, 'functions': functions, 'possible_fn_selects': possible_fn_selects}, f"TypedClient.java") _write_java_file('java_utility.j2', { - 'package': java_package}, f"Utility_.java") + 'package': package_name}, f"Utility_.java") - _write_java_file('java_select.j2', {'package': java_package, 'possible_fn_selects': possible_fn_selects}, f"Select_.java") + _write_java_file('java_select.j2', {'package': package_name, 'possible_fn_selects': possible_fn_selects}, f"Select_.java") elif target == 'py': @@ -297,6 +370,77 @@ def _write_java_file(jinja_file: str, input: dict, output_file: str) -> None: else: print(output) + elif target == 'go': + + if not package_name: + raise Exception('Go code generation requires --package to be set') + + go_entries: list[dict[str, object]] = [] + go_functions: list[dict[str, object]] = [] + + for schema_entry in schema_data: + schema_key = _find_schema_key(schema_entry) + if schema_key.startswith('info') or schema_key.startswith('headers'): + continue + + base_name = schema_key.split('.')[1] + go_name = _to_pascal_case(base_name) + doc = schema_entry.get('///') + + if schema_key.startswith('struct'): + fields = cast(dict[str, object], schema_entry[schema_key]) + go_entries.append({ + 'kind': 'struct', + 'schema_key': schema_key, + 'name': go_name, + 'doc': doc, + 'fields': _process_go_fields(fields), + }) + + elif schema_key.startswith('union'): + tags = cast(list[dict[str, object]], schema_entry[schema_key]) + go_entries.append({ + 'kind': 'union', + 'schema_key': schema_key, + 'name': go_name, + 'doc': doc, + 'tags': _process_go_tags(tags), + }) + + elif schema_key.startswith('fn'): + input_fields = cast(dict[str, object], schema_entry[schema_key]) + result_tags = cast(list[dict[str, object]], schema_entry['->']) + fn_entry: dict[str, object] = { + 'kind': 'function', + 'schema_key': schema_key, + 'name': go_name, + 'doc': doc, + 'input_fields': _process_go_fields(input_fields), + 'output_tags': _process_go_tags(result_tags), + 'select': possible_fn_selects.get(schema_key, {}), + 'raw_name': schema_key, + 'camel_name': _to_camel_case(base_name), + } + go_entries.append(fn_entry) + go_functions.append(fn_entry) + + go_template = template_env.get_template('go_all.j2') + output = go_template.render({ + 'package': package_name, + 'entries': go_entries, + 'functions': go_functions, + 'possible_fn_selects': possible_fn_selects, + }) + + if output_dir: + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + file_path = output_path / "generated.go" + with file_path.open("w") as f: + f.write(output) + else: + print(output) + @click.command() @click.option('--port', default=8000, help='Port to run the mock server on', envvar='MOCK_SERVER_PORT') diff --git a/sdk/cli/telepact_cli/templates/go_all.j2 b/sdk/cli/telepact_cli/templates/go_all.j2 new file mode 100644 index 000000000..ca86d504c --- /dev/null +++ b/sdk/cli/telepact_cli/templates/go_all.j2 @@ -0,0 +1,785 @@ +{%- macro go_non_nullable_simple(typ) -%} + {%- if typ == 'boolean' -%}bool + {%- elif typ == 'integer' -%}int + {%- elif typ == 'number' -%}float64 + {%- elif typ == 'string' -%}string + {%- elif typ == 'any' -%}any + {%- elif typ == 'bytes' -%}[]byte + {%- elif typ.startswith('fn.') -%}{{ typ | regex_replace('^.*\\.', '') | to_pascal_case }}Input + {%- elif typ.startswith('struct.') or typ.startswith('union.') -%}{{ typ | regex_replace('^.*\\.', '') | to_pascal_case }} + {%- elif typ == '_ext.Select_' -%}Select + {%- else -%}{{ raise_error('Unsupported type: ' + typ) }} + {%- endif -%} +{%- endmacro %} + +{%- macro go_type(t) -%} + {%- if t is string -%} + {%- set nullable = '?' in t -%} + {%- set typ = t | replace('?', '') -%} + {%- set base = go_non_nullable_simple(typ) -%} + {%- if nullable -%} + {%- if base[:2] == '[]' or base[:4] == 'map[' or base == 'any' -%} + {{ base }} + {%- else -%} + *{{ base }} + {%- endif -%} + {%- else -%} + {{ base }} + {%- endif -%} + {%- elif t is mapping -%} + map[string]{{ go_type(t.values() | first) }} + {%- elif t is sequence -%} + []{{ go_type(t[0]) }} + {%- else -%} + {{ raise_error('Unsupported type structure for Go generation') }} + {%- endif -%} +{%- endmacro %} + +{%- macro go_param_type(field) -%} + {%- if field.optional -%} + Optional[{{ go_type(field.type) }}] + {%- else -%} + {{ go_type(field.type) }} + {%- endif -%} +{%- endmacro %} + +{%- macro go_method_return_type(field) -%} + {%- if field.optional -%} + ({{ go_type(field.type) }}, bool) + {%- else -%} + {{ go_type(field.type) }} + {%- endif -%} +{%- endmacro %} + +{%- macro go_constructor_name(typ) -%} + {%- if typ.startswith('struct.') -%}New{{ typ | regex_replace('^.*\\.', '') | to_pascal_case }}FromPseudoJSON + {%- elif typ.startswith('union.') -%}New{{ typ | regex_replace('^.*\\.', '') | to_pascal_case }}FromPseudoJSON + {%- elif typ.startswith('fn.') -%}New{{ typ | regex_replace('^.*\\.', '') | to_pascal_case }}InputFromPseudoJSON + {%- elif typ == '_ext.Select_' -%}NewSelectFromPseudoJSON + {%- else -%}{{ raise_error('Unsupported constructor for type: ' + typ) }} + {%- endif -%} +{%- endmacro %} + +{%- macro doc_comment(doc, indent='') -%} + {%- if doc -%} + {%- if doc is sequence and doc is not string -%} + {%- for line in doc %} +{{ indent }}// {{ line | trim }} + {%- endfor %} + {%- else -%} +{{ indent }}// {{ doc | trim }} + {%- endif -%} + {%- endif -%} +{%- endmacro %} + +{%- macro encode_expression(t, expr, depth=0) -%} + {%- if t is string -%} + {%- set nullable = '?' in t -%} + {%- set typ = t | replace('?', '') -%} + {%- set base = go_non_nullable_simple(typ) -%} + {%- set resolved_type = go_type(t) -%} + {%- if nullable -%} + {%- if resolved_type.startswith('*') -%} + {%- if typ.startswith('struct.') or typ.startswith('union.') or typ.startswith('fn.') or typ == '_ext.Select_' -%} + encodeNullable({{ expr }}, func(v {{ base }}) any { return {{ encode_expression(typ, 'v', depth + 1) }} }) + {%- else -%} + encodeNullable({{ expr }}, identity[{{ base }}]) + {%- endif -%} + {%- else -%} + {{ encode_expression(typ, expr, depth + 1) }} + {%- endif -%} + {%- else -%} + {%- if typ.startswith('struct.') or typ.startswith('union.') or typ.startswith('fn.') or typ == '_ext.Select_' -%} + {{ expr }}.PseudoJSON() + {%- else -%} + {{ expr }} + {%- endif -%} + {%- endif -%} + {%- elif t is mapping -%} + encodeMap({{ expr }}, func(v{{ depth }} {{ go_type(t.values() | first) }}) any { return {{ encode_expression(t.values() | first, 'v' ~ depth, depth + 1) }} }) + {%- elif t is sequence -%} + encodeSlice({{ expr }}, func(e{{ depth }} {{ go_type(t[0]) }}) any { return {{ encode_expression(t[0], 'e' ~ depth, depth + 1) }} }) + {%- else -%} + {{ raise_error('Unsupported type for encode expression') }} + {%- endif -%} +{%- endmacro %} + +{%- macro decode_expression(t, expr, depth=0) -%} + {%- if t is string -%} + {%- set nullable = '?' in t -%} + {%- set typ = t | replace('?', '') -%} + {%- set resolved_type = go_type(t) -%} + {%- if nullable -%} + {%- if resolved_type.startswith('*') -%} + nullable({{ expr }}, func(v any) {{ go_non_nullable_simple(typ) }} { return {{ decode_expression(typ, 'v', depth + 1) }} }) + {%- else -%} + {{ decode_expression(typ, expr, depth + 1) }} + {%- endif -%} + {%- else -%} + {%- if typ == 'boolean' -%} + asBool({{ expr }}) + {%- elif typ == 'integer' -%} + asInt({{ expr }}) + {%- elif typ == 'number' -%} + asFloat64({{ expr }}) + {%- elif typ == 'string' -%} + asString({{ expr }}) + {%- elif typ == 'bytes' -%} + asBytes({{ expr }}) + {%- elif typ == 'any' -%} + {{ expr }} + {%- elif typ.startswith('struct.') or typ.startswith('union.') or typ.startswith('fn.') or typ == '_ext.Select_' -%} + {{ go_constructor_name(typ) }}(asMap({{ expr }})) + {%- else -%} + {{ raise_error('Unsupported decode type: ' + typ) }} + {%- endif -%} + {%- endif -%} + {%- elif t is mapping -%} + decodeMap({{ expr }}, func(e{{ depth }} any) {{ go_type(t.values() | first) }} { return {{ decode_expression(t.values() | first, 'e' ~ depth, depth + 1) }} }) + {%- elif t is sequence -%} + decodeSlice({{ expr }}, func(e{{ depth }} any) {{ go_type(t[0]) }} { return {{ decode_expression(t[0], 'e' ~ depth, depth + 1) }} }) + {%- else -%} + {{ raise_error('Unsupported type for decode expression') }} + {%- endif -%} +{%- endmacro %} + +{%- macro struct_block(type_name, doc, fields) -%} +{{ doc_comment(doc) }} +type {{ type_name }} struct { + pseudoJSON map[string]any +} + +func New{{ type_name }}FromPseudoJSON(p map[string]any) {{ type_name }} { + return {{ type_name }}{pseudoJSON: cloneMap(p)} +} + +func (v {{ type_name }}) PseudoJSON() map[string]any { + return cloneMap(v.pseudoJSON) +} + +{%- set required_fields = fields | selectattr('optional', 'equalto', False) | list -%} +{%- set optional_fields = fields | selectattr('optional', 'equalto', True) | list -%} +{%- if fields %} +{%- set ordered_fields = required_fields + optional_fields %} +func New{{ type_name }}({%- for field in ordered_fields %}{{- '\n ' ~ field.param_name ~ ' ' ~ go_param_type(field) ~ ',' -}}{%- endfor %}{{- '\n' -}}) {{ type_name }} { + input := make(map[string]any) + {%- for field in fields %} + {%- if field.optional %} + if encoded{{ loop.index }}, ok := encodeOptional({{ field.param_name }}, func(v {{ go_type(field.type) }}) any { return {{ encode_expression(field.type, 'v') }} }); ok { + input["{{ field.json_name }}"] = encoded{{ loop.index }} + } + {%- else %} + input["{{ field.json_name }}"] = {{ encode_expression(field.type, field.param_name) }} + {%- endif %} + {%- endfor %} + return {{ type_name }}{pseudoJSON: input} +} +{%- else %} +func New{{ type_name }}() {{ type_name }} { + return {{ type_name }}{pseudoJSON: make(map[string]any)} +} +{%- endif %} + +{%- for field in fields %} +func (v {{ type_name }}) {{ field.method_name }}() {{ go_method_return_type(field) }} { + {%- if field.optional %} + raw, ok := v.pseudoJSON["{{ field.json_name }}"] + if !ok { + var zero {{ go_type(field.type) }} + return zero, false + } + return {{ decode_expression(field.type, 'raw') }}, true + {%- else %} + raw := v.pseudoJSON["{{ field.json_name }}"] + return {{ decode_expression(field.type, 'raw') }} + {%- endif %} +} + +{% endfor %} +{%- endmacro %} + +{%- macro emit_struct(entry) -%} +{{ struct_block(entry.name, entry.doc, entry.fields) }} +{%- endmacro %} + +{%- macro emit_union(entry) -%} +{{ doc_comment(entry.doc) }} +type {{ entry.name }} struct { + pseudoJSON map[string]any +} + +func New{{ entry.name }}FromPseudoJSON(p map[string]any) {{ entry.name }} { + return {{ entry.name }}{pseudoJSON: cloneMap(p)} +} + +func (v {{ entry.name }}) PseudoJSON() map[string]any { + return cloneMap(v.pseudoJSON) +} + +{%- for tag in entry.tags %} +func New{{ entry.name }}From{{ tag.name }}(payload {{ entry.name }}{{ tag.name }}) {{ entry.name }} { + return {{ entry.name }}{pseudoJSON: map[string]any{"{{ tag.json_name }}": payload.PseudoJSON()}} +} +{%- endfor %} + +func (v {{ entry.name }}) TaggedValue() TaggedValue[string, any] { + tag := firstKey(v.pseudoJSON) + switch tag { + {%- for tag in entry.tags %} + case "{{ tag.json_name }}": + return TaggedValue[string, any]{Tag: "{{ tag.json_name }}", Value: New{{ entry.name }}{{ tag.name }}FromPseudoJSON(asMap(v.pseudoJSON["{{ tag.json_name }}"]))} + {%- endfor %} + default: + value := map[string]any{} + if raw, ok := v.pseudoJSON[tag]; ok { + value = asMap(raw) + } + return TaggedValue[string, any]{Tag: "NoMatch_", Value: UntypedTaggedValue{Tag: tag, Value: value}} + } +} + +{%- for tag in entry.tags %} +{{ struct_block(entry.name ~ tag.name, tag.doc, tag.fields) }} +{%- endfor %} +{%- endmacro %} + +{%- macro emit_function(entry) -%} +{{ doc_comment(entry.doc) }} +type {{ entry.name }}Input struct { + pseudoJSON map[string]any +} + +func New{{ entry.name }}InputFromPseudoJSON(p map[string]any) {{ entry.name }}Input { + return {{ entry.name }}Input{pseudoJSON: cloneMap(p)} +} + +func (v {{ entry.name }}Input) PseudoJSON() map[string]any { + return cloneMap(v.pseudoJSON) +} + +{%- set fields = entry.input_fields -%} +{%- set required_fields = fields | selectattr('optional', 'equalto', False) | list -%} +{%- set optional_fields = fields | selectattr('optional', 'equalto', True) | list -%} +{%- if fields %} +{%- set ordered_fields = required_fields + optional_fields %} +func New{{ entry.name }}Input({%- for field in ordered_fields %}{{- '\n ' ~ field.param_name ~ ' ' ~ go_param_type(field) ~ ',' -}}{%- endfor %}{{- '\n' -}}) {{ entry.name }}Input { + payload := make(map[string]any) + {%- for field in fields %} + {%- if field.optional %} + if encoded{{ loop.index }}, ok := encodeOptional({{ field.param_name }}, func(v {{ go_type(field.type) }}) any { return {{ encode_expression(field.type, 'v') }} }); ok { + payload["{{ field.json_name }}"] = encoded{{ loop.index }} + } + {%- else %} + payload["{{ field.json_name }}"] = {{ encode_expression(field.type, field.param_name) }} + {%- endif %} + {%- endfor %} + return {{ entry.name }}Input{pseudoJSON: map[string]any{"{{ entry.schema_key }}": payload}} +} +{%- else %} +func New{{ entry.name }}Input() {{ entry.name }}Input { + return {{ entry.name }}Input{pseudoJSON: map[string]any{"{{ entry.schema_key }}": map[string]any{}}} +} +{%- endif %} + +func (v {{ entry.name }}Input) payload() map[string]any { + return asMap(v.pseudoJSON["{{ entry.schema_key }}"]) +} + +{%- for field in fields %} +func (v {{ entry.name }}Input) {{ field.method_name }}() {{ go_method_return_type(field) }} { + payload := v.payload() + {%- if field.optional %} + raw, ok := payload["{{ field.json_name }}"] + if !ok { + var zero {{ go_type(field.type) }} + return zero, false + } + return {{ decode_expression(field.type, 'raw') }}, true + {%- else %} + raw := payload["{{ field.json_name }}"] + return {{ decode_expression(field.type, 'raw') }} + {%- endif %} +} + +{%- endfor %} + +{%- set output_entry = { + 'name': entry.name ~ 'Output', + 'doc': entry.doc, + 'tags': entry.output_tags +} -%} +{{ emit_union(output_entry) }} + +type {{ entry.name }}Select struct { + pseudoJSON map[string]any +} + +func New{{ entry.name }}Select() *{{ entry.name }}Select { + return &{{ entry.name }}Select{pseudoJSON: map[string]any{}} +} + +func (s *{{ entry.name }}Select) PseudoJSON() map[string]any { + return cloneMap(s.pseudoJSON) +} + +{%- set select_info = entry.select -%} +{%- for key, value in select_info.items() %} +{%- if key.startswith('struct') -%} +{%- set type_name = key | regex_replace('^.*\.', '') | to_pascal_case -%} +{%- for field in value -%} +{%- set method_name = type_name ~ (field | replace('!', '') | to_pascal_case) -%}{{- '\n' -}} +func (s *{{ entry.name }}Select) {{ method_name }}() *{{ entry.name }}Select { + fields := ensureStringSlice(s.pseudoJSON["{{ key }}"]) + fields = appendUnique(fields, "{{ field }}") + s.pseudoJSON["{{ key }}"] = fields + return s +} +{%- endfor -%} +{%- elif key.startswith('union') -%} +{%- set type_name = key | regex_replace('^.*\.', '') | to_pascal_case -%} +{%- for tag_key, fields_list in value.items() -%} +{%- set tag_name = tag_key | to_pascal_case -%} +{%- for field in fields_list -%} +{%- set method_name = type_name ~ tag_name ~ (field | replace('!', '') | to_pascal_case) -%}{{- '\n' -}} +func (s *{{ entry.name }}Select) {{ method_name }}() *{{ entry.name }}Select { + tags := ensureStringMap(s.pseudoJSON["{{ key }}"]) + fields := appendUnique(tags["{{ tag_key }}"], "{{ field }}") + tags["{{ tag_key }}"] = fields + s.pseudoJSON["{{ key }}"] = tags + return s +} +{%- endfor -%} +{%- endfor -%} +{%- elif key == '->' -%} +{%- set ok_fields = value.get('Ok_', []) -%} +{%- for field in ok_fields -%} +{%- set method_name = 'Ok' ~ (field | replace('!', '') | to_pascal_case) -%}{{- '\n' -}} +func (s *{{ entry.name }}Select) {{ method_name }}() *{{ entry.name }}Select { + result := ensureStringMap(s.pseudoJSON["->"]) + fields := appendUnique(result["Ok_"], "{{ field }}") + result["Ok_"] = fields + s.pseudoJSON["->"] = result + return s +} +{%- endfor -%} +{%- endif -%} +{%- endfor %} + +{%- endmacro %} + +{%- macro emit_select_overall(functions) -%} +type Select struct { + pseudoJSON map[string]any +} + +func NewSelect() *Select { + return &Select{pseudoJSON: map[string]any{}} +} + +func NewSelectFromPseudoJSON(p map[string]any) Select { + return Select{pseudoJSON: cloneMap(p)} +} + +func (s *Select) PseudoJSON() map[string]any { + return cloneMap(s.pseudoJSON) +} + +{%- for fn in functions %} +func SelectFor{{ fn.name }}(selectValue *{{ fn.name }}Select) *Select { + if selectValue == nil { + return &Select{pseudoJSON: map[string]any{}} + } + return &Select{pseudoJSON: cloneMap(selectValue.pseudoJSON)} +} + +{%- endfor %} +{%- endmacro %} + +{%- macro emit_typed_client(functions) -%} +type TypedClient struct { + client *telepact.Client +} + +func NewTypedClient(client *telepact.Client) *TypedClient { + return &TypedClient{client: client} +} + +{%- for fn in functions %} +func (c *TypedClient) {{ fn.name }}(headers map[string]any, input {{ fn.name }}Input) (telepact.TypedMessage[{{ fn.name }}Output], error) { + if c == nil || c.client == nil { + return telepact.TypedMessage[{{ fn.name }}Output]{}, errors.New("telepact: typed client not configured") + } + + request := telepact.NewMessage(headers, input.PseudoJSON()) + response, err := c.client.Request(request) + if err != nil { + return telepact.TypedMessage[{{ fn.name }}Output]{}, err + } + + return telepact.NewTypedMessage(response.Headers, New{{ fn.name }}OutputFromPseudoJSON(response.Body)), nil +} + +{%- endfor %} +func (c *TypedClient) Invoke(target string, headers map[string]any, payload map[string]any) (map[string]any, map[string]any, bool, error) { + if c == nil || c.client == nil { + return nil, nil, false, errors.New("telepact: typed client not configured") + } + + switch target { + {%- for fn in functions %} + case "{{ fn.raw_name }}": + body := map[string]any{target: payload} + result, err := c.{{ fn.name }}(headers, New{{ fn.name }}InputFromPseudoJSON(body)) + if err != nil { + return nil, nil, true, err + } + return result.Headers, result.Body.PseudoJSON(), true, nil + {%- endfor %} + default: + return nil, nil, false, nil + } +} +{%- endmacro %} + +{%- macro emit_typed_server(functions) -%} +type TypedServer interface { + {%- for fn in functions %} + {{ fn.name }}(headers map[string]any, input {{ fn.name }}Input) (telepact.TypedMessage[{{ fn.name }}Output], error) + {%- endfor %} +} + +type TypedServerHandler struct { + Impl TypedServer +} + +func NewTypedServerHandler(impl TypedServer) *TypedServerHandler { + return &TypedServerHandler{Impl: impl} +} + +func (h *TypedServerHandler) Handler(message telepact.Message) (telepact.Message, error) { + if h == nil || h.Impl == nil { + return telepact.Message{}, errors.New("telepact: typed server not configured") + } + + target, err := message.BodyTarget() + if err != nil { + return telepact.Message{}, err + } + + switch target { + {%- for fn in functions %} + case "{{ fn.raw_name }}": + result, ierr := h.Impl.{{ fn.name }}(message.Headers, New{{ fn.name }}InputFromPseudoJSON(message.Body)) + if ierr != nil { + return telepact.Message{}, ierr + } + return telepact.NewMessage(result.Headers, result.Body.PseudoJSON()), nil + {%- endfor %} + default: + return telepact.Message{}, fmt.Errorf("telepact: unknown function %s", target) + } +} +{%- endmacro %} + +// Code generated by the Telepact CLI. DO NOT EDIT. +package {{ package }} + +import ( + "errors" + "fmt" + + telepact "github.com/telepact/telepact/lib/go" +) + +type Optional[T any] struct { + value T + present bool +} + +func Some[T any](value T) Optional[T] { + return Optional[T]{value: value, present: true} +} + +func None[T any]() Optional[T] { + var zero T + return Optional[T]{value: zero, present: false} +} + +func (o Optional[T]) Value() (T, bool) { + return o.value, o.present +} + +type TaggedValue[T comparable, U any] struct { + Tag T + Value U +} + +type UntypedTaggedValue struct { + Tag string + Value map[string]any +} + +func cloneMap(src map[string]any) map[string]any { + if src == nil { + return map[string]any{} + } + dst := make(map[string]any, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} + +func identity[T any](value T) any { + return any(value) +} + +func encodeOptional[T any](opt Optional[T], encode func(T) any) (any, bool) { + value, ok := opt.Value() + if !ok { + var zero any + return zero, false + } + return encode(value), true +} + +func encodeNullable[T any](ptr *T, encode func(T) any) any { + if ptr == nil { + return nil + } + return encode(*ptr) +} + +func encodeSlice[T any](values []T, encode func(T) any) []any { + if values == nil { + return nil + } + out := make([]any, len(values)) + for i, v := range values { + out[i] = encode(v) + } + return out +} + +func encodeMap[T any](values map[string]T, encode func(T) any) map[string]any { + if values == nil { + return nil + } + out := make(map[string]any, len(values)) + for k, v := range values { + out[k] = encode(v) + } + return out +} + +func decodeSlice[T any](value any, decode func(any) T) []T { + if value == nil { + return nil + } + src := asSlice(value) + out := make([]T, len(src)) + for i, v := range src { + out[i] = decode(v) + } + return out +} + +func decodeMap[T any](value any, decode func(any) T) map[string]T { + if value == nil { + return nil + } + src := asMap(value) + out := make(map[string]T, len(src)) + for k, v := range src { + out[k] = decode(v) + } + return out +} + +func nullable[T any](value any, decode func(any) T) *T { + if value == nil { + return nil + } + result := decode(value) + return &result +} + +func asMap(value any) map[string]any { + if value == nil { + return map[string]any{} + } + switch typed := value.(type) { + case map[string]any: + return cloneMap(typed) + case map[any]any: + result := make(map[string]any, len(typed)) + for k, v := range typed { + result[fmt.Sprint(k)] = v + } + return result + default: + panic(fmt.Sprintf("telepact: expected map[string]any, got %T", value)) + } +} + +func asSlice(value any) []any { + if value == nil { + return nil + } + switch typed := value.(type) { + case []any: + return typed + case []string: + out := make([]any, len(typed)) + for i, v := range typed { + out[i] = v + } + return out + case []map[string]any: + out := make([]any, len(typed)) + for i, v := range typed { + out[i] = v + } + return out + default: + panic(fmt.Sprintf("telepact: expected slice, got %T", value)) + } +} + +func asBool(value any) bool { + if value == nil { + return false + } + if typed, ok := value.(bool); ok { + return typed + } + panic(fmt.Sprintf("telepact: expected bool, got %T", value)) +} + +func asInt(value any) int { + switch typed := value.(type) { + case int: + return typed + case int64: + return int(typed) + case int32: + return int(typed) + case float64: + return int(typed) + case float32: + return int(typed) + default: + panic(fmt.Sprintf("telepact: expected integer, got %T", value)) + } +} + +func asFloat64(value any) float64 { + switch typed := value.(type) { + case float64: + return typed + case float32: + return float64(typed) + case int: + return float64(typed) + case int64: + return float64(typed) + case int32: + return float64(typed) + default: + panic(fmt.Sprintf("telepact: expected number, got %T", value)) + } +} + +func asString(value any) string { + if value == nil { + return "" + } + if typed, ok := value.(string); ok { + return typed + } + panic(fmt.Sprintf("telepact: expected string, got %T", value)) +} + +func asBytes(value any) []byte { + if value == nil { + return nil + } + if typed, ok := value.([]byte); ok { + return typed + } + panic(fmt.Sprintf("telepact: expected []byte, got %T", value)) +} + +func appendUnique(list []string, value string) []string { + for _, item := range list { + if item == value { + return list + } + } + return append(list, value) +} + +func ensureStringSlice(value any) []string { + if value == nil { + return []string{} + } + switch typed := value.(type) { + case []string: + return append([]string{}, typed...) + case []any: + result := make([]string, 0, len(typed)) + for _, v := range typed { + result = append(result, fmt.Sprint(v)) + } + return result + default: + panic(fmt.Sprintf("telepact: expected []string, got %T", value)) + } +} + +func ensureStringMap(value any) map[string][]string { + if value == nil { + return map[string][]string{} + } + switch typed := value.(type) { + case map[string][]string: + result := make(map[string][]string, len(typed)) + for k, v := range typed { + result[k] = append([]string{}, v...) + } + return result + case map[string]any: + result := make(map[string][]string, len(typed)) + for k, v := range typed { + result[k] = ensureStringSlice(v) + } + return result + default: + panic(fmt.Sprintf("telepact: expected map[string][]string, got %T", value)) + } +} + +func firstKey(m map[string]any) string { + for k := range m { + return k + } + return "" +} + +{%- for entry in entries if entry.kind == 'struct' %} +{{ emit_struct(entry) }} +{%- endfor %} + +{%- for entry in entries if entry.kind == 'union' %} +{{ emit_union(entry) }} +{%- endfor %} + +{%- for entry in entries if entry.kind == 'function' %} +{{ emit_function(entry) }} +{%- endfor %} + +{{ emit_select_overall(functions) }} + +{{ emit_typed_client(functions) }} + +{{ emit_typed_server(functions) }} diff --git a/sdk/cli/tests/data/example1.telepact.json b/sdk/cli/tests/data/example1.telepact.json index 10b2b9aa7..045f24869 100644 --- a/sdk/cli/tests/data/example1.telepact.json +++ b/sdk/cli/tests/data/example1.telepact.json @@ -86,7 +86,7 @@ ], "fn.test": { "field1": "integer", - "field2": "boolean" + "field42": "any" }, "->": [{ "Ok_": { diff --git a/sdk/cli/tests/test_main.py b/sdk/cli/tests/test_main.py index 80792ccc4..c3d64b924 100644 --- a/sdk/cli/tests/test_main.py +++ b/sdk/cli/tests/test_main.py @@ -15,6 +15,8 @@ #| from typing import Generator +from pathlib import Path +import traceback import asyncio import pytest from click.testing import CliRunner @@ -205,6 +207,23 @@ def test_command_ts(runner: CliRunner) -> None: # open the generated file and check if it contains the expected content # TODO: implement this part + +def test_command_go(runner: CliRunner) -> None: + output_dir = Path('tests/output/go') + result = runner.invoke( + main, ['codegen', '--schema-dir', 'tests/data', '--lang', 'go', '--out', str(output_dir), '--package', 'output']) + + if result.exc_info: + traceback_str = ''.join(traceback.format_exception(*result.exc_info)) + print(traceback_str) + + print(f'Output: {result.output}') + + assert result.exit_code == 0 + + # open the generated file and check if it contains the expected content + # TODO: implement this part + def test_empty_schema(runner: CliRunner) -> None: os.makedirs('tests/tmp/wrong', exist_ok=True) diff --git a/test/lib/go/.gitignore b/test/lib/go/.gitignore new file mode 100644 index 000000000..736cb15fe --- /dev/null +++ b/test/lib/go/.gitignore @@ -0,0 +1,7 @@ +bin/ +metrics.txt +telepact.tgz +telepact.tar.gz +telepact.zip +*.log +cmd/dispatcher/gen/ diff --git a/test/lib/go/Makefile b/test/lib/go/Makefile new file mode 100644 index 000000000..2e5bdcc43 --- /dev/null +++ b/test/lib/go/Makefile @@ -0,0 +1,51 @@ +#| +#| Copyright The Telepact Authors +#| +#| Licensed under the Apache License, Version 2.0 (the "License"); +#| you may not use this file except in compliance with the License. +#| You may obtain a copy of the License at +#| +#| https://www.apache.org/licenses/LICENSE-2.0 +#| +#| Unless required by applicable law or agreed to in writing, software +#| distributed under the License is distributed on an "AS IS" BASIS, +#| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +#| See the License for the specific language governing permissions and +#| limitations under the License. +#| + +SHELL := /bin/bash + +BIN := bin/telepact-go-dispatcher +GO_SOURCES := $(shell find cmd -type f -name '*.go') $(shell find internal -type f -name '*.go' 2>/dev/null) +CODEGEN_DIR := cmd/dispatcher/gen + +all: codegen build + +build: $(BIN) + +$(BIN): go.mod go.sum $(GO_SOURCES) + mkdir -p bin + GO111MODULE=on go build -o $(BIN) ./cmd/dispatcher + +codegen: + rm -rf $(CODEGEN_DIR) + mkdir -p $(CODEGEN_DIR) + telepact codegen --schema-dir ../../runner/schema/example --lang go --out $(CODEGEN_DIR) --package gen + gofmt -w $(CODEGEN_DIR) + +mod-tidy: + GO111MODULE=on go mod tidy + +run: + GO111MODULE=on go run ./cmd/dispatcher + +test-server: codegen + GO111MODULE=on go run ./cmd/dispatcher + +clean: + rm -rf bin + rm -f metrics.txt + rm -rf $(CODEGEN_DIR) + +.PHONY: all build codegen mod-tidy run test-server clean diff --git a/test/lib/go/README.md b/test/lib/go/README.md new file mode 100644 index 000000000..3605fd573 --- /dev/null +++ b/test/lib/go/README.md @@ -0,0 +1,17 @@ +## Go test harness + +This harness provides the Go runtime for the shared integration tests that live under `test/runner`. + +### Build & install dependencies + +```sh +make +``` + +### Run the dispatcher locally + +```sh +NATS_URL=nats://127.0.0.1:4222 make test-server +``` + +The harness writes Prometheus metrics to `metrics.txt` when it shuts down. diff --git a/test/lib/go/cmd/dispatcher/code_gen_handler.go b/test/lib/go/cmd/dispatcher/code_gen_handler.go new file mode 100644 index 000000000..444d057a3 --- /dev/null +++ b/test/lib/go/cmd/dispatcher/code_gen_handler.go @@ -0,0 +1,696 @@ +package main + +import ( + "fmt" + + telepact "github.com/telepact/telepact/lib/go" + gen "github.com/telepact/telepact/test/lib/go/cmd/dispatcher/gen" +) + +type codeGenHandler struct { + handler *gen.TypedServerHandler +} + +func newCodeGenHandler(enabled bool) *codeGenHandler { + if !enabled { + return nil + } + impl := &typedCodeGenServer{} + return &codeGenHandler{handler: gen.NewTypedServerHandler(impl)} +} + +func (c *codeGenHandler) Handle(message telepact.Message) (telepact.Message, error) { + if c == nil || c.handler == nil { + return message, nil + } + + response, err := c.handler.Handler(message) + if err != nil { + return telepact.Message{}, err + } + + headers := response.Headers + if headers == nil { + headers = map[string]any{} + } + headers["@codegens_"] = true + + return telepact.NewMessage(headers, response.Body), nil +} + +type typedCodeGenServer struct{} + +func (s *typedCodeGenServer) CircularLink1(headers map[string]any, input gen.CircularLink1Input) (telepact.TypedMessage[gen.CircularLink1Output], error) { + return telepact.TypedMessage[gen.CircularLink1Output]{}, telepact.NewTelepactError("generated server circularLink1 not implemented") +} + +func (s *typedCodeGenServer) CircularLink2(headers map[string]any, input gen.CircularLink2Input) (telepact.TypedMessage[gen.CircularLink2Output], error) { + return telepact.TypedMessage[gen.CircularLink2Output]{}, telepact.NewTelepactError("generated server circularLink2 not implemented") +} + +func (s *typedCodeGenServer) Example(headers map[string]any, input gen.ExampleInput) (telepact.TypedMessage[gen.ExampleOutput], error) { + return telepact.TypedMessage[gen.ExampleOutput]{}, telepact.NewTelepactError("generated server example not implemented") +} + +func (s *typedCodeGenServer) GetBigList(headers map[string]any, input gen.GetBigListInput) (telepact.TypedMessage[gen.GetBigListOutput], error) { + return telepact.TypedMessage[gen.GetBigListOutput]{}, telepact.NewTelepactError("generated server getBigList not implemented") +} + +func (s *typedCodeGenServer) SelfLink(headers map[string]any, input gen.SelfLinkInput) (telepact.TypedMessage[gen.SelfLinkOutput], error) { + return telepact.TypedMessage[gen.SelfLinkOutput]{}, telepact.NewTelepactError("generated server selfLink not implemented") +} + +func (s *typedCodeGenServer) Test(headers map[string]any, input gen.TestInput) (telepact.TypedMessage[gen.TestOutput], error) { + if boolValue(headers["@error"]) { + body := gen.NewTestOutputFromErrorExample2(gen.NewTestOutputErrorExample2("Boom!")) + return telepact.NewTypedMessage(map[string]any{}, body), nil + } + + valueOpt := gen.None[gen.Value]() + if top, ok := input.Value(); ok { + mapped, hasValue, err := s.mapValue(top) + if err != nil { + return telepact.TypedMessage[gen.TestOutput]{}, err + } + if hasValue { + valueOpt = gen.Some(mapped) + } + } + + okBody := gen.NewTestOutputOk(valueOpt) + body := gen.NewTestOutputFromOk(okBody) + + return telepact.NewTypedMessage(map[string]any{}, body), nil +} + +func (s *typedCodeGenServer) mapValue(top gen.Value) (gen.Value, bool, error) { + if v, ok := top.Bool(); ok { + return newValueWith("bool!", v), true, nil + } + if v, ok := top.NullBool(); ok { + return newValueWith("nullBool!", pointerValue(v, func(b bool) any { return b })), true, nil + } + if v, ok := top.ArrBool(); ok { + return newValueWith("arrBool!", cloneSlice(v)), true, nil + } + if v, ok := top.ArrNullBool(); ok { + return newValueWith("arrNullBool!", slicePointerValue(v, func(b bool) any { return b })), true, nil + } + if v, ok := top.ObjBool(); ok { + return newValueWith("objBool!", mapValueWith(v, func(b bool) any { return b })), true, nil + } + if v, ok := top.ObjNullBool(); ok { + return newValueWith("objNullBool!", mapPointerValue(v, func(b bool) any { return b })), true, nil + } + if v, ok := top.Int(); ok { + return newValueWith("int!", v), true, nil + } + if v, ok := top.NullInt(); ok { + return newValueWith("nullInt!", pointerValue(v, func(i int) any { return i })), true, nil + } + if v, ok := top.ArrInt(); ok { + return newValueWith("arrInt!", cloneSlice(v)), true, nil + } + if v, ok := top.ArrNullInt(); ok { + return newValueWith("arrNullInt!", slicePointerValue(v, func(i int) any { return i })), true, nil + } + if v, ok := top.ObjInt(); ok { + return newValueWith("objInt!", mapValueWith(v, func(i int) any { return i })), true, nil + } + if v, ok := top.ObjNullInt(); ok { + return newValueWith("objNullInt!", mapPointerValue(v, func(i int) any { return i })), true, nil + } + if v, ok := top.Num(); ok { + return newValueWith("num!", v), true, nil + } + if v, ok := top.NullNum(); ok { + return newValueWith("nullNum!", pointerValue(v, func(f float64) any { return f })), true, nil + } + if v, ok := top.ArrNum(); ok { + return newValueWith("arrNum!", cloneSlice(v)), true, nil + } + if v, ok := top.ArrNullNum(); ok { + return newValueWith("arrNullNum!", slicePointerValue(v, func(f float64) any { return f })), true, nil + } + if v, ok := top.ObjNum(); ok { + return newValueWith("objNum!", mapValueWith(v, func(f float64) any { return f })), true, nil + } + if v, ok := top.ObjNullNum(); ok { + return newValueWith("objNullNum!", mapPointerValue(v, func(f float64) any { return f })), true, nil + } + if v, ok := top.Str(); ok { + return newValueWith("str!", v), true, nil + } + if v, ok := top.NullStr(); ok { + return newValueWith("nullStr!", pointerValue(v, func(s string) any { return s })), true, nil + } + if v, ok := top.ArrStr(); ok { + return newValueWith("arrStr!", cloneSlice(v)), true, nil + } + if v, ok := top.ArrNullStr(); ok { + return newValueWith("arrNullStr!", slicePointerValue(v, func(s string) any { return s })), true, nil + } + if v, ok := top.ObjStr(); ok { + return newValueWith("objStr!", mapValueWith(v, func(s string) any { return s })), true, nil + } + if v, ok := top.ObjNullStr(); ok { + return newValueWith("objNullStr!", mapPointerValue(v, func(s string) any { return s })), true, nil + } + if v, ok := top.Arr(); ok { + return newValueWith("arr!", cloneAnySlice(v)), true, nil + } + if v, ok := top.ArrArr(); ok { + converted := make([]any, len(v)) + for i, inner := range v { + converted[i] = cloneAnySlice(inner) + } + return newValueWith("arrArr!", converted), true, nil + } + if v, ok := top.ObjArr(); ok { + converted := make(map[string]any, len(v)) + for k, inner := range v { + converted[k] = cloneAnySlice(inner) + } + return newValueWith("objArr!", converted), true, nil + } + if v, ok := top.Obj(); ok { + return newValueWith("obj!", cloneStringAnyMap(v)), true, nil + } + if v, ok := top.ArrObj(); ok { + converted := make([]any, len(v)) + for i, inner := range v { + converted[i] = cloneStringAnyMap(inner) + } + return newValueWith("arrObj!", converted), true, nil + } + if v, ok := top.ObjObj(); ok { + converted := make(map[string]any, len(v)) + for k, inner := range v { + converted[k] = cloneStringAnyMap(inner) + } + return newValueWith("objObj!", converted), true, nil + } + if v, ok := top.Any(); ok { + return newValueWith("any!", cloneAny(v)), true, nil + } + if v, ok := top.NullAny(); ok { + return newValueWith("nullAny!", cloneAny(v)), true, nil + } + if v, ok := top.ArrAny(); ok { + return newValueWith("arrAny!", cloneAnySlice(v)), true, nil + } + if v, ok := top.ArrNullAny(); ok { + return newValueWith("arrNullAny!", cloneAnySlice(v)), true, nil + } + if v, ok := top.ObjAny(); ok { + return newValueWith("objAny!", cloneStringAnyMap(v)), true, nil + } + if v, ok := top.ObjNullAny(); ok { + return newValueWith("objNullAny!", cloneStringAnyMap(v)), true, nil + } + if v, ok := top.Bytes(); ok { + return newValueWith("bytes!", cloneBytes(v)), true, nil + } + if v, ok := top.NullBytes(); ok { + if v == nil { + return newValueWith("nullBytes!", nil), true, nil + } + return newValueWith("nullBytes!", cloneBytes(v)), true, nil + } + if v, ok := top.ArrBytes(); ok { + return newValueWith("arrBytes!", cloneBytesSlice(v)), true, nil + } + if v, ok := top.ArrNullBytes(); ok { + return newValueWith("arrNullBytes!", cloneBytesSlice(v)), true, nil + } + if v, ok := top.ObjBytes(); ok { + return newValueWith("objBytes!", cloneBytesMap(v)), true, nil + } + if v, ok := top.ObjNullBytes(); ok { + return newValueWith("objNullBytes!", cloneBytesMap(v)), true, nil + } + if v, ok := top.Struct(); ok { + mapped := s.mapStruct(v) + return newValueWith("struct!", mapped.PseudoJSON()), true, nil + } + if v, ok := top.NullStruct(); ok { + result := pointerValue(v, func(sv gen.ExStruct) any { + mapped := s.mapStruct(sv) + return mapped.PseudoJSON() + }) + return newValueWith("nullStruct!", result), true, nil + } + if v, ok := top.ArrStruct(); ok { + converted := make([]any, len(v)) + for i, sv := range v { + mapped := s.mapStruct(sv) + converted[i] = mapped.PseudoJSON() + } + return newValueWith("arrStruct!", converted), true, nil + } + if v, ok := top.ArrNullStruct(); ok { + converted := slicePointerValue(v, func(sv gen.ExStruct) any { + mapped := s.mapStruct(sv) + return mapped.PseudoJSON() + }) + return newValueWith("arrNullStruct!", converted), true, nil + } + if v, ok := top.ObjStruct(); ok { + converted := make(map[string]any, len(v)) + for k, sv := range v { + mapped := s.mapStruct(sv) + converted[k] = mapped.PseudoJSON() + } + return newValueWith("objStruct!", converted), true, nil + } + if v, ok := top.ObjNullStruct(); ok { + converted := mapPointerValue(v, func(sv gen.ExStruct) any { + mapped := s.mapStruct(sv) + return mapped.PseudoJSON() + }) + return newValueWith("objNullStruct!", converted), true, nil + } + if v, ok := top.Union(); ok { + mapped, err := s.mapUnion(v) + if err != nil { + return gen.Value{}, false, err + } + return newValueWith("union!", mapped.PseudoJSON()), true, nil + } + if v, ok := top.NullUnion(); ok { + var result any + if v != nil { + mapped, err := s.mapUnion(*v) + if err != nil { + return gen.Value{}, false, err + } + result = mapped.PseudoJSON() + } + return newValueWith("nullUnion!", result), true, nil + } + if v, ok := top.ArrUnion(); ok { + converted, err := s.mapUnionSlice(v) + if err != nil { + return gen.Value{}, false, err + } + return newValueWith("arrUnion!", converted), true, nil + } + if v, ok := top.ArrNullUnion(); ok { + converted, err := s.mapUnionPointerSlice(v) + if err != nil { + return gen.Value{}, false, err + } + return newValueWith("arrNullUnion!", converted), true, nil + } + if v, ok := top.ObjUnion(); ok { + converted, err := s.mapUnionMap(v) + if err != nil { + return gen.Value{}, false, err + } + return newValueWith("objUnion!", converted), true, nil + } + if v, ok := top.ObjNullUnion(); ok { + converted, err := s.mapUnionPointerMap(v) + if err != nil { + return gen.Value{}, false, err + } + return newValueWith("objNullUnion!", converted), true, nil + } + if v, ok := top.Fn(); ok { + mapped := s.mapExampleInput(v) + return newValueWith("fn!", mapped.PseudoJSON()), true, nil + } + if v, ok := top.NullFn(); ok { + result := pointerValue(v, func(fn gen.ExampleInput) any { + mapped := s.mapExampleInput(fn) + return mapped.PseudoJSON() + }) + return newValueWith("nullFn!", result), true, nil + } + if v, ok := top.ArrFn(); ok { + converted := make([]any, len(v)) + for i, fn := range v { + mapped := s.mapExampleInput(fn) + converted[i] = mapped.PseudoJSON() + } + return newValueWith("arrFn!", converted), true, nil + } + if v, ok := top.ArrNullFn(); ok { + converted := slicePointerValue(v, func(fn gen.ExampleInput) any { + mapped := s.mapExampleInput(fn) + return mapped.PseudoJSON() + }) + return newValueWith("arrNullFn!", converted), true, nil + } + if v, ok := top.ObjFn(); ok { + converted := make(map[string]any, len(v)) + for k, fn := range v { + mapped := s.mapExampleInput(fn) + converted[k] = mapped.PseudoJSON() + } + return newValueWith("objFn!", converted), true, nil + } + if v, ok := top.ObjNullFn(); ok { + converted := mapPointerValue(v, func(fn gen.ExampleInput) any { + mapped := s.mapExampleInput(fn) + return mapped.PseudoJSON() + }) + return newValueWith("objNullFn!", converted), true, nil + } + if v, ok := top.Sel(); ok { + return newValueWith("sel!", v.PseudoJSON()), true, nil + } + if v, ok := top.NullSel(); ok { + result := pointerValue(v, func(sel gen.Select) any { return sel.PseudoJSON() }) + return newValueWith("nullSel!", result), true, nil + } + if v, ok := top.ArrSel(); ok { + converted := make([]any, len(v)) + for i, sel := range v { + converted[i] = sel.PseudoJSON() + } + return newValueWith("arrSel!", converted), true, nil + } + if v, ok := top.ArrNullSel(); ok { + converted := slicePointerValue(v, func(sel gen.Select) any { return sel.PseudoJSON() }) + return newValueWith("arrNullSel!", converted), true, nil + } + if v, ok := top.ObjSel(); ok { + converted := make(map[string]any, len(v)) + for k, sel := range v { + converted[k] = sel.PseudoJSON() + } + return newValueWith("objSel!", converted), true, nil + } + if v, ok := top.ObjNullSel(); ok { + converted := mapPointerValue(v, func(sel gen.Select) any { return sel.PseudoJSON() }) + return newValueWith("objNullSel!", converted), true, nil + } + + return gen.Value{}, false, nil +} + +func (s *typedCodeGenServer) mapStruct(value gen.ExStruct) gen.ExStruct { + opt, hasOpt := value.Optional() + opt2, hasOpt2 := value.Optional2() + return gen.NewExStruct(value.Required(), toOptional(opt, hasOpt), toOptional(opt2, hasOpt2)) +} + +func (s *typedCodeGenServer) mapExampleInput(value gen.ExampleInput) gen.ExampleInput { + opt, hasOpt := value.Optional() + return gen.NewExampleInput(value.Required(), toOptional(opt, hasOpt)) +} + +func (s *typedCodeGenServer) mapUnion(value gen.ExUnion) (gen.ExUnion, error) { + tagged := value.TaggedValue() + switch tagged.Tag { + case "One": + return gen.NewExUnionFromOne(gen.NewExUnionOne()), nil + case "Two": + two, ok := tagged.Value.(gen.ExUnionTwo) + if !ok { + return gen.ExUnion{}, fmt.Errorf("telepact: unexpected union payload type %T", tagged.Value) + } + opt, hasOpt := two.Optional() + return gen.NewExUnionFromTwo(gen.NewExUnionTwo(two.Required(), toOptional(opt, hasOpt))), nil + default: + return gen.ExUnion{}, fmt.Errorf("telepact: unknown union tag %q", tagged.Tag) + } +} + +func (s *typedCodeGenServer) mapUnionSlice(values []gen.ExUnion) ([]any, error) { + if values == nil { + return nil, nil + } + result := make([]any, len(values)) + for i, value := range values { + mapped, err := s.mapUnion(value) + if err != nil { + return nil, err + } + result[i] = mapped.PseudoJSON() + } + return result, nil +} + +func (s *typedCodeGenServer) mapUnionPointerSlice(values []*gen.ExUnion) ([]any, error) { + if values == nil { + return nil, nil + } + result := make([]any, len(values)) + for i, value := range values { + if value == nil { + result[i] = nil + continue + } + mapped, err := s.mapUnion(*value) + if err != nil { + return nil, err + } + result[i] = mapped.PseudoJSON() + } + return result, nil +} + +func (s *typedCodeGenServer) mapUnionMap(values map[string]gen.ExUnion) (map[string]any, error) { + if values == nil { + return nil, nil + } + result := make(map[string]any, len(values)) + for key, value := range values { + mapped, err := s.mapUnion(value) + if err != nil { + return nil, err + } + result[key] = mapped.PseudoJSON() + } + return result, nil +} + +func (s *typedCodeGenServer) mapUnionPointerMap(values map[string]*gen.ExUnion) (map[string]any, error) { + if values == nil { + return nil, nil + } + result := make(map[string]any, len(values)) + for key, value := range values { + if value == nil { + result[key] = nil + continue + } + mapped, err := s.mapUnion(*value) + if err != nil { + return nil, err + } + result[key] = mapped.PseudoJSON() + } + return result, nil +} + +func newValueWith(key string, value any) gen.Value { + payload := map[string]any{key: value} + return gen.NewValueFromPseudoJSON(payload) +} + +func toOptional[T any](value T, present bool) gen.Optional[T] { + if present { + return gen.Some(value) + } + return gen.None[T]() +} + +func pointerValue[T any](ptr *T, mapper func(T) any) any { + if ptr == nil { + return nil + } + return mapper(*ptr) +} + +func slicePointerValue[T any](values []*T, mapper func(T) any) []any { + if values == nil { + return nil + } + result := make([]any, len(values)) + for i, value := range values { + if value == nil { + result[i] = nil + continue + } + result[i] = mapper(*value) + } + return result +} + +func mapValueWith[T any](values map[string]T, mapper func(T) any) map[string]any { + if values == nil { + return nil + } + result := make(map[string]any, len(values)) + for key, value := range values { + result[key] = mapper(value) + } + return result +} + +func mapPointerValue[T any](values map[string]*T, mapper func(T) any) map[string]any { + if values == nil { + return nil + } + result := make(map[string]any, len(values)) + for key, value := range values { + if value == nil { + result[key] = nil + continue + } + result[key] = mapper(*value) + } + return result +} + +func cloneSlice[T any](values []T) []T { + if values == nil { + return nil + } + result := make([]T, len(values)) + copy(result, values) + return result +} + +func cloneAnySlice(values []any) []any { + if values == nil { + return nil + } + result := make([]any, len(values)) + for i, value := range values { + result[i] = cloneAny(value) + } + return result +} + +func cloneStringAnyMap(values map[string]any) map[string]any { + if values == nil { + return nil + } + result := make(map[string]any, len(values)) + for key, value := range values { + result[key] = cloneAny(value) + } + return result +} + +func cloneAny(value any) any { + switch typed := value.(type) { + case []any: + return cloneAnySlice(typed) + case map[string]any: + return cloneStringAnyMap(typed) + case map[any]any: + converted := make(map[string]any, len(typed)) + for key, entry := range typed { + converted[fmt.Sprint(key)] = cloneAny(entry) + } + return converted + case []byte: + return cloneBytes(typed) + default: + return typed + } +} + +func cloneBytes(value []byte) []byte { + if value == nil { + return nil + } + result := make([]byte, len(value)) + copy(result, value) + return result +} + +func cloneBytesSlice(values [][]byte) []any { + if values == nil { + return nil + } + result := make([]any, len(values)) + for i, value := range values { + if value == nil { + result[i] = nil + continue + } + result[i] = cloneBytes(value) + } + return result +} + +func cloneBytesMap(values map[string][]byte) map[string]any { + if values == nil { + return nil + } + result := make(map[string]any, len(values)) + for key, value := range values { + if value == nil { + result[key] = nil + continue + } + result[key] = cloneBytes(value) + } + return result +} + +type generatedTypedClient struct { + client *telepact.Client + typed *gen.TypedClient +} + +func newGeneratedTypedClient(client *telepact.Client) *generatedTypedClient { + if client == nil { + return &generatedTypedClient{} + } + return &generatedTypedClient{client: client, typed: gen.NewTypedClient(client)} +} + +func (g *generatedTypedClient) Handle(message telepact.Message) (telepact.Message, error) { + if g == nil || g.client == nil { + return telepact.Message{}, telepact.NewTelepactError("generated client not configured") + } + + target, err := message.BodyTarget() + if err != nil { + return telepact.Message{}, err + } + + payload, err := message.BodyPayload() + if err != nil { + return telepact.Message{}, err + } + + if g.typed == nil { + g.typed = gen.NewTypedClient(g.client) + } + + headers, body, handled, err := g.typed.Invoke(target, message.Headers, payload) + if err != nil { + return telepact.Message{}, err + } + + if handled { + if headers == nil { + headers = map[string]any{} + } + if body == nil { + body = map[string]any{} + } + headers["@codegenc_"] = true + return telepact.NewMessage(headers, body), nil + } + + response, err := g.client.Request(message) + if err != nil { + return telepact.Message{}, err + } + + if response.Headers == nil { + response.Headers = map[string]any{} + } + response.Headers["@codegenc_"] = true + + return response, nil +} diff --git a/test/lib/go/cmd/dispatcher/main.go b/test/lib/go/cmd/dispatcher/main.go new file mode 100644 index 000000000..add2f9942 --- /dev/null +++ b/test/lib/go/cmd/dispatcher/main.go @@ -0,0 +1,1304 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "log" + "os" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + nats "github.com/nats-io/nats.go" + "github.com/prometheus/client_golang/prometheus" + telepact "github.com/telepact/telepact/lib/go" + "github.com/vmihailenco/msgpack/v5" +) + +const backwardsCompatibleChangeSchema = ` +[ + { + "struct.BackwardsCompatibleChange": {} + } +] +` + +func main() { + logger := log.New(os.Stdout, "[telepact-go] ", log.LstdFlags|log.Lmicroseconds) + + natsURL := os.Getenv("NATS_URL") + if natsURL == "" { + logger.Fatal("NATS_URL env var not set") + } + + subject := os.Getenv("TP_HARNESS_SUBJECT") + if subject == "" { + subject = "go" + } + + metricsFile := "metrics.txt" + + nc, err := nats.Connect(natsURL, nats.Name("telepact-go-test-harness")) + if err != nil { + logger.Fatalf("failed to connect to NATS: %v", err) + } + defer func() { + _ = nc.Drain() + }() + + dispatcher := NewDispatcher(nc, logger, metricsFile) + if err := dispatcher.Start(subject); err != nil { + logger.Fatalf("failed to start dispatcher: %v", err) + } + + <-dispatcher.Done() + + if err := dispatcher.Close(); err != nil { + logger.Printf("error while shutting down dispatcher: %v", err) + } + + if err := dispatcher.WriteMetrics(); err != nil { + logger.Printf("failed to write metrics: %v", err) + } +} + +type Dispatcher struct { + conn *nats.Conn + logger *log.Logger + metrics *metricRegistry + dispatcherSub *nats.Subscription + servers map[string]*nats.Subscription + serversMu sync.Mutex + done chan struct{} + doneOnce sync.Once +} + +func NewDispatcher(conn *nats.Conn, logger *log.Logger, metricsFile string) *Dispatcher { + return &Dispatcher{ + conn: conn, + logger: logger, + metrics: newMetricRegistry(metricsFile), + servers: make(map[string]*nats.Subscription), + done: make(chan struct{}), + } +} + +func (d *Dispatcher) Start(subject string) error { + sub, err := d.conn.Subscribe(subject, d.handleMessage) + if err != nil { + return err + } + d.dispatcherSub = sub + d.logger.Printf("dispatcher listening on subject %s", subject) + return nil +} + +func (d *Dispatcher) Done() <-chan struct{} { + return d.done +} + +func (d *Dispatcher) Close() error { + d.stopAllServers() + if d.dispatcherSub != nil { + if err := d.dispatcherSub.Drain(); err != nil { + d.logger.Printf("failed to drain dispatcher subscription: %v", err) + } + } + return nil +} + +func (d *Dispatcher) WriteMetrics() error { + if d.metrics == nil { + return nil + } + return d.metrics.WriteToFile() +} + +func (d *Dispatcher) handleMessage(msg *nats.Msg) { + response := buildErrorResponse() + + if err := d.processCommand(msg.Data); err != nil { + d.logger.Printf("dispatcher command failed: %v", err) + } else { + response = buildOKResponse() + } + + if err := respond(msg, response); err != nil { + d.logger.Printf("failed to send dispatcher response: %v", err) + } +} + +func (d *Dispatcher) processCommand(data []byte) error { + envelope, err := parseEnvelope(data) + if err != nil { + return err + } + + target, payload, err := firstBodyEntry(envelope.Body) + if err != nil { + return err + } + + switch target { + case "Ping": + return nil + case "End": + d.finish() + return nil + case "Stop": + cfg, err := asMap(payload) + if err != nil { + return err + } + id := stringValue(cfg["id"]) + if id == "" { + return errors.New("missing id in Stop payload") + } + return d.stopServer(id) + case "StartServer": + cfg, err := asMap(payload) + if err != nil { + return err + } + sub, err := startTestServer(d, cfg) + if err != nil { + return err + } + return d.trackServer(cfg, sub) + case "StartClientServer": + cfg, err := asMap(payload) + if err != nil { + return err + } + sub, err := startClientTestServer(d, cfg) + if err != nil { + return err + } + return d.trackServer(cfg, sub) + case "StartMockServer": + cfg, err := asMap(payload) + if err != nil { + return err + } + sub, err := startMockTestServer(d, cfg) + if err != nil { + return err + } + return d.trackServer(cfg, sub) + case "StartSchemaServer": + cfg, err := asMap(payload) + if err != nil { + return err + } + sub, err := startSchemaTestServer(d, cfg) + if err != nil { + return err + } + return d.trackServer(cfg, sub) + default: + return fmt.Errorf("unknown dispatcher target %s", target) + } +} + +func (d *Dispatcher) trackServer(cfg map[string]any, sub *nats.Subscription) error { + id := stringValue(cfg["id"]) + if id == "" { + return errors.New("missing id in payload") + } + + d.serversMu.Lock() + defer d.serversMu.Unlock() + + if old, exists := d.servers[id]; exists { + _ = old.Drain() + } + d.servers[id] = sub + return nil +} + +func (d *Dispatcher) stopServer(id string) error { + d.serversMu.Lock() + sub, exists := d.servers[id] + if exists { + delete(d.servers, id) + } + d.serversMu.Unlock() + + if !exists { + return fmt.Errorf("server %s not found", id) + } + + return sub.Drain() +} + +func (d *Dispatcher) stopAllServers() { + d.serversMu.Lock() + subs := make([]*nats.Subscription, 0, len(d.servers)) + for _, sub := range d.servers { + subs = append(subs, sub) + } + d.servers = make(map[string]*nats.Subscription) + d.serversMu.Unlock() + + for _, sub := range subs { + _ = sub.Drain() + } +} + +func (d *Dispatcher) finish() { + d.doneOnce.Do(func() { + close(d.done) + }) +} + +func buildOKResponse() envelope { + return envelope{ + Headers: map[string]any{}, + Body: map[string]any{"Ok_": map[string]any{}}, + } +} + +func buildErrorResponse() envelope { + return envelope{ + Headers: map[string]any{}, + Body: map[string]any{"ErrorUnknown": map[string]any{}}, + } +} + +type envelope struct { + Headers map[string]any + Body map[string]any +} + +func parseEnvelope(data []byte) (envelope, error) { + var raw []any + if err := json.Unmarshal(data, &raw); err != nil { + return envelope{}, err + } + if len(raw) != 2 { + return envelope{}, errors.New("invalid envelope length") + } + + headers, err := asMap(raw[0]) + if err != nil { + return envelope{}, err + } + + body, err := asMap(raw[1]) + if err != nil { + return envelope{}, err + } + + return envelope{Headers: headers, Body: body}, nil +} + +func respond(msg *nats.Msg, env envelope) error { + payload := []any{env.Headers, env.Body} + data, err := json.Marshal(payload) + if err != nil { + return err + } + return msg.Respond(data) +} + +func firstBodyEntry(body map[string]any) (string, any, error) { + for key, value := range body { + return key, value, nil + } + return "", nil, errors.New("empty body") +} + +func asMap(value any) (map[string]any, error) { + switch typed := value.(type) { + case map[string]any: + return typed, nil + case map[any]any: + converted := make(map[string]any, len(typed)) + for k, v := range typed { + converted[fmt.Sprint(k)] = v + } + return converted, nil + default: + return nil, errors.New("value is not a map") + } +} + +func stringValue(value any) string { + switch typed := value.(type) { + case string: + return typed + case fmt.Stringer: + return typed.String() + case json.Number: + return typed.String() + default: + return "" + } +} + +type metricRegistry struct { + file string + mu sync.Mutex + reg *prometheus.Registry + timers map[string]prometheus.Summary +} + +func newMetricRegistry(file string) *metricRegistry { + return &metricRegistry{ + file: file, + reg: prometheus.NewRegistry(), + timers: make(map[string]prometheus.Summary), + } +} + +func (m *metricRegistry) Observe(topic string, duration time.Duration) { + if m == nil { + return + } + + summary := m.summaryForTopic(topic) + if summary == nil { + return + } + + summary.Observe(duration.Seconds()) +} + +func (m *metricRegistry) summaryForTopic(topic string) prometheus.Summary { + m.mu.Lock() + defer m.mu.Unlock() + + if summary, ok := m.timers[topic]; ok { + return summary + } + + name := sanitizeMetricName(topic) + metric := prometheus.NewSummary(prometheus.SummaryOpts{ + Name: name, + Help: fmt.Sprintf("Latency summary for topic %s", topic), + }) + + if err := m.reg.Register(metric); err != nil { + return nil + } + + m.timers[topic] = metric + return metric +} + +func (m *metricRegistry) WriteToFile() error { + if m == nil || m.file == "" { + return nil + } + + m.mu.Lock() + defer m.mu.Unlock() + + return prometheus.WriteToTextfile(m.file, m.reg) +} + +func sanitizeMetricName(topic string) string { + replacer := strings.NewReplacer( + ".", "_", + "-", "_", + ":", "_", + "/", "_", + " ", "_", + ) + + sanitized := replacer.Replace(topic) + sanitized = strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' { + return r + } + return '_' + }, sanitized) + + if sanitized == "" { + sanitized = "telepact_metric" + } + if sanitized[0] >= '0' && sanitized[0] <= '9' { + sanitized = "telepact_" + sanitized + } + + return sanitized +} + +type serverConfig struct { + ID string + APISchemaPath string + FrontdoorTopic string + BackdoorTopic string + ClientFrontdoor string + ClientBackdoor string + AuthRequired bool + UseCodegen bool + UseBinary bool + UseTestClient bool + Config map[string]any +} + +func parseServerConfig(cfg map[string]any) (serverConfig, error) { + if cfg == nil { + return serverConfig{}, errors.New("nil server configuration") + } + + result := serverConfig{Config: make(map[string]any)} + result.ID = stringValue(cfg["id"]) + if result.ID == "" { + return serverConfig{}, errors.New("missing id") + } + + if raw, ok := cfg["apiSchemaPath"]; ok { + result.APISchemaPath = stringValue(raw) + } + if raw, ok := cfg["frontdoorTopic"]; ok { + result.FrontdoorTopic = stringValue(raw) + } + if raw, ok := cfg["backdoorTopic"]; ok { + result.BackdoorTopic = stringValue(raw) + } + if raw, ok := cfg["clientFrontdoorTopic"]; ok { + result.ClientFrontdoor = stringValue(raw) + } + if raw, ok := cfg["clientBackdoorTopic"]; ok { + result.ClientBackdoor = stringValue(raw) + } + + if raw, ok := cfg["authRequired!"]; ok { + result.AuthRequired = boolValue(raw) + } else if raw, ok := cfg["authRequired"]; ok { + result.AuthRequired = boolValue(raw) + } + + if raw, ok := cfg["useCodeGen"]; ok { + result.UseCodegen = boolValue(raw) + } + if raw, ok := cfg["useBinary"]; ok { + result.UseBinary = boolValue(raw) + } + if raw, ok := cfg["useTestClient"]; ok { + result.UseTestClient = boolValue(raw) + } + + if raw, ok := cfg["config!"]; ok { + if converted, err := asMap(raw); err == nil { + result.Config = converted + } + } + + return result, nil +} + +func startClientTestServer(d *Dispatcher, rawCfg map[string]any) (*nats.Subscription, error) { + cfg, err := parseServerConfig(rawCfg) + if err != nil { + return nil, err + } + if cfg.ClientFrontdoor == "" { + return nil, errors.New("missing client frontdoor topic") + } + if cfg.ClientBackdoor == "" { + return nil, errors.New("missing client backdoor topic") + } + + adapter := func(ctx context.Context, request telepact.Message, serializer *telepact.Serializer) (telepact.Message, error) { + bytes, err := serializer.Serialize(request) + if err != nil { + var serializationErr *telepact.SerializationError + if errors.As(err, &serializationErr) && isNumberTooBigError(serializationErr.Unwrap()) { + headers := map[string]any{"numberTooBig": true} + return telepact.NewMessage(headers, map[string]any{"ErrorUnknown_": map[string]any{}}), nil + } + return telepact.Message{}, err + } + + if ctx == nil { + ctx = context.Background() + } + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + reply, err := d.conn.RequestWithContext(ctx, cfg.ClientBackdoor, bytes) + if err != nil { + return telepact.Message{}, err + } + + return serializer.Deserialize(reply.Data) + } + + clientOptions := telepact.NewClientOptions() + clientOptions.UseBinary = cfg.UseBinary + clientOptions.AlwaysSendJSON = !cfg.UseBinary + client, err := telepact.NewClient(adapter, clientOptions) + if err != nil { + return nil, err + } + + var testClient *telepact.TestClient + if cfg.UseTestClient { + testClient = telepact.NewTestClient(client, telepact.TestClientOptions{}) + } + + var generatedClient *generatedTypedClient + if cfg.UseCodegen { + generatedClient = newGeneratedTypedClient(client) + } + + sub, err := d.conn.Subscribe(cfg.ClientFrontdoor, func(msg *nats.Msg) { + d.handleClientRequest(msg, client, generatedClient, testClient, cfg) + }) + if err != nil { + return nil, err + } + + d.logger.Printf("client-server %s listening on %s", cfg.ID, cfg.ClientFrontdoor) + return sub, nil +} + +func startMockTestServer(d *Dispatcher, rawCfg map[string]any) (*nats.Subscription, error) { + cfg, err := parseServerConfig(rawCfg) + if err != nil { + return nil, err + } + if cfg.APISchemaPath == "" { + return nil, errors.New("missing apiSchemaPath") + } + if cfg.FrontdoorTopic == "" { + return nil, errors.New("missing frontdoor topic") + } + + schema, err := telepact.MockTelepactSchemaFromDirectory(cfg.APISchemaPath) + if err != nil { + return nil, err + } + + options := telepact.NewMockServerOptions() + options.OnError = func(err error) { + if err != nil { + d.logger.Printf("mock server error: %v", err) + } + } + options.EnableMessageResponseGeneration = false + if min, ok := intFromAny(cfg.Config["minLength"]); ok { + options.GeneratedCollectionLengthMin = min + } + if max, ok := intFromAny(cfg.Config["maxLength"]); ok { + options.GeneratedCollectionLengthMax = max + } + if boolValue(cfg.Config["enableGen"]) { + options.EnableMessageResponseGeneration = true + } + + mockServer, err := telepact.NewMockServer(schema, options) + if err != nil { + return nil, err + } + + sub, err := d.conn.Subscribe(cfg.FrontdoorTopic, func(msg *nats.Msg) { + start := time.Now() + resp, err := mockServer.Process(msg.Data) + if err != nil { + d.logger.Printf("mock server process error: %v", err) + _ = msg.Respond(buildUnknownPayload()) + return + } + + if d.metrics != nil { + d.metrics.Observe(cfg.FrontdoorTopic, time.Since(start)) + } + + if err := respondWithBytes(msg, resp.Bytes); err != nil { + d.logger.Printf("mock server respond error: %v", err) + } + }) + if err != nil { + return nil, err + } + + d.logger.Printf("mock server %s listening on %s", cfg.ID, cfg.FrontdoorTopic) + return sub, nil +} + +func startSchemaTestServer(d *Dispatcher, rawCfg map[string]any) (*nats.Subscription, error) { + cfg, err := parseServerConfig(rawCfg) + if err != nil { + return nil, err + } + if cfg.APISchemaPath == "" { + return nil, errors.New("missing apiSchemaPath") + } + if cfg.FrontdoorTopic == "" { + return nil, errors.New("missing frontdoor topic") + } + + schema, err := telepact.TelepactSchemaFromDirectory(cfg.APISchemaPath) + if err != nil { + return nil, err + } + + handler := func(message telepact.Message) (telepact.Message, error) { + body := message.Body + payload, ok := body["fn.validateSchema"].(map[string]any) + if !ok { + return telepact.NewMessage(map[string]any{}, map[string]any{"ErrorUnknown_": map[string]any{}}), nil + } + + input, ok := payload["input"].(map[string]any) + if !ok { + return telepact.NewMessage(map[string]any{}, map[string]any{"ErrorUnknown_": map[string]any{}}), nil + } + + key := firstKey(input) + switch key { + case "PseudoJson": + pseudo, _ := input[key].(map[string]any) + failures, err := validatePseudoJSONSchema(pseudo) + if err != nil { + return telepact.Message{}, err + } + if len(failures) > 0 { + body := map[string]any{"ErrorValidationFailure": map[string]any{"cases": failures}} + return telepact.NewMessage(map[string]any{}, body), nil + } + case "Json": + if union, _ := input[key].(map[string]any); union != nil { + if schemaJSON, ok := union["schema"].(string); ok { + if _, err := telepact.TelepactSchemaFromJSON(schemaJSON); err != nil { + return schemaValidationFailureMessage(err), nil + } + } + } + case "Directory": + if union, _ := input[key].(map[string]any); union != nil { + if dir, ok := union["schemaDirectory"].(string); ok { + if _, err := telepact.TelepactSchemaFromDirectory(dir); err != nil { + return schemaValidationFailureMessage(err), nil + } + } + } + default: + return telepact.NewMessage(map[string]any{}, map[string]any{"ErrorUnknown_": map[string]any{}}), nil + } + + return telepact.NewMessage(map[string]any{}, map[string]any{"Ok_": map[string]any{}}), nil + } + + options := telepact.NewServerOptions() + options.OnError = func(err error) { + if err != nil { + d.logger.Printf("schema server error: %v", err) + } + } + options.AuthRequired = false + + server, err := telepact.NewServer(schema, handler, options) + if err != nil { + return nil, err + } + + sub, err := d.conn.Subscribe(cfg.FrontdoorTopic, func(msg *nats.Msg) { + d.handleServerRequest(server, cfg.FrontdoorTopic, msg) + }) + if err != nil { + return nil, err + } + + d.logger.Printf("schema server %s listening on %s", cfg.ID, cfg.FrontdoorTopic) + return sub, nil +} + +func startTestServer(d *Dispatcher, rawCfg map[string]any) (*nats.Subscription, error) { + cfg, err := parseServerConfig(rawCfg) + if err != nil { + return nil, err + } + if cfg.APISchemaPath == "" { + return nil, errors.New("missing apiSchemaPath") + } + if cfg.FrontdoorTopic == "" { + return nil, errors.New("missing frontdoor topic") + } + if !cfg.UseCodegen && cfg.BackdoorTopic == "" { + return nil, errors.New("missing backdoor topic") + } + + files, err := telepact.NewTelepactSchemaFiles(cfg.APISchemaPath) + if err != nil { + return nil, err + } + + tele, err := telepact.TelepactSchemaFromFileJSONMap(files.FilenamesToJSON) + if err != nil { + return nil, err + } + + alternateMap := cloneStringStringMap(files.FilenamesToJSON) + alternateMap["backwardsCompatibleChange"] = backwardsCompatibleChangeSchema + alternateTele, err := telepact.TelepactSchemaFromFileJSONMap(alternateMap) + if err != nil { + return nil, err + } + + var serveAlternate atomic.Bool + codegenHandler := newCodeGenHandler(cfg.UseCodegen) + + const requestTimeout = 5 * time.Second + + forwardRequest := func(message telepact.Message) (telepact.Message, error) { + payload := []any{message.Headers, message.Body} + payloadBytes, err := json.Marshal(payload) + if err != nil { + return telepact.Message{}, err + } + + ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) + defer cancel() + + reply, err := d.conn.RequestWithContext(ctx, cfg.BackdoorTopic, payloadBytes) + if err != nil { + return telepact.Message{}, err + } + + var response []any + decoder := json.NewDecoder(bytes.NewReader(reply.Data)) + decoder.UseNumber() + if err := decoder.Decode(&response); err != nil { + return telepact.Message{}, err + } + normalized := normalizeJSONNumbers(response) + responseSlice, ok := normalized.([]any) + if !ok { + return telepact.Message{}, fmt.Errorf("invalid backdoor response payload") + } + response = responseSlice + if len(response) != 2 { + return telepact.Message{}, errors.New("invalid backdoor response payload") + } + + headers, err := asMap(response[0]) + if err != nil { + return telepact.Message{}, err + } + body, err := asMap(response[1]) + if err != nil { + return telepact.Message{}, err + } + + return telepact.NewMessage(headers, body), nil + } + + type handlerError struct{} + + handler := func(message telepact.Message) (telepact.Message, error) { + reqHeaders := message.Headers + + if boolValue(reqHeaders["@toggleAlternateServer_"]) { + serveAlternate.Store(!serveAlternate.Load()) + } + + if boolValue(reqHeaders["@throwError_"]) { + return telepact.Message{}, fmt.Errorf("telepact: requested server error") + } + + var msg telepact.Message + var err error + if codegenHandler != nil { + msg, err = codegenHandler.Handle(message) + if err != nil { + return telepact.Message{}, err + } + } else { + if cfg.BackdoorTopic == "" { + return telepact.Message{}, fmt.Errorf("telepact: backdoor topic not configured") + } + + msg, err = forwardRequest(message) + if err != nil { + return telepact.Message{}, err + } + } + + return msg, nil + } + + options := telepact.NewServerOptions() + options.AuthRequired = cfg.AuthRequired + options.OnError = func(err error) { + if err != nil { + d.logger.Printf("server error: %v", err) + } + } + options.OnRequest = func(msg telepact.Message) { + if boolValue(msg.Headers["@onRequestError_"]) { + panic(handlerError{}) + } + } + options.OnResponse = func(msg telepact.Message) { + if boolValue(msg.Headers["@onResponseError_"]) { + panic(handlerError{}) + } + } + + server, err := telepact.NewServer(tele, handler, options) + if err != nil { + return nil, err + } + + alternateOptions := telepact.NewServerOptions() + alternateOptions.AuthRequired = cfg.AuthRequired + alternateOptions.OnError = func(err error) { + if err != nil { + d.logger.Printf("alternate server error: %v", err) + } + } + alternateServer, err := telepact.NewServer(alternateTele, handler, alternateOptions) + if err != nil { + return nil, err + } + + sub, err := d.conn.Subscribe(cfg.FrontdoorTopic, func(msg *nats.Msg) { + start := time.Now() + var ( + resp telepact.Response + err error + ) + + if serveAlternate.Load() { + resp, err = alternateServer.Process(msg.Data) + } else { + override := map[string]any{"@override": "new"} + resp, err = server.ProcessWithHeaders(msg.Data, override) + } + + if err != nil { + d.logger.Printf("server.process error: %v", err) + _ = msg.Respond(buildUnknownPayload()) + return + } + + if d.metrics != nil { + d.metrics.Observe(cfg.FrontdoorTopic, time.Since(start)) + } + + if err := respondWithBytes(msg, resp.Bytes); err != nil { + d.logger.Printf("server respond error: %v", err) + } + }) + if err != nil { + return nil, err + } + + d.logger.Printf("server %s listening on %s", cfg.ID, cfg.FrontdoorTopic) + return sub, nil +} + +func (d *Dispatcher) handleServerRequest(server *telepact.Server, topic string, msg *nats.Msg) { + start := time.Now() + resp, err := server.Process(msg.Data) + if err != nil { + d.logger.Printf("server.process error: %v", err) + _ = msg.Respond(buildUnknownPayload()) + return + } + + if d.metrics != nil { + d.metrics.Observe(topic, time.Since(start)) + } + + if err := respondWithBytes(msg, resp.Bytes); err != nil { + d.logger.Printf("server respond error: %v", err) + } +} + +func (d *Dispatcher) handleClientRequest( + msg *nats.Msg, + client *telepact.Client, + generatedClient *generatedTypedClient, + testClient *telepact.TestClient, + cfg serverConfig, +) { + start := time.Now() + + request, err := deserializePseudoJSON(msg.Data) + if err != nil { + d.logger.Printf("client request decode error: %v", err) + _ = msg.Respond(buildUnknownPayload()) + return + } + + headers, err := asMap(request[0]) + if err != nil { + d.logger.Printf("client headers decode error: %v", err) + _ = msg.Respond(buildUnknownPayload()) + return + } + + body, err := asMap(request[1]) + if err != nil { + d.logger.Printf("client body decode error: %v", err) + _ = msg.Respond(buildUnknownPayload()) + return + } + + message := telepact.NewMessage(headers, body) + + var response telepact.Message + + switch { + case testClient != nil: + if seed, ok := intFromAny(headers["@setSeed"]); ok { + testClient.SetSeed(int32(seed)) + } + expectMatch := true + if raw, ok := headers["@expectMatch"]; ok { + expectMatch = boolValue(raw) + } + + var expected map[string]any + if raw := headers["@expectedPseudoJsonBody"]; raw != nil { + if converted, err := asMap(raw); err == nil { + expected = converted + } + } + + resp, err := testClient.AssertRequest(message, expected, expectMatch) + if err != nil { + d.logger.Printf("test client assertion failed: %v", err) + headers := map[string]any{"@assertionError": true} + response = telepact.NewMessage(headers, map[string]any{"ErrorUnknown_": map[string]any{}}) + } else { + response = resp + } + case generatedClient != nil: + resp, err := generatedClient.Handle(message) + if err != nil { + d.logger.Printf("generated client error: %v", err) + _ = msg.Respond(buildUnknownPayload()) + return + } + response = resp + default: + resp, err := client.Request(message) + if err != nil { + d.logger.Printf("client request error: %v", err) + _ = msg.Respond(buildUnknownPayload()) + return + } + response = resp + } + + if containsBytes(response.Body) { + if response.Headers == nil { + response.Headers = map[string]any{} + } + response.Headers["@clientReturnedBinary"] = true + } + + if generatedClient != nil { + if response.Headers == nil { + response.Headers = map[string]any{} + } + if _, exists := response.Headers["@codegens_"]; !exists { + response.Headers["@codegens_"] = true + } + } + + payload := []any{response.Headers, response.Body} + data, err := json.Marshal(payload) + if err != nil { + d.logger.Printf("client response marshal error: %v", err) + _ = msg.Respond(buildUnknownPayload()) + return + } + + if d.metrics != nil { + d.metrics.Observe(cfg.ClientFrontdoor, time.Since(start)) + } + + if err := msg.Respond(data); err != nil { + d.logger.Printf("client respond error: %v", err) + } +} + +func deserializePseudoJSON(data []byte) ([]any, error) { + var envelope []any + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + if err := decoder.Decode(&envelope); err != nil { + decoder := msgpack.NewDecoder(bytes.NewReader(data)) + if err := decoder.Decode(&envelope); err != nil { + return nil, fmt.Errorf("decode pseudo-json: %w", err) + } + } + normalized := normalizeJSONNumbers(envelope) + normalizedSlice, ok := normalized.([]any) + if !ok { + return nil, errors.New("invalid pseudo-json envelope type") + } + envelope = normalizedSlice + if len(envelope) != 2 { + return nil, errors.New("invalid pseudo-json envelope length") + } + return envelope, nil +} + +func buildUnknownPayload() []byte { + payload := []any{map[string]any{}, map[string]any{"ErrorUnknown_": map[string]any{}}} + bytes, _ := json.Marshal(payload) + return bytes +} + +func respondWithBytes(msg *nats.Msg, data []byte) error { + return msg.Respond(data) +} + +func containsBytes(value any) bool { + switch typed := value.(type) { + case []byte: + return true + case map[string]any: + for _, v := range typed { + if containsBytes(v) { + return true + } + } + case map[any]any: + for _, v := range typed { + if containsBytes(v) { + return true + } + } + case []any: + for _, v := range typed { + if containsBytes(v) { + return true + } + } + case [][]byte: + return true + } + return false +} + +func normalizeJSONNumbers(value any) any { + switch typed := value.(type) { + case map[string]any: + normalized := make(map[string]any, len(typed)) + for key, entry := range typed { + normalized[key] = normalizeJSONNumbers(entry) + } + return normalized + case []any: + normalized := make([]any, len(typed)) + for i, entry := range typed { + normalized[i] = normalizeJSONNumbers(entry) + } + return normalized + case json.Number: + if intval, err := typed.Int64(); err == nil { + return intval + } + if str := typed.String(); !strings.ContainsAny(str, ".eE") && !strings.ContainsRune(str, '.') { + return typed + } + if floatval, err := typed.Float64(); err == nil { + return floatval + } + return typed + default: + return typed + } +} + +func boolValue(value any) bool { + switch typed := value.(type) { + case bool: + return typed + case string: + flag, err := strconv.ParseBool(typed) + return err == nil && flag + case float64: + return typed != 0 + case float32: + return typed != 0 + case int: + return typed != 0 + case int64: + return typed != 0 + case json.Number: + flag, err := strconv.ParseFloat(string(typed), 64) + return err == nil && flag != 0 + default: + return false + } +} + +func intFromAny(value any) (int, bool) { + switch typed := value.(type) { + case int: + return typed, true + case int32: + return int(typed), true + case int64: + return int(typed), true + case float64: + return int(typed), true + case float32: + return int(typed), true + case json.Number: + parsed, err := typed.Int64() + return int(parsed), err == nil + case string: + parsed, err := strconv.Atoi(typed) + return parsed, err == nil + default: + return 0, false + } +} + +func cloneStringStringMap(source map[string]string) map[string]string { + if source == nil { + return nil + } + result := make(map[string]string, len(source)) + for key, value := range source { + result[key] = value + } + return result +} + +func firstKey(m map[string]any) string { + for key := range m { + return key + } + return "" +} + +func isNumberTooBigError(err error) bool { + if err == nil { + return false + } + var numErr *strconv.NumError + if errors.As(err, &numErr) && numErr.Err == strconv.ErrRange { + return true + } + var unsupported *json.UnsupportedValueError + if errors.As(err, &unsupported) { + return true + } + var invalid *json.InvalidUTF8Error + if errors.As(err, &invalid) { + return true + } + var marshaler *json.MarshalerError + if errors.As(err, &marshaler) { + return true + } + msg := err.Error() + return strings.Contains(msg, "range") || strings.Contains(msg, "too large") || strings.Contains(msg, "overflow") || strings.Contains(msg, "non-finite") || strings.Contains(msg, "not representable") || strings.Contains(msg, "cannot serialize") || strings.Contains(msg, "value must") +} + +func schemaValidationFailureMessage(err error) telepact.Message { + if err == nil { + return telepact.NewMessage(map[string]any{}, map[string]any{"Ok_": map[string]any{}}) + } + var parseErr *telepact.TelepactSchemaParseError + if errors.As(err, &parseErr) { + return telepact.NewMessage( + map[string]any{}, + map[string]any{"ErrorValidationFailure": map[string]any{"cases": parseErr.SchemaParseFailuresPseudoJSON}}, + ) + } + return telepact.NewMessage(map[string]any{}, map[string]any{"ErrorUnknown_": map[string]any{}}) +} + +func validatePseudoJSONSchema(input map[string]any) ([]map[string]any, error) { + schemaRaw, ok := input["schema"] + if !ok { + return nil, errors.New("missing schema") + } + + schemaJSON, err := toJSONString(schemaRaw) + if err != nil { + return nil, err + } + + var extendJSON string + if extendRaw, ok := input["extend!"]; ok { + extendJSON, err = toJSONString(extendRaw) + if err != nil { + return nil, err + } + + _, err = telepact.TelepactSchemaFromFileJSONMap(map[string]string{ + "default": schemaJSON, + "extend": extendJSON, + }) + } else { + _, err = telepact.TelepactSchemaFromJSON(schemaJSON) + } + if err == nil { + return nil, nil + } + + parseErr, ok := err.(*telepact.TelepactSchemaParseError) + if !ok { + return nil, err + } + + var entries []map[string]any + switch typed := parseErr.SchemaParseFailuresPseudoJSON.(type) { + case []map[string]any: + entries = typed + case []any: + entries = make([]map[string]any, 0, len(typed)) + for _, entry := range typed { + if converted, err := asMap(entry); err == nil { + entries = append(entries, converted) + } + } + default: + return nil, fmt.Errorf("unexpected parse failure pseudo json type %T", parseErr.SchemaParseFailuresPseudoJSON) + } + + result := make([]map[string]any, 0, len(entries)) + for _, converted := range entries { + failure := make(map[string]any, len(converted)) + if doc, ok := converted["document"].(string); ok { + failure["document"] = doc + } + if loc, ok := converted["location"]; ok { + failure["location"] = loc + } + if path, ok := converted["path"]; ok { + failure["path"] = path + } + if reason, ok := converted["reason"].(map[string]any); ok { + failure["reason"] = reason + } + result = append(result, failure) + } + + return result, nil +} + +func toJSONString(value any) (string, error) { + switch typed := value.(type) { + case string: + return typed, nil + case json.RawMessage: + return string(typed), nil + default: + bytes, err := json.Marshal(typed) + if err != nil { + return "", err + } + return string(bytes), nil + } +} diff --git a/test/lib/go/go.mod b/test/lib/go/go.mod new file mode 100644 index 000000000..e53637938 --- /dev/null +++ b/test/lib/go/go.mod @@ -0,0 +1,27 @@ +module github.com/telepact/telepact/test/lib/go + +go 1.22 + +require ( + github.com/nats-io/nats.go v1.37.0 + github.com/prometheus/client_golang v1.19.0 + github.com/telepact/telepact/lib/go v0.0.0 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/klauspost/compress v1.17.2 // indirect + github.com/nats-io/nkeys v0.4.7 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/common v0.48.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + golang.org/x/crypto v0.18.0 // indirect + golang.org/x/sys v0.16.0 // indirect + google.golang.org/protobuf v1.32.0 // indirect +) + +replace github.com/telepact/telepact/lib/go => ../../../lib/go diff --git a/test/lib/go/go.sum b/test/lib/go/go.sum new file mode 100644 index 000000000..be182a505 --- /dev/null +++ b/test/lib/go/go.sum @@ -0,0 +1,40 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4= +github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/nats-io/nats.go v1.37.0 h1:07rauXbVnnJvv1gfIyghFEo6lUcYRY0WXc3x7x0vUxE= +github.com/nats-io/nats.go v1.37.0/go.mod h1:Ubdu4Nh9exXdSz0RVWRFBbRfrbSxOYd26oF0wkWclB8= +github.com/nats-io/nkeys v0.4.7 h1:RwNJbbIdYCoClSDNY7QVKZlyb/wfT6ugvFCiKy6vDvI= +github.com/nats-io/nkeys v0.4.7/go.mod h1:kqXRgRDPlGy7nGaEDMuYzmiJCIAAWDK0IMBtDmGD0nc= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= +github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= +github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= +google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/test/runner/Makefile b/test/runner/Makefile index 525bbfebd..5e4895b0f 100644 --- a/test/runner/Makefile +++ b/test/runner/Makefile @@ -14,7 +14,7 @@ #| limitations under the License. #| -test: setup java py ts +test: setup java py ts go poetry run python -m pytest test-java: setup java @@ -35,6 +35,12 @@ test-ts: setup ts test-trace-ts: setup ts poetry run python -m pytest -k test_client_server_case[ts-0] -s -vv +test-go: setup go + poetry run python -m pytest -k [go + +test-trace-go: setup go + poetry run python -m pytest -k test_client_server_case[go-0] -s -vv + setup: poetry install @@ -47,6 +53,9 @@ py: ts: $(MAKE) -C ../lib/ts +go: + $(MAKE) -C ../lib/go + clean: rm -rf .pytest_cache rm -rf __pycache__ diff --git a/test/runner/parse_cases.py b/test/runner/parse_cases.py index 59a8e33f3..c4daf81b8 100644 --- a/test/runner/parse_cases.py +++ b/test/runner/parse_cases.py @@ -18,8 +18,8 @@ 'schema': [ [[{}, {'fn.validateSchema': {'input': {'PseudoJson': {'schema': [{'struct.Example': {}}, {'struct.Example': {}}]}}}}], [{}, {'ErrorValidationFailure': {'cases': [{'document': 'auto_', 'location': {'row': 1, 'col': 27}, 'path': [1, 'struct.Example'], 'reason': {'PathCollision': {'document': 'auto_', 'location': {'row': 1, 'col': 3}, 'location': {'row': 1, 'col': 3}, 'path': [0, 'struct.Example']}}}]}}]], [[{}, {'fn.validateSchema': {'input': {'PseudoJson': {'schema': [{'struct.Example': {}}], 'extend!': [{'struct.Example': {}}]}}}}], [{}, {'ErrorValidationFailure': {'cases': [{'document': 'extend', 'location': {'row': 1, 'col': 3}, 'path': [0, 'struct.Example'], 'reason': {'PathCollision': {'document': 'default', 'location': {'row': 1, 'col': 3}, 'path': [0, 'struct.Example']}}}]}}]], - [[{}, {'fn.validateSchema': {'input': {'PseudoJson': {'schema': [{'struct.Example': {'field': ['boolean'], 'field!': ['integer']}}]}}}}], [{}, {'ErrorValidationFailure': {'cases': [{'document': 'auto_', 'location': {'row': 1, 'col': 44}, 'path': [0, 'struct.Example', 'field!'], 'reason': {'PathCollision': {'document': 'auto_', 'location': {'row': 1, 'col': 22}, 'path': [0, 'struct.Example', 'field']}}}]}}]], - [[{}, {'fn.validateSchema': {'input': {'PseudoJson': {'schema': [{'struct.Example': {'field!': ['boolean'], 'field': ['integer']}}]}}}}], [{}, {'ErrorValidationFailure': {'cases': [{'document': 'auto_', 'location': {'row': 1, 'col': 45}, 'path': [0, 'struct.Example', 'field'], 'reason': {'PathCollision': {'document': 'auto_', 'location': {'row': 1, 'col': 22}, 'path': [0, 'struct.Example', 'field!']}}}]}}]], + [[{}, {'fn.validateSchema': {'input': {'PseudoJson': {'schema': [{'struct.Example': {'field': ['boolean'], 'field!': ['integer']}}]}}}}], [{}, {'ErrorValidationFailure': {'cases': [{'document': 'auto_', 'location': {'row': 1, 'col': 3}, 'path': [0, 'struct.Example'], 'reason': {'DuplicateField': {'field': 'field'}}}]}}]], + [[{}, {'fn.validateSchema': {'input': {'PseudoJson': {'schema': [{'struct.Example': {'field!': ['boolean'], 'field': ['integer']}}]}}}}], [{}, {'ErrorValidationFailure': {'cases': [{'document': 'auto_', 'location': {'row': 1, 'col': 3}, 'path': [0, 'struct.Example'], 'reason': {'DuplicateField': {'field': 'field'}}}]}}]], [[{}, {'fn.validateSchema': {'input': {'PseudoJson': {'schema': [{'invalid.Example': {}}]}}}}], [{}, {'ErrorValidationFailure': {'cases': [{'document': 'auto_', 'location': {'row': 1, 'col': 2}, 'path': [0], 'reason': {'ObjectKeyRegexMatchCountUnexpected': {'regex': '^(((fn|errors|headers|info)|((struct|union|_ext)(<[0-2]>)?))\\..*)', 'actual': 0, 'expected': 1, 'keys': ['invalid.Example']}}}]}}]], [[{}, {'fn.validateSchema': {'input': {'PseudoJson': {'schema': None}}}}], [{}, {'ErrorValidationFailure': {'cases': [{'document': 'auto_', 'location': {'row': 1, 'col': 1}, 'path': [], 'reason': {'TypeUnexpected': {'actual': {'Null': {}}, 'expected': {'Array': {}}}}}]}}]], [[{}, {'fn.validateSchema': {'input': {'PseudoJson': {'schema': False}}}}], [{}, {'ErrorValidationFailure': {'cases': [{'document': 'auto_', 'location': {'row': 1, 'col': 1}, 'path': [], 'reason': {'TypeUnexpected': {'actual': {'Boolean': {}}, 'expected': {'Array': {}}}}}]}}]], diff --git a/test/runner/schema/parse/schema.telepact.json b/test/runner/schema/parse/schema.telepact.json index 00b441c75..17f8eb251 100644 --- a/test/runner/schema/parse/schema.telepact.json +++ b/test/runner/schema/parse/schema.telepact.json @@ -83,6 +83,10 @@ "path": ["any"], "location": "struct.JsonLocation" } + }, { + "DuplicateField": { + "field": "string" + } }, { "FileNamePatternInvalid": { "expected": "string" diff --git a/tool/telepact_project_cli/telepact_project_cli/cli.py b/tool/telepact_project_cli/telepact_project_cli/cli.py index f6fefea4d..d967fe128 100644 --- a/tool/telepact_project_cli/telepact_project_cli/cli.py +++ b/tool/telepact_project_cli/telepact_project_cli/cli.py @@ -228,6 +228,8 @@ def bump_version2(version: str) -> str: release_targets.add('py') if 'lib/ts' in path: release_targets.add('ts') + if 'lib/go' in path: + release_targets.add('go') if 'bind/dart' in path: release_targets.add('dart') if 'sdk/cli' in path: @@ -367,6 +369,7 @@ def github_labels() -> None: "lib/java": "java", "lib/py": "py", "lib/ts": "ts", + "lib/go": "go", "bind/dart": "dart", "sdk/cli": "cli", "sdk/console": "console",