diff --git a/go.mod b/go.mod index 6964bbc..289d1c6 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/jarcoal/httpmock v1.1.0 github.com/pkg/errors v0.9.1 github.com/rs/cors v1.8.2 + github.com/santhosh-tekuri/jsonschema/v5 v5.0.0 github.com/sirupsen/logrus v1.8.1 github.com/spf13/viper v1.12.0 github.com/stretchr/testify v1.7.1 diff --git a/go.sum b/go.sum index 4112aa0..dc85625 100644 --- a/go.sum +++ b/go.sum @@ -389,6 +389,8 @@ github.com/rs/cors v1.8.2 h1:KCooALfAYGs415Cwu5ABvv9n9509fSiG5SQJn/AQo4U= github.com/rs/cors v1.8.2/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/sagikazarmark/crypt v0.6.0/go.mod h1:U8+INwJo3nBv1m6A/8OBXAq7Jnpspk5AxSgDyEQcea8= +github.com/santhosh-tekuri/jsonschema/v5 v5.0.0 h1:TToq11gyfNlrMFZiYujSekIsPd9AmsA2Bj/iv+s4JHE= +github.com/santhosh-tekuri/jsonschema/v5 v5.0.0/go.mod h1:FKdcjfQW6rpZSnxxUvEA5H/cDPdvJ/SZJQLWWXWGrZ0= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= diff --git a/pkg/fftypes/ffi.go b/pkg/fftypes/ffi.go new file mode 100644 index 0000000..0414111 --- /dev/null +++ b/pkg/fftypes/ffi.go @@ -0,0 +1,133 @@ +// Copyright © 2022 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// 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 +// +// http://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 fftypes + +import ( + "context" + "database/sql/driver" + "encoding/json" + + "github.com/hyperledger/firefly-common/pkg/i18n" + "github.com/santhosh-tekuri/jsonschema/v5" +) + +type FFIParamValidator interface { + Compile(ctx jsonschema.CompilerContext, m map[string]interface{}) (jsonschema.ExtSchema, error) + GetMetaSchema() *jsonschema.Schema + GetExtensionName() string +} + +type FFIReference struct { + ID *UUID `ffstruct:"FFIReference" json:"id,omitempty"` + Name string `ffstruct:"FFIReference" json:"name,omitempty"` + Version string `ffstruct:"FFIReference" json:"version,omitempty"` +} + +type FFI struct { + ID *UUID `ffstruct:"FFI" json:"id,omitempty" ffexcludeinput:"true"` + Message *UUID `ffstruct:"FFI" json:"message,omitempty" ffexcludeinput:"true"` + Namespace string `ffstruct:"FFI" json:"namespace,omitempty" ffexcludeinput:"true"` + Name string `ffstruct:"FFI" json:"name"` + Description string `ffstruct:"FFI" json:"description"` + Version string `ffstruct:"FFI" json:"version"` + Methods []*FFIMethod `ffstruct:"FFI" json:"methods,omitempty"` + Events []*FFIEvent `ffstruct:"FFI" json:"events,omitempty"` +} + +type FFIMethod struct { + ID *UUID `ffstruct:"FFIMethod" json:"id,omitempty" ffexcludeinput:"true"` + Interface *UUID `ffstruct:"FFIMethod" json:"interface,omitempty" ffexcludeinput:"true"` + Name string `ffstruct:"FFIMethod" json:"name"` + Namespace string `ffstruct:"FFIMethod" json:"namespace,omitempty" ffexcludeinput:"true"` + Pathname string `ffstruct:"FFIMethod" json:"pathname" ffexcludeinput:"true"` + Description string `ffstruct:"FFIMethod" json:"description"` + Params FFIParams `ffstruct:"FFIMethod" json:"params"` + Returns FFIParams `ffstruct:"FFIMethod" json:"returns"` + Details JSONObject `ffstruct:"FFIMethod" json:"details,omitempty"` +} + +type FFIEventDefinition struct { + Name string `ffstruct:"FFIEvent" json:"name"` + Description string `ffstruct:"FFIEvent" json:"description"` + Params FFIParams `ffstruct:"FFIEvent" json:"params"` + Details JSONObject `ffstruct:"FFIEvent" json:"details,omitempty"` +} + +type FFIEvent struct { + ID *UUID `ffstruct:"FFIEvent" json:"id,omitempty" ffexcludeinput:"true"` + Interface *UUID `ffstruct:"FFIEvent" json:"interface,omitempty" ffexcludeinput:"true"` + Namespace string `ffstruct:"FFIEvent" json:"namespace,omitempty" ffexcludeinput:"true"` + Pathname string `ffstruct:"FFIEvent" json:"pathname,omitempty" ffexcludeinput:"true"` + Signature string `ffstruct:"FFIEvent" json:"signature" ffexcludeinput:"true"` + FFIEventDefinition +} + +type FFIParam struct { + Name string `ffstruct:"FFIParam" json:"name"` + Schema *JSONAny `ffstruct:"FFIParam" json:"schema,omitempty"` +} + +type FFIParams []*FFIParam + +type FFIGenerationRequest struct { + Namespace string `ffstruct:"FFIGenerationRequest" json:"namespace,omitempty"` + Name string `ffstruct:"FFIGenerationRequest" json:"name"` + Description string `ffstruct:"FFIGenerationRequest" json:"description"` + Version string `ffstruct:"FFIGenerationRequest" json:"version"` + Input *JSONAny `ffstruct:"FFIGenerationRequest" json:"input"` +} + +func (f *FFI) Validate(ctx context.Context, existing bool) (err error) { + if err = ValidateFFNameField(ctx, f.Namespace, "namespace"); err != nil { + return err + } + if err = ValidateFFNameField(ctx, f.Name, "name"); err != nil { + return err + } + if err = ValidateFFNameField(ctx, f.Version, "version"); err != nil { + return err + } + return nil +} + +func (f *FFI) Topic() string { + return TypeNamespaceNameTopicHash("ffi", f.Namespace, f.Name) +} + +func (f *FFI) SetBroadcastMessage(msgID *UUID) { + f.Message = msgID +} + +// Scan implements sql.Scanner +func (p *FFIParams) Scan(src interface{}) error { + switch src := src.(type) { + case nil: + p = nil + return nil + case string: + return json.Unmarshal([]byte(src), &p) + case []byte: + return json.Unmarshal(src, &p) + default: + return i18n.NewError(context.Background(), i18n.MsgTypeRestoreFailed, src, p) + } +} + +func (p FFIParams) Value() (driver.Value, error) { + bytes, _ := json.Marshal(p) + return bytes, nil +} diff --git a/pkg/fftypes/ffi_param_validator.go b/pkg/fftypes/ffi_param_validator.go new file mode 100644 index 0000000..487e294 --- /dev/null +++ b/pkg/fftypes/ffi_param_validator.go @@ -0,0 +1,96 @@ +// Copyright © 2022 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// 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 +// +// http://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 fftypes + +import ( + "github.com/santhosh-tekuri/jsonschema/v5" +) + +type BaseFFIParamValidator struct{} + +func (v BaseFFIParamValidator) Compile(ctx jsonschema.CompilerContext, m map[string]interface{}) (jsonschema.ExtSchema, error) { + return nil, nil +} + +func (v *BaseFFIParamValidator) GetMetaSchema() *jsonschema.Schema { + return jsonschema.MustCompileString("ffi.json", `{ + "$ref": "#/$defs/ffiParam", + "$defs": { + "integerTypeOptions": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "integer", + "string" + ] + } + } + }, + "ffiParam": { + "oneOf": [ + { + "properties": { + "type": { + "type": [ + "string" + ], + "enum": [ + "boolean", + "integer", + "string", + "array", + "object" + ] + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "oneOf": { + "type": "array", + "items": { + "$ref": "#/$defs/integerTypeOptions" + } + } + }, + "required": [ + "oneOf" + ] + } + ] + } + } + }`) +} + +func (v *BaseFFIParamValidator) GetExtensionName() string { + return "ffi" +} + +func NewFFISchemaCompiler() *jsonschema.Compiler { + c := jsonschema.NewCompiler() + c.Draft = jsonschema.Draft2020 + v := BaseFFIParamValidator{} + c.RegisterExtension(v.GetExtensionName(), v.GetMetaSchema(), v) + return c +} diff --git a/pkg/fftypes/ffi_param_validator_test.go b/pkg/fftypes/ffi_param_validator_test.go new file mode 100644 index 0000000..77a381d --- /dev/null +++ b/pkg/fftypes/ffi_param_validator_test.go @@ -0,0 +1,35 @@ +// Copyright © 2022 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// 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 +// +// http://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 fftypes + +import ( + "testing" + + "github.com/santhosh-tekuri/jsonschema/v5" + "github.com/stretchr/testify/assert" +) + +func TestGetBaseFFIParamValidator(t *testing.T) { + c := NewFFISchemaCompiler() + assert.NotNil(t, c) +} +func TestBaseFFIParamValidatorCompile(t *testing.T) { + v := BaseFFIParamValidator{} + c, err := v.Compile(jsonschema.CompilerContext{}, map[string]interface{}{}) + assert.Nil(t, c) + assert.NoError(t, err) +} diff --git a/pkg/fftypes/ffi_test.go b/pkg/fftypes/ffi_test.go new file mode 100644 index 0000000..288eb8f --- /dev/null +++ b/pkg/fftypes/ffi_test.go @@ -0,0 +1,149 @@ +// Copyright © 2021 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// 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 +// +// http://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 fftypes + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValidateFFI(t *testing.T) { + ffi := &FFI{ + Name: "math", + Namespace: "default", + Version: "v1.0.0", + Methods: []*FFIMethod{ + { + Name: "sum", + Params: []*FFIParam{ + { + Name: "x", + Schema: JSONAnyPtr(`{"type": "integer"}, "details": {"type": "uint256"}`), + }, + { + Name: "y", + Schema: JSONAnyPtr(`{"type": "integer"}, "details": {"type": "uint256"}`), + }, + }, + Returns: []*FFIParam{ + { + Name: "z", + Schema: JSONAnyPtr(`{"type": "integer"}, "details": {"type": "uint256"}`), + }, + }, + }, + }, + Events: []*FFIEvent{ + { + FFIEventDefinition: FFIEventDefinition{ + Name: "sum", + Params: []*FFIParam{ + { + Name: "z", + Schema: JSONAnyPtr(`{"type": "integer"}, "details": {"type": "uint256"}`), + }, + }, + }, + }, + }, + } + err := ffi.Validate(context.Background(), true) + assert.NoError(t, err) +} + +func TestValidateFFIBadVersion(t *testing.T) { + ffi := &FFI{ + Name: "math", + Namespace: "default", + Version: "*(&!$%^)", + } + err := ffi.Validate(context.Background(), true) + assert.Regexp(t, "FF00140", err) +} + +func TestValidateFFIBadName(t *testing.T) { + ffi := &FFI{ + Name: "(*%&#%)", + Namespace: "default", + Version: "v1.0.0", + } + err := ffi.Validate(context.Background(), true) + assert.Regexp(t, "FF00140", err) +} + +func TestValidateFFIBadNamespace(t *testing.T) { + ffi := &FFI{ + Name: "math", + Namespace: "", + Version: "v1.0.0", + } + err := ffi.Validate(context.Background(), true) + assert.Regexp(t, "FF00140", err) +} + +func TestFFIParamsScan(t *testing.T) { + params := &FFIParams{} + err := params.Scan([]byte(`[{"name": "x", "type": "integer", "internalType": "uint256"}]`)) + assert.NoError(t, err) +} + +func TestFFIParamsScanString(t *testing.T) { + params := &FFIParams{} + err := params.Scan(`[{"name": "x", "type": "integer", "internalType": "uint256"}]`) + assert.NoError(t, err) +} + +func TestFFIParamsScanNil(t *testing.T) { + params := &FFIParams{} + err := params.Scan(nil) + assert.Nil(t, err) +} + +func TestFFIParamsScanError(t *testing.T) { + params := &FFIParams{} + err := params.Scan(map[string]interface{}{"type": "not supported for scanning FFIParams"}) + assert.Regexp(t, "FF00105", err) +} + +func TestFFIParamsValue(t *testing.T) { + params := &FFIParams{ + &FFIParam{ + Name: "x", + Schema: JSONAnyPtr(`{"type": "integer", "details": {"type": "uint256"}}`), + }, + } + + val, err := params.Value() + assert.NoError(t, err) + assert.Equal(t, []byte(`[{"name":"x","schema":{"type":"integer","details":{"type":"uint256"}}}]`), val) +} + +func TestFFITopic(t *testing.T) { + ffi := &FFI{ + Namespace: "ns1", + } + assert.Equal(t, "01a982a7251400a7ec64fccce6febee3942a56e37967fa2ba26d7d6f43523c82", ffi.Topic()) +} + +func TestFFISetBroadCastMessage(t *testing.T) { + msgID := NewUUID() + ffi := &FFI{} + ffi.SetBroadcastMessage(msgID) + assert.Equal(t, ffi.Message, msgID) +} diff --git a/pkg/fftypes/hashutils.go b/pkg/fftypes/hashutils.go new file mode 100644 index 0000000..b2e0e39 --- /dev/null +++ b/pkg/fftypes/hashutils.go @@ -0,0 +1,31 @@ +// Copyright © 2022 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// 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 +// +// http://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 fftypes + +import ( + "crypto/sha256" +) + +func TypeNamespaceNameTopicHash(objType string, ns string, name string) string { + // Topic generation function for ordering anything with a type, namespace and name. + // Means all messages racing for this name will be consistently ordered by all parties. + h := sha256.New() + h.Write([]byte(objType)) + h.Write([]byte(ns)) + h.Write([]byte(name)) + return HashResult(h).String() +} diff --git a/pkg/fftypes/validations.go b/pkg/fftypes/validations.go new file mode 100644 index 0000000..f51dd34 --- /dev/null +++ b/pkg/fftypes/validations.go @@ -0,0 +1,58 @@ +// Copyright © 2022 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// 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 +// +// http://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 fftypes + +import ( + "context" + "regexp" + + "github.com/hyperledger/firefly-common/pkg/i18n" +) + +var ( + ffNameValidator = regexp.MustCompile(`^[0-9a-zA-Z]([0-9a-zA-Z._-]{0,62}[0-9a-zA-Z])?$`) + ffSafeCharsValidator = regexp.MustCompile(`^[0-9a-zA-Z._-]*$`) +) + +func ValidateSafeCharsOnly(ctx context.Context, str string, fieldName string) error { + if !ffSafeCharsValidator.MatchString(str) { + return i18n.NewError(ctx, i18n.MsgSafeCharsOnly, fieldName) + } + return nil +} + +func ValidateFFNameField(ctx context.Context, str string, fieldName string) error { + if !ffNameValidator.MatchString(str) { + return i18n.NewError(ctx, i18n.MsgInvalidName, fieldName) + } + return nil +} + +func ValidateFFNameFieldNoUUID(ctx context.Context, str string, fieldName string) error { + if _, err := ParseUUID(ctx, str); err == nil { + // Name must not be a UUID + return i18n.NewError(ctx, i18n.MsgNoUUID, fieldName) + } + return ValidateFFNameField(ctx, str, fieldName) +} + +func ValidateLength(ctx context.Context, str string, fieldName string, max int) error { + if len([]byte(str)) > max { + return i18n.NewError(ctx, i18n.MsgFieldTooLong, fieldName, max) + } + return nil +} diff --git a/pkg/fftypes/validations_test.go b/pkg/fftypes/validations_test.go new file mode 100644 index 0000000..e9317ba --- /dev/null +++ b/pkg/fftypes/validations_test.go @@ -0,0 +1,66 @@ +// Copyright © 2021 Kaleido, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +// +// 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 +// +// http://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 fftypes + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestValidateFFNameField(t *testing.T) { + + err := ValidateFFNameField(context.Background(), "_badstart", "badField") + assert.Regexp(t, "FF00140.*badField", err) + + err = ValidateFFNameField(context.Background(), "badend_", "badField") + assert.Regexp(t, "FF00140.*badField", err) + + err = ValidateFFNameField(context.Background(), "0123456789_123456789-123456789.123456789-123456789_1234567890123", "badField") + assert.NoError(t, err) + + err = ValidateFFNameField(context.Background(), "0123456789_123456789-123456789.123456789-123456789_12345678901234", "badField") + assert.Regexp(t, "FF00140.*badField", err) + + err = ValidateFFNameField(context.Background(), "af34658e-a728-4b21-b9cf-8451f07be065", "badField") + assert.NoError(t, err) + + err = ValidateFFNameFieldNoUUID(context.Background(), "not_a_uuid", "badField") + assert.NoError(t, err) + + err = ValidateFFNameFieldNoUUID(context.Background(), "af34658e-a728-4b21-b9cf-8451f07be065", "badField") + assert.Regexp(t, "FF00141.*badField", err) +} + +func TestValidateLength(t *testing.T) { + + err := ValidateLength(context.Background(), "long string", "test", 5) + assert.Regexp(t, "FF00135.*test", err) + + err = ValidateLength(context.Background(), "short string", "test", 50) + assert.NoError(t, err) + +} + +func TestValidateSafeCharsOnly(t *testing.T) { + err := ValidateSafeCharsOnly(context.Background(), "only_safe_chars", "test") + assert.NoError(t, err) + + err = ValidateSafeCharsOnly(context.Background(), "has a space", "test") + assert.Regexp(t, "FF00139.*test", err) +}