From f2e4fd90263988a4e9c5f766b0e496814b6bed7a Mon Sep 17 00:00:00 2001 From: Evgeniy Litvin Date: Wed, 11 Apr 2018 16:05:29 +0300 Subject: [PATCH] Move toolkit to public repo (#1) * Copy files from ngp.api.toolkit * Remove references to external docs * Remove gentool and addressbook references * Change Infoblox-CTO/ngp.api.toolkit to infobloxopen/atlas-app-toolkit * Regenerate files pb files * Fix tests * Gopkg.lock update * Add latest changes for auth middleware from development * Add health check from development * Readme updates --- .gitignore | 5 + Gopkg.lock | 228 ++++ Gopkg.toml | 68 ++ Makefile | 42 + README.md | 783 +++++++++++++ cli/atlas/README.md | 85 ++ cli/atlas/formatting.go | 67 ++ cli/atlas/formatting_test.go | 124 ++ cli/atlas/main.go | 237 ++++ cli/atlas/template-bindata.go | 504 ++++++++ cli/atlas/templates/.gitignore.gotmpl | 1 + cli/atlas/templates/Makefile.gotmpl | 100 ++ cli/atlas/templates/README.md.gotmpl | 51 + .../templates/cmd/config/config.go.gotmpl | 7 + .../templates/cmd/gateway/handler.go.gotmpl | 12 + .../templates/cmd/gateway/main.go.gotmpl | 35 + .../templates/cmd/gateway/swagger.go.gotmpl | 10 + cli/atlas/templates/cmd/server/main.go.gotmpl | 47 + .../docker/Dockerfile.application.gotmpl | 7 + .../docker/Dockerfile.gateway.gotmpl | 7 + .../templates/proto/service.proto.gotmpl | 56 + cli/atlas/templates/svc/zserver.go.gotmpl | 22 + gw/errors.go | 114 ++ gw/errors_test.go | 77 ++ gw/fields.go | 81 ++ gw/fields_test.go | 218 ++++ gw/header.go | 115 ++ gw/header_test.go | 66 ++ gw/operator.go | 162 +++ gw/operator_test.go | 113 ++ gw/response.go | 228 ++++ gw/response_test.go | 200 ++++ gw/status.go | 233 ++++ gw/status_test.go | 113 ++ health/checkers.go | 28 + health/dnsprobecheck.go | 26 + health/handler.go | 98 ++ health/httpgetcheck.go | 30 + health/types.go | 4 + mw/README.md | 6 + mw/auth/interceptor.go | 137 +++ mw/auth/interceptor_test.go | 107 ++ mw/auth/options.go | 121 ++ mw/auth/options_test.go | 182 +++ mw/auth/tenantid.go | 40 + mw/auth/tenantid_test.go | 39 + mw/operator.go | 173 +++ mw/operator_test.go | 132 +++ op/collection_operators.pb.go | 1043 +++++++++++++++++ op/collection_operators.proto | 155 +++ op/fields.go | 142 +++ op/fields_test.go | 166 +++ op/filtering.go | 329 ++++++ op/filtering_lexer.go | 374 ++++++ op/filtering_lexer_test.go | 65 + op/filtering_parser.go | 386 ++++++ op/filtering_parser_test.go | 365 ++++++ op/filtering_test.go | 212 ++++ op/gorm/collection_operators.go | 90 ++ op/gorm/collection_operators_test.go | 63 + op/gorm/filtering.go | 139 +++ op/gorm/filtering_test.go | 129 ++ op/pagination.go | 85 ++ op/pagination_test.go | 108 ++ op/sorting.go | 67 ++ op/sorting_test.go | 61 + pb/README.md | 31 + pb/converter.go | 157 +++ pb/converter_test.go | 173 +++ pb/converter_test_objects.go | 148 +++ rpc/errdetails/error_details.go | 67 ++ rpc/errdetails/error_details.pb.go | 90 ++ rpc/errdetails/error_details.proto | 17 + rpc/errdetails/error_details_test.go | 132 +++ 74 files changed, 10135 insertions(+) create mode 100644 .gitignore create mode 100644 Gopkg.lock create mode 100644 Gopkg.toml create mode 100644 Makefile create mode 100644 README.md create mode 100644 cli/atlas/README.md create mode 100644 cli/atlas/formatting.go create mode 100644 cli/atlas/formatting_test.go create mode 100644 cli/atlas/main.go create mode 100644 cli/atlas/template-bindata.go create mode 100644 cli/atlas/templates/.gitignore.gotmpl create mode 100644 cli/atlas/templates/Makefile.gotmpl create mode 100644 cli/atlas/templates/README.md.gotmpl create mode 100644 cli/atlas/templates/cmd/config/config.go.gotmpl create mode 100644 cli/atlas/templates/cmd/gateway/handler.go.gotmpl create mode 100644 cli/atlas/templates/cmd/gateway/main.go.gotmpl create mode 100644 cli/atlas/templates/cmd/gateway/swagger.go.gotmpl create mode 100644 cli/atlas/templates/cmd/server/main.go.gotmpl create mode 100644 cli/atlas/templates/docker/Dockerfile.application.gotmpl create mode 100644 cli/atlas/templates/docker/Dockerfile.gateway.gotmpl create mode 100644 cli/atlas/templates/proto/service.proto.gotmpl create mode 100644 cli/atlas/templates/svc/zserver.go.gotmpl create mode 100644 gw/errors.go create mode 100644 gw/errors_test.go create mode 100644 gw/fields.go create mode 100644 gw/fields_test.go create mode 100644 gw/header.go create mode 100644 gw/header_test.go create mode 100644 gw/operator.go create mode 100644 gw/operator_test.go create mode 100644 gw/response.go create mode 100644 gw/response_test.go create mode 100644 gw/status.go create mode 100644 gw/status_test.go create mode 100644 health/checkers.go create mode 100644 health/dnsprobecheck.go create mode 100644 health/handler.go create mode 100644 health/httpgetcheck.go create mode 100644 health/types.go create mode 100644 mw/README.md create mode 100644 mw/auth/interceptor.go create mode 100644 mw/auth/interceptor_test.go create mode 100644 mw/auth/options.go create mode 100644 mw/auth/options_test.go create mode 100644 mw/auth/tenantid.go create mode 100644 mw/auth/tenantid_test.go create mode 100644 mw/operator.go create mode 100644 mw/operator_test.go create mode 100644 op/collection_operators.pb.go create mode 100644 op/collection_operators.proto create mode 100644 op/fields.go create mode 100644 op/fields_test.go create mode 100644 op/filtering.go create mode 100644 op/filtering_lexer.go create mode 100644 op/filtering_lexer_test.go create mode 100644 op/filtering_parser.go create mode 100644 op/filtering_parser_test.go create mode 100644 op/filtering_test.go create mode 100644 op/gorm/collection_operators.go create mode 100644 op/gorm/collection_operators_test.go create mode 100644 op/gorm/filtering.go create mode 100644 op/gorm/filtering_test.go create mode 100644 op/pagination.go create mode 100644 op/pagination_test.go create mode 100644 op/sorting.go create mode 100644 op/sorting_test.go create mode 100644 pb/README.md create mode 100644 pb/converter.go create mode 100644 pb/converter_test.go create mode 100644 pb/converter_test_objects.go create mode 100644 rpc/errdetails/error_details.go create mode 100644 rpc/errdetails/error_details.pb.go create mode 100644 rpc/errdetails/error_details.proto create mode 100644 rpc/errdetails/error_details_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..b91e857d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +vendor +bin +postgres-data +debug +.vscode diff --git a/Gopkg.lock b/Gopkg.lock new file mode 100644 index 00000000..d57a7f6c --- /dev/null +++ b/Gopkg.lock @@ -0,0 +1,228 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + name = "github.com/DATA-DOG/go-sqlmock" + packages = ["."] + revision = "d76b18b42f285b792bf985118980ce9eacea9d10" + version = "v1.3.0" + +[[projects]] + name = "github.com/davecgh/go-spew" + packages = ["spew"] + revision = "346938d642f2ec3594ed81d874461961cd0faa76" + version = "v1.1.0" + +[[projects]] + name = "github.com/dgrijalva/jwt-go" + packages = ["."] + revision = "06ea1031745cb8b3dab3f6a236daf2b0aa468b7e" + version = "v3.2.0" + +[[projects]] + branch = "master" + name = "github.com/golang/protobuf" + packages = [ + "jsonpb", + "proto", + "ptypes", + "ptypes/any", + "ptypes/duration", + "ptypes/struct", + "ptypes/timestamp", + "ptypes/wrappers" + ] + revision = "bbd03ef6da3a115852eaf24c8a1c46aeb39aa175" + +[[projects]] + name = "github.com/google/uuid" + packages = ["."] + revision = "064e2069ce9c359c118179501254f67d7d37ba24" + version = "0.2" + +[[projects]] + branch = "master" + name = "github.com/grpc-ecosystem/go-grpc-middleware" + packages = [ + ".", + "auth", + "logging/logrus/ctxlogrus", + "tags", + "tags/logrus", + "util/metautils" + ] + revision = "eb23b08d08bbe930113a6512a7a829050341448c" + +[[projects]] + name = "github.com/grpc-ecosystem/grpc-gateway" + packages = [ + "runtime", + "runtime/internal", + "utilities" + ] + revision = "07f5e79768022f9a3265235f0db4ac8c3f675fec" + version = "v1.3.1" + +[[projects]] + branch = "master" + name = "github.com/grpc-ecosystem/grpc-opentracing" + packages = ["go/otgrpc"] + revision = "0e7658f8ee99ee5aa683e2a032b8880091b7a055" + +[[projects]] + branch = "master" + name = "github.com/infobloxopen/go-trees" + packages = [ + "dltree", + "domaintree", + "iptree", + "numtree", + "strtree" + ] + revision = "ba3cf0abf6c4176275bfdabf40f4da38da29becb" + +[[projects]] + branch = "master" + name = "github.com/infobloxopen/themis" + packages = [ + "pdp", + "pdp-service", + "pep" + ] + revision = "cfc3dc97e1401bacaf2350cc813fbd0fb4fa519d" + +[[projects]] + name = "github.com/jinzhu/gorm" + packages = ["."] + revision = "6ed508ec6a4ecb3531899a69cbc746ccf65a4166" + version = "v1.9.1" + +[[projects]] + branch = "master" + name = "github.com/jinzhu/inflection" + packages = ["."] + revision = "04140366298a54a039076d798123ffa108fff46c" + +[[projects]] + name = "github.com/opentracing/opentracing-go" + packages = [ + ".", + "ext", + "log" + ] + revision = "1949ddbfd147afd4d964a9f00b24eb291e0e7c38" + version = "v1.0.2" + +[[projects]] + name = "github.com/pmezard/go-difflib" + packages = ["difflib"] + revision = "792786c7400a136282c1664665ae0a8db921c6c2" + version = "v1.0.0" + +[[projects]] + name = "github.com/sirupsen/logrus" + packages = ["."] + revision = "c155da19408a8799da419ed3eeb0cb5db0ad5dbc" + version = "v1.0.5" + +[[projects]] + name = "github.com/stretchr/testify" + packages = ["assert"] + revision = "12b6f73e6084dad08a7c6e575284b177ecafbc71" + version = "v1.2.1" + +[[projects]] + branch = "master" + name = "golang.org/x/crypto" + packages = ["ssh/terminal"] + revision = "80db560fac1fb3e6ac81dbc7f8ae4c061f5257bd" + +[[projects]] + branch = "master" + name = "golang.org/x/net" + packages = [ + "context", + "http2", + "http2/hpack", + "idna", + "internal/timeseries", + "lex/httplex", + "trace" + ] + revision = "6078986fec03a1dcc236c34816c71b0e05018fda" + +[[projects]] + branch = "master" + name = "golang.org/x/sys" + packages = [ + "unix", + "windows" + ] + revision = "641605214e7dab930817f68e2fef560efbb033e5" + +[[projects]] + name = "golang.org/x/text" + packages = [ + "collate", + "collate/build", + "internal/colltab", + "internal/gen", + "internal/tag", + "internal/triegen", + "internal/ucd", + "language", + "secure/bidirule", + "transform", + "unicode/bidi", + "unicode/cldr", + "unicode/norm", + "unicode/rangetable" + ] + revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0" + version = "v0.3.0" + +[[projects]] + branch = "master" + name = "google.golang.org/genproto" + packages = [ + "googleapis/rpc/code", + "googleapis/rpc/status" + ] + revision = "f8c8703595236ae70fdf8789ecb656ea0bcdcf46" + +[[projects]] + name = "google.golang.org/grpc" + packages = [ + ".", + "balancer", + "balancer/base", + "balancer/roundrobin", + "codes", + "connectivity", + "credentials", + "encoding", + "encoding/proto", + "grpclb/grpc_lb_v1/messages", + "grpclog", + "internal", + "keepalive", + "metadata", + "naming", + "peer", + "resolver", + "resolver/dns", + "resolver/passthrough", + "stats", + "status", + "tap", + "transport" + ] + revision = "8e4536a86ab602859c20df5ebfd0bd4228d08655" + version = "v1.10.0" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + inputs-digest = "ae9afecba22387a51b144a80aa90329104333fe037a29c14e495c4808168ef4f" + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml new file mode 100644 index 00000000..0aebe165 --- /dev/null +++ b/Gopkg.toml @@ -0,0 +1,68 @@ +# Gopkg.toml example +# +# Refer to https://golang.github.io/dep/docs/Gopkg.toml.html +# for detailed Gopkg.toml documentation. +# +# required = ["github.com/user/thing/cmd/thing"] +# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] +# +# [[constraint]] +# name = "github.com/user/project" +# version = "1.0.0" +# +# [[constraint]] +# name = "github.com/user/project2" +# branch = "dev" +# source = "github.com/myfork/project2" +# +# [[override]] +# name = "github.com/x/y" +# version = "2.4.0" +# +# [prune] +# non-go = false +# go-tests = true +# unused-packages = true +[[constraint]] + branch = "master" + name = "github.com/infobloxopen/themis" + +[[constraint]] + branch = "master" + name = "github.com/dgrijalva/jwt-go" + +[[constraint]] + branch = "master" + name = "github.com/golang/protobuf" + +[[constraint]] + branch = "master" + name = "github.com/grpc-ecosystem/go-grpc-middleware" + +[[constraint]] + name = "github.com/grpc-ecosystem/grpc-gateway" + version = "1.3.1" + +[[constraint]] + name = "github.com/lyft/protoc-gen-validate" + version = "0.0.5" + +[[constraint]] + name = "github.com/sirupsen/logrus" + version = "1.0.4" + +[[constraint]] + branch = "master" + name = "golang.org/x/net" + +[[constraint]] + branch = "master" + name = "google.golang.org/genproto" + +[[constraint]] + name = "google.golang.org/grpc" + version = "1.10.0" + +[prune] + go-tests = true + unused-packages = true diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..0fcbaf6b --- /dev/null +++ b/Makefile @@ -0,0 +1,42 @@ +# Absolute github repository name. +REPO := github.com/infobloxopen/atlas-app-toolkit + +# Build directory absolute path. +PROJECT_ROOT = $(CURDIR) + +# Utility docker image to build Go binaries +# https://github.com/infobloxopen/buildtool +BUILDTOOL_IMAGE := infoblox/buildtool:v8 + +# Utility docker image to generate Go files from .proto definition. +# https://github.com/infobloxopen/atlas-gentool +GENTOOL_IMAGE := infoblox/atlas-gentool:v2 + +BUILDER := docker run --rm -v $(PROJECT_ROOT):/go/src/$(REPO) -w /go/src/$(REPO) $(BUILDTOOL_IMAGE) +# Set BUILD_TYPE environment variable to "local" in order to use local go instance instead of buildtool +ifeq ($(BUILD_TYPE), local) +BUILDER := +endif + +.PHONY: default +default: test + +test: check-fmt vendor + $(BUILDER) go test ./... + +vendor: + $(BUILDER) dep ensure + +check-fmt: + test -z `$(BUILDER) go fmt ./...` + +.gen-op: + docker run --rm -v $(PROJECT_ROOT):/go/src/$(REPO) $(GENTOOL_IMAGE) \ + --go_out=:. $(REPO)/op/collection_operators.proto + +.gen-errdetails: + docker run --rm -v $(PROJECT_ROOT):/go/src/$(REPO) $(GENTOOL_IMAGE) \ + --go_out=:. $(REPO)/rpc/errdetails/error_details.proto + +.PHONY: gen +gen: .gen-op .gen-errdetails diff --git a/README.md b/README.md new file mode 100644 index 00000000..90bc00a5 --- /dev/null +++ b/README.md @@ -0,0 +1,783 @@ +# NGP API Toolkit + +1. [Getting Started](#getting-started) + 1. [Plugins](#plugins) + 1. [gRPC Protobuf](#grpc-protobuf) + 2. [gRPC Gateway](#grpc-gateway) + 3. [Middlewares](#middlewares) + 4. [Validation](#validation) + 5. [Documentation](#documentation) + 6. [Swagger](#swagger) + 2. [Build image](#build-image) + 3. [Example](#example) +2. [REST API Syntax Specification](#rest-api-syntax-specification) + 1. [Resources and Collections](#resources-and-collections) + 2. [HTTP Headers](#http-headers) + 3. [Responses](#responses) + 4. [Errors](#errors) + 5. [Collection Operators](#collection-operators) + 1. [Field Selection](#field-selection) + 2. [Sorting](#sorting) + 3. [Filtering](#filtering) + 4. [Pagination](#pagination) + + +## Getting Started + +Toolkit provides a means to have a generated code that supports a certain common functionality that +is typicall requesred for any service. +Toolkit declares its own format for Resposes, Errors, Long Running operations, Collection operators. +More details on this can be found in appropriate section on this page. + +Tollkit approach provides following features: +- Application may be composed from one or more independent services (micro-service architecture) +- Service is supposed to be a gRPC service +- REST API is presented by a separate service (gRPC Gateway) that serves as a reverse-proxy and +forwards incoming HTTP requests to gRPC services + +### Plugins + +NGP API Toolkit is not a framework it is a set of plugins for Google Protocol Buffer compiler. + +#### gRPC Protobuf + +See official documentation for [Protocol Buffer](https://developers.google.com/protocol-buffers/) and +for [gRPC](https://grpc.io/docs) + +As an alternative you may use [this plugin](https://github.com/gogo/protobuf) to generate Golang code. That is the same +as official plugin but with [gadgets](https://github.com/gogo/protobuf/blob/master/extensions.md). + +#### gRPC Gateway + +See official [documentation](https://github.com/grpc-ecosystem/grpc-gateway) + +#### Middlewares + +The one of requirements for NGP API Toolkit was support of Pipeline model. +We recommend to use gRPC server interceptor as middleware. See [examples](https://github.com/grpc-ecosystem/go-grpc-middleware) + +##### GetTenantID + +We offer a convenient way to extract the TenantID field from an incoming authorization token. +For this purpose `mw.GetTenantID(ctx)` function can be used: +``` +func (s *contactsServer) Read(ctx context.Context, req *ReadRequest) (*ReadResponse, error) { + input := req.GetContact() + + tenantID, err := mw.GetTenantID(ctx) + if err == nil { + input.TenantId = tenantID + } else if input.GetTenantId() == "" { + return nil, err + } + + c, err := DefaultReadContact(ctx, input, s.db) + if err != nil { + return nil, err + } + return &ReadResponse{Contact: c}, nil +} +``` + +When bootstrapping a gRPC server, add middleware that will extract the tenant_id token from the request context and set it in the request struct. The middleware will have to navigate the request struct via reflection, in the case that the tenant_id field is nested within the request (like if it's in a request wrapper as per our example above) + + +#### Validation +We recommend to use [this validation plugin](https://github.com/lyft/protoc-gen-validate) to generate +`Validate` method for your gRPC requests. + +As an alternative you may use [this plugin](https://github.com/mwitkow/go-proto-validators) too. + +Validation can be invoked "automatically" if you add [this](https://github.com/grpc-ecosystem/go-grpc-middleware/tree/master/validator) middleware as a gRPC server interceptor. + +#### Documentation + +We recommend to use [this plugin](https://github.com/pseudomuto/protoc-gen-doc) to generate documentation. + +Documentation can be generated in different formats. + +Here are several most used instructions used in documentation generation: + +##### Leading comments + +Leading comments can be used everywhere. + +```proto +/** + * This is a leading comment for a message +*/ + +message SomeMessage { + // this is another leading comment + string value = 1; +} +``` + +##### Trailing comments + +Fields, Service Methods, Enum Values and Extensions support trailing comments. + +```proto +enum MyEnum { + DEFAULT = 0; // the default value + OTHER = 1; // the other value +} +``` + +##### Excluding comments + +If you want to have some comment in your proto files, but don't want them to be part of the docs, you can simply prefix the comment with @exclude. + +Example: include only the comment for the id field + +```proto +/** + * @exclude + * This comment won't be rendered + */ +message ExcludedMessage { + string id = 1; // the id of this message. + string name = 2; // @exclude the name of this message + + /* @exclude the value of this message. */ + int32 value = 3; +} +``` + +#### Swagger + +Optionally you may generate [Swagger](https://swagger.io/) schema from your proto file. +To do so install [this plugin](https://github.com/grpc-ecosystem/grpc-gateway/tree/master/protoc-gen-swagger). + +```sh +go get -u github.com/golang/protobuf/protoc-gen-go +``` + +Then invoke it as a plugin for Proto Compiler + +```sh +protoc -I/usr/local/include -I. \ + -I$GOPATH/src \ + -I$GOPATH/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis \ + --swagger_out=logtostderr=true:. \ + path/to/your_service.proto +``` + +##### How to add Swagger definitions in my proto scheme? + +```proto +import "protoc-gen-swagger/options/annotations.proto"; + +option (grpc.gateway.protoc_gen_swagger.options.openapiv2_swagger) = { + info: { + title: "My Service"; + version: "1.0"; + }; + schemes: HTTP; + schemes: HTTPS; + consumes: "application/json"; + produces: "application/json"; +}; + +message MyMessage { + option (grpc.gateway.protoc_gen_swagger.options.openapiv2_schema) = { + external_docs: { + url: "https://infoblox.com/docs/mymessage"; + description: "MyMessage description"; + } +}; +``` + +For more Swagger options see [this scheme](https://github.com/grpc-ecosystem/grpc-gateway/blob/master/protoc-gen-swagger/options/openapiv2.proto) + +See example [contacts app](https://github.com/infobloxopen/atlas-contacts-app/blob/master/proto/contacts.proto). +Here is a [generated Swagger schema](https://github.com/infobloxopen/atlas-contacts-app/blob/master/proto/contacts.swagger.json). + +**NOTE** [Well Known Types](https://developers.google.com/protocol-buffers/docs/reference/google.protobuf) are +generated in a bit unusual way: + +```json + "protobufEmpty": { + "type": "object", + "description": "service Foo {\n rpc Bar(google.protobuf.Empty) returns (google.protobuf.Empty);\n }\n\nThe JSON representation for `Empty` is empty JSON object `{}`.", + "title": "A generic empty message that you can re-use to avoid defining duplicated\nempty messages in your APIs. A typical example is to use it as the request\nor the response type of an API method. For instance:" + }, +``` + +### Build Image + +For convenience purposes there is an atlas-gentool image available which contains a pre-installed set of often used plugins. +For more details see [infobloxopen/atlas-gentool](https://github.com/infobloxopen/atlas-gentool) repository. + +## Example + +An example app that is based on api-toolkit can be found [here](https://github.com/infobloxopen/atlas-contacts-app) + +## REST API Syntax Specification + +Toolkit enforces some of the API syntax requirements that are common for +applications that are written by Infoblox. All public REST API endpoints must follow the same guidelines mentioned below. + +### Resources and Collections + +#### How to define REST API Endpoints in my proto scheme? + +You can map your gRPC service methods to one or more REST API endpoints. +See [this reference](https://cloud.google.com/service-management/reference/rpc/google.api#http) how to do it. + +```proto +// It is possible to define multiple HTTP methods for one RPC by using +// the `additional_bindings` option. Example: +// +// service Messaging { +// rpc GetMessage(GetMessageRequest) returns (Message) { +// option (google.api.http) = { +// get: "/v1/messages/{message_id}" +// additional_bindings { +// get: "/v1/users/{user_id}/messages/{message_id}" +// } +// }; +// } +// } +// message GetMessageRequest { +// string message_id = 1; +// string user_id = 2; +// } +// +// +// This enables the following two alternative HTTP JSON to RPC +// mappings: +// +// HTTP | RPC +// -----|----- +// `GET /v1/messages/123456` | `GetMessage(message_id: "123456")` +// `GET /v1/users/me/messages/123456` | `GetMessage(user_id: "me" message_id: "123456")` +``` + +### HTTP Headers + +#### How are HTTP request headers mapped to gRPC client metadata? + +[Answer](https://github.com/grpc-ecosystem/grpc-gateway/wiki/How-to-customize-your-gateway#mapping-from-http-request-headers-to-grpc-client-metadata) + +#### How can I get HTTP request header on my gRPC service? + +To extract headers from metadata all you need is to use +[FromIncomingContext](https://godoc.org/google.golang.org/grpc/metadata#FromIncomingContext) function + +```golang +import ( + "context" + + "google.golang.org/grpc/metadata" + "github.com/grpc-ecosystem/grpc-gateway/runtime" +) + +func (s *myServiceImpl) MyMethod(ctx context.Context, req *MyRequest) (*MyResponse, error) { + var userAgent string + + if md, ok := metadata.FromIncomingContext(ctx); ok { + // Uppercase letters are automatically converted to lowercase, see metadata.New + if u, ok [runtime.MetadataPrefix+"user-agent"]; ok { + userAgen = u[0] + } + } +} +``` + +Also you can use our helper function `gw.Header()` + +```golang +import ( + "context" + + "github.com/infobloxopen/atlas-app-toolkit/gw" +) + +func (s *myServiceImpl) MyMethod(ctx context.Context, req *MyRequest) (*MyResponse, error) { + var userAgent string + + if h, ok := gw.Header(ctx, "user-agent"); ok { + userAgent = h + } +} +``` + +#### How can I send gRPC metadata? + +To send metadata to gRPC-Gateway from your gRPC service you need to use [SetHeader](https://godoc.org/google.golang.org/grpc#SetHeader) function. + +```golang +import ( + "context" + + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +func (s *myServiceImpl) MyMethod(ctx context.Context, req *MyRequest) (*MyResponse, error) { + md := metadata.Pairs("myheader", "myvalue") + if err := grpc.SetHeader(ctx, md); err != nil { + return nil, err + } + return nil, nil +} +``` + +If you do not use any custom outgoing header matcher you would see something like that: +```sh +> curl -i http://localhost:8080/contacts/v1/contacts + +HTTP/1.1 200 OK +Content-Type: application/json +Grpc-Metadata-Myheader: myvalue +Date: Wed, 31 Jan 2018 15:28:52 GMT +Content-Length: 2 + +{} +``` + +### Responses + +By default gRPC-Gateway translates non-error gRPC response into HTTP response +with status code set to `200 - OK`. + +A HTTP response returned from gRPC-Gateway does not comform REST API Syntax +and has no `success` section. + +In order to override this behavior gRPC-Gateway wiki recommends to overwrite +`ForwardResponseMessage` and `ForwardResponseStream` functions correspondingly. +See [this article](https://github.com/grpc-ecosystem/grpc-gateway/wiki/How-to-customize-your-gateway#replace-a-response-forwarder-per-method) + +#### How can I overwrite default Forwarders? + +``` +import ( + "github.com/infobloxopen/atlas-app-toolkit/gw" +) + +func init() { + forward_App_ListObjects_0 = gw.ForwardResponseMessage +} +``` + +You can also refer [example app](https://github.com/github.com/infobloxopen/atlas-contacts-app/pb/contacts/contacts.overwrite.pb.gw.go) + +#### Which forwarders I need to use to comply our REST API? + +We made default [ForwardResponseMessage](gw/response.go#L36) and [ForwardResponseMessage](gw/response.go#L38) +implementations that conform REST API Syntax. + +**NOTE** the forwarders still set `200 - OK` as HTTP status code if no errors encountered. + +### How can I set 201/202/204/206 HTTP status codes? + +In order to set HTTP status codes propely you need to send metadata from your +gRPC service so that default forwarders will be able to read them and set codes. +That is a common approach in gRPC to send extra information for response as +metadata. + +We recommend use [gRPC status package](https://godoc.org/google.golang.org/grpc/status) +and our custom function [SetStatus](gw/status.go#L44) to add extra metadata +to the gRPC response. + +See documentation in package [status](gw/status.go). + +Also you may use shortcuts like: `SetCreated`, `SetUpdated` and `SetDeleted`. + +```golang +import ( + "github.com/infobloxopen/atlas-app-toolkit/gw" +) + +func (s *myService) MyMethod(req *MyRequest) (*MyResponse, error) { + err := gw.SetCreated(ctx, "created 1 item") + return &MyResponse{Result: []*Item{item}}, err +} +``` + +### Response format +Services render resources in responses in JSON format by default unless another format is specified in the request Accept header that the service supports. + +Services must embed their response in a Success JSON structure. + +The Success JSON structure provides a uniform structure for expressing normal responses using a structure similar to the Error JSON structure used to render errors. The structure provides an enumerated set of codes and associated HTTP statuses (see Errors below) along with a message. + +The Success JSON structure has the following format. The results tag is optional and appears when the response contains one or more resources. +``` +{ + "success": { + "status": , + "code": , + "message": + }, + "results": +} +``` + +### Errors + +#### Format +Method error responses are rendered in the Error JSON format. The Error JSON format is similar to the Success JSON format for error responses using a structure similar to the Success JSON structure for consistency. + +The Error JSON structure has the following format. The details tag is optional and appears when the service provides more details about the error. +``` +{ + "error": { + "status": , + "code": , + "message": + }, + "details": [ + { + "message": , + "code": , + "target": , + }, + ... + ] +} +``` + +#### How can I convert a gRPC error to a HTTP error response in accordance with REST API Syntax Specification? + +You can write your own `ProtoErrorHandler` or use `gw.DefaultProtoErrorHandler` one. + +How to handle error on gRPC-Gateway see [article](https://mycodesmells.com/post/grpc-gateway-error-handler) + +How to use [gw.DefaultProtoErrorHandler](gw/errors.go#L25) see example below: + +```golang +import ( + "github.com/grpc-ecosystem/grpc-gateway/runtime" + "github.com/infobloxopen/atlas-app-toolkit/gw" + + "github.com/yourrepo/yourapp" +) + +func main() { + // create error handler option + errHandler := runtime.WithProtoErrorHandler(gw.DefaultProtoErrorHandler) + + // pass that option as a parameter + mux := runtime.NewServeMux(errHandler) + + // register you app handler + yourapp.RegisterAppHandlerFromEndpoint(ctx, mux, addr) + + ... + + // Profit! +} +``` + +You can find sample in example folder. See [code](example/cmd/gateway/main.go) + +#### How can I send error with details from my gRPC service? + +The idiomatic way to send an error from you gRPC service is to simple return +it from you gRPC handler either as `status.Errorf()` or `errors.New()`. + +```golang +import ( + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func (s *myServiceImpl) MyMethod(req *MyRequest) (*MyResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method is not implemented: %v", req) +} +``` + +To attach details to your error you have to use `grpc/status` package. +You can use our default implementation of error details (`rpc/errdetails`) or your own one. + +```golang +import ( + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/infobloxopen/atlas-app-toolkit/rpc/errdetails" +) + +func (s *myServiceImpl) MyMethod(req *MyRequest) (*MyResponse, error) { + s := status.New(codes.Unimplemented, "MyMethod is not implemented") + s = s.WithDetails(errdetails.New(codes.Internal), "myservice", "in progress") + return nil, s.Err() +} +``` + +With `gw.DefaultProtoErrorHandler` enabled JSON response will look like: +```json +{ + "error": { + "status": 501, + "code": "NOT_IMPLEMENTED", + "message": "MyMethod is not implemented" + }, + "details": [ + { + "code": "INTERNAL", + "message": "in progress", + "target": "myservice" + } + ] +} +``` + +### Collection Operators + +For methods that return collections, operations may be implemented using the following conventions. +The operations are implied by request parameters in query strings. + In some cases, stateful operational information may be passed in responses. +Toolkit introduces a set of common request parameters that can be used to control +the way collections are returned. API toolkit provides some convenience methods +to support these parameters in your application. + +#### How can I add support for collection operators in my gRPC-Gateway? + +You can enable support of collection operators in your gRPC-Gateway by adding +a `runtime.ServeMuxOption` using `runtime.WithMetadata(gw.MetadataAnnotator)`. + +```golang +import ( + "github.com/grpc-ecosystem/grpc-gateway/runtime" + "github.com/infobloxopen/atlas-app-toolkit/gw" + + "github.com/yourrepo/yourapp" +) + +func main() { + // create collection operator handler + opHandler := runtime.WithMetadata(gw.MetadataAnnotator) + + // pass that option as a parameter + mux := runtime.NewServeMux(opHandler) + + // register you app handler + yourapp.RegisterAppHandlerFromEndpoint(ctx, mux, addr) + + ... +} +``` + +If you want to explicitly declare one of collection operators in your `proto` +scheme, to do so just import `collection_operators.proto`. + +```proto +import "github.com/infobloxopen/atlas-app-toolkit/op/collection_operators.proto"; + +message MyRequest { + infoblox.api.Sorting sorting = 1; +} +``` + +After you declare one of collection operator in your `proto` message you need +to add `mw.WithCollectionOperator` server interceptor to the chain in your +gRPC service. + +```golang + server := grpc.NewServer( + grpc.UnaryInterceptor( + grpc_middleware.ChainUnaryServer( // middleware chain + ... + mw.WithCollectionOperator(), // collection operators + ... + ), + ), + ) +``` + +Doing so all collection operators that defined in your proto message will be +populated in case if they provided in incoming HTTP request. + +#### How can I apply collection operators passed to my GRPC service to a GORM query? + +You can use `ApplyCollectionOperators` method from [op/gorm](op/gorm) package. + +```golang +... +gormDB, err = ApplyCollectionOperators(gormDB, ctx) +if err != nil { + ... +} +var people []Person +gormDB.Find(&people) +... +``` + +Separate methods per each collection operator are also available. + +Check out [example](example/tagging/service.go) and [implementation](op/gorm/collection_operators.go). + +#### Field Selection + + +A service may implement field selection of collection data to reduce the volume of data in the result. A collection of response resources can be transformed by specifying a set of JSON tags to be returned. For a “flat” resource, the tag name is straightforward. If field selection is allowed on non-flat hierarchical resources, the service should implement a qualified naming scheme such as dot-qualification to reference data down the hierarchy. If a resource does not have the specified tag, the tag does not appear in the output resource. + +| Request Parameter | Description | +| ----------------- |------------------------------------------| +| _fields | A comma-separated list of JSON tag names.| + +API toolkit provides a default support to strip fields in response. As it is not possible to completely remove all the fields +(such as primitives) from `proto.Message`. Because of this fields are additionally truncated on `grpc-gateway`. From gRPC it is also possible +to access `_fields` from request, use them to perform data fetch operations and control output. This can be done by setting +appropriate metadata keys that will be handled by `grpc-gateway`. See example below: + +``` + fields := gw.FieldSelection(ctx) + if fields != nil { + // ... work with fields + gw.SetFieldSelection(ctx, fields) //in case fields were changed comparing to what was in request + } + +``` + +##### How to define field selection in my request? + +```proto +import "github.com/infobloxopen/atlas-app-toolkit/op/collection_operators.proto"; + +message MyRequest { + infoblox.api.FieldSelection fields = 1; +} +``` + +#### Sorting + +A service may implement collection sorting. A collection of response resources can be sorted by their JSON tags. For a “flat” resource, the tag name is straightforward. If sorting is allowed on non-flat hierarchical resources, the service should implement a qualified naming scheme such as dot-qualification to reference data down the hierarchy. If a resource does not have the specified tag, its value is assumed to be null. + +| Request Parameter | Description | +| ----------------- |------------------------------------------| +| _order_by | A comma-separated list of JSON tag names. The sort direction can be specified by a suffix separated by whitespace before the tag name. The suffix “asc” sorts the data in ascending order. The suffix “desc” sorts the data in descending order. If no suffix is specified the data is sorted in ascending order. | + +##### How to define sorting in my request? + +```proto +import "github.com/infobloxopen/atlas-app-toolkit/op/collection_operators.proto"; + +message MyRequest { + infoblox.api.Sorting sort = 1; +} +``` + +##### How can I get sorting operator on my gRPC service? + +You may get it by using `gw.Sorting` function. Please note that if `_order_by` +has not been specified in an incoming HTTP request `gw.Sorting` returns `nil, nil`. + +```golang +import ( + "context" + + "github.com/infobloxopen/atlas-app-toolkit/gw" + "github.com/infobloxopen/atlas-app-toolkit/op" +) + +func (s *myServiceImpl) MyMethod(ctx context.Context, req *MyRequest) (*MyResponse, error) { + if sort, err := gw.Sorting(ctx); err != nil { + return nil, err + // check if sort has been specified!!! + } else if sort != nil { + // do sorting + // + // if you use gORM you may do the following + // db.Order(sort.GoString()) + } +} +``` + +Also you may want to declare sorting parameter in your `proto` message. +In this case it will be populated automatically if you using +`mw.WithCollectionOperator` server interceptor. + +See documentation in [op package](op/sorting.go) + +#### Filtering + +A service may implement filtering. A collection of response resources can be filtered by a logical expression string that includes JSON tag references to values in each resource, literal values, and logical operators. If a resource does not have the specified tag, its value is assumed to be null. + + +| Request Parameter | Description | +| ----------------- |------------------------------------------| +| _filter | A string expression containing JSON tags, literal values, and logical operators. | + +Literal values include numbers (integer and floating-point), and quoted (both single- or double-quoted) literal strings, and “null”. The following operators are commonly used in filter expressions. + +| Operator | Description | Example | +| ------------ |--------------------------|----------------------------------------------------------| +| == | eq | Equal | city == ‘Santa Clara’ | +| != | ne | Not Equal | city != null | +| > | gt | Greater Than | price > 20 | +| >= | ge | Greater Than or Equal To | price >= 10 | +| < | lt | Less Than | price < 20 | +| <= | le | Less Than or Equal To | price <= 100 | +| and | Logical AND | price <= 200 and price > 3.5 | +| ~ | match | Matches Regex | name ~ “john .*” | +| !~ | nomatch | Does Not Match Regex | name !~ “john .*” | +| or | Logical OR | price <= 3.5 or price > 200 | +| not | Logical NOT | not price <= 3.5 | +| () | Grouping | (priority == 1 or city == ‘Santa Clara’) and price > 100 | + +Usage of filtering features from the toolkit is similar to [sorting](#sorting). + +Note: if you decide to use toolkit provided `infoblox.api.Filtering` proto type, then you'll not be able to use swagger schema generation, since it's plugin doesn't work with recursive nature of `infoblox.api.Filtering`. + +##### How to define filtering in my request? + +```proto +import "github.com/infobloxopen/atlas-app-toolkit/op/collection_operators.proto"; + +message MyRequest { + infoblox.api.Filtering filter = 1; +} +``` + +#### Pagination + +A service may implement pagination of collections. Pagination of response resources can be client-driven, server-driven, or both. + +Client-driven pagination is a model in which rows are addressable by offset and page size. This scheme is similar to SQL query pagination where row offset and page size determine the rows in the query response. + +Server-driven pagination is a model in which the server returns some amount of data along with a token indicating there is more data and where subsequent queries can get the next page of data. This scheme is used by AWS Dynamo where, depending on the individual resource size, pages can run into thousands of resources. + +Some data sources can provide the number of resources a query will generate, while others cannot. + +The paging model provided by the service is influenced by the expectations of the client. GUI clients prefer moderate page sizes, say no more than 1,000 resources per page. A “streaming” client may be able to consume tens of thousands of resources per page. + +Consider the service behavior when no paging parameters are in the request. Some services may provide all the resources unpaged, while other services may have a default page size and provide the first page of data. In either case, the service should document its paging behavior in the absence of paging parameters. + +Consider the service behavior when the query sorts or filters the data, and the underlying data is changing over time. A service may cache some amount of sorted and filtered data to be paged using client-driven paging, particularly in a GUI context. There is a trade-off between paged data coherence and changes to the data. The cache expiration time attempts to balance these competing factors. + + +| Paging Mode | Request Parameters | Response Parameters | Description | +| ---------------------- |--------------------|---------------------|--------------------------------------------------------------| +| Client-driven paging | _offset | | The integer index (zero-origin) of the offset into a collection of resources. If omitted or null the value is assumed to be “0”. | +| | _limit | | The integer number of resources to be returned in the response. The service may impose maximum value. If omitted the service may impose a default value. | +| | | _offset | The service may optionally* include the offset of the next page of resources. A null value indicates no more pages. | +| | | _size | The service may optionally include the total number of resources being paged. | +| Server-driven paging | _page_token | | The service-defined string used to identify a page of resources. A null value indicates the first page. | +| | | _page_token | The service response should contain a string to indicate the next page of resources. A null value indicates no more pages. | +| | | _size | The service may optionally include the total number of resources being paged. | +| Composite paging | _page_token | | The service-defined string used to identify a page of resources. A null value indicates the first page. | +| | _offset | | The integer index (zero-origin) of the offset into a collection of resources in the page defined by the page token. If omitted or null the value is assumed to be “0”. | +| | _limit | | The integer number of resources to be returned in the response. The service may impose maximum value. If omitted the service may impose a default value. | +| | | _page_token | The service response should contain a string to indicate the next page of resources. A null value indicates no more pages. | +| | | _offset | The service should include the offset of the next page of resources in the page defined by the page token. A null value indicates no more pages, at which point the client should request the page token in the response to get the next page. | +| | | _size | The service may optionally include the total number of resources being paged. | + +Note: Response offsets are optional since the client can often keep state on the last offset/limit request. + +##### How to define pagination in my request/response? + +```proto +import "github.com/infobloxopen/atlas-app-toolkit/op/collection_operators.proto"; + +message MyRequest { + infoblox.api.Pagination paging = 1; +} + +message MyResponse { + infoblox.api.PageInfo page = 1; +} +``` diff --git a/cli/atlas/README.md b/cli/atlas/README.md new file mode 100644 index 00000000..4da81220 --- /dev/null +++ b/cli/atlas/README.md @@ -0,0 +1,85 @@ +# Atlas CLI +This command-line tool helps developers become productive on Atlas. It aims to provide a better development experience by reducing the initial time and effort it takes to build applications. + +## Getting Started +These instructions will help you get the Atlas command-line tool up and running on your machine. + +### Prerequisites +Please install the following dependencies before running the Atlas command-line tool. + +#### goimports + +The `goimports` package resolves missing import paths in Go projects. It is part of the [official Go ecosystem](https://golang.org/pkg/#other) and can be installed with the following command: +``` +$ go get golang.org/x/tools/cmd/goimports +``` +#### dep + +This is a dependency management tool for Go. You can install `dep` with Homebrew: + +```sh +$ brew install dep +``` +More detailed installation instructions are available on the [GitHub repository](https://github.com/golang/dep). + +### Installing +The following steps will install the `atlas` binary to your `$GOBIN` directory. + +```sh +$ go get github.com/infobloxopen/atlas-app-toolkit/cli/atlas +``` +You're all set! Alternatively, you can clone the repository and install the binary manually. + +```sh +$ git clone https://github.com/infobloxopen/atlas-app-toolkit.git +$ cd ngp.api.toolkit/cli/atlas +$ go install +``` + +## Bootstrap an Application +Rather than build applications completely from scratch, you can leverage the command-line tool to initialize a new project. This will generate the necessary files and folders to get started. + +```sh +$ atlas init-app name=my-application +$ cd my-application +``` +#### Flags +Here's the full set of flags for the `init-app` command. + +| Flag | Description | Required | Default Value | +| ------------- | ----------------------------------------------------------- | ------------- | ------------- | +| `name` | The name of the new application | Yes | N/A | +| `gateway` | Initialize the application with a gRPC gateway | No | `false` | +| `registry` | The Docker registry where application images are pushed | No | `""` | + +You can run `atlas init-app --help` to see these flags and their descriptions on the command-line. + +#### Additional Examples + + +```sh +# generates an application with a grpc gateway +atlas init-app name=my-application -gateway +``` + +```sh +# specifies a docker registry +atlas init-app name=my-application -registry=infoblox +``` +Images names will vary depending on whether or not a Docker registry has been provided. + +```sh +# docker registry was provided +registry-name/image-name:image-version +``` + +```sh +# docker registry was not provided +image-name:image-version +``` + +Of course, you may include all the flags in the `init-app` command. + +```sh +atlas init-app name=my-application -gateway -registry=infoblox +``` \ No newline at end of file diff --git a/cli/atlas/formatting.go b/cli/atlas/formatting.go new file mode 100644 index 00000000..d675d4a3 --- /dev/null +++ b/cli/atlas/formatting.go @@ -0,0 +1,67 @@ +package main + +import ( + "fmt" + "strings" + "unicode" +) + +var ( + errEmptyServiceName = formatError{"empty service name"} + errInvalidFirstRune = formatError{"leading non-letter in service name"} + errInvalidProjectRoot = formatError{"project must be initialized inside $GOPATH/src directory"} +) + +// ServiceName takes a string and formats it into a valid gRPC service name +func ServiceName(str string) (string, error) { + if len(str) < 1 { + return str, errEmptyServiceName + } + if first := rune(str[0]); !unicode.IsLetter(first) { + return str, errInvalidFirstRune + } + // split string on one or more non-alphanumeric runes + fields := strings.FieldsFunc(str, isSpecial) + for i, _ := range fields { + fields[i] = strings.Title(fields[i]) + } + return strings.Join(fields, ""), nil +} + +// ServerURL takes a string and forms a valid URL string +func ServerURL(str string) (string, error) { + if len(str) < 1 { + return str, errEmptyServiceName + } + // split string on one or more non-alphanumeric runes + fields := strings.FieldsFunc(str, isSpecial) + url := strings.Join(fields, "-") + return strings.ToLower(url), nil +} + +// ProjectRoot determines the root directory of an application. The project +// root is considered to be anything after go/src/... +func ProjectRoot(dirString string) (string, error) { + dirs := strings.Split(dirString, "/") + for i, dir := range dirs { + if strings.ToLower(dir) == "go" && i+1 < len(dirs) { + if i+2 < len(dirs) && strings.ToLower(dirs[i+1]) == "src" { + return strings.Join(dirs[i+2:], "/"), nil + } + } + } + return "", errInvalidProjectRoot +} + +// isSpecial checks if rune is non-alphanumeric +func isSpecial(r rune) bool { + return (r < '0' || r > '9') && (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') +} + +type formatError struct { + msg string +} + +func (e formatError) Error() string { + return fmt.Sprintf("formatting error: %s", e.msg) +} diff --git a/cli/atlas/formatting_test.go b/cli/atlas/formatting_test.go new file mode 100644 index 00000000..486e33dc --- /dev/null +++ b/cli/atlas/formatting_test.go @@ -0,0 +1,124 @@ +package main + +import ( + "testing" +) + +func TestServiceName(t *testing.T) { + var tests = []struct { + appname string + expected string + err error + }{ + {"ddi.dns.config", "DdiDnsConfig", nil}, + {"ddi-dns_config&007!", "DdiDnsConfig007", nil}, + {"addressbook", "Addressbook", nil}, + {"addressBook", "AddressBook", nil}, + {"addressøB00k", "AddressB00k", nil}, + {"", "", errEmptyServiceName}, + {"4ddressBook", "4ddressBook", errInvalidFirstRune}, + } + for _, test := range tests { + name, err := ServiceName(test.appname) + if err != test.err { + t.Log(err) + t.Errorf("Unexpected formatting error: %s - expected %s", name, test.expected) + } + if name != test.expected { + t.Errorf("Unexpected service name: %s - expected %s", name, test.expected) + } + } +} + +func TestServerURL(t *testing.T) { + var tests = []struct { + appname string + expected string + err error + }{ + {"ddi.dns.config", "ddi-dns-config", nil}, + {"ddi-dns_config&007!", "ddi-dns-config-007", nil}, + {"addressbook", "addressbook", nil}, + {"addressBook", "addressbook", nil}, + {"addressøB00k", "address-b00k", nil}, + {"", "", errEmptyServiceName}, + {"4ddressbook", "4ddressbook", nil}, + } + for _, test := range tests { + name, err := ServerURL(test.appname) + if err != test.err { + t.Errorf("Unexpected formatting error: %v - expected %v", err, test.err) + } + if name != test.expected { + t.Errorf("Unexpected service name: %s - expected %s", name, test.expected) + } + } +} + +func TestProjectRoot(t *testing.T) { + var tests = []struct { + path string + expected string + err error + }{ + { + "go/src/ProjectRoot", + "ProjectRoot", + nil, + }, + { + "go/src/github.com/secret_project", + "github.com/secret_project", + nil, + }, + { + "/Users/john/go/src/github.com/infobloxopen/helloWorld", + "github.com/infobloxopen/helloWorld", + nil, + }, + { + "/Users/john/go", + "", + errInvalidProjectRoot, + }, + { + "go/src", + "", + errInvalidProjectRoot, + }, + } + for _, test := range tests { + root, err := ProjectRoot(test.path) + if root != test.expected { + t.Errorf("Unexpected service name: %s - expected %s", root, test.expected) + } + if err != test.err { + t.Errorf("Unexpected formatting error: %v - expected %v", err, test.err) + } + } +} + +func TestIsSpecial(t *testing.T) { + var tests = []struct { + r rune + expected bool + }{ + {'a', false}, + {'z', false}, + {'A', false}, + {'Z', false}, + {'0', false}, + {'9', false}, + {'_', true}, + {'.', true}, + {'&', true}, + {'/', true}, + {' ', true}, + {'Σ', true}, + } + for _, test := range tests { + if result := isSpecial(test.r); result != test.expected { + t.Errorf("Unexpected alphanumeric result: %t - expected %t", result, test.expected) + } + } +} diff --git a/cli/atlas/main.go b/cli/atlas/main.go new file mode 100644 index 00000000..7606b319 --- /dev/null +++ b/cli/atlas/main.go @@ -0,0 +1,237 @@ +//go:generate go-bindata -o template-bindata.go templates/... +package main + +import ( + "errors" + "flag" + "fmt" + "html/template" + "os" + "os/exec" + "strings" +) + +const ( + // the full set of command names + COMMAND_INIT_APP = "init-app" + + // the full set of flag names + FLAG_NAME = "name" + FLAG_REGISTRY = "registry" + FLAG_GATEWAY = "gateway" +) + +var ( + // flagset for initializing the application + initialize = flag.NewFlagSet(COMMAND_INIT_APP, flag.ExitOnError) + initializeName = initialize.String(FLAG_NAME, "", "the application name (required)") + initializeRegistry = initialize.String(FLAG_REGISTRY, "", "the Docker registry (optional)") + initializeGateway = initialize.Bool(FLAG_GATEWAY, false, "generate project with a gRPC gateway (default false)") +) + +func main() { + commandList := []string{COMMAND_INIT_APP} + if len(os.Args) < 2 { + fmt.Printf("Command is required. Please choose one of %v\n", fmt.Sprint(commandList)) + os.Exit(1) + } + switch command := os.Args[1]; command { + case COMMAND_INIT_APP: + initialize.Parse(os.Args[2:]) + initializeApplication() + default: + fmt.Printf("Command \"%s\" is not valid. Please choose one of %v\n", command, fmt.Sprint(commandList)) + os.Exit(1) + } +} + +type Application struct { + Name string + Registry string + Root string + WithGateway bool +} + +func (a Application) GenerateDockerfile() { + a.generateFile("docker/Dockerfile.server", "templates/docker/Dockerfile.application.gotmpl") +} + +func (a Application) GenerateGatewayDockerfile() { + a.generateFile("docker/Dockerfile.gateway", "templates/docker/Dockerfile.gateway.gotmpl") +} + +func (a Application) GenerateReadme() { + a.generateFile("README.md", "templates/README.md.gotmpl") +} + +func (a Application) GenerateGitignore() { + a.generateFile(".gitignore", "templates/.gitignore.gotmpl") +} + +func (a Application) GenerateMakefile() { + a.generateFile("Makefile", "templates/Makefile.gotmpl") +} + +func (a Application) GenerateProto() { + a.generateFile("proto/service.proto", "templates/proto/service.proto.gotmpl") +} + +func (a Application) GenerateServerMain() { + a.generateFile("cmd/server/main.go", "templates/cmd/server/main.go.gotmpl") +} + +func (a Application) GenerateGatewayMain() { + a.generateFile("cmd/gateway/main.go", "templates/cmd/gateway/main.go.gotmpl") +} + +func (a Application) GenerateGatewayHandler() { + a.generateFile("cmd/gateway/handler.go", "templates/cmd/gateway/handler.go.gotmpl") +} + +func (a Application) GenerateGatewaySwagger() { + a.generateFile("cmd/gateway/swagger.go", "templates/cmd/gateway/swagger.go.gotmpl") +} + +func (a Application) GenerateConfig() { + a.generateFile("cmd/config/config.go", "templates/cmd/config/config.go.gotmpl") +} + +func (a Application) GenerateService() { + a.generateFile("svc/zserver.go", "templates/svc/zserver.go.gotmpl") +} + +// generateFile creates a file by rendering a template +func (a Application) generateFile(filename, templatePath string) { + t := template.New("file").Funcs(template.FuncMap{ + "Title": strings.Title, + "Service": ServiceName, + "URL": ServerURL, + }) + bytes, err := Asset(templatePath) + if err != nil { + panic(err) + } + t, err = t.Parse(string(bytes)) + if err != nil { + panic(err) + } + file, err := os.Create(filename) + if err != nil { + panic(err) + } + defer file.Close() + if err := t.Execute(file, a); err != nil { + panic(err) + } +} + +// directories returns a list of all project folders +func (a Application) directories() []string { + dirnames := []string{ + "cmd/server", + "cmd/config", + "pb", + "svc", + "proto", + "docker", + "deploy", + "migrations", + } + if a.WithGateway { + dirnames = append(dirnames, fmt.Sprintf("cmd/%s", "gateway")) + } + return dirnames +} + +// initializeApplication generates brand-new application +func initializeApplication() { + name := *initializeName + if *initializeName == "" { + initialize.PrintDefaults() + os.Exit(1) + } + wd, err := os.Getwd() + if err != nil { + printErr(err) + } + root, err := ProjectRoot(wd) + if err != nil { + printErr(err) + } + app := Application{name, *initializeRegistry, root, *initializeGateway} + // initialize project directories + if _, err := os.Stat(name); !os.IsNotExist(err) { + msg := fmt.Sprintf("directory '%v' already exists.", name) + printErr(errors.New(msg)) + } + os.Mkdir(name, os.ModePerm) + os.Chdir(name) + for _, dir := range app.directories() { + os.MkdirAll(fmt.Sprintf("./%s", dir), os.ModePerm) + } + // initialize project files + app.GenerateDockerfile() + app.GenerateReadme() + app.GenerateGitignore() + app.GenerateMakefile() + app.GenerateProto() + app.GenerateServerMain() + app.GenerateConfig() + app.GenerateService() + if app.WithGateway { + app.GenerateGatewayDockerfile() + app.GenerateGatewayMain() + app.GenerateGatewayHandler() + app.GenerateGatewaySwagger() + } + // run post-initialization commands + if err := generateProtobuf(); err != nil { + printErr(err) + } + if err := resolveImports(app.directories()); err != nil { + printErr(err) + } + if err := initDep(); err != nil { + printErr(err) + } +} + +func printErr(err error) { + fmt.Printf("Unable to initialize application: %s\n", err.Error()) + os.Exit(1) +} + +// initDep calls "dep init" to generate .toml files +func initDep() error { + fmt.Print("Starting dep project... ") + err := exec.Command("dep", "init").Run() + if err != nil { + return err + } + fmt.Println("done!") + return nil +} + +// generateProtobuf calls "make protobuf" to render initial .pb files +func generateProtobuf() error { + fmt.Print("Generating protobuf files... ") + err := exec.Command("make", "protobuf").Run() + if err != nil { + return err + } + fmt.Println("done!") + return nil +} + +// resolveImports calls "goimports" to determine Go imports +func resolveImports(dirs []string) error { + fmt.Print("Resolving imports... ") + for _, dir := range dirs { + err := exec.Command("goimports", "-w", dir).Run() + if err != nil { + return err + } + } + fmt.Println("done!") + return nil +} diff --git a/cli/atlas/template-bindata.go b/cli/atlas/template-bindata.go new file mode 100644 index 00000000..47315efb --- /dev/null +++ b/cli/atlas/template-bindata.go @@ -0,0 +1,504 @@ +// Code generated by go-bindata. +// sources: +// templates/.gitignore.gotmpl +// templates/Makefile.gotmpl +// templates/README.md.gotmpl +// templates/cmd/config/config.go.gotmpl +// templates/cmd/gateway/handler.go.gotmpl +// templates/cmd/gateway/main.go.gotmpl +// templates/cmd/gateway/swagger.go.gotmpl +// templates/cmd/server/main.go.gotmpl +// templates/docker/Dockerfile.application.gotmpl +// templates/docker/Dockerfile.gateway.gotmpl +// templates/proto/service.proto.gotmpl +// templates/svc/zserver.go.gotmpl +// DO NOT EDIT! + +package main + +import ( + "bytes" + "compress/gzip" + "fmt" + "io" + "io/ioutil" + "os" + "path/filepath" + "strings" + "time" +) + +func bindataRead(data []byte, name string) ([]byte, error) { + gz, err := gzip.NewReader(bytes.NewBuffer(data)) + if err != nil { + return nil, fmt.Errorf("Read %q: %v", name, err) + } + + var buf bytes.Buffer + _, err = io.Copy(&buf, gz) + clErr := gz.Close() + + if err != nil { + return nil, fmt.Errorf("Read %q: %v", name, err) + } + if clErr != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +type asset struct { + bytes []byte + info os.FileInfo +} + +type bindataFileInfo struct { + name string + size int64 + mode os.FileMode + modTime time.Time +} + +func (fi bindataFileInfo) Name() string { + return fi.name +} +func (fi bindataFileInfo) Size() int64 { + return fi.size +} +func (fi bindataFileInfo) Mode() os.FileMode { + return fi.mode +} +func (fi bindataFileInfo) ModTime() time.Time { + return fi.modTime +} +func (fi bindataFileInfo) IsDir() bool { + return false +} +func (fi bindataFileInfo) Sys() interface{} { + return nil +} + +var _templatesGitignoreGotmpl = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x4a\xca\xcc\xe3\x02\x04\x00\x00\xff\xff\x0c\xbf\xc6\xec\x04\x00\x00\x00") + +func templatesGitignoreGotmplBytes() ([]byte, error) { + return bindataRead( + _templatesGitignoreGotmpl, + "templates/.gitignore.gotmpl", + ) +} + +func templatesGitignoreGotmpl() (*asset, error) { + bytes, err := templatesGitignoreGotmplBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "templates/.gitignore.gotmpl", size: 4, mode: os.FileMode(420), modTime: time.Unix(1522341651, 0)} + a := &asset{bytes: bytes, info: info} + return a, nil +} + +var _templatesMakefileGotmpl = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\xb4\x56\x51\x8f\x9b\x38\x10\x7e\xc6\xbf\x62\x14\x45\x6a\xdc\x3b\x83\xee\xed\xc4\x29\x6a\x69\x96\x66\xb9\xee\xc2\x8a\xb0\xad\x56\xaa\x84\x58\x30\x2c\x2a\xc1\x1c\x98\x6c\x57\xbb\xf9\xef\x27\x83\x1d\x20\xd9\xf4\x54\x55\x97\x87\x80\xed\xf1\x37\x9f\xbf\xf1\xcc\x70\xe3\x7b\x7f\xdb\xab\x20\xf4\x3d\x2f\x00\x4d\xd3\x34\x73\x09\xcf\xcf\xa0\xfb\x8c\x71\xd8\xef\x0d\xf1\xee\x46\x5b\x0a\xfb\x3d\xfa\x70\xeb\x5c\x5d\x84\x37\x56\x70\x09\xca\xf4\x3e\x2f\xd1\x85\xb7\xfa\x64\xfb\x1f\x9d\x2b\xbb\x5f\xeb\x16\xe6\x8b\xd5\xad\x7f\xe1\xf8\xd8\x48\x58\xfc\x8d\xd6\x08\xdd\x6e\x6c\xdf\xb5\xae\x6d\x18\xfd\x3a\x43\xb1\x80\xd1\xda\x09\xc2\x95\x77\x7d\xed\xf4\x34\x7a\x8c\xe6\x81\x16\x05\x64\x39\x87\x84\x36\x71\x9d\xdf\x53\x20\x24\xc9\x6b\xfe\xb4\x24\x6d\xd9\xb4\x55\xc5\x6a\x4e\x13\x20\x24\x2a\x1e\xa3\xa7\x06\x5e\x5e\x80\xc6\x0f\x0c\xaa\x9a\x92\x98\x6d\xb7\x39\xc7\xc8\xb9\xb6\xd6\x76\xf8\xd9\xf6\x37\x8e\xe7\x0a\xe8\x77\xca\xab\xa0\x83\x49\x42\x77\x64\xbe\x18\xfc\x63\x84\x36\xb6\xff\xd9\xf6\xc3\x0f\x8e\x6b\xf9\x77\xa0\xd8\x0c\xe7\xc7\x46\x43\xeb\x1d\xad\x95\xa1\x3c\xb7\xb4\x1b\x6b\x8a\x8d\x78\x9b\x1c\x59\x77\x84\x06\xad\xf3\x14\x74\x9f\x66\x79\xc3\xeb\x27\xd8\xef\x3b\xf5\x87\xa1\x88\x00\x2d\x13\xb9\x20\x43\x61\xce\x17\x93\x53\x61\x05\x3d\xc4\x02\x7a\x2e\x47\xc1\xc1\xc6\x45\x17\x8e\x34\x2f\xa8\x2e\x59\x49\x0a\x5f\x72\xfe\xb0\x8e\x38\x7d\x8c\x84\x5b\xb4\xb6\x02\xfb\x8b\x75\xf7\x23\x0d\xb2\xde\xfa\x60\xfa\x5f\x2a\x1c\xdb\xff\xba\x0e\x44\x42\x9e\xea\xa1\x7c\xfc\x94\x20\x8a\xe0\xc1\x15\x0a\xec\x4d\x10\xde\xba\x4e\x10\x5e\x79\xeb\xe1\xd2\x72\xda\xf0\xb0\x2d\x73\xae\x17\x2c\x43\x32\x31\x82\xbb\x1b\x1b\xde\x2d\x61\x96\xd0\x34\x6a\x0b\x3e\x43\x79\x4a\xff\x81\x85\x12\x4d\xac\xe3\xdf\x87\x65\x8c\xb4\xb5\x27\xd3\x69\xfc\x13\x3c\x8d\x8c\x21\x6d\xe3\xaf\x84\x78\xa1\xe7\x86\x97\xde\x26\x18\x56\x55\x62\x24\x79\x5d\x0a\x21\xe6\x8b\xe8\xbe\xa9\x22\xfe\x00\xf3\x45\x11\x35\xfc\x91\xd5\x09\xcc\x17\xd7\xd6\x27\xbb\x3b\xe8\x95\xb3\x09\x30\xc6\x78\x80\x74\xdc\x70\xe5\xb9\x81\xe5\xb8\xb6\xdf\x23\x4a\x2a\xd8\x68\xea\xd8\x38\x8a\x5e\x47\x74\x65\xad\x2e\xed\x13\xa2\xa4\xfa\x96\x25\x79\x0d\xf3\xc5\x6b\xd0\xd8\x38\xba\x31\x8c\xc4\x51\xfc\x40\x91\x86\xb4\x3e\x10\xa1\x7f\xeb\xba\xb6\x3f\x86\xec\x0b\x06\xd4\x6d\x09\x84\xd4\xdb\x73\x96\xbf\x2d\x81\xec\x46\x7e\xa5\x4a\xd8\x3c\x43\xe5\x80\xd3\x11\x3a\x00\x09\x8f\x79\x99\xb2\xfb\x82\x7d\x37\xee\xdb\xbc\x48\x38\x63\x85\xb9\xfb\xf3\x60\xbf\xb6\x5d\xdb\xb7\x02\xcf\x3f\xb1\x8f\x39\x33\x32\x5a\xf6\x1b\xfe\x40\xda\x14\x19\xc6\x01\x9b\x9c\x01\x03\x79\x3c\xa7\xd8\x60\x2b\xd1\x84\xfa\x13\x06\x3f\xc2\x3d\x4c\x1c\xb6\x60\x44\xcb\x24\x4f\x11\x5a\x7b\x3d\x62\xf8\xf1\xca\x5a\x6f\x64\x15\x54\x71\xc5\x40\x72\x20\x3b\x61\xd4\x5d\x79\x69\x23\x8c\xc8\x0e\x48\xcc\x44\xa1\x50\x8b\x37\xd6\xea\x93\xb5\xb6\x37\xe3\xab\x28\xe3\x2c\x38\x64\x0c\x8a\xbc\xe1\xa0\x1b\xba\xae\xc3\x0b\x64\x35\xad\x04\xca\x4c\x37\x76\xb4\x4c\x58\x6d\xcc\x44\xbd\xb2\xfc\xd5\x65\xb8\xf6\xc4\x05\xdd\x8c\x8b\x8b\x80\x48\xf3\x32\x01\x1d\x48\xc9\x38\x90\xee\x62\xbf\x79\xab\x36\xbf\x7d\x03\x84\x3f\x55\x14\x52\x20\xdd\xed\x9f\xbd\xd5\x33\x36\x43\x48\xbf\xb9\xf4\xdc\x3b\x13\x64\x7e\x21\xf9\x34\xbb\x6c\x85\xbe\xd8\xbd\x5a\xeb\x40\xe6\xfd\x90\xf6\x07\xac\xa8\x28\x50\x54\x14\x26\xf4\xce\xa1\xaa\x19\x67\xf7\x6d\xfa\x2b\x98\xe9\x96\xa3\x74\xcb\x4d\xa4\xbd\x9f\x2f\xa6\x3a\x60\x20\xf4\x3b\x8d\x21\x63\xe9\x96\x03\x69\xc4\x3d\x79\xde\xc3\xd7\xbf\x86\xdd\xc2\x31\x12\x7f\x3d\x90\xc0\x18\x2b\xdf\xf1\xea\xe2\x3a\xc4\x11\x8f\x26\x54\xec\xf0\x80\x28\xdb\x40\xff\x50\x43\xd2\xa5\x82\x1a\xa8\x1e\x3e\xdd\xd2\xdb\xa0\xf1\xc0\x3c\x21\xd4\xe3\x74\x04\x46\xd7\x0f\x03\x61\x22\x03\xc6\x7d\x16\x0f\x13\x5d\xb5\x38\x71\x27\x59\x4c\x46\x47\x5c\xb4\xf7\xb2\x7a\xf4\x6e\x49\x3a\x60\x0e\x85\x1f\x03\xe1\xc3\x7c\xd7\x38\x30\xe8\x67\xfa\xa0\xe2\xa0\x7a\x83\xea\x38\x6a\x42\x0a\xa5\x46\x92\xe3\xff\xa1\xc2\xc4\x21\x9a\x8c\x7e\x52\xf6\x69\x6f\xc7\xa3\x99\x33\x2e\xe5\xa9\xa6\x43\xf3\x55\xb5\x4f\x1b\xaf\x94\x7b\xd2\xf5\xa5\xde\x32\x35\x9e\x9f\xc9\x71\xf7\x3f\x50\xa8\xda\xe6\x01\x89\xbf\x91\x3b\x31\x3c\x0e\xe0\xeb\xd1\x3b\xde\x32\x25\x31\x66\xa0\xde\x0e\x7e\x65\xa6\x23\xf5\xd2\x6b\x3c\xd4\x55\xf8\x8a\x34\x42\x32\x16\xb2\x96\x2f\xab\xa2\xcd\xf2\xb2\x59\x66\x75\x15\x9b\xba\x58\x7a\x85\x0f\x11\x84\x08\x11\x36\xea\xc3\xa5\xdb\x5c\xb0\x8c\xb3\x86\x27\xb4\xae\x97\xbc\x6e\xe9\x01\x40\x50\x92\x9b\x76\x51\x91\x27\x11\xa7\xdd\x86\x59\x11\x95\xd9\x32\x63\xa6\x3e\xeb\x59\x34\x8f\x51\x96\xd1\xba\x5b\x34\xf5\x93\x8f\xaf\xee\x08\xdd\x47\x68\x1e\x53\xbd\x1b\x0d\x31\xee\x2b\x1b\xea\x1f\x26\xd2\x46\xf7\x28\xa1\x15\xd0\xb2\x69\x6b\x0a\xa4\x5f\x27\xac\x2c\x9e\x8e\xf7\x92\xb6\x12\xd4\xd0\x64\x74\x0e\x09\xfd\x1b\x00\x00\xff\xff\x59\x49\x12\x90\x72\x0c\x00\x00") + +func templatesMakefileGotmplBytes() ([]byte, error) { + return bindataRead( + _templatesMakefileGotmpl, + "templates/Makefile.gotmpl", + ) +} + +func templatesMakefileGotmpl() (*asset, error) { + bytes, err := templatesMakefileGotmplBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "templates/Makefile.gotmpl", size: 3186, mode: os.FileMode(420), modTime: time.Unix(1522342462, 0)} + a := &asset{bytes: bytes, info: info} + return a, nil +} + +var _templatesReadmeMdGotmpl = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x5c\x54\x4d\x6f\xdc\x36\x10\xbd\xf3\x57\x0c\xe0\x4b\x0d\x34\xda\x38\x06\xfa\x91\x9b\x8b\xb8\x41\x0f\x69\x83\x38\x48\x0e\x46\x11\x8f\xa4\x27\x89\x05\xc5\x61\x39\xa3\xdd\x08\x41\xfe\x7b\x41\x4a\xde\x3a\x39\xed\x42\xf3\xf1\xde\xcc\x7b\xc3\x0b\xfa\xf2\x85\x9a\x3f\x79\x06\x7d\xfd\xea\xdc\xa7\xf7\x93\x57\x1a\x11\x91\xd9\xd0\xd3\xbb\xdb\x9b\x57\x6f\x6e\x9b\xb9\xa7\xc1\x07\x50\x10\x51\x84\x95\x06\x09\x41\x4e\x4a\x4c\xf7\x49\xd2\x12\x38\x93\x61\x4e\x81\x0d\x7f\xff\x30\x99\x25\x7d\x79\x38\x8c\x5e\xad\x19\xbd\x4d\x4b\xdb\x74\x32\x1f\xde\x2e\x39\x05\xfc\x26\x62\xd3\xe1\xea\xf9\xaf\xd7\x57\x57\x6d\xfb\xfc\xfa\xa7\xab\xe1\xfa\x45\xff\xcb\xcf\xfc\xe2\xb2\xf9\xe4\xdc\x5f\x11\x94\x38\xf3\x98\x39\x4d\x24\x03\xa5\x2c\xff\xa0\x33\xea\xa1\x5d\xf6\xc9\xbc\x44\x1a\x05\x4a\x13\x32\x1a\xe7\x2e\x2e\xe8\x35\xcc\x7c\x1c\xe9\xce\x38\x1b\x7a\xe7\xde\x4f\x50\x90\x8f\x6a\x79\xe9\x4a\x85\xd2\xc9\x87\x40\x23\x8c\x56\x59\x88\xa9\x93\xb4\x96\xee\x36\xe1\x8c\xb0\x24\xe2\xd8\x53\x5e\x62\x2c\xdd\x24\x96\xdc\x4c\x41\x3a\x0e\x34\x73\x37\xf9\x08\x1a\x24\x53\x8f\x23\x82\xa4\x19\xd1\x6a\x85\x41\x2b\x7e\x5a\x72\x12\x85\x36\x74\x07\x50\x8f\x14\x64\xad\x49\xa5\x28\x8a\x41\x4b\xd3\x49\x4e\x64\xb2\x87\xbf\x21\x20\x91\x98\x82\x3f\x82\x74\x55\xc3\x5c\xa7\xbb\xa0\xb7\x19\x19\xff\x2e\x5e\xbd\x41\x9d\xfb\x38\xb1\x91\x4d\x3e\x8e\x5a\x87\x89\x40\x5f\x1a\x96\x71\x39\x84\xda\x51\x65\xb0\x13\x67\x54\x7a\x3b\xe0\x93\x78\xe9\xfc\xf0\xf0\xe0\x5e\x17\x2c\x7c\xe6\x39\x05\x68\xfd\x52\x01\xff\xd8\x32\x7d\x1c\x9d\xbb\x21\x35\xa4\x67\xed\xfa\xac\xfc\x92\x22\xfb\x32\xc6\x70\x2e\x23\xab\x7c\x10\x42\x65\x33\xf1\x11\x05\xad\xac\x9a\xbf\xd9\x14\xe2\xd1\x67\x89\xf5\xff\xbe\xe3\xc6\xb9\x3b\x5e\xe9\xb4\x4d\x84\x0a\xb5\x29\xd5\xe2\x29\xc5\x12\xdb\xf1\x36\x96\x37\x45\x27\x24\xb0\xed\x69\x4b\x34\x1f\x68\xf0\xd1\xeb\x84\x7e\x4b\xba\x8d\x3d\x9d\xbc\x4d\xc4\xf1\xb1\xba\x10\x1f\x77\xb7\xa8\xcc\xa0\x9e\x8d\x49\x16\x7b\xf4\xc2\xb6\x78\x92\x4c\x8b\x96\x24\xbf\x89\x57\x64\x31\x0b\x45\xd4\x59\x36\xd3\xbd\x3a\xcb\xeb\xdc\x4d\xdf\x13\xf7\xbd\x2f\x56\xe3\xb0\x4b\xcd\x6d\xe9\xfb\xbd\xda\x5e\x89\x53\x0a\xbe\xe3\x92\xdc\xd0\x1b\x5e\x5b\x50\xf0\x6a\x1b\xa1\x4e\xe6\x59\x22\x25\x6f\x03\x87\xa0\x54\xed\xd6\x2e\xe3\x58\x29\x5b\xb9\xca\xd1\x43\x37\x0e\xef\x76\xab\x16\xe6\xc5\x84\xea\xdc\xed\xe7\x14\xd8\x9f\x5d\x96\x97\x58\xa3\xbc\x98\xcc\xf5\xa2\x6b\x5e\x1d\xaa\x92\x39\x3b\xed\xbc\xec\xff\xb7\xf5\xe8\x08\xfa\x80\xac\x5e\x62\x35\xc4\x47\xd0\xa2\xa0\xfb\x3b\xcc\x1f\x90\xb7\x6b\x7f\x79\x38\x28\xe6\x23\x72\x23\x79\x3c\x5c\xd6\xee\xc7\x73\x4d\x43\xbf\x57\x34\x3c\x7e\x53\xe2\x23\xfb\xc0\x6d\xc0\x8f\xa4\xd8\xf4\xbd\x37\x1e\xeb\x7d\x54\x5a\x19\x49\xd4\x9b\xe4\xf5\xe9\x7b\x72\x7e\x4a\xca\x61\x1e\xf6\xb3\x39\x94\xc2\xcb\x86\xdc\x7f\x01\x00\x00\xff\xff\x07\x15\x93\x01\xcb\x04\x00\x00") + +func templatesReadmeMdGotmplBytes() ([]byte, error) { + return bindataRead( + _templatesReadmeMdGotmpl, + "templates/README.md.gotmpl", + ) +} + +func templatesReadmeMdGotmpl() (*asset, error) { + bytes, err := templatesReadmeMdGotmplBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "templates/README.md.gotmpl", size: 1227, mode: os.FileMode(420), modTime: time.Unix(1522344113, 0)} + a := &asset{bytes: bytes, info: info} + return a, nil +} + +var _templatesCmdConfigConfigGoGotmpl = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x2a\x48\x4c\xce\x4e\x4c\x4f\x55\x48\xce\xcf\x4b\xcb\x4c\xe7\xe2\x4a\xce\xcf\x2b\x2e\x51\xd0\xe0\x52\x50\x08\x76\x0d\x0a\x73\x0d\x8a\x77\x74\x71\x09\x72\x0d\x0e\x56\xb0\x55\x50\x32\xd0\x03\x43\x2b\x4b\x03\x4b\x03\x25\x2e\x05\x85\xea\x6a\x85\xcc\x34\x05\xbd\xf0\xcc\x92\x0c\xf7\xc4\x92\xd4\xf2\xc4\x4a\x85\xda\x5a\x77\xc7\x10\xd7\x70\xc7\x48\x6c\xfa\x2c\x0c\x2c\xc0\xfa\x60\x4a\x42\x83\x7c\x40\xd2\xfa\xd5\xd5\x0a\x7a\x7e\x89\xb9\xa9\x0a\x35\x0a\x20\xa1\xda\x5a\xfd\x32\x43\x7d\x25\x90\xf1\xa9\x79\x29\x0a\xb5\xb5\x5c\x9a\x5c\x80\x00\x00\x00\xff\xff\x6f\xdc\xa5\x59\xa7\x00\x00\x00") + +func templatesCmdConfigConfigGoGotmplBytes() ([]byte, error) { + return bindataRead( + _templatesCmdConfigConfigGoGotmpl, + "templates/cmd/config/config.go.gotmpl", + ) +} + +func templatesCmdConfigConfigGoGotmpl() (*asset, error) { + bytes, err := templatesCmdConfigConfigGoGotmplBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "templates/cmd/config/config.go.gotmpl", size: 167, mode: os.FileMode(420), modTime: time.Unix(1522341651, 0)} + a := &asset{bytes: bytes, info: info} + return a, nil +} + +var _templatesCmdGatewayHandlerGoGotmpl = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x7c\x90\xb1\x6e\xa3\x40\x10\x86\x6b\xf6\x29\xfe\xeb\x40\x42\xeb\xde\x92\x2b\xdf\x9d\x7c\xc5\xd9\x96\xcf\xd2\x15\x51\x8a\x0d\x8c\x61\x15\x18\xd0\x30\xd8\x44\x84\x77\x8f\xd6\x98\x54\x51\xaa\xd5\xfc\xda\xf9\x66\xbe\x69\x5d\xf6\xea\x0a\x42\xed\x3c\x1b\xb3\x5a\x61\x4f\xb7\x71\x84\xdd\xbb\x9a\xf0\x8e\x7f\x24\x57\x9f\xd1\x34\xed\x1c\xe7\x15\x09\x84\xb4\x17\xee\xe0\x18\xbb\xf3\xf9\x88\xf2\x91\x6b\xe9\x14\x1d\xc9\x95\x3a\x68\x49\x28\x4e\xc7\x2d\x0a\xa7\x74\x73\x6f\xe6\xd2\x73\xf6\x35\x18\x9f\xe4\x38\xd3\x01\x59\xc3\x4a\x83\xda\xed\xfc\xa6\x70\x79\x2e\xe8\x54\x3c\x17\x29\x9a\x56\x3b\x58\x6b\xa5\x67\xf5\x35\xd9\xc0\xa0\xbf\xfd\x70\x68\xd5\x37\x9c\x20\x2e\x55\x5b\xfb\xe0\xa5\x20\x91\x46\x12\x8c\x26\xaa\xfb\x01\xeb\x0d\x96\xbe\x3d\xdd\x96\xd6\x38\x30\xad\xb5\x89\x89\x72\xef\xaa\x43\x98\xb0\xde\xe0\xe9\xb9\x90\x36\xb3\x3f\xe7\xc8\x37\x3c\xde\xeb\xff\x5e\xcb\x3f\xdc\x51\xd6\x0b\xc5\xc9\x64\x22\x12\x09\xdf\xdb\x17\x7b\xa2\xc2\x77\x4a\xf2\x9d\xe2\x6f\x69\xea\x5f\x9c\xb7\x8d\x67\x0d\xba\x29\xea\x7e\x98\x1d\x53\x2c\xe3\x13\x13\xf9\x4b\xd8\x1d\x3f\x36\x60\x5f\x85\xfd\xa3\xf9\xea\xa1\xbc\x6b\x99\x68\x32\x4b\x76\x47\xb0\xaf\xcc\x64\x3e\x02\x00\x00\xff\xff\xf4\xfc\xbe\xcb\xcc\x01\x00\x00") + +func templatesCmdGatewayHandlerGoGotmplBytes() ([]byte, error) { + return bindataRead( + _templatesCmdGatewayHandlerGoGotmpl, + "templates/cmd/gateway/handler.go.gotmpl", + ) +} + +func templatesCmdGatewayHandlerGoGotmpl() (*asset, error) { + bytes, err := templatesCmdGatewayHandlerGoGotmplBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "templates/cmd/gateway/handler.go.gotmpl", size: 460, mode: os.FileMode(420), modTime: time.Unix(1522341651, 0)} + a := &asset{bytes: bytes, info: info} + return a, nil +} + +var _templatesCmdGatewayMainGoGotmpl = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x74\x53\xcd\x6e\xdb\x3c\x10\x3c\x8b\x4f\xb1\x9f\x0e\x1f\x24\xc0\x91\x90\x6b\x8a\x1c\xdc\xc6\x49\x0e\x49\x60\xd8\xf9\x41\x51\x14\xc1\x56\x5a\xc9\x6c\x28\xd2\x5d\x52\xb6\x83\xd4\xef\x5e\x90\x92\xfc\x93\x20\x27\x9b\xcb\xe5\xec\xcc\xec\x68\x89\xc5\x0b\xd6\x04\x0d\x4a\x2d\xc4\x0a\x19\x12\x11\xcd\x89\x57\xc4\xe3\xb2\x64\x00\xeb\x58\xea\x5a\x44\x57\xe8\x68\x8d\xaf\xa1\x38\xd4\xe6\x6b\xac\x6b\xe2\x0b\xb9\xef\x4b\x85\xa8\x5a\x5d\x04\xbc\x24\x85\x37\x11\xe5\x39\x14\x4c\xe8\x08\xae\xef\xef\xa7\xb0\x40\x5d\x2a\x62\xa8\x0c\x43\xdd\x61\x8a\x88\x98\xaf\xfb\xfa\xd9\x39\x70\xab\x9d\x6c\x28\x7b\x92\x6e\x31\x65\xe3\xcc\x84\xd9\x0c\x0d\x49\xbd\xce\x42\xf1\x96\xac\xc5\x9a\x0e\xef\x52\x11\xd9\x40\xbd\x3f\x8f\x80\x38\x20\xde\xd1\xfa\xed\x0d\xb2\x3b\x6c\x08\xfe\x82\x97\x27\x0b\x82\xed\x76\xc0\x2c\x8c\x76\xb4\x71\xd9\x57\x2c\x5e\x6a\x36\xad\x2e\x93\x74\x04\x7b\x1b\x02\xd0\x7e\x48\x9e\x07\xb9\x4b\x40\xa5\xe0\x57\xeb\xc0\x31\x4a\x25\x75\x0d\x71\x1e\x83\xd1\x20\x75\x61\x1a\x7f\x66\xfa\xd3\x92\x75\xf6\x1d\x31\x38\x87\x85\x73\xcb\x6c\xee\x51\xa6\x4c\x95\xdc\x24\x22\x8a\x0a\xa3\x2b\x59\x67\x57\xe3\xfb\xc9\xd3\xf8\xfb\xf3\xc3\xec\xe6\xc7\x99\x22\x9d\x7c\xac\xa7\x27\xa7\x3f\x47\x22\x7a\x27\x57\x44\xa9\x88\x64\x15\x54\xff\x77\x0e\x5a\x2a\xbf\x80\x48\x99\x3a\xbb\x44\x87\x4a\xe9\x84\xd8\x0b\xd8\x06\x0d\x0d\x2e\xbb\x9d\x90\x2e\x97\x46\x6a\x67\xc1\x99\x61\x41\x56\x44\x4d\xbb\xf1\xe6\x05\xa6\x77\xb4\x0e\x76\xdc\xb6\x9b\x24\x0d\x57\x59\x37\x35\x89\xf3\x03\x6b\x1f\x66\x37\xb0\xdd\xe6\xab\xd3\x3c\x1e\xc1\x11\xb9\xa3\x47\x97\xad\x2e\x92\x38\xb7\x5d\x7e\x7c\x6f\x1f\xa5\x63\x8f\xfd\xfb\x1d\x1f\xef\xab\x5b\xd0\x10\x1a\xc0\xb2\x64\xb2\x56\x44\x81\xdf\x8d\xb4\x8e\xf4\x58\x97\x81\x65\x72\x90\xd6\x11\x34\xed\x26\x15\xdb\x3e\x98\x52\x4b\xb7\x0b\x66\x49\x15\xb6\xca\xed\x30\x57\xa8\x5a\xb2\x5f\xc0\x2c\x9d\x34\x1a\x95\x7a\x85\xce\xfb\x96\xa9\x84\x95\x44\x28\x4c\xd3\xa0\x2e\x4f\x94\xd4\x04\x95\xc2\xda\x8a\xc8\xff\x84\x55\xea\xfa\x11\x39\xf9\xff\x30\x37\x71\x67\x42\x3c\xea\x81\xb2\xf9\x64\xf6\x38\x99\x3d\x8f\x2f\x2e\x66\x93\xf9\x7c\x04\x71\xaf\x03\x4c\xd5\xc9\x9b\x4d\xbf\xf5\xce\xc5\xe9\x47\xec\x23\x61\x71\xcf\x7b\x8f\x3e\x44\xe4\x73\xf8\x5e\xe9\xe7\x13\xf6\x1f\xb5\x67\xdf\x1d\x4e\x4a\xe9\x25\xc4\x76\x81\x4c\xfe\x4f\x29\x99\x0a\x67\xf8\x75\xc0\xed\x1b\xb3\xdf\xd6\x68\xa8\xa4\xa2\x1d\xf4\x14\xd9\x52\xe2\x17\xf0\x2f\x00\x00\xff\xff\x7d\x08\x9d\x84\x6c\x04\x00\x00") + +func templatesCmdGatewayMainGoGotmplBytes() ([]byte, error) { + return bindataRead( + _templatesCmdGatewayMainGoGotmpl, + "templates/cmd/gateway/main.go.gotmpl", + ) +} + +func templatesCmdGatewayMainGoGotmpl() (*asset, error) { + bytes, err := templatesCmdGatewayMainGoGotmplBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "templates/cmd/gateway/main.go.gotmpl", size: 1132, mode: os.FileMode(420), modTime: time.Unix(1522341651, 0)} + a := &asset{bytes: bytes, info: info} + return a, nil +} + +var _templatesCmdGatewaySwaggerGoGotmpl = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x5c\x8f\x41\x4b\xf4\x40\x0c\x86\xcf\x3b\xbf\x22\x0c\x7c\xd0\xfd\x2c\xd3\xbb\xd0\x9b\xc8\x22\x1e\xca\x6e\xc5\xf3\x50\xd3\x99\xd1\x6e\x3a\x4d\x52\x2b\x88\xff\x5d\xda\xed\xc9\x6b\xde\x3c\xc9\xf3\x66\xdf\x7d\xf8\x80\x70\xf5\x89\x8c\xa9\x2a\xb8\x2c\x3e\x04\xe4\x93\xa7\xb7\x01\x19\x18\x75\x66\x12\xf0\x04\xa7\xb6\x6d\x20\xee\x73\x8d\x5e\x41\x90\x3f\x51\x40\x23\x82\xdc\x30\x90\x8c\x9d\xe9\x67\xea\xfe\x1c\x2a\x78\x81\xa8\x9a\xdd\x19\x25\x8f\x24\xf8\xca\x49\x91\x4b\x60\x9c\xe0\xff\x9e\x4c\x33\x8a\x1e\xe1\xdb\x1c\x32\xdc\xd7\x20\xca\x89\x82\xb8\x96\xd3\xb5\x61\xec\xd3\x57\xc1\x38\xb9\x97\xf3\xb3\x6b\xbc\xc6\x12\x6c\xb5\xbf\xad\xec\x71\x65\x6a\xe8\xd3\x80\xd9\x6b\x74\x4f\x63\xa2\x62\x57\x78\x48\x5c\x42\xde\x36\xee\x6a\xb0\x6e\x87\xdc\xbb\x8c\x64\xcd\x61\x18\x83\x6b\x38\x91\xf6\x85\x5d\x1b\x25\x0a\xf0\x4f\xec\x0d\xd9\xcc\x2e\x6b\xcf\xc7\x34\x60\xc1\xcb\x66\xbc\x65\x3f\xe6\x37\x00\x00\xff\xff\x9b\x89\x3c\xbd\x3d\x01\x00\x00") + +func templatesCmdGatewaySwaggerGoGotmplBytes() ([]byte, error) { + return bindataRead( + _templatesCmdGatewaySwaggerGoGotmpl, + "templates/cmd/gateway/swagger.go.gotmpl", + ) +} + +func templatesCmdGatewaySwaggerGoGotmpl() (*asset, error) { + bytes, err := templatesCmdGatewaySwaggerGoGotmplBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "templates/cmd/gateway/swagger.go.gotmpl", size: 317, mode: os.FileMode(420), modTime: time.Unix(1522341651, 0)} + a := &asset{bytes: bytes, info: info} + return a, nil +} + +var _templatesCmdServerMainGoGotmpl = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x94\x53\x5f\x6b\xdb\x3e\x14\x7d\x96\x3e\xc5\xfd\xf9\xe1\x37\xbb\x38\xf6\x7b\x4b\x1f\xba\x36\x83\xc1\x28\x25\x61\x7d\x2d\xaa\x7c\xa3\x88\xc9\x92\xb9\x52\x1c\x42\xe6\xef\x3e\x24\x2b\x4d\x18\x1b\x2c\x2f\x16\xdc\x3f\xe7\x48\xe7\x1c\x0f\x42\xfe\x10\x0a\xa1\x17\xda\x72\xae\xfb\xc1\x51\x80\x92\xb3\xe3\xb1\xbd\x81\xb0\x45\x8f\x30\x17\x3d\x08\x42\xd8\x0a\xea\x16\xd2\x75\xd8\xc1\x3b\x4a\xb1\xf3\x08\xca\x9d\x06\xa4\xb0\x9f\x02\x10\x7a\x67\x46\x8c\xcb\x3d\xdc\xb4\xd3\xc4\x59\xa1\x74\xd8\xee\xde\x1b\xe9\xfa\x56\xd1\x20\x17\x28\x9d\x3f\xf8\x80\x7d\xab\xdc\x22\x55\x7a\xdd\x75\x06\xf7\x82\xb0\xb8\x72\xbe\x35\x4e\x29\x6d\x55\x3c\x69\xe7\xaf\x5e\x1f\x85\xd1\x9d\x08\x8e\x0a\x5e\x71\x3e\x0a\x8a\xcf\x7f\xe8\x3a\x42\xef\xc1\x07\xd2\x56\xc5\xc6\x66\x67\x65\x52\xa9\xac\xe0\xc8\x59\x24\x45\x82\xdb\x7b\x98\x69\x9b\x67\xdc\x97\x15\x67\x6d\x0b\x92\x50\x04\x04\x8b\x7b\x08\x72\x00\xa3\x7d\x40\x8b\xb4\xe1\xcc\xd8\x1a\x90\xd2\x96\xc5\xd0\x7c\x4b\x9d\xb2\x08\x72\x28\x6a\xc8\x94\x15\x67\x7a\x93\xa6\xfe\xbb\x07\xab\x4d\x24\xcb\x6c\xcd\x17\x11\x84\x31\xb6\x44\xa2\x8a\xb3\xe9\x77\x36\xb5\x7a\x79\x04\x8f\x34\x22\xc1\x5e\x87\x2d\x9c\x5f\x09\x72\x1b\x0d\x66\xb9\x7b\x7b\x0f\x51\x86\x78\xe9\x75\xaa\x94\x9c\xb1\x54\xf9\x6e\x05\x1d\xbe\xda\x80\x24\x71\x08\x2e\x35\x52\xe7\xed\x0c\xd6\x3c\x46\xb0\x34\x79\xde\x66\xf1\x32\x59\x4b\xed\xec\x05\x77\x6a\x26\x88\x0f\xa9\x9b\x8b\xe5\x4b\xb2\xaa\x3e\x21\x65\x53\xff\x08\x93\x15\xff\x0b\xc6\xd9\x8f\xa5\x0d\x74\x28\x67\xed\xaa\x19\x3a\x7d\xe3\x67\xb6\x8a\x50\x45\x0f\x28\xa9\xa6\x65\xca\xba\xc1\x1e\x6d\x98\x1f\x91\x54\x0c\x5b\x4c\x6a\x65\x69\x39\xf3\x1f\x2e\xfa\x31\x69\xf8\x59\x78\x2d\xb3\x14\x57\xf9\x37\xbc\x37\xab\x7c\x85\xe3\x11\x9a\x67\xd1\x23\xfc\x84\x75\xbe\xcc\x34\x65\xcc\x99\xb8\x86\x8b\x70\x44\xf2\x54\x6d\xd2\x4c\x69\x6c\x75\xf7\x8f\xac\x53\x0e\xb3\xb6\x3a\xcc\x61\x6e\x5b\xe8\x70\x23\x76\x26\x9c\xe2\x23\xe6\x30\xde\x81\x1b\xa2\x10\xc2\x98\x03\x78\x0c\x30\x6a\x01\xd2\xf5\xbd\xb0\xdd\xc2\x68\x8b\xb0\x31\x42\x79\xce\xe2\xd1\xac\xd3\xcf\xf2\x2a\xa8\xfc\x3f\x87\xb9\x86\x22\x23\x15\x35\x48\x67\x37\x5a\x35\xeb\xe5\xea\x75\xb9\x7a\x7b\x78\x7a\x5a\x2d\xd7\xeb\x1a\x8a\xa4\xef\x45\x74\x4f\x1b\x55\x86\x7d\x11\xe4\xb1\xac\xf8\xc4\x7f\x05\x00\x00\xff\xff\xde\xb9\x17\xae\xad\x04\x00\x00") + +func templatesCmdServerMainGoGotmplBytes() ([]byte, error) { + return bindataRead( + _templatesCmdServerMainGoGotmpl, + "templates/cmd/server/main.go.gotmpl", + ) +} + +func templatesCmdServerMainGoGotmpl() (*asset, error) { + bytes, err := templatesCmdServerMainGoGotmplBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "templates/cmd/server/main.go.gotmpl", size: 1197, mode: os.FileMode(420), modTime: time.Unix(1522341651, 0)} + a := &asset{bytes: bytes, info: info} + return a, nil +} + +var _templatesDockerDockerfileApplicationGotmpl = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x72\x0b\xf2\xf7\x55\x48\xcc\x29\xc8\xcc\x4b\xb5\x32\xd6\x33\xe5\xe2\x0a\xf7\x0f\xf2\x76\xf1\x0c\x52\xd0\x2f\x2d\x2e\xd2\xcf\xc9\x4f\x4e\xcc\xe1\xe2\x72\xf6\x0f\x88\x54\x48\xca\xcc\xd3\x2f\x4e\x2d\x2a\x4b\x2d\x42\x62\x72\x71\xb9\xfa\x85\x04\x45\x06\xf8\x7b\xfa\x85\x28\x44\x2b\x21\x24\x94\x62\xb9\x00\x01\x00\x00\xff\xff\x6b\xe1\xc2\x91\x5b\x00\x00\x00") + +func templatesDockerDockerfileApplicationGotmplBytes() ([]byte, error) { + return bindataRead( + _templatesDockerDockerfileApplicationGotmpl, + "templates/docker/Dockerfile.application.gotmpl", + ) +} + +func templatesDockerDockerfileApplicationGotmpl() (*asset, error) { + bytes, err := templatesDockerDockerfileApplicationGotmplBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "templates/docker/Dockerfile.application.gotmpl", size: 91, mode: os.FileMode(420), modTime: time.Unix(1522341651, 0)} + a := &asset{bytes: bytes, info: info} + return a, nil +} + +var _templatesDockerDockerfileGatewayGotmpl = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x72\x0b\xf2\xf7\x55\x48\xcc\x29\xc8\xcc\x4b\xb5\x32\xd6\x33\xe5\xe2\x0a\xf7\x0f\xf2\x76\xf1\x0c\x52\xd0\x2f\x2d\x2e\xd2\xcf\xc9\x4f\x4e\xcc\xe1\xe2\x72\xf6\x0f\x88\x54\x48\xca\xcc\xd3\x4f\x4f\x2c\x49\x2d\x4f\xac\x44\x66\x73\x71\xb9\xfa\x85\x04\x45\x06\xf8\x7b\xfa\x85\x28\x44\x2b\x21\xc9\x28\xc5\x72\x01\x02\x00\x00\xff\xff\x64\x58\x5f\x58\x5e\x00\x00\x00") + +func templatesDockerDockerfileGatewayGotmplBytes() ([]byte, error) { + return bindataRead( + _templatesDockerDockerfileGatewayGotmpl, + "templates/docker/Dockerfile.gateway.gotmpl", + ) +} + +func templatesDockerDockerfileGatewayGotmpl() (*asset, error) { + bytes, err := templatesDockerDockerfileGatewayGotmplBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "templates/docker/Dockerfile.gateway.gotmpl", size: 94, mode: os.FileMode(420), modTime: time.Unix(1522341651, 0)} + a := &asset{bytes: bytes, info: info} + return a, nil +} + +var _templatesProtoServiceProtoGotmpl = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x9c\x55\xc1\x6e\xe3\x46\x0c\xbd\xeb\x2b\x58\x9f\x36\x45\x2c\xa1\xed\x2d\x46\x0e\x45\x37\xdb\x2e\x50\x74\x8b\x24\x40\x8f\x05\x35\xa2\xa4\xc1\x8e\x86\xd3\x21\x65\x47\x48\xfd\xef\xc5\xcc\x58\x8e\xbb\x49\xf7\xd0\x8b\x21\x8d\xc8\x47\xce\x7b\xe4\xb3\x2c\x5e\xf1\x09\x6e\x61\x13\x22\x2b\xff\xb0\xd9\x55\x55\x40\xf3\x19\x07\x02\xa1\xb8\xb7\x86\x76\x55\x65\xa7\xc0\x51\x61\x33\x30\x0f\x8e\x9a\x1c\xda\xce\x7d\x43\x53\xd0\xa5\xce\xaf\x9b\xdd\x97\x51\x18\x6c\x83\xde\xb3\xa2\x5a\xf6\xf2\x3a\xcc\xea\x38\xb7\xb5\xe1\xa9\x71\x4b\xaf\x05\xd5\x6c\x07\xf2\xdb\x3d\x3a\xdb\xa1\x52\xf3\xea\xe1\x15\xca\x45\x96\x1c\x70\x18\x28\x36\x1c\x72\xc1\xaf\x17\xff\xe2\x26\x6a\x27\x12\xc5\x29\x9c\x23\xab\x02\x03\x03\xff\xb9\x12\x72\x0b\x9b\xe7\x67\xa8\xef\x99\x15\x8e\xc7\x26\x3d\xff\x86\x13\xa5\xe7\xd0\xee\x42\x9b\xb2\x9a\x06\xee\xcc\xc8\xd7\xf9\xf7\x9e\xfe\x9a\x49\xf4\x1a\xd0\x77\xa7\x03\x09\xec\x85\x60\xc2\xcf\x04\x73\x00\x84\x8d\x28\x46\xa5\xb8\x01\x7a\xc2\x29\x38\x02\x1d\x51\x13\x8e\x28\xfa\x4e\xc0\x7a\x08\x0e\x0d\x41\xcf\x11\x74\x24\xc0\x10\x9c\x35\xf9\x62\xb0\x5e\x00\x3a\xea\xad\xb7\xe5\xb2\xf0\x51\x01\x9d\xe3\x83\xe4\xf8\x1f\xd5\xa1\x24\xc0\x9f\x7e\xfd\x08\xca\x30\x90\xa7\x88\x4a\x80\x1e\xc8\x77\x5b\xe5\x2d\xf9\xee\x5c\x7e\x2d\x23\xa9\x45\x4e\xc8\x13\x7b\xd1\x98\x0b\xd6\xf0\x38\x52\xbe\x4a\x02\x5c\x53\x64\xe4\xd9\x75\xd0\x12\x44\xca\xbd\x76\x70\xb0\x3a\xa6\x02\x17\xcd\x6e\x25\x90\xb1\xbd\x35\x2f\x5d\x8b\x19\x69\xc2\x3a\xd3\xf6\x0b\x45\x02\x8c\x04\xc2\x13\xc1\x48\x2e\xf4\xb3\x83\x48\xc2\x73\x34\x24\xa9\x73\xb4\x1d\x2c\x3c\x27\x4a\x16\x9e\xe3\x0b\x4e\xa6\xf9\x26\xa1\x8c\xaa\x41\x6e\x9a\xe6\x62\xba\xac\xef\xb9\x75\xfc\xc4\x81\x7c\x83\x89\x8c\xad\x61\xaf\x68\x54\xb6\x18\x42\xd3\x3a\x6e\x9b\x09\x45\x29\x96\x81\x68\xd6\xcf\x65\x18\x2e\x61\x3b\xda\x93\xe3\x40\x51\xea\x32\x42\xb9\x42\x19\x42\x76\xdb\x76\xee\x7b\x8a\xd2\x74\x6c\xa4\x79\x95\x7c\xd1\xd3\x10\x83\xd9\x92\x61\x59\x44\xe9\xf4\x3a\xa0\xd2\x01\x97\xff\x5b\x4e\x74\x71\x54\x98\xc4\x10\x96\x33\x39\xbd\xf5\xc3\x37\xf9\xfc\xf1\xd3\xfb\x4f\x37\xf0\x81\xd3\x68\x80\x4d\x9a\xce\x26\x4f\x4c\xe2\xb6\x9d\xad\xeb\x0a\xad\x7c\xf0\x6f\x4b\x54\x00\x1e\x74\x36\x3a\x47\x7a\x09\x9e\x48\x04\x87\xa2\x91\xcc\x56\xcb\x97\x0b\xe9\x6b\xb8\x43\x33\xc2\xda\x38\x24\xb0\xd2\xfc\x9a\x0b\x56\x00\x41\x26\x74\x0e\x1c\x0f\xd6\x60\xd2\xde\x70\xec\xd2\x0c\x26\x09\xe3\x94\xa1\xae\x21\xab\x63\xbd\xf5\x03\x60\x46\x12\x8a\x96\x24\xc5\x79\x9c\x28\xb9\xc7\x4c\x10\xd0\x46\xa9\xab\x15\x3e\x8d\x2c\x3c\x57\x00\xa2\x31\x65\xae\xe7\xb7\xf0\xdd\xae\x3a\x56\xff\x8a\x3b\xad\x6d\x0e\x8f\x14\x08\x95\xca\xfa\x02\x99\x91\xe5\x3f\x72\x4e\x9b\xfd\xd5\xa4\x73\xf9\xe4\x37\x70\x0b\xdf\x67\x9c\x33\xb3\xef\xd3\x12\x53\xde\xbe\xb3\xb9\xfc\x0d\x0f\xc5\x87\xe1\x78\x5c\x2d\x39\xfb\x89\x55\x81\x89\x74\xe4\x4e\x6a\xf8\x40\xe4\xa0\x8f\x44\x09\x4c\x19\xcc\x88\x7e\x28\x48\x89\x93\xc4\xcd\xdb\x88\x49\x79\x52\xa5\xb8\x7d\x11\xce\xe3\x64\xfd\x90\x90\x0c\xfb\x3d\xf9\x93\xab\xdc\xd3\x44\x53\x4b\xb1\x78\xdb\x49\x30\x9b\x0d\xe0\xd2\xba\x56\x37\xa0\x94\x39\xa3\x73\x4b\x16\x69\x20\x85\x48\x13\xef\xa9\xab\xab\xf5\x1e\x6f\xf7\x94\x29\x0c\xa6\x94\x79\x77\xa1\xc8\x15\x44\xd2\x39\x7a\x59\x4f\x0b\xe7\x57\x39\x03\x12\x9a\xed\xa1\xfe\xc3\xea\xf8\x73\xd9\xa5\x64\xcd\x2b\xb9\xbf\x47\xde\xdb\x2e\x59\x6f\x08\xd6\x0f\x92\xee\x7d\x20\xf2\x70\x7f\xf7\xf0\x98\x8c\x30\xb0\xf5\x2a\x99\xdb\xb5\xbf\x95\xdf\x0c\x7f\xfa\x43\x78\x77\xda\x44\x0c\xb6\x4e\x7b\x7a\x05\xb7\xa7\xfa\x00\x81\x45\x6f\x60\xd3\x24\xc9\x37\xa7\xb3\x96\xbb\xe5\x06\x36\xdf\x96\xf7\xe3\xee\xf9\x39\x15\x83\xe3\xb1\x02\x38\x56\xc7\xea\x9f\x00\x00\x00\xff\xff\x80\xb9\xb5\x17\x83\x07\x00\x00") + +func templatesProtoServiceProtoGotmplBytes() ([]byte, error) { + return bindataRead( + _templatesProtoServiceProtoGotmpl, + "templates/proto/service.proto.gotmpl", + ) +} + +func templatesProtoServiceProtoGotmpl() (*asset, error) { + bytes, err := templatesProtoServiceProtoGotmplBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "templates/proto/service.proto.gotmpl", size: 1923, mode: os.FileMode(420), modTime: time.Unix(1522342599, 0)} + a := &asset{bytes: bytes, info: info} + return a, nil +} + +var _templatesSvcZserverGoGotmpl = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x74\x92\xcf\x6b\xdb\x4e\x10\xc5\xcf\xd6\x5f\xf1\xbe\x86\x6f\x90\x8a\xd8\xdc\x03\xb9\x34\x2d\xa5\x87\x3a\x10\xf7\x56\x4a\x59\xaf\x46\xd6\x12\x69\x77\x3d\x3b\xf2\x0f\x54\xfd\xef\x65\x25\x3b\x36\xa1\x3d\x19\xcf\xbc\xfd\xbc\x37\xa3\x09\xda\xbc\xea\x2d\x21\xee\x4d\x96\xd5\xbd\x33\x58\xd1\xe1\xa3\x8e\xd6\xac\x89\xf7\xc4\x79\x81\x3c\x6c\xd4\x30\x40\xad\x74\x47\xf8\x8d\x54\xb7\x86\x30\x8e\xb3\xa2\x04\x31\x7b\x2e\x30\x64\x0b\x26\xe9\xd9\xe1\x8e\x4c\xe3\xe7\xee\x30\x96\x70\xb6\xcd\xc6\x2c\xbb\xbf\xc7\xf7\xe7\x4f\xcf\x0f\x78\xf2\x1d\xa1\x0f\x38\x58\x69\x70\xf2\x3d\xc3\x1f\x1c\x6c\x17\x5a\xea\xc8\x89\x16\xeb\x1d\x7c\x0d\x69\x08\x7f\x35\x4e\xa8\x38\xff\x53\xf8\x6c\x1a\x0f\xed\x2a\x5c\x4d\xd1\xe9\xd7\xc9\x21\x4e\xd0\x7f\xa0\x97\x51\x34\x0b\xf1\x12\x89\x47\x47\x3d\x6b\xdd\x1b\x3a\xb0\x17\xaf\xf0\x55\x60\x23\x6a\xcf\xa8\xa8\xf3\x2e\x0a\xcf\x98\xd0\x73\xf0\x91\x22\xbc\x6b\x4f\x53\x82\xd8\xf8\xbe\xad\x26\xda\x9e\x9c\xf4\xba\x6d\x4f\xd8\x10\x98\x3a\xbf\xa7\x4a\x65\x72\x0a\x74\x9b\x33\x0a\xf7\x46\x86\xdb\xe5\xbc\x50\x68\xb5\xa1\x79\xac\x69\x43\x29\xab\x0e\xa1\xb5\x66\xf6\x3d\xc7\x43\x45\xb5\x75\x36\x95\x22\xa4\xd1\x82\x46\xef\x09\x1b\x22\x97\x68\x53\x97\x2a\x58\x17\x6d\x45\xef\x66\x9a\xbf\x74\x7e\x4d\x52\x4c\x7e\xb9\x91\x23\x8c\x77\x42\x47\x51\x4f\xf3\x6f\x09\xa6\x1d\x3e\x84\x8d\x4a\x8a\x17\xda\xf5\x14\xa5\x40\x7e\xad\xc4\xe0\x5d\xa4\xdb\x33\x48\xdc\x88\x87\x47\xfc\xf8\x79\x91\x0d\x63\xb6\x48\x2b\xfc\x55\x82\x52\x87\xb5\xdb\xa6\xc5\xec\xd4\x17\x92\x24\x88\xf9\xf4\x74\xc1\x67\x5e\x12\xd5\x9d\xa8\x75\x60\xeb\xa4\xce\x97\x49\x64\xdd\x16\xff\x47\xd4\xec\xbb\x69\x22\xe2\xff\x96\x25\x48\x7d\xa3\x18\xf5\x96\x8a\x6c\x71\xf6\x7e\x4c\x1b\x23\x57\x4d\x23\xc6\x12\x77\x97\x18\x17\xfc\x58\x64\x8b\xf1\x7a\xb0\xef\x86\x19\xce\xcf\xc4\x76\xa4\x56\xfe\x90\x17\x6a\x2d\x6c\xdd\x36\x2f\xde\xee\xf9\x4f\x00\x00\x00\xff\xff\x57\xb9\xc6\x80\x3a\x03\x00\x00") + +func templatesSvcZserverGoGotmplBytes() ([]byte, error) { + return bindataRead( + _templatesSvcZserverGoGotmpl, + "templates/svc/zserver.go.gotmpl", + ) +} + +func templatesSvcZserverGoGotmpl() (*asset, error) { + bytes, err := templatesSvcZserverGoGotmplBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "templates/svc/zserver.go.gotmpl", size: 826, mode: os.FileMode(420), modTime: time.Unix(1522341651, 0)} + a := &asset{bytes: bytes, info: info} + return a, nil +} + +// Asset loads and returns the asset for the given name. +// It returns an error if the asset could not be found or +// could not be loaded. +func Asset(name string) ([]byte, error) { + cannonicalName := strings.Replace(name, "\\", "/", -1) + if f, ok := _bindata[cannonicalName]; ok { + a, err := f() + if err != nil { + return nil, fmt.Errorf("Asset %s can't read by error: %v", name, err) + } + return a.bytes, nil + } + return nil, fmt.Errorf("Asset %s not found", name) +} + +// MustAsset is like Asset but panics when Asset would return an error. +// It simplifies safe initialization of global variables. +func MustAsset(name string) []byte { + a, err := Asset(name) + if err != nil { + panic("asset: Asset(" + name + "): " + err.Error()) + } + + return a +} + +// AssetInfo loads and returns the asset info for the given name. +// It returns an error if the asset could not be found or +// could not be loaded. +func AssetInfo(name string) (os.FileInfo, error) { + cannonicalName := strings.Replace(name, "\\", "/", -1) + if f, ok := _bindata[cannonicalName]; ok { + a, err := f() + if err != nil { + return nil, fmt.Errorf("AssetInfo %s can't read by error: %v", name, err) + } + return a.info, nil + } + return nil, fmt.Errorf("AssetInfo %s not found", name) +} + +// AssetNames returns the names of the assets. +func AssetNames() []string { + names := make([]string, 0, len(_bindata)) + for name := range _bindata { + names = append(names, name) + } + return names +} + +// _bindata is a table, holding each asset generator, mapped to its name. +var _bindata = map[string]func() (*asset, error){ + "templates/.gitignore.gotmpl": templatesGitignoreGotmpl, + "templates/Makefile.gotmpl": templatesMakefileGotmpl, + "templates/README.md.gotmpl": templatesReadmeMdGotmpl, + "templates/cmd/config/config.go.gotmpl": templatesCmdConfigConfigGoGotmpl, + "templates/cmd/gateway/handler.go.gotmpl": templatesCmdGatewayHandlerGoGotmpl, + "templates/cmd/gateway/main.go.gotmpl": templatesCmdGatewayMainGoGotmpl, + "templates/cmd/gateway/swagger.go.gotmpl": templatesCmdGatewaySwaggerGoGotmpl, + "templates/cmd/server/main.go.gotmpl": templatesCmdServerMainGoGotmpl, + "templates/docker/Dockerfile.application.gotmpl": templatesDockerDockerfileApplicationGotmpl, + "templates/docker/Dockerfile.gateway.gotmpl": templatesDockerDockerfileGatewayGotmpl, + "templates/proto/service.proto.gotmpl": templatesProtoServiceProtoGotmpl, + "templates/svc/zserver.go.gotmpl": templatesSvcZserverGoGotmpl, +} + +// AssetDir returns the file names below a certain +// directory embedded in the file by go-bindata. +// For example if you run go-bindata on data/... and data contains the +// following hierarchy: +// data/ +// foo.txt +// img/ +// a.png +// b.png +// then AssetDir("data") would return []string{"foo.txt", "img"} +// AssetDir("data/img") would return []string{"a.png", "b.png"} +// AssetDir("foo.txt") and AssetDir("notexist") would return an error +// AssetDir("") will return []string{"data"}. +func AssetDir(name string) ([]string, error) { + node := _bintree + if len(name) != 0 { + cannonicalName := strings.Replace(name, "\\", "/", -1) + pathList := strings.Split(cannonicalName, "/") + for _, p := range pathList { + node = node.Children[p] + if node == nil { + return nil, fmt.Errorf("Asset %s not found", name) + } + } + } + if node.Func != nil { + return nil, fmt.Errorf("Asset %s not found", name) + } + rv := make([]string, 0, len(node.Children)) + for childName := range node.Children { + rv = append(rv, childName) + } + return rv, nil +} + +type bintree struct { + Func func() (*asset, error) + Children map[string]*bintree +} + +var _bintree = &bintree{nil, map[string]*bintree{ + "templates": &bintree{nil, map[string]*bintree{ + ".gitignore.gotmpl": &bintree{templatesGitignoreGotmpl, map[string]*bintree{}}, + "Makefile.gotmpl": &bintree{templatesMakefileGotmpl, map[string]*bintree{}}, + "README.md.gotmpl": &bintree{templatesReadmeMdGotmpl, map[string]*bintree{}}, + "cmd": &bintree{nil, map[string]*bintree{ + "config": &bintree{nil, map[string]*bintree{ + "config.go.gotmpl": &bintree{templatesCmdConfigConfigGoGotmpl, map[string]*bintree{}}, + }}, + "gateway": &bintree{nil, map[string]*bintree{ + "handler.go.gotmpl": &bintree{templatesCmdGatewayHandlerGoGotmpl, map[string]*bintree{}}, + "main.go.gotmpl": &bintree{templatesCmdGatewayMainGoGotmpl, map[string]*bintree{}}, + "swagger.go.gotmpl": &bintree{templatesCmdGatewaySwaggerGoGotmpl, map[string]*bintree{}}, + }}, + "server": &bintree{nil, map[string]*bintree{ + "main.go.gotmpl": &bintree{templatesCmdServerMainGoGotmpl, map[string]*bintree{}}, + }}, + }}, + "docker": &bintree{nil, map[string]*bintree{ + "Dockerfile.application.gotmpl": &bintree{templatesDockerDockerfileApplicationGotmpl, map[string]*bintree{}}, + "Dockerfile.gateway.gotmpl": &bintree{templatesDockerDockerfileGatewayGotmpl, map[string]*bintree{}}, + }}, + "proto": &bintree{nil, map[string]*bintree{ + "service.proto.gotmpl": &bintree{templatesProtoServiceProtoGotmpl, map[string]*bintree{}}, + }}, + "svc": &bintree{nil, map[string]*bintree{ + "zserver.go.gotmpl": &bintree{templatesSvcZserverGoGotmpl, map[string]*bintree{}}, + }}, + }}, +}} + +// RestoreAsset restores an asset under the given directory +func RestoreAsset(dir, name string) error { + data, err := Asset(name) + if err != nil { + return err + } + info, err := AssetInfo(name) + if err != nil { + return err + } + err = os.MkdirAll(_filePath(dir, filepath.Dir(name)), os.FileMode(0755)) + if err != nil { + return err + } + err = ioutil.WriteFile(_filePath(dir, name), data, info.Mode()) + if err != nil { + return err + } + err = os.Chtimes(_filePath(dir, name), info.ModTime(), info.ModTime()) + if err != nil { + return err + } + return nil +} + +// RestoreAssets restores an asset under the given directory recursively +func RestoreAssets(dir, name string) error { + children, err := AssetDir(name) + // File + if err != nil { + return RestoreAsset(dir, name) + } + // Dir + for _, child := range children { + err = RestoreAssets(dir, filepath.Join(name, child)) + if err != nil { + return err + } + } + return nil +} + +func _filePath(dir, name string) string { + cannonicalName := strings.Replace(name, "\\", "/", -1) + return filepath.Join(append([]string{dir}, strings.Split(cannonicalName, "/")...)...) +} diff --git a/cli/atlas/templates/.gitignore.gotmpl b/cli/atlas/templates/.gitignore.gotmpl new file mode 100644 index 00000000..ba077a40 --- /dev/null +++ b/cli/atlas/templates/.gitignore.gotmpl @@ -0,0 +1 @@ +bin diff --git a/cli/atlas/templates/Makefile.gotmpl b/cli/atlas/templates/Makefile.gotmpl new file mode 100644 index 00000000..689fdc8d --- /dev/null +++ b/cli/atlas/templates/Makefile.gotmpl @@ -0,0 +1,100 @@ +PROJECT_ROOT := {{ .Root }}/{{ .Name }} +BUILD_PATH := bin +DOCKERFILE_PATH := $(CURDIR)/docker + +USERNAME := $(USER) +GIT_COMMIT := $(shell git describe --dirty=-unsupported --always || echo pre-commit) +IMAGE_VERSION ?= $(USERNAME)-dev-$(GIT_COMMIT) + +SERVER_BINARY := $(BUILD_PATH)/server +SERVER_PATH := $(PROJECT_ROOT)/cmd/server +SERVER_IMAGE := {{ if .Registry }}{{ .Registry }}/{{ end }}{{ .Name }}:$(IMAGE_VERSION) +SERVER_DOCKERFILE := $(DOCKERFILE_PATH)/Dockerfile.server +{{ if .WithGateway }} +GATEWAY_BINARY := $(BUILD_PATH)/gateway +GATEWAY_PATH := $(PROJECT_ROOT)/cmd/gateway +GATEWAY_IMAGE := {{ if .Registry }}{{ .Registry }}/{{ end }}{{ .Name }}-gateway:$(IMAGE_VERSION) +GATEWAY_DOCKERFILE := $(DOCKERFILE_PATH)/Dockerfile.gateway +{{ end }} +TEST_UNIT_LOG := test_unit.log + +BUILD_TYPE ?= "default" +ifeq ($(BUILD_TYPE), "default") + GO_PATH := /go + SRCROOT_ON_HOST := $(shell dirname $(abspath $(lastword $(MAKEFILE_LIST)))) + SRCROOT_IN_CONTAINER := $(GO_PATH)/src/$(PROJECT_ROOT) + GO_CACHE := -pkgdir $(SRCROOT_IN_CONTAINER)/$(BUILD_PATH)/go-cache + + DOCKER_RUNNER := docker run --rm + DOCKER_RUNNER += -v $(SRCROOT_ON_HOST):$(SRCROOT_IN_CONTAINER) + DOCKER_BUILDER := infoblox/buildtool:v8 + DOCKER_GENERATOR := infobloxcto/gentool:v1 + BUILDER := $(DOCKER_RUNNER) -w $(SRCROOT_IN_CONTAINER) $(DOCKER_BUILDER) + GENERATOR := $(DOCKER_RUNNER) $(DOCKER_GENERATOR) +endif + +GO_BUILD_FLAGS ?= $(GO_CACHE) -i -v +GO_TEST_FLAGS ?= -v -cover +GO_TEST_PACKAGES := $(shell $(BUILDER) go list ./... | grep -v "./vendor/") +SEARCH_GOFILES := $(BUILDER) find . -not -path '*/vendor/*' -type f -name "*.go" + +.PHONY: default +default: test server{{ if .WithGateway }} gateway{{ end }} + +.PHONY: all +all: vendor protobuf test server{{ if .WithGateway }} gateway{{ end }} + +.PHONY: fmt +fmt: + @$(SEARCH_GOFILES) -exec gofmt -s -w {} \; + +.PHONY: test +test: fmt + @$(BUILDER) go test $(GO_TEST_FLAGS) $(GO_TEST_PACKAGES) + +.PHONY: server +server: server-build server-docker + +.PHONY: server-build +server-build: + @$(BUILDER) go build $(GO_BUILD_FLAGS) -o $(SERVER_BINARY) $(SERVER_PATH) + +.PHONY: server-docker +server-docker: server-build + @docker build -f $(SERVER_DOCKERFILE) -t $(SERVER_IMAGE) . +{{ if .WithGateway }} +.PHONY: gateway +gateway: gateway-build gateway-docker + @$(BUILDER) go build $(GO_BUILD_FLAGS) -o $(SERVER_BINARY) $(SERVER_PATH) + +.PHONY: gateway-build +gateway-build: + @$(BUILDER) go build $(GO_BUILD_FLAGS) -o $(GATEWAY_BINARY) $(GATEWAY_PATH) + +.PHONY: gateway-docker +gateway-docker: + @docker build -f $(GATEWAY_DOCKERFILE) -t $(GATEWAY_IMAGE) . +{{ end }} +{{- if .Registry }} +.PHONY: push +push: + @docker push $(SERVER_IMAGE){{ if .WithGateway }} + @docker push $(GATEWAY_IMAGE){{ end }} +{{ end }} +.PHONY: protobuf +protobuf: + @$(GENERATOR) \ + --go_out=plugins=grpc:. \ + {{ if .WithGateway -}} + --grpc-gateway_out=logtostderr=true:. \ + {{ end -}} + --validate_out="lang=go:." \ + --swagger_out=:. $(PROJECT_ROOT)/proto/service.proto + +.PHONY: vendor +vendor: + $(BUILDER) dep ensure -vendor-only + +.PHONY: vendor-update +vendor-update: + $(BUILDER) dep ensure diff --git a/cli/atlas/templates/README.md.gotmpl b/cli/atlas/templates/README.md.gotmpl new file mode 100644 index 00000000..fcc2cb8c --- /dev/null +++ b/cli/atlas/templates/README.md.gotmpl @@ -0,0 +1,51 @@ +# {{ .Name }} + +_This generated README.md file loosely follows a [popular template](https://gist.github.com/PurpleBooth/109311bb0361f32d87a2)._ + +One paragraph of project description goes here. + +## Getting Started + +These instructions will get you a copy of the project up and running on your local machine for development and testing purposes. See deployment for notes on how to deploy the project on a live system. + +### Prerequisites + +What things you need to install the software and how to install them. + +``` +Give examples +``` + +### Installing + +A step-by-step series of examples that tell you have to get a development environment running. + +Say what the step will be. + +``` +Give the example +``` + +And repeat. + +``` +until finished +``` + +End with an example of getting some data out of the system or using it for a little demo. + +## Deployment + +Add additional notes about how to deploy this application. Maybe list some common pitfalls or debugging strategies. + +## Running the tests + +Explain how to run the automated tests for this system. + +``` +Give an example +``` + +## Versioning + +We use [SemVer](http://semver.org/) for versioning. For the versions available, see the [tags on this repository](https://github.com/your/project/tags). diff --git a/cli/atlas/templates/cmd/config/config.go.gotmpl b/cli/atlas/templates/cmd/config/config.go.gotmpl new file mode 100644 index 00000000..646bac09 --- /dev/null +++ b/cli/atlas/templates/cmd/config/config.go.gotmpl @@ -0,0 +1,7 @@ +package config + +const ( + SERVER_ADDRESS = "0.0.0.0:9090" + {{ if .WithGateway }}GATEWAY_ADDRESS = "0.0.0.0:8080" + GATEWAY_URL = "/{{ .Name | URL }}/v1/" {{ end }} +) diff --git a/cli/atlas/templates/cmd/gateway/handler.go.gotmpl b/cli/atlas/templates/cmd/gateway/handler.go.gotmpl new file mode 100644 index 00000000..7f4a2402 --- /dev/null +++ b/cli/atlas/templates/cmd/gateway/handler.go.gotmpl @@ -0,0 +1,12 @@ +package main + +// New{{ .Name | Service}}Handler returns an HTTP handler that serves the gRPC gateway +func New{{ .Name | Service }}Handler(ctx context.Context, grpcAddr string, opts ...runtime.ServeMuxOption) (http.Handler, error) { + mux := runtime.NewServeMux(opts...) + dialOpts := []grpc.DialOption{grpc.WithInsecure()} + err := pb.Register{{ .Name | Service }}HandlerFromEndpoint(ctx, mux, grpcAddr, dialOpts) + if err != nil { + return nil, err + } + return mux, nil +} diff --git a/cli/atlas/templates/cmd/gateway/main.go.gotmpl b/cli/atlas/templates/cmd/gateway/main.go.gotmpl new file mode 100644 index 00000000..99a6a25c --- /dev/null +++ b/cli/atlas/templates/cmd/gateway/main.go.gotmpl @@ -0,0 +1,35 @@ +package main + +var ( + ServerAddr string + GatewayAddr string + SwaggerDir string +) + +func main() { + // create HTTP handler for gateway + errHandler := runtime.WithProtoErrorHandler(gw.ProtoMessageErrorHandler) + serverHandler, err := New{{ .Name | Service }}Handler(context.Background(), ServerAddr, errHandler) + // strip all but trailing "/" on incoming requests + serverHandler = http.StripPrefix( + config.GATEWAY_URL[:len(config.GATEWAY_URL)-1], + serverHandler, + ) + if err != nil { + log.Fatalln(err) + } + // map HTTP endpoints to handlers + mux := http.NewServeMux() + mux.Handle("/{{ .Name | URL }}/v1/", serverHandler) + mux.HandleFunc("/swagger/", SwaggerHandler) + // serve handlers on the gateway address + http.ListenAndServe(GatewayAddr, mux) +} + +func init() { + // default gateway values; optionally configured via command-line flags + flag.StringVar(&ServerAddr, "server", config.SERVER_ADDRESS, "address of the gRPC server") + flag.StringVar(&GatewayAddr, "gateway", config.GATEWAY_ADDRESS, "address of the gateway server") + flag.StringVar(&SwaggerDir, "swagger-dir", "share", "directory of the swagger.json file") + flag.Parse() +} diff --git a/cli/atlas/templates/cmd/gateway/swagger.go.gotmpl b/cli/atlas/templates/cmd/gateway/swagger.go.gotmpl new file mode 100644 index 00000000..e2500350 --- /dev/null +++ b/cli/atlas/templates/cmd/gateway/swagger.go.gotmpl @@ -0,0 +1,10 @@ +package main + +// SwaggerHandler returns an HTTP handler that serves the swagger spec +func SwaggerHandler(rw http.ResponseWriter, req *http.Request) { + p := strings.TrimPrefix(req.URL.Path, "/swagger/") + p = filepath.Join(SwaggerDir, p) + p += ".swagger.json" + log.Printf("serving %s", p) + http.ServeFile(rw, req, p) +} diff --git a/cli/atlas/templates/cmd/server/main.go.gotmpl b/cli/atlas/templates/cmd/server/main.go.gotmpl new file mode 100644 index 00000000..cf980b77 --- /dev/null +++ b/cli/atlas/templates/cmd/server/main.go.gotmpl @@ -0,0 +1,47 @@ +package main + +import ( + {{/* these imports are hard-coded because goimports can't resolve them */}} + "github.com/grpc-ecosystem/go-grpc-middleware" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/logrus" + "github.com/grpc-ecosystem/go-grpc-middleware/validator" +) + +var ( + Address string +) + +func main() { + logger := logrus.New() + // create new tcp listenerf + ln, err := net.Listen("tcp", Address) + if err != nil { + logger.Fatalln(err) + } + // create new gRPC server with middleware chain + server := grpc.NewServer( + grpc.UnaryInterceptor( + grpc_middleware.ChainUnaryServer( + // validation middleware + grpc_validator.UnaryServerInterceptor(), + // logging middleware + grpc_logrus.UnaryServerInterceptor(logrus.NewEntry(logger)), + ), + ), + ) + // register service implementation with the grpc server + s, err := svc.NewBasicServer() + if err != nil { + logger.Fatalln(err) + } + pb.Register{{ .Name | Service }}Server(server, s) + if err := server.Serve(ln); err != nil { + logger.Fatalln(err) + } +} + +func init() { + // default server address; optionally set via command-line flags + flag.StringVar(&Address, "address", config.SERVER_ADDRESS, "the gRPC server address") + flag.Parse() +} diff --git a/cli/atlas/templates/docker/Dockerfile.application.gotmpl b/cli/atlas/templates/docker/Dockerfile.application.gotmpl new file mode 100644 index 00000000..b5d29811 --- /dev/null +++ b/cli/atlas/templates/docker/Dockerfile.application.gotmpl @@ -0,0 +1,7 @@ +FROM alpine:3.5 + +WORKDIR /usr/local + +COPY bin/server bin/server + +ENTRYPOINT ["bin/server"] diff --git a/cli/atlas/templates/docker/Dockerfile.gateway.gotmpl b/cli/atlas/templates/docker/Dockerfile.gateway.gotmpl new file mode 100644 index 00000000..6a363e75 --- /dev/null +++ b/cli/atlas/templates/docker/Dockerfile.gateway.gotmpl @@ -0,0 +1,7 @@ +FROM alpine:3.5 + +WORKDIR /usr/local + +COPY bin/gateway bin/gateway + +ENTRYPOINT ["bin/gateway"] diff --git a/cli/atlas/templates/proto/service.proto.gotmpl b/cli/atlas/templates/proto/service.proto.gotmpl new file mode 100644 index 00000000..48371817 --- /dev/null +++ b/cli/atlas/templates/proto/service.proto.gotmpl @@ -0,0 +1,56 @@ +syntax = "proto3"; + +package service; + +import "google/protobuf/empty.proto"; +import "google/api/annotations.proto"; +import "github.com/lyft/protoc-gen-validate/validate/validate.proto"; +import "protoc-gen-swagger/options/annotations.proto"; +import "google/protobuf/timestamp.proto"; + +option go_package = "{{ .Root }}/{{ .Name }}/pb;pb"; + +// Echo, EchoRequest, and EchoResponse make up a "starter" example that +// stands in place for the application protobuf definitions. It allows the Atlas +// CLI to generate an end-to-end example for the sake of demonstration. The Echo +// example should be replaced with an application-specific protobuf schema. + +// Here are some helpful resources to aid you in your protobuf quest: +// https://github.com/infobloxopen/atlas-contacts-app/blob/master/proto/contacts.proto +// https://developers.google.com/protocol-buffers/docs/proto +// https://github.com/grpc-ecosystem/grpc-gateway +// https://developers.google.com/protocol-buffers/docs/style + +// Happy protobuffing! + +// TODO: Follow instructions to build your own protobuf schema. + +// TODO: Stucture your own messages to suit your application. Each protocol +// buffer message is a small logical record of information, containing a +// series of name-value pairs. +message Echo { + string message = 1; +} + +message EchoRequest { + repeated Echo echos = 1; +} + +message EchoResponse { + repeated Echo echos = 1; + string time = 2; +} + +// TODO: Define the {{ .Name | Service }} service and its methods. Feel free +// to change the name of {{ .Name | Service }} to better-suit your naming +// conventions. Remember, Echo is a simple example that should eventually +// get removed. +service {{ .Name | Service }} { + rpc Echo (EchoRequest) returns (EchoResponse) { + {{ if .WithGateway }}// TODO: Provide mappings between REST endpoints and service methods. + option (google.api.http) = { + post: "/echo" + body: "*" + };{{ end }} + } +} diff --git a/cli/atlas/templates/svc/zserver.go.gotmpl b/cli/atlas/templates/svc/zserver.go.gotmpl new file mode 100644 index 00000000..540f128d --- /dev/null +++ b/cli/atlas/templates/svc/zserver.go.gotmpl @@ -0,0 +1,22 @@ +package svc + +func NewBasicServer() (pb.{{ .Name | Service }}Server, error) { + return &echoServer{}, nil +} + +// TODO: Come up with your own implementation of the {{ .Name | Service }} +// service. Echo and echoServer make up simple implementation of the "starter" +// example in service.proto. It is for demonstration purposes only and should +// eventually be removed. +type echoServer struct{} + +// TODO: Replace Echo with the application service definitions that have been +// defined inside service.proto. +func (echoServer) Echo(ctx context.Context, req *pb.EchoRequest) (*pb.EchoResponse, error) { + echos := []*pb.Echo{} + for _, e := range req.GetEchos() { + response := fmt.Sprintf("Echoing %s from server!", e.Message) + echos = append(echos, &pb.Echo{response}) + } + return &pb.EchoResponse{echos, time.Now().String()}, nil +} diff --git a/gw/errors.go b/gw/errors.go new file mode 100644 index 00000000..190557d9 --- /dev/null +++ b/gw/errors.go @@ -0,0 +1,114 @@ +package gw + +import ( + "context" + "fmt" + "io" + "net/http" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/grpclog" + "google.golang.org/grpc/status" + + "github.com/grpc-ecosystem/grpc-gateway/runtime" +) + +// ProtoStreamErrorHandlerFunc handles the error as a gRPC error generated via status package and replies to the request. +// Addition bool argument indicates whether method (http.ResponseWriter.WriteHeader) was called or not. +type ProtoStreamErrorHandlerFunc func(context.Context, bool, *runtime.ServeMux, runtime.Marshaler, http.ResponseWriter, *http.Request, error) + +// RestError represents an error in accordance with REST API Syntax Specification. +// See: https://github.com/infobloxopen/atlas-app-toolkit#errors +type RestError struct { + Status *RestStatus `json:"error,omitempty"` + Details []interface{} `json:"details,omitempty"` +} + +var ( + // ProtoMessageErrorHandler uses PrefixOutgoingHeaderMatcher. + // To use ProtoErrorHandler with custom outgoing header matcher call NewProtoMessageErrorHandler. + ProtoMessageErrorHandler = NewProtoMessageErrorHandler(PrefixOutgoingHeaderMatcher) + // ProtoStreamErrorHandler uses PrefixOutgoingHeaderMatcher. + // To use ProtoErrorHandler with custom outgoing header matcher call NewProtoStreamErrorHandler. + ProtoStreamErrorHandler = NewProtoStreamErrorHandler(PrefixOutgoingHeaderMatcher) +) + +// NewProtoMessageErrorHandler returns runtime.ProtoErrorHandlerFunc +func NewProtoMessageErrorHandler(out runtime.HeaderMatcherFunc) runtime.ProtoErrorHandlerFunc { + h := &ProtoErrorHandler{out} + return h.MessageHandler +} + +// NewProtoStreamErrorHandler returns ProtoStreamErrorHandlerFunc +func NewProtoStreamErrorHandler(out runtime.HeaderMatcherFunc) ProtoStreamErrorHandlerFunc { + h := &ProtoErrorHandler{out} + return h.StreamHandler +} + +// ProtoErrorHandler implements runtime.ProtoErrorHandlerFunc in method MessageHandler +// and ProtoStreamErrorHandlerFunc in method StreamHandler +// in accordance with REST API Syntax Specification. +// See RestError for the JSON format of an error +type ProtoErrorHandler struct { + OutgoingHeaderMatcher runtime.HeaderMatcherFunc +} + +// MessageHandler implements runtime.ProtoErrorHandlerFunc +// in accordance with REST API Syntax Specification. +// See RestError for the JSON format of an error +func (h *ProtoErrorHandler) MessageHandler(ctx context.Context, mux *runtime.ServeMux, marshaler runtime.Marshaler, rw http.ResponseWriter, req *http.Request, err error) { + + md, ok := runtime.ServerMetadataFromContext(ctx) + if !ok { + grpclog.Printf("error handler: failed to extract ServerMetadata from context") + } + + handleForwardResponseServerMetadata(h.OutgoingHeaderMatcher, rw, md) + handleForwardResponseTrailerHeader(rw, md) + + h.writeError(ctx, false, marshaler, rw, err) + + handleForwardResponseTrailer(rw, md) +} + +// StreamHandler implements ProtoStreamErrorHandlerFunc +// in accordance with REST API Syntax Specification. +// See RestError for the JSON format of an error +func (h *ProtoErrorHandler) StreamHandler(ctx context.Context, headerWritten bool, mux *runtime.ServeMux, marshaler runtime.Marshaler, rw http.ResponseWriter, req *http.Request, err error) { + h.writeError(ctx, headerWritten, marshaler, rw, err) +} + +func (h *ProtoErrorHandler) writeError(ctx context.Context, headerWritten bool, marshaler runtime.Marshaler, rw http.ResponseWriter, err error) { + const fallback = `{"code":"INTERNAL","status":500,"message":"%s"}` + + st, ok := status.FromError(err) + if !ok { + st = status.New(codes.Unknown, err.Error()) + } + + restErr := &RestError{ + Status: Status(ctx, st), + Details: st.Details(), + } + + if !headerWritten { + rw.Header().Del("Trailer") + rw.Header().Set("Content-Type", marshaler.ContentType()) + rw.WriteHeader(restErr.Status.HTTPStatus) + } + + buf, merr := marshaler.Marshal(restErr) + if merr != nil { + grpclog.Printf("error handler: failed to marshal error message %q: %v", restErr, merr) + rw.WriteHeader(http.StatusInternalServerError) + + if _, err := io.WriteString(rw, fmt.Sprintf(fallback, merr)); err != nil { + grpclog.Printf("error handler: failed to write response: %v", err) + } + return + } + + if _, err := rw.Write(buf); err != nil { + grpclog.Printf("error handler: failed to write response: %v", err) + } +} diff --git a/gw/errors_test.go b/gw/errors_test.go new file mode 100644 index 00000000..cce784ee --- /dev/null +++ b/gw/errors_test.go @@ -0,0 +1,77 @@ +package gw + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/grpc-ecosystem/grpc-gateway/runtime" + "google.golang.org/genproto/googleapis/rpc/code" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestProtoMessageErrorHandlerUnknownCode(t *testing.T) { + err := fmt.Errorf("simple text error") + v := new(RestError) + + rw := httptest.NewRecorder() + ProtoMessageErrorHandler(context.Background(), nil, &runtime.JSONBuiltin{}, rw, nil, err) + + if ct := rw.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("invalid content-type: %s - expected: %s", ct, "application/json") + } + if rw.Code != http.StatusInternalServerError { + t.Errorf("invalid http status code: %d - expected: %d", rw.Code, http.StatusInternalServerError) + } + + if err := json.Unmarshal(rw.Body.Bytes(), v); err != nil { + t.Fatalf("failed to unmarshal response: %s", err) + } + + if v.Status.HTTPStatus != http.StatusInternalServerError { + t.Errorf("invalid http status: %s", v.Status.HTTPStatus) + } + + if v.Status.Code != code.Code_UNKNOWN.String() { + t.Errorf("invalid code: %s", v.Status.Code) + } + + if v.Status.Message != "simple text error" { + t.Errorf("invalid message: %s", v.Status.Message) + } +} + +func TestProtoMessageErrorHandlerUnimplementedCode(t *testing.T) { + err := status.Error(codes.Unimplemented, "service not implemented") + v := new(RestError) + + rw := httptest.NewRecorder() + ProtoMessageErrorHandler(context.Background(), nil, &runtime.JSONBuiltin{}, rw, nil, err) + + if ct := rw.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("invalid content-type: %s - expected: %s", ct, "application/json") + } + if rw.Code != http.StatusNotImplemented { + t.Errorf("invalid status code: %d - expected: %d", rw.Code, http.StatusNotImplemented) + } + + if err := json.Unmarshal(rw.Body.Bytes(), v); err != nil { + t.Fatalf("failed to unmarshal response: %s", err) + } + + if v.Status.HTTPStatus != http.StatusNotImplemented { + t.Errorf("invalid http status: %s", v.Status.HTTPStatus) + } + + if v.Status.Code != "NOT_IMPLEMENTED" { + t.Errorf("invalid code: %s", v.Status.Code) + } + + if v.Status.Message != "service not implemented" { + t.Errorf("invalid message: %s", v.Status.Message) + } +} diff --git a/gw/fields.go b/gw/fields.go new file mode 100644 index 00000000..9ab8eb64 --- /dev/null +++ b/gw/fields.go @@ -0,0 +1,81 @@ +package gw + +import ( + "context" + "net/http" + + "github.com/grpc-ecosystem/grpc-gateway/runtime" + "github.com/infobloxopen/atlas-app-toolkit/op" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +//SetFieldSelection sets op.FieldSelection to gRPC metadata +func SetFieldSelection(ctx context.Context, fields *op.FieldSelection) error { + fieldsStr := fields.GoString() + md := metadata.Pairs( + runtime.MetadataPrefix+fieldsMetaKey, fieldsStr, + ) + return grpc.SetHeader(ctx, md) +} + +//FieldSelection extracts op.FieldSelection from gRPC metadata +func FieldSelection(ctx context.Context) *op.FieldSelection { + fields, ok := Header(ctx, fieldsMetaKey) + if !ok { + return nil + } + return op.ParseFieldSelection(fields) +} + +//retainFields function extracts the configuration for fields that +//need to be ratained either from gRPC response or from original request +//(in case when gRPC side didn't set any preferences) and retains only +//this fields on outgoing response (dynmap). +func retainFields(ctx context.Context, req *http.Request, dynmap map[string]interface{}) { + fieldsStr, ok := Header(ctx, fieldsMetaKey) + if !ok && req != nil { + //no fields in gprc response -> try to get from original request + vals := req.URL.Query() + fieldsStr = vals.Get(fieldsQueryKey) + } + + if fieldsStr == "" { + return + } + + fields := op.ParseFieldSelection(fieldsStr) + if fields != nil { + for _, result := range dynmap { + if results, ok := result.([]interface{}); ok { + for _, r := range results { + if m, ok := r.(map[string]interface{}); ok { + doRetainFields(m, fields.Fields) + } + } + } + } + } +} + +func doRetainFields(obj map[string]interface{}, fields op.FieldSelectionMap) { + if fields == nil || len(fields) == 0 { + return + } + + for key := range obj { + if _, ok := fields[key]; !ok { + delete(obj, key) + } else { + switch x := obj[key].(type) { + case map[string]interface{}: + fds := fields[key].Subs + if fds != nil && len(fds) > 0 { + doRetainFields(x, fds) + } + case []interface{}: + //ingnoring arrays for now + } + } + } +} diff --git a/gw/fields_test.go b/gw/fields_test.go new file mode 100644 index 00000000..939320aa --- /dev/null +++ b/gw/fields_test.go @@ -0,0 +1,218 @@ +package gw + +import ( + "context" + "encoding/json" + "net/http" + "reflect" + "testing" + + "github.com/grpc-ecosystem/grpc-gateway/runtime" + "github.com/infobloxopen/atlas-app-toolkit/op" + "google.golang.org/grpc/metadata" +) + +func TestRetain(t *testing.T) { + data := ` + { + "result": [ + { + "x": "1", + "y": "2" + }, + { + "x": "3", + "y": "4", + "z": "5" + } + ] + }` + + expected := ` + { + "result": [ + { + "y": "2" + }, + { + "y": "4" + } + ] + }` + + var indata map[string]interface{} + err := json.Unmarshal([]byte(data), &indata) + if err != nil { + t.Errorf("Error parsing test input %s", data) + return + } + + var expdata map[string]interface{} + err = json.Unmarshal([]byte(expected), &expdata) + if err != nil { + t.Errorf("Error parsing test expected result %s", expected) + return + } + + md := runtime.ServerMetadata{ + HeaderMD: metadata.Pairs( + runtime.MetadataPrefix+fieldsMetaKey, "y", + ), + } + ctx := runtime.NewServerMetadataContext(context.Background(), md) + retainFields(ctx, nil, indata) + + if !reflect.DeepEqual(indata, expdata) { + t.Errorf("Unexpected result %v while expecting %v", indata, expdata) + } + +} + +func TestDoRetain(t *testing.T) { + data := ` + { + "a":{ + "b":{ + "c":"ccc", + "d":"ddd", + "x":"xxx" + }, + "e":"eee", + "r":"rrr" + }, + "z":"zzz", + "q":"qqq" + }` + + ensureRetain(t, data, "", ` + { + "a":{ + "b":{ + "c":"ccc", + "d":"ddd", + "x":"xxx" + }, + "e":"eee", + "r":"rrr" + }, + "z":"zzz", + "q":"qqq" + } + `) + + ensureRetain(t, data, "a.b.c,a.b.d,a.e,z", ` + { + "a":{ + "b":{ + "c":"ccc", + "d":"ddd" + }, + "e":"eee" + }, + "z":"zzz" + } + `) + + ensureRetain(t, data, "a.b", ` + { + "a":{ + "b":{ + "c":"ccc", + "d":"ddd", + "x":"xxx" + } + } + } + `) + + ensureRetain(t, data, "q", ` + { + "q":"qqq" + } + `) + + ensureRetain(t, data, "a.e,z", ` + { + "a":{ + "e":"eee" + }, + "z":"zzz" + } + `) + + ensureRetain(t, data, "a.mmm,vvv", ` + { + "a":{} + } + `) + + ensureRetain(t, data, "q.bbb", ` + { + "q":"qqq" + } + `) + + ensureRetain(t, data, "a.b.mmm", ` + { + "a":{ + "b":{} + } + } + `) + +} + +func ensureRetain(t *testing.T, input, fields, expected string) { + var indata map[string]interface{} + err := json.Unmarshal([]byte(input), &indata) + if err != nil { + t.Errorf("Error parsing test input %s", input) + return + } + + var expdata map[string]interface{} + err = json.Unmarshal([]byte(expected), &expdata) + if err != nil { + t.Errorf("Error parsing test expected result %s", expected) + return + } + + flds := op.ParseFieldSelection(fields) + doRetainFields(indata, flds.Fields) + + if !reflect.DeepEqual(indata, expdata) { + t.Errorf("Filtering input %s on fields %s returned %v while expecting %v", input, fields, indata, expdata) + return + } +} + +func TestFieldSelection(t *testing.T) { + // fields parameters is not specified + req, err := http.NewRequest(http.MethodGet, "http://app.com?someparam=1", nil) + if err != nil { + t.Fatalf("failed to build new http request: %s", err) + } + + md := MetadataAnnotator(context.Background(), req) + ctx := metadata.NewIncomingContext(context.Background(), md) + + flds := FieldSelection(ctx) + if flds != nil { + t.Fatalf("unexpected fields result: %v, expected nil", flds) + } + + // fields parameters is specified + req, err = http.NewRequest(http.MethodGet, "http://app.com?_fields=name,address.street&someparam=1", nil) + if err != nil { + t.Fatalf("failed to build new http request: %s", err) + } + + md = MetadataAnnotator(context.Background(), req) + ctx = metadata.NewIncomingContext(context.Background(), md) + + flds = FieldSelection(ctx) + expected := &op.FieldSelection{Fields: op.FieldSelectionMap{"name": &op.Field{Name: "name"}, "address": &op.Field{Name: "address", Subs: op.FieldSelectionMap{"street": &op.Field{Name: "street"}}}}} + if !reflect.DeepEqual(flds, expected) { + t.Errorf("Unexpected result %v while expecting %v", flds, expected) + } +} diff --git a/gw/header.go b/gw/header.go new file mode 100644 index 00000000..9bcc5485 --- /dev/null +++ b/gw/header.go @@ -0,0 +1,115 @@ +package gw + +import ( + "context" + "fmt" + "net/http" + "net/textproto" + "strings" + + "google.golang.org/grpc/metadata" + + "github.com/grpc-ecosystem/grpc-gateway/runtime" +) + +// Header returns first value for a given key if it exists in gRPC metadata +// from incoming or outcoming context, otherwise returns (nil, false) +// +// Calls HeaderN(ctx, key, 1) +// +// Provided key is converted to lowercase (see grpc/metadata.New). +// If key is not found the prefix "grpcgateway-" is added to the key and +// key is being searched once again. +func Header(ctx context.Context, key string) (string, bool) { + if l, ok := HeaderN(ctx, key, 1); ok { + return l[0], ok + } + return "", false +} + +// HeaderN returns first n values for a given key if it exists in gRPC metadata +// from incoming or outcoming context, otherwise returns (nil, false) +// +// If n < 0 all values for a given key will be returned +// If n > 0 at least n values will be returned, or (nil, false) +// If n == 0 result is (nil, false) +// +// Provided key is converted to lowercase (see grpc/metadata.New). +// If key is not found the prefix "grpcgateway-" is added to the key and +// key is being searched once again. +func HeaderN(ctx context.Context, key string, n int) (val []string, found bool) { + if n == 0 { + return + } + + if smd, ok := runtime.ServerMetadataFromContext(ctx); ok { + ctx = metadata.NewIncomingContext(ctx, smd.HeaderMD) + } + + imd, iok := metadata.FromIncomingContext(ctx) + omd, ook := metadata.FromOutgoingContext(ctx) + + md := metadata.Join(imd, omd) + + if !iok && !ook { + return nil, false + } + + key = strings.ToLower(key) + if v, ok := md[key]; ok { + val = append(val, v...) + found = true + } + // If md contains 'key' and 'runtime.MetadataPrefix + key' + // collect them all + key = runtime.MetadataPrefix + key + if v, ok := md[key]; ok { + val = append(val, v...) + found = true + } + + switch { + case !found: + return + case n < 0 || len(val) == n: + return + case len(val) < n: + return nil, false + default: + return val[:n], found + } +} + +// PrefixOutgoingHeaderMatcher prefixes outgoing gRPC metadata with +// runtime.MetadataHeaderPrefix ("Grpc-Metadata-"). +// It behaves like the default gRPC-Gateway outgoing header matcher +// (if none is provided as an option). +func PrefixOutgoingHeaderMatcher(key string) (string, bool) { + return fmt.Sprintf("%s%s", runtime.MetadataHeaderPrefix, key), true +} + +func handleForwardResponseServerMetadata(matcher runtime.HeaderMatcherFunc, w http.ResponseWriter, md runtime.ServerMetadata) { + for k, vs := range md.HeaderMD { + if h, ok := matcher(k); ok { + for _, v := range vs { + w.Header().Add(h, v) + } + } + } +} + +func handleForwardResponseTrailerHeader(w http.ResponseWriter, md runtime.ServerMetadata) { + for k := range md.TrailerMD { + tKey := textproto.CanonicalMIMEHeaderKey(fmt.Sprintf("%s%s", runtime.MetadataTrailerPrefix, k)) + w.Header().Add("Trailer", tKey) + } +} + +func handleForwardResponseTrailer(w http.ResponseWriter, md runtime.ServerMetadata) { + for k, vs := range md.TrailerMD { + tKey := fmt.Sprintf("%s%s", runtime.MetadataTrailerPrefix, k) + for _, v := range vs { + w.Header().Add(tKey, v) + } + } +} diff --git a/gw/header_test.go b/gw/header_test.go new file mode 100644 index 00000000..207e34f4 --- /dev/null +++ b/gw/header_test.go @@ -0,0 +1,66 @@ +package gw + +import ( + "context" + "testing" + + "google.golang.org/grpc/metadata" + + "github.com/grpc-ecosystem/grpc-gateway/runtime" +) + +func TestHeader(t *testing.T) { + imd := metadata.Pairs("key1", "val1") + omd := metadata.Pairs("key2", "val2", "grpcgateway-key2", "val2") + + ictx := metadata.NewIncomingContext(context.Background(), imd) + ctx := metadata.NewOutgoingContext(ictx, omd) + + if v, ok := Header(ctx, "key1"); !ok { + t.Error("failed to get 'key1'") + } else if v != "val1" { + t.Errorf("invalid value of 'key1': %s", v) + } + + if v, ok := Header(ctx, "key2"); !ok { + t.Error("failed to get 'key2'") + } else if v != "val2" { + t.Errorf("invalid value of 'key2': %s", v) + } +} + +func TestHeaderN(t *testing.T) { + imd := metadata.Pairs("key1", "val1") + omd := metadata.Pairs("key2", "val2", "grpcgateway-key2", "val2") + + ictx := metadata.NewIncomingContext(context.Background(), imd) + ctx := metadata.NewOutgoingContext(ictx, omd) + + if v, ok := HeaderN(ctx, "key1", -1); !ok { + t.Error("failed to get 'key1'") + } else if len(v) != 1 || v[0] != "val1" { + t.Errorf("invalid value of 'key1': %s", v) + } + + if v, ok := HeaderN(ctx, "key2", 2); !ok { + t.Error("failed to get 'key2'") + } else if len(v) != 2 || v[0] != "val2" || v[1] != "val2" { + t.Errorf("invalid value of 'key2': %s", v) + } + + if v, ok := HeaderN(ctx, "key1", 0); ok || v != nil { + t.Errorf("invalid result with n==0: %s, %s", v, ok) + } + + if v, ok := HeaderN(ctx, "key1", 10); ok || v != nil { + t.Errorf("invalid result with n>len(md): %s, %s", v, ok) + } +} + +func TestPrefixOutgoingHeaderMatcher(t *testing.T) { + key := "Content-Type" + v, ok := PrefixOutgoingHeaderMatcher(key) + if !ok || v != runtime.MetadataHeaderPrefix+key { + t.Errorf("header %s is not matched: %s, %s", key, v, ok) + } +} diff --git a/gw/operator.go b/gw/operator.go new file mode 100644 index 00000000..0aabcaed --- /dev/null +++ b/gw/operator.go @@ -0,0 +1,162 @@ +package gw + +import ( + "context" + "net/http" + "strconv" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + + "github.com/grpc-ecosystem/grpc-gateway/runtime" + + "github.com/infobloxopen/atlas-app-toolkit/op" +) + +const ( + filterQueryKey = "_filter" + filterMetaKey = "operator-filter" + sortQueryKey = "_order_by" + sortMetaKey = "operator-sort" + fieldsQueryKey = "_fields" + fieldsMetaKey = "operator-fields" + limitQueryKey = "_limit" + limitMetaKey = "operator-limit" + offsetQueryKey = "_offset" + offsetMetaKey = "operator-offset" + pageTokenQueryKey = "_page_token" + pageTokenMetaKey = "operator-page-token" + pageInfoSizeMetaKey = "status-page-info-size" + pageInfoOffsetMetaKey = "status-page-info-offset" + pageInfoPageTokenMetaKey = "status-page-info-page_token" +) + +// MetadataAnnotator is a function for passing metadata to a gRPC context +// It must be mainly used as ServeMuxOption for gRPC Gateway 'ServeMux' +// See: 'WithMetadata' option. +// +// MetadataAnnotator extracts values of collections operators from incoming +// HTTP request accroding to REST API Syntax. +// E.g: +// - _order_by="name asc,age desc" +// - _fields="name,age" +// - _filter="name == 'John'" +// - _limit=1000 +// - _offset=1001 +// - _page_token=QWxhZGRpbjpvcGVuIHNlc2FtZQ +func MetadataAnnotator(ctx context.Context, req *http.Request) metadata.MD { + vals := req.URL.Query() + mdmap := make(map[string]string) + + if v := vals.Get(sortQueryKey); v != "" { + mdmap[runtime.MetadataPrefix+sortMetaKey] = v + } + if v := vals.Get(fieldsQueryKey); v != "" { + mdmap[runtime.MetadataPrefix+fieldsMetaKey] = v + } + + if v := vals.Get(filterQueryKey); v != "" { + mdmap[runtime.MetadataPrefix+filterMetaKey] = v + } + + if v := vals.Get(offsetQueryKey); v != "" { + mdmap[runtime.MetadataPrefix+offsetMetaKey] = v + } + + if v := vals.Get(limitQueryKey); v != "" { + mdmap[runtime.MetadataPrefix+limitMetaKey] = v + } + + if v := vals.Get(pageTokenQueryKey); v != "" { + mdmap[runtime.MetadataPrefix+pageTokenMetaKey] = v + } + + return metadata.New(mdmap) +} + +// Sorting extracts sort parameters from incoming gRPC context. +// If sorting collection operator has not been specified in query string of +// incoming HTTP request function returns (nil, nil). +// If provided sorting parameters are invalid function returns +// `status.Error(codes.InvalidArgument, parser_error)` +// See: `op.ParseSorting` for details. +func Sorting(ctx context.Context) (*op.Sorting, error) { + raw, ok := Header(ctx, sortMetaKey) + if !ok { + return nil, nil + } + + s, err := op.ParseSorting(raw) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + return s, nil +} + +// Filtering extracts filter parameters from incoming gRPC context. +// If filtering collection operator has not been specified in query string of +// incoming HTTP request function returns (nil, nil). +// If provided filtering parameters are invalid function returns +// `status.Error(codes.InvalidArgument, parser_error)` +// See: `op.ParseFiltering` for details. +func Filtering(ctx context.Context) (*op.Filtering, error) { + raw, ok := Header(ctx, filterMetaKey) + if !ok { + return nil, nil + } + + f, err := op.ParseFiltering(raw) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + return f, nil +} + +// Pagination extracts pagination parameters from incoming gRPC context. +// If some of parameters has not been specified in query string of incoming +// HTTP request corresponding fields in `op.PaginationRequest` structure will be set +// to nil. +// If provided pagination parameters are invalid function returns +// `status.Error(codes.InvalidArgument, parser_error)` +// See: `op.ParsePagination` for details. +func Pagination(ctx context.Context) (*op.Pagination, error) { + l, lok := Header(ctx, limitMetaKey) + o, ook := Header(ctx, offsetMetaKey) + pt, ptok := Header(ctx, pageTokenMetaKey) + + if !lok && !ook && !ptok { + return nil, nil + } + + p, err := op.ParsePagination(l, o, pt) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + return p, nil +} + +// SetPagination sets page info to outgoing gRPC context. +func SetPageInfo(ctx context.Context, p *op.PageInfo) error { + m := make(map[string]string) + + if pt := p.GetPageToken(); pt != "" { + m[pageInfoPageTokenMetaKey] = pt + } + + if o := p.GetOffset(); o != 0 && p.NoMore() { + m[pageInfoOffsetMetaKey] = "null" + } else if o != 0 { + m[pageInfoOffsetMetaKey] = strconv.FormatUint(uint64(o), 10) + } + + if s := p.GetSize(); s != 0 { + m[pageInfoSizeMetaKey] = strconv.FormatUint(uint64(s), 10) + } + + return grpc.SetHeader(ctx, metadata.New(m)) +} diff --git a/gw/operator_test.go b/gw/operator_test.go new file mode 100644 index 00000000..dd01e617 --- /dev/null +++ b/gw/operator_test.go @@ -0,0 +1,113 @@ +package gw + +import ( + "context" + "net/http" + "testing" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + + "github.com/infobloxopen/atlas-app-toolkit/op" +) + +func TestSorting(t *testing.T) { + // sort parameters is not specified + req, err := http.NewRequest(http.MethodGet, "http://app.com?someparam=1", nil) + if err != nil { + t.Fatalf("failed to build new http request: %s", err) + } + + md := MetadataAnnotator(context.Background(), req) + ctx := metadata.NewIncomingContext(context.Background(), md) + + s, err := Sorting(ctx) + if err != nil || s != nil { + t.Fatalf("invalid error: %s, %s - expected: nil, nil", s, err) + } + + // invalid sort parameters + req, err = http.NewRequest(http.MethodGet, "http://app.com?_order_by=name dasc, age desc&someparam=1", nil) + if err != nil { + t.Fatalf("failed to build new http request: %s", err) + } + + md = MetadataAnnotator(context.Background(), req) + ctx = metadata.NewIncomingContext(context.Background(), md) + + _, err = Sorting(ctx) + if err == nil { + t.Fatal("no error returned") + } + if s, ok := status.FromError(err); !ok { + t.Fatal("no status error retunred") + } else if s.Code() != codes.InvalidArgument { + t.Errorf("invalid status code: %s - expected: %s", s.Code(), codes.InvalidArgument) + } + + // valid sort parameters + req, err = http.NewRequest(http.MethodGet, "http://app.com?_order_by=name asc, age desc&someparam=1", nil) + if err != nil { + t.Fatalf("failed to build new http request: %s", err) + } + + md = MetadataAnnotator(context.Background(), req) + ctx = metadata.NewIncomingContext(context.Background(), md) + + s, err = Sorting(ctx) + if err != nil { + t.Fatalf("failed to extract sorting parameters from context: %s", err) + } + + if len(s.GetCriterias()) != 2 { + t.Fatalf("invalid number of sort criterias: %s - expected: 2", len(s.GetCriterias())) + } + if c := s.GetCriterias(); c[0].GoString() != "name ASC" || c[0].Tag != "name" || c[0].Order != op.SortCriteria_ASC { + t.Errorf("invalid sort criteria: %v - expected: %v", c[0], op.SortCriteria{"name", op.SortCriteria_ASC}) + } + if c := s.GetCriterias(); c[1].GoString() != "age DESC" || c[1].Tag != "age" || c[1].Order != op.SortCriteria_DESC { + t.Errorf("invalid sort criteria: %v - expected: %v", c[1], op.SortCriteria{"age", op.SortCriteria_DESC}) + } +} + +func TestPagination(t *testing.T) { + // valid pagination request + req, err := http.NewRequest(http.MethodGet, "http://app.com?_limit=20&_offset=10&_page_token=ptoken", nil) + if err != nil { + t.Fatalf("failed to build new http request: %s", err) + } + + md := MetadataAnnotator(context.Background(), req) + ctx := metadata.NewIncomingContext(context.Background(), md) + + page, err := Pagination(ctx) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if page.GetLimit() != 20 || page.GetOffset() != 10 || page.GetPageToken() != "ptoken" { + t.Errorf("invalid pagination: %s - expected: %s", page, &op.Pagination{Limit: 20, Offset: 10, PageToken: "ptoken"}) + } + + // invalid pagination request + req, err = http.NewRequest(http.MethodGet, "http://app.com?_limit=twenty&_offset=10", nil) + if err != nil { + t.Fatalf("failed to build new http request: %s", err) + } + + md = MetadataAnnotator(context.Background(), req) + ctx = metadata.NewIncomingContext(context.Background(), md) + + _, err = Pagination(ctx) + if err == nil { + t.Fatalf("unexpected nil error") + } + s, ok := status.FromError(err) + if !ok { + t.Fatalf("unexpected non status error: %s", s) + } + if s.Code() != codes.InvalidArgument { + t.Errorf("invalid status error code: %s", s.Code()) + } +} diff --git a/gw/response.go b/gw/response.go new file mode 100644 index 00000000..412188a1 --- /dev/null +++ b/gw/response.go @@ -0,0 +1,228 @@ +package gw + +import ( + "context" + "fmt" + "io" + "net/http" + + "github.com/golang/protobuf/proto" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/grpclog" + + "github.com/grpc-ecosystem/grpc-gateway/runtime" +) + +type ( + // ForwardResponseMessageFunc forwards gRPC response to HTTP client inaccordance with REST API Syntax + ForwardResponseMessageFunc func(context.Context, *runtime.ServeMux, runtime.Marshaler, http.ResponseWriter, *http.Request, proto.Message, ...func(context.Context, http.ResponseWriter, proto.Message) error) + // ForwardResponseStreamFunc forwards gRPC stream response to HTTP client inaccordance with REST API Syntax + ForwardResponseStreamFunc func(context.Context, *runtime.ServeMux, runtime.Marshaler, http.ResponseWriter, *http.Request, func() (proto.Message, error), ...func(context.Context, http.ResponseWriter, proto.Message) error) +) + +// ResponseForwarder implements ForwardResponseMessageFunc in method ForwardMessage +// and ForwardResponseStreamFunc in method ForwardStream +// in accordance with REST API Syntax Specification. +// See: https://github.com/infobloxopen/atlas-app-toolkit#responses +// for format of JSON response. +type ResponseForwarder struct { + OutgoingHeaderMatcher runtime.HeaderMatcherFunc + MessageErrHandler runtime.ProtoErrorHandlerFunc + StreamErrHandler ProtoStreamErrorHandlerFunc +} + +var ( + // ForwardResponseMessage is default implementation of ForwardResponseMessageFunc + ForwardResponseMessage = NewForwardResponseMessage(PrefixOutgoingHeaderMatcher, ProtoMessageErrorHandler, ProtoStreamErrorHandler) + // ForwardResponseStream is default implementation of ForwardResponseStreamFunc + ForwardResponseStream = NewForwardResponseStream(PrefixOutgoingHeaderMatcher, ProtoMessageErrorHandler, ProtoStreamErrorHandler) +) + +// NewForwardResponseMessage returns ForwardResponseMessageFunc +func NewForwardResponseMessage(out runtime.HeaderMatcherFunc, meh runtime.ProtoErrorHandlerFunc, seh ProtoStreamErrorHandlerFunc) ForwardResponseMessageFunc { + fw := &ResponseForwarder{out, meh, seh} + return fw.ForwardMessage +} + +// NewForwardResponseStream returns ForwardResponseStreamFunc +func NewForwardResponseStream(out runtime.HeaderMatcherFunc, meh runtime.ProtoErrorHandlerFunc, seh ProtoStreamErrorHandlerFunc) ForwardResponseStreamFunc { + fw := &ResponseForwarder{out, meh, seh} + return fw.ForwardStream +} + +// ForwardMessage implements runtime.ForwardResponseMessageFunc +func (fw *ResponseForwarder) ForwardMessage(ctx context.Context, mux *runtime.ServeMux, marshaler runtime.Marshaler, rw http.ResponseWriter, req *http.Request, resp proto.Message, opts ...func(context.Context, http.ResponseWriter, proto.Message) error) { + md, ok := runtime.ServerMetadataFromContext(ctx) + if !ok { + grpclog.Printf("forward response message: failed to extract ServerMetadata from context") + fw.MessageErrHandler(ctx, mux, marshaler, rw, req, fmt.Errorf("forward response message: internal error")) + } + + handleForwardResponseServerMetadata(fw.OutgoingHeaderMatcher, rw, md) + handleForwardResponseTrailerHeader(rw, md) + + rw.Header().Set("Content-Type", marshaler.ContentType()) + + if err := handleForwardResponseOptions(ctx, rw, resp, opts); err != nil { + fw.MessageErrHandler(ctx, mux, marshaler, rw, req, err) + return + } + + // here we start doing a bit strange things + // 1. marshal response into bytes + // 2. unmarshal bytes into dynamic map[string]interface{} + // 3. add our custom metadata into dynamic map + // 4. marshal dynamic map into bytes again :\ + // all that steps are needed because of this requirements: + // -- To allow compatibility with existing systems, + // -- the results tag name can be changed to a service-defined tag. + // -- In this way the success data becomes just a tag added to an existing structure. + data, err := marshaler.Marshal(resp) + if err != nil { + grpclog.Printf("forward response: failed to marshal response: %v", err) + fw.MessageErrHandler(ctx, mux, marshaler, rw, req, err) + } + + var dynmap map[string]interface{} + if err := marshaler.Unmarshal(data, &dynmap); err != nil { + grpclog.Printf("forward response: failed to unmarshal response: %v", err) + fw.MessageErrHandler(ctx, mux, marshaler, rw, req, err) + } + + retainFields(ctx, req, dynmap) + + // Here we set "Location" header which contains a url to a long running task + // Using it we can retrieve its status + rst := Status(ctx, nil) + if rst.Code == CodeName(LongRunning) { + location, exists := Header(ctx, "Location") + + if !exists || location == "" { + err := fmt.Errorf("Header Location should be set for long running operation") + grpclog.Printf("forward response: %v", err) + fw.MessageErrHandler(ctx, mux, marshaler, rw, req, err) + } + rw.Header().Add("Location", location) + } + // this is the edge case, if user sends response that has field 'success' + // let him see his response object instead of our status + if _, ok := dynmap["success"]; !ok { + dynmap["success"] = rst + } + + data, err = marshaler.Marshal(dynmap) + if err != nil { + grpclog.Printf("forward response: failed to marshal response: %v", err) + fw.MessageErrHandler(ctx, mux, marshaler, rw, req, err) + } + + rw.WriteHeader(rst.HTTPStatus) + + if _, err = rw.Write(data); err != nil { + grpclog.Printf("forward response: failed to write response: %v", err) + } + + handleForwardResponseTrailer(rw, md) +} + +// ForwardStream implements runtime.ForwardResponseStreamFunc. +// RestStatus comes first in the chuncked result. +func (fw *ResponseForwarder) ForwardStream(ctx context.Context, mux *runtime.ServeMux, marshaler runtime.Marshaler, rw http.ResponseWriter, req *http.Request, recv func() (proto.Message, error), opts ...func(context.Context, http.ResponseWriter, proto.Message) error) { + flusher, ok := rw.(http.Flusher) + if !ok { + grpclog.Printf("forward response stream: flush not supported in %T", rw) + fw.StreamErrHandler(ctx, false, mux, marshaler, rw, req, fmt.Errorf("forward response message: internal error")) + return + } + + md, ok := runtime.ServerMetadataFromContext(ctx) + if !ok { + grpclog.Printf("forward response stream: failed to extract ServerMetadata from context") + fw.StreamErrHandler(ctx, false, mux, marshaler, rw, req, fmt.Errorf("forward response message: internal error")) + return + } + handleForwardResponseServerMetadata(fw.OutgoingHeaderMatcher, rw, md) + + rw.Header().Set("Transfer-Encoding", "chunked") + rw.Header().Set("Content-Type", marshaler.ContentType()) + + if err := handleForwardResponseOptions(ctx, rw, nil, opts); err != nil { + fw.StreamErrHandler(ctx, false, mux, marshaler, rw, req, err) + return + } + + rst := Status(ctx, nil) + // if user did not set status explicitly + if rst.Code == "" || rst.Code == CodeName(codes.OK) { + rst.Code = CodeName(PartialContent) + } + if rst.HTTPStatus == http.StatusOK { + rst.HTTPStatus = HTTPStatusFromCode(PartialContent) + } + v := map[string]interface{}{"success": rst} + + rw.WriteHeader(rst.HTTPStatus) + + data, err := marshaler.Marshal(v) + if err != nil { + fw.StreamErrHandler(ctx, true, mux, marshaler, rw, req, err) + return + } + + if _, err := rw.Write(data); err != nil { + grpclog.Printf("forward response stream: failed to write status object: %s", err) + return + } + + var delimiter []byte + if d, ok := marshaler.(runtime.Delimited); ok { + delimiter = d.Delimiter() + } else { + delimiter = []byte("\n") + } + + for { + resp, err := recv() + if err == io.EOF { + return + } + if err != nil { + fw.StreamErrHandler(ctx, true, mux, marshaler, rw, req, err) + return + } + if err := handleForwardResponseOptions(ctx, rw, resp, opts); err != nil { + fw.StreamErrHandler(ctx, true, mux, marshaler, rw, req, err) + return + } + + data, err := marshaler.Marshal(resp) + if err != nil { + fw.StreamErrHandler(ctx, true, mux, marshaler, rw, req, err) + return + } + + if _, err := rw.Write(data); err != nil { + grpclog.Printf("forward response stream: failed to write response object: %s", err) + return + } + + if _, err = rw.Write(delimiter); err != nil { + grpclog.Printf("forward response stream: failed to send delimiter chunk: %v", err) + return + } + flusher.Flush() + } +} + +func handleForwardResponseOptions(ctx context.Context, rw http.ResponseWriter, resp proto.Message, opts []func(context.Context, http.ResponseWriter, proto.Message) error) error { + if len(opts) == 0 { + return nil + } + for _, opt := range opts { + if err := opt(ctx, rw, resp); err != nil { + grpclog.Printf("error handling ForwardResponseOptions: %v", err) + return err + } + } + return nil +} diff --git a/gw/response_test.go b/gw/response_test.go new file mode 100644 index 00000000..ac085e53 --- /dev/null +++ b/gw/response_test.go @@ -0,0 +1,200 @@ +package gw + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/golang/protobuf/proto" + "google.golang.org/grpc/metadata" + + "github.com/grpc-ecosystem/grpc-gateway/runtime" +) + +type user struct { + Name string `json:"user"` + Age int `json:"age"` +} + +type result struct { + Users []*user `json"users"` +} + +func (m *result) Reset() {} +func (m *result) ProtoMessage() {} +func (m *result) String() string { return "" } + +type badresult struct { + Success []*user `json:"success"` +} + +func (m *badresult) Reset() {} +func (m *badresult) ProtoMessage() {} +func (m *badresult) String() string { return "" } + +type response struct { + Status *RestStatus `json:"success"` + Result []*user `json:"users"` +} + +func TestForwardResponseMessage(t *testing.T) { + md := runtime.ServerMetadata{ + HeaderMD: metadata.Pairs( + runtime.MetadataPrefix+"status-code", CodeName(Created), + runtime.MetadataPrefix+"status-message", "created 1 item", + ), + } + ctx := runtime.NewServerMetadataContext(context.Background(), md) + + rw := httptest.NewRecorder() + ForwardResponseMessage(ctx, nil, &runtime.JSONBuiltin{}, rw, nil, &result{Users: []*user{{"Poe", 209}, {"Hemingway", 119}}}) + + if rw.Code != http.StatusCreated { + t.Errorf("invalid http status code: %d - expected: %d", rw.Code, http.StatusCreated) + } + + if ct := rw.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("invalid content-type: %s - expected: %s", ct, "application/json") + } + + v := &response{} + if err := json.Unmarshal(rw.Body.Bytes(), v); err != nil { + t.Fatalf("failed to unmarshal JSON response: %s", err) + } + + if v.Status.Code != CodeName(Created) { + t.Errorf("invalid status code: %s - expected: %s", v.Status.Code, CodeName(Created)) + } + + if v.Status.HTTPStatus != http.StatusCreated { + t.Errorf("invalid http status code: %d - expected: %d", v.Status.HTTPStatus, http.StatusCreated) + } + + if v.Status.Message != "created 1 item" { + t.Errorf("invalid status message: %s - expected: %s", v.Status.Message, "created 1 item") + } + + if l := len(v.Result); l != 2 { + t.Fatal("invalid number of items in response result: %d - expected: %d", l, 2) + } + + poe, hemingway := v.Result[0], v.Result[1] + if poe.Name != "Poe" || poe.Age != 209 { + t.Errorf("invalid result item: %+v - expected: %+v", poe, &user{"Poe", 209}) + } + + if hemingway.Name != "Hemingway" || hemingway.Age != 119 { + t.Errorf("invalid result item: %+v - expected: %+v", hemingway, &user{"Hemingway", 119}) + } +} + +func TestForwardResponseMessageWithSuccessField(t *testing.T) { + ctx := runtime.NewServerMetadataContext(context.Background(), runtime.ServerMetadata{}) + + rw := httptest.NewRecorder() + ForwardResponseMessage( + ctx, nil, &runtime.JSONBuiltin{}, rw, nil, + &badresult{Success: []*user{{"Poe", 209}, {"Hemingway", 119}}}, + ) + + var v map[string][]*user + if err := json.Unmarshal(rw.Body.Bytes(), &v); err != nil { + t.Fatalf("failed to unmarshal response: %s", err) + } + l, ok := v["success"] + if !ok { + t.Fatal("invalid response: missing 'success' field") + } + if len(l) != 2 { + t.Fatalf("invalid number of items in response: %d - expected: %d", len(l), 2) + } + if u := l[0]; u.Name != "Poe" || u.Age != 209 { + t.Errorf("invalid response item: %+v - expected: %+v", u, &user{"Poe", 209}) + } + if u := l[1]; u.Name != "Hemingway" || u.Age != 119 { + t.Errorf("invalid response item: %+v - expected: %+v", u, &user{"Hemingway", 119}) + } +} + +func TestForwardResponseStream(t *testing.T) { + md := runtime.ServerMetadata{ + HeaderMD: metadata.Pairs( + runtime.MetadataPrefix+"status-message", "returned 1 item", + ), + } + ctx := runtime.NewServerMetadataContext(context.Background(), md) + rw := httptest.NewRecorder() + + count := 0 + items := []*result{ + {[]*user{{"Poe", 209}}}, + {[]*user{{"Hemingway", 119}}}, + } + recv := func() (proto.Message, error) { + if count < len(items) { + i := items[count] + count++ + return i, nil + } + return nil, io.EOF + } + + ForwardResponseStream(ctx, nil, &runtime.JSONBuiltin{}, rw, nil, recv) + + // if not set explicitly should be set by default + if rw.Code != http.StatusPartialContent { + t.Errorf("invalid http status code:%d - expected: %d", rw.Code, http.StatusPartialContent) + } + if ct := rw.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("invalid content-type: %s - expected: %s", ct, "application/json") + } + if te := rw.Header().Get("Transfer-Encoding"); te != "chunked" { + t.Errorf("invalid transfer-encoding: %s - expected: %s", te, "chunked") + } + + dec := json.NewDecoder(rw.Body) + + var sv map[string]*RestStatus + if err := dec.Decode(&sv); err != nil { + t.Fatalf("failed to unmarshal response status: %s", err) + } + if s, ok := sv["success"]; !ok { + t.Fatalf("invalid status response: %v (%v)", s, sv) + } + rst := sv["success"] + if rst.Code != CodeName(PartialContent) { + t.Errorf("invalid status code: %s - expected: %s", rst.Code, CodeName(PartialContent)) + } + if rst.HTTPStatus != http.StatusPartialContent { + t.Errorf("invalid http status code: %d - expected: %d", rst.HTTPStatus, http.StatusPartialContent) + } + if rst.Message != "returned 1 item" { + t.Errorf("invalid status message: %s - expected: %s", rst.Message, "returned 1 item") + } + + var rv *result + // test Poe + if err := dec.Decode(&rv); err != nil { + t.Fatalf("failed to unmarshal response chunked result: %s", err) + } + if len(rv.Users) != 1 { + t.Fatalf("invalid number of items in chuncked result: %d - expected: %d", len(rv.Users), 1) + } + if u := rv.Users[0]; u.Name != "Poe" || u.Age != 209 { + t.Errorf("invalid item from chuncked result: %+v - expected: %+v", u, &user{"Poe", 209}) + } + + // test Hemingway + if err := dec.Decode(&rv); err != nil { + t.Fatalf("failed to unmarshal response chunked result: %s", err) + } + if len(rv.Users) != 1 { + t.Fatalf("invalid number of items in chuncked result: %d - expected: %d", len(rv.Users), 1) + } + if u := rv.Users[0]; u.Name != "Hemingway" || u.Age != 119 { + t.Errorf("invalid item from chuncked result: %+v - expected: %+v", u, &user{"Hemingway", 119}) + } +} diff --git a/gw/status.go b/gw/status.go new file mode 100644 index 00000000..e6ca62f7 --- /dev/null +++ b/gw/status.go @@ -0,0 +1,233 @@ +package gw + +import ( + "context" + "net/http" + + "google.golang.org/genproto/googleapis/rpc/code" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/grpclog" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + + "github.com/grpc-ecosystem/grpc-gateway/runtime" +) + +const ( + // These custom codes defined here to conform REST API Syntax + // It is supposed that you do not send them over the wire as part of gRPC Status, + // because they will be treated as Unknown by gRPC library. + // You should use them to send successfull status of your RPC method + // using SetStatus function from this package. + Created codes.Code = 10000 + iota // 10000 is an offset from standard codes + Updated + Deleted + LongRunning + PartialContent +) + +// RestStatus represents a response status in accordance with REST API Syntax. +// See: https://github.com/infobloxopen/atlas-app-toolkit#responses +type RestStatus struct { + HTTPStatus int `json:"status,omitempty"` + // Code is a string representation of an error code + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` + + // Pagination response parameters + PageToken string `json:"_page_token,omitempty"` + Offset string `json:"_offset,omitempty"` + Size string `json:"_size,omitempty"` +} + +// SetStatus sets gRPC status as gRPC metadata +// Status.Code will be set with metadata key `grpcgateway-status-code` and +// with value as string name of the code. +// Status.Message will be set with metadata key `grpcgateway-status-message` +// and with corresponding value. +func SetStatus(ctx context.Context, st *status.Status) error { + if st == nil { + return nil + } + + md := metadata.Pairs( + runtime.MetadataPrefix+"status-code", CodeName(st.Code()), + runtime.MetadataPrefix+"status-message", st.Message(), + ) + return grpc.SetHeader(ctx, md) +} + +// SetCreated is a shortcut for SetStatus(ctx, status.New(Created, msg)) +func SetCreated(ctx context.Context, msg string) error { + return SetStatus(ctx, status.New(Created, msg)) +} + +// SetUpdated is a shortcut for SetStatus(ctx, status.New(Updated, msg)) +func SetUpdated(ctx context.Context, msg string) error { + return SetStatus(ctx, status.New(Updated, msg)) +} + +// SetDeleted is a shortcut for SetStatus(ctx, status.New(Deleted, msg)) +func SetDeleted(ctx context.Context, msg string) error { + return SetStatus(ctx, status.New(Deleted, msg)) +} + +// SetRunning is a shortcut for SetStatus(ctx, status.New(LongRunning, url)) +func SetRunning(ctx context.Context, message, resource string) error { + grpc.SetHeader(ctx, metadata.Pairs("Location", resource)) + return SetStatus(ctx, status.New(LongRunning, message)) +} + +// Status returns REST representation of gRPC status. +// If status.Status is not nil it will be converted in accrodance with REST +// API Syntax otherwise context will be used to extract +// `grpcgatewau-status-code` and `grpcgateway-status-message` from +// gRPC metadata. +// If `grpcgatewau-status-code` is not set it is assumed that it is OK. +func Status(ctx context.Context, st *status.Status) *RestStatus { + var rst RestStatus + + if st != nil { + rst.Code = CodeName(st.Code()) + rst.HTTPStatus = HTTPStatusFromCode(st.Code()) + rst.Message = st.Message() + + return &rst + } + + if sc, ok := Header(ctx, "status-code"); ok { + rst.Code = sc + } else { + rst.Code = CodeName(codes.OK) + } + if sm, ok := Header(ctx, "status-message"); ok { + rst.Message = sm + } + rst.HTTPStatus = HTTPStatusFromCode(Code(rst.Code)) + + // PageInfo + if pt, ok := Header(ctx, pageInfoPageTokenMetaKey); ok { + rst.PageToken = pt + } + if o, ok := Header(ctx, pageInfoOffsetMetaKey); ok { + rst.Offset = o + } + if s, ok := Header(ctx, pageInfoSizeMetaKey); ok { + rst.Size = s + } + + return &rst +} + +// CodeName returns stringname of gRPC code, function handles as standard +// codes from "google.golang.org/grpc/codes" as well as custom ones defined +// in this package. +// The codes.Unimplemented is named "NOT_IMPLEMENTED" in accordance with +// REST API Syntax Specification. +func CodeName(c codes.Code) string { + switch c { + case codes.Unimplemented: + return "NOT_IMPLEMENTED" + case Created: + return "CREATED" + case Updated: + return "UPDATED" + case Deleted: + return "DELETED" + case LongRunning: + return "LONG_RUNNING_OP" + case PartialContent: + return "PARTIAL_CONTENT" + default: + var cname string + if cn, ok := code.Code_name[int32(c)]; !ok { + cname = code.Code_UNKNOWN.String() + } else { + cname = cn + } + return cname + } +} + +// Code returns an instance of gRPC code by its string name. +// The `cname` must be in upper case and one of the code names +// defined in REST API Syntax. +// If code name is invalid or unknow the codes.Unknown will be returned. +func Code(cname string) codes.Code { + switch cname { + case "NOT_IMPLEMENTED": + return codes.Unimplemented + case "CREATED": + return Created + case "UPDATED": + return Updated + case "DELETED": + return Deleted + case "LONG_RUNNING_OP": + return LongRunning + case "PARTIAL_CONTENT": + return PartialContent + default: + var c codes.Code + if cc, ok := code.Code_value[cname]; !ok { + c = codes.Unknown + } else { + c = codes.Code(cc) + } + return c + } +} + +// HTTPStatusFromCode converts a gRPC error code into the corresponding HTTP response status. +func HTTPStatusFromCode(code codes.Code) int { + switch code { + case Created: + return http.StatusCreated + case Updated: + return http.StatusCreated + case Deleted: + return http.StatusNoContent + case LongRunning: + return http.StatusAccepted + case PartialContent: + return http.StatusPartialContent + case codes.OK: + return http.StatusOK + case codes.Canceled: + return 499 // (gRPC-Gateway - http.StatusRequestTimeout = 408) + case codes.Unknown: + return http.StatusInternalServerError + case codes.InvalidArgument: + return http.StatusBadRequest + case codes.DeadlineExceeded: + return http.StatusGatewayTimeout // = 504 (gRPC-Gateway - http.StatusRequestTimeout = 408) + case codes.NotFound: + return http.StatusNotFound + case codes.AlreadyExists: + return http.StatusConflict + case codes.PermissionDenied: + return http.StatusForbidden + case codes.Unauthenticated: + return http.StatusUnauthorized + case codes.ResourceExhausted: + return http.StatusTooManyRequests // = 429 (gRPC-Gateway - http.StatusForbidden = 403) + case codes.FailedPrecondition: + return http.StatusBadRequest // = 400 (gRPC-Gateway - http.StatusPreconditionFailed = 412) + case codes.Aborted: + return http.StatusConflict + case codes.OutOfRange: + return http.StatusBadRequest + case codes.Unimplemented: + return http.StatusNotImplemented + case codes.Internal: + return http.StatusInternalServerError + case codes.Unavailable: + return http.StatusServiceUnavailable + case codes.DataLoss: + return http.StatusInternalServerError + } + + grpclog.Printf("Unknown gRPC error code: %v", code) + return http.StatusInternalServerError +} diff --git a/gw/status_test.go b/gw/status_test.go new file mode 100644 index 00000000..7505d8a4 --- /dev/null +++ b/gw/status_test.go @@ -0,0 +1,113 @@ +package gw + +import ( + "context" + "net/http" + "testing" + + "google.golang.org/genproto/googleapis/rpc/code" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + + "github.com/grpc-ecosystem/grpc-gateway/runtime" +) + +func TestStatus(t *testing.T) { + // test REST status from gRPC one + rst := Status(context.Background(), status.New(codes.OK, "success message")) + if rst.Code != CodeName(codes.OK) { + t.Errorf("invalid status code: %s - expected: %s", rst.Code, CodeName(codes.OK)) + } + if rst.HTTPStatus != http.StatusOK { + t.Errorf("invalid http status code %d - expected: %d", rst.HTTPStatus, http.StatusOK) + } + if rst.Message != "success message" { + t.Errorf("invalid status message: %s - expected: %s", rst.Message, "success message") + } + + // test REST status from incoming context + md := metadata.Pairs( + runtime.MetadataPrefix+"status-code", CodeName(Created), + runtime.MetadataPrefix+"status-message", "created message", + ) + ctx := metadata.NewIncomingContext(context.Background(), md) + rst = Status(ctx, nil) + + if rst.Code != CodeName(Created) { + t.Errorf("invalid status code: %s - expected: %s", rst.Code, CodeName(Created)) + } + if rst.HTTPStatus != http.StatusCreated { + t.Errorf("invalid http status code %d - expected: %d", rst.HTTPStatus, http.StatusCreated) + } + if rst.Message != "created message" { + t.Errorf("invalid status message: %s - expected: %s", rst.Message, "created message") + } +} + +func TestCodeName(t *testing.T) { + // test renamed code + if cn := CodeName(codes.Unimplemented); cn != "NOT_IMPLEMENTED" { + t.Errorf("invalid code name: %s - expected: %s", cn, "NOT_IMPLEMENTED") + } + + // test custom code + if cn := CodeName(LongRunning); cn != "LONG_RUNNING_OP" { + t.Errorf("invalid code name: %s - expected: %s", cn, "LONG_RUNNING_OP") + } + + // test standard code + if cn := CodeName(codes.OutOfRange); cn != code.Code_name[int32(code.Code_OUT_OF_RANGE)] { + t.Errorf("invalid code name: %s - expected: %s", cn, code.Code_name[int32(code.Code_OUT_OF_RANGE)]) + } +} + +func TestCode(t *testing.T) { + // test renamed code + if c := Code("NOT_IMPLEMENTED"); c != codes.Unimplemented { + t.Errorf("invalid code: %s - expected: %s", c, codes.Unimplemented) + } + // test custom code + if c := Code("LONG_RUNNING_OP"); c != LongRunning { + t.Errorf("invalid code: %s - expected: %s", c, LongRunning) + } + // test standard code + if c := Code(code.Code_name[int32(code.Code_OUT_OF_RANGE)]); c != codes.OutOfRange { + t.Errorf("invalid code: %s - expected: %s", c, codes.OutOfRange) + } +} + +func TestHTTPStatusFromCode(t *testing.T) { + // test overwritten code + if sc := HTTPStatusFromCode(codes.Canceled); sc != 499 { + t.Errorf("invalid http status: %d - expected: %d", sc, 499) + } + // test custom code + if sc := HTTPStatusFromCode(Created); sc != http.StatusCreated { + t.Errorf("invalid http status: %d - expected: %d", sc, http.StatusCreated) + } + // test standard code + if sc := HTTPStatusFromCode(codes.NotFound); sc != http.StatusNotFound { + t.Errorf("invalid http status: %d - expected: %d", sc, http.StatusNotFound) + } +} + +func TestPageInfo(t *testing.T) { + md := metadata.Pairs( + runtime.MetadataPrefix+pageInfoSizeMetaKey, "10", + runtime.MetadataPrefix+pageInfoOffsetMetaKey, "100", + runtime.MetadataPrefix+pageInfoPageTokenMetaKey, "ptoken", + ) + ctx := metadata.NewIncomingContext(context.Background(), md) + rst := Status(ctx, nil) + + if rst.Size != "10" { + t.Errorf("invalid status size: %s - expected: %s", rst.Size, "10") + } + if rst.Offset != "100" { + t.Errorf("invalid status offset: %s - expected: %s", rst.Offset, "100") + } + if rst.PageToken != "ptoken" { + t.Errorf("invalid status page token: %s - expected: %s", rst.PageToken, "ptoken") + } +} diff --git a/health/checkers.go b/health/checkers.go new file mode 100644 index 00000000..959627c6 --- /dev/null +++ b/health/checkers.go @@ -0,0 +1,28 @@ +package health + +import ( + "fmt" + "net/http" + "net/url" + "time" +) + +func HttpGetCheck(url *url.URL, timeout time.Duration) Check { + client := http.Client{ + Timeout: timeout, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } + return func() error { + resp, err := client.Get(url.String()) + if err != nil { + return err + } + resp.Body.Close() + if resp.StatusCode != 200 { + return fmt.Errorf("%d: %s", resp.StatusCode, resp.Status) + } + return nil + } +} diff --git a/health/dnsprobecheck.go b/health/dnsprobecheck.go new file mode 100644 index 00000000..ecedb39e --- /dev/null +++ b/health/dnsprobecheck.go @@ -0,0 +1,26 @@ +package health + +import ( + "context" + "fmt" + "net" + "time" +) + +// DNSProbeCheck returns a Check that determines wheteher service +// with specified dns name is reachable or not using net.Resolver's LookupHost method. +func DNSProbeCheck(host string, timeout time.Duration) Check { + resolver := net.Resolver{} + return func() error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + addrs, err := resolver.LookupHost(ctx, host) + if err != nil { + return err + } + if len(addrs) < 1 { + return fmt.Errorf("could not resolve host") + } + return nil + } +} diff --git a/health/handler.go b/health/handler.go new file mode 100644 index 00000000..a3e47293 --- /dev/null +++ b/health/handler.go @@ -0,0 +1,98 @@ +package health + +import ( + "net/http" + "sync" +) + +type checksHandler struct { + lock sync.RWMutex + mux *http.ServeMux + livenessChecks map[string]Check + readinessChecks map[string]Check +} + +// Checker ... +type Checker interface { + AddLiveness(name string, check Check) + AddReadiness(name string, check Check) + Handler() http.Handler +} + +// NewChecksHandler accepts two strings: health and ready paths. +// These paths will be used for liveness and readiness checks. +func NewChecksHandler(healthPath, readyPath string) Checker { + ch := &checksHandler{ + livenessChecks: map[string]Check{}, + readinessChecks: map[string]Check{}, + mux: &http.ServeMux{}, + } + if healthPath[0] != '/' { + healthPath = "/" + healthPath + } + if readyPath[0] != '/' { + readyPath = "/" + readyPath + } + ch.mux.Handle(healthPath, http.HandlerFunc(ch.healthEndpoint)) + ch.mux.Handle(readyPath, http.HandlerFunc(ch.readyEndpoint)) + return ch +} + +func (ch *checksHandler) AddLiveness(name string, check Check) { + ch.lock.Lock() + defer ch.lock.Unlock() + + ch.livenessChecks[name] = check +} + +func (ch *checksHandler) AddReadiness(name string, check Check) { + ch.lock.Lock() + defer ch.lock.Unlock() + + ch.readinessChecks[name] = check +} + +func (ch *checksHandler) Handler() http.Handler { + return ch.mux +} + +func (ch *checksHandler) healthEndpoint(rw http.ResponseWriter, r *http.Request) { + ch.handle(rw, r, ch.livenessChecks) +} + +func (ch *checksHandler) readyEndpoint(rw http.ResponseWriter, r *http.Request) { + ch.handle(rw, r, ch.readinessChecks) +} + +func (ch *checksHandler) handle(rw http.ResponseWriter, r *http.Request, checks map[string]Check) { + if r.Method != http.MethodGet { + http.Error(rw, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return + } + + errors := map[string]error{} + status := http.StatusOK + ch.lock.RLock() + defer ch.lock.RUnlock() + for name, check := range checks { + if check == nil { + continue + } + if err := check(); err != nil { + status = http.StatusServiceUnavailable + errors[name] = err + } + } + + rw.WriteHeader(status) + + // Uncomment to write errors and get non-empty response + // rw.Header().Set("Content-Type", "application/json; charset=utf-8") + // if status == http.StatusOK { + // rw.Write([]byte("{}\n")) + // } else { + // encoder := json.NewEncoder(rw) + // encoder.SetIndent("", " ") + // encoder.Encode(errors) + // } +} diff --git a/health/httpgetcheck.go b/health/httpgetcheck.go new file mode 100644 index 00000000..2433488b --- /dev/null +++ b/health/httpgetcheck.go @@ -0,0 +1,30 @@ +package health + +import ( + "fmt" + "net/http" + "time" +) + +// HTTPGetCheck returns a Check that performs an HTTP GET request to the +// specified URL. It fails whether timeout is reached or non-200-OK status code returned. +func HTTPGetCheck(url string, timeout time.Duration) Check { + client := http.Client{ + Timeout: timeout, + // never follow redirects + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } + return func() error { + resp, err := client.Get(url) + if err != nil { + return err + } + resp.Body.Close() + if resp.StatusCode != 200 { + return fmt.Errorf("%d: %s", resp.StatusCode, resp.Status) + } + return nil + } +} diff --git a/health/types.go b/health/types.go new file mode 100644 index 00000000..567bb2d8 --- /dev/null +++ b/health/types.go @@ -0,0 +1,4 @@ +package health + +// Check +type Check func() error diff --git a/mw/README.md b/mw/README.md new file mode 100644 index 00000000..405dbf16 --- /dev/null +++ b/mw/README.md @@ -0,0 +1,6 @@ +# API Middleware + +This directory contains all the middleware for the Atlas API framework. + +## Auth +This is an Infoblox-specific interceptor that authorizes requests to applications by leveraging Themis. \ No newline at end of file diff --git a/mw/auth/interceptor.go b/mw/auth/interceptor.go new file mode 100644 index 00000000..5d224e61 --- /dev/null +++ b/mw/auth/interceptor.go @@ -0,0 +1,137 @@ +package auth + +import ( + "context" + "time" + + "github.com/grpc-ecosystem/go-grpc-middleware/auth" + "github.com/grpc-ecosystem/go-grpc-middleware/tags/logrus" + pdp "github.com/infobloxopen/themis/pdp-service" + "github.com/infobloxopen/themis/pep" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" +) + +var ( + ErrInternal = grpc.Errorf(codes.Internal, "unable to process request") + ErrUnauthorized = grpc.Errorf(codes.PermissionDenied, "unauthorized") +) + +// Builder is responsible for creating requests to Themis. The response +// from Themis will determine if a request is authorized or unauthorized +type Builder interface { + // build(...) uses the incoming request context to make a separate request + // to Themis + build(context.Context) (pdp.Request, error) +} + +// Handler decides whether or not a request from Themis is authorized +type Handler interface { + // handle(...) takes the response from Themis and return a boolean (true for + // authorized, false for unauthorized) + handle(context.Context, pdp.Response) (bool, error) +} + +// attributer uses the context to build a slice of attributes +type attributer func(context.Context) ([]*pdp.Attribute, error) + +// defaultBuilder provides a default implementation of the Builder interface +type defaultBuilder struct{ getters []attributer } + +// build makes pdp.Request objects based on all the options provived by the +// user (e.g. WithJWT or WithRules) +func (d defaultBuilder) build(ctx context.Context) (pdp.Request, error) { + attributes := []*pdp.Attribute{} + for _, getter := range d.getters { + attrs, err := getter(ctx) + if err != nil { + return pdp.Request{}, err + } + attributes = combineAttributes(attributes, attrs) + } + return pdp.Request{attributes}, nil +} + +// NewBuilder returns an instance of the default Builder that includes all of +// of the user-provided options +func NewBuilder(opts ...option) Builder { + db := defaultBuilder{} + for _, opt := range opts { + opt(&db) + } + return db +} + +// defaultHandler provides a default implementation of the Handler interface +type defaultHandler struct{} + +// handle denies all incoming requests that do not generate a PERMIT response +// from Themis +func (defaultHandler) handle(ctx context.Context, res pdp.Response) (bool, error) { + if res.Effect != pdp.Response_PERMIT { + return false, nil + } + return true, nil +} + +// NewHandler returns an instance of the default handler +func NewHandler() Handler { return defaultHandler{} } + +// Authorizer glues together a Builder and a Handler. It is responsible for +// sending requests and receiving responses to/from Themis +type Authorizer struct { + PDPAddress string + Bldr Builder + Hdlr Handler +} + +// AuthFunc builds the "AuthFunc" using the pep client that comes with Themis +func (a Authorizer) AuthFunc() grpc_auth.AuthFunc { + clientFactory := func() pep.Client { + return pep.NewClient( + pep.WithConnectionTimeout(time.Second * 2), + ) + } + return a.authFunc(clientFactory) +} + +// authFunc builds the "AuthFunc" type, which is the function that gets called +// by the authorization interceptor. The AuthFunc type is part of the gRPC +// authorization library, so a detailed explanation can be found here: +// https://github.com/grpc-ecosystem/go-grpc-middleware/blob/master/auth/auth.go +func (a Authorizer) authFunc(factory func() pep.Client) grpc_auth.AuthFunc { + return func(ctx context.Context) (context.Context, error) { + logger := ctx_logrus.Extract(ctx) + pepClient := factory() + // open connection to themis + if err := pepClient.Connect(a.PDPAddress); err != nil { + logger.Errorf("failed connecting to themis: %v", err) + return ctx, ErrInternal + } + defer pepClient.Close() + // build a pdp request and send it to themis + req, err := a.Bldr.build(ctx) + if err != nil { + logger.Errorf("failed building themis request: %v", err) + return ctx, err + } + res := pdp.Response{} + if err := pepClient.Validate(req, &res); err != nil { + logger.Errorf("error sending message to themis: %v", err) + return ctx, ErrInternal + } + logger.Infof("themis response: %v", res) + // handle response from themis + authorized, err := a.Hdlr.handle(ctx, res) + if err != nil { + logger.Errorf("error handling response from themis: %v", err) + return ctx, ErrInternal + } + if !authorized { + logger.Info("request unauthorized") + return ctx, ErrUnauthorized + } + logger.Info("request authorized") + return ctx, nil + } +} diff --git a/mw/auth/interceptor_test.go b/mw/auth/interceptor_test.go new file mode 100644 index 00000000..64f12f57 --- /dev/null +++ b/mw/auth/interceptor_test.go @@ -0,0 +1,107 @@ +package auth + +import ( + "context" + "errors" + "testing" + + pdp "github.com/infobloxopen/themis/pdp-service" + "github.com/infobloxopen/themis/pep" +) + +func TestAuthFunc(t *testing.T) { + var authFuncTests = []struct { + authorizer Authorizer + factory func() pep.Client + expected error + }{ + { + Authorizer{"", NewBuilder(), NewHandler()}, + func() pep.Client { return mockClient{} }, + nil, + }, + { + Authorizer{"", NewBuilder(), NewHandler()}, + func() pep.Client { return mockClient{deny: true} }, + ErrUnauthorized, + }, + { + Authorizer{"", NewBuilder(), NewHandler()}, + func() pep.Client { return mockClient{errOnConnect: true} }, + ErrInternal, + }, + { + Authorizer{"", NewBuilder(), NewHandler()}, + func() pep.Client { return mockClient{errOnValidate: true} }, + ErrInternal, + }, + { + Authorizer{"", mockBuilder{errOnBuild: true}, NewHandler()}, + func() pep.Client { return mockClient{} }, + ErrInternal, + }, + { + Authorizer{"", NewBuilder(), mockHandler{errOnHandle: true}}, + func() pep.Client { return mockClient{} }, + ErrInternal, + }, + } + for _, test := range authFuncTests { + authFunc := test.authorizer.authFunc(test.factory) + _, err := authFunc(context.Background()) + if test.expected != err { + t.Errorf("Invalid authfunc error: %v - expected %v", err, test.expected) + } + } +} + +type mockBuilder struct { + errOnBuild bool +} + +func (m mockBuilder) build(context.Context) (pdp.Request, error) { + if m.errOnBuild { + return pdp.Request{}, ErrInternal + } + return pdp.Request{}, nil +} + +type mockHandler struct { + errOnHandle bool +} + +func (m mockHandler) handle(context.Context, pdp.Response) (bool, error) { + if m.errOnHandle { + return false, ErrInternal + } + return false, nil +} + +type mockClient struct { + errOnConnect bool + errOnValidate bool + deny bool +} + +func (m mockClient) Connect(string) error { + if m.errOnConnect { + return pep.ErrorConnected + } + return nil +} + +func (m mockClient) Close() {} + +func (m mockClient) Validate(in, out interface{}) error { + if m.errOnValidate { + return errors.New("Unable to validate request") + } + o, ok := out.(*pdp.Response) + if !ok { + return pep.ErrorInvalidStruct + } + if !m.deny { + o.Effect = pdp.Response_PERMIT + } + return nil +} diff --git a/mw/auth/options.go b/mw/auth/options.go new file mode 100644 index 00000000..96cd1292 --- /dev/null +++ b/mw/auth/options.go @@ -0,0 +1,121 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "path" + "strings" + + jwt "github.com/dgrijalva/jwt-go" + "github.com/grpc-ecosystem/go-grpc-middleware/auth" + pdp "github.com/infobloxopen/themis/pdp-service" + "google.golang.org/grpc/transport" +) + +// functional options for the defaultBuilder +type option func(*defaultBuilder) + +// WithJWT allows for token-based authorization using JWT. When WithJWT has been +// added as a build parameter, every field in the token payload will be included +// in the request to Themis +func WithJWT(keyfunc jwt.Keyfunc) option { + withTokenJWTFunc := func(ctx context.Context) ([]*pdp.Attribute, error) { + attributes := []*pdp.Attribute{} + token, err := getToken(ctx, keyfunc) + if err != nil { + return attributes, ErrUnauthorized + } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return attributes, ErrInternal + } + for k, v := range claims { + attr := &pdp.Attribute{k, "string", fmt.Sprint(v)} + attributes = append(attributes, attr) + } + return attributes, nil + } + return func(d *defaultBuilder) { + d.getters = append(d.getters, withTokenJWTFunc) + } +} + +// getToken parses the token into a jwt.Token type from the grpc metadata. +// WARNING: if keyfunc is nil, the token will get parsed but not verified +// because it has been checked previously in the stack. More information +// here: https://godoc.org/github.com/dgrijalva/jwt-go#Parser.ParseUnverified +func getToken(ctx context.Context, keyfunc jwt.Keyfunc) (jwt.Token, error) { + tokenStr, err := grpc_auth.AuthFromMD(ctx, "token") + if err != nil { + return jwt.Token{}, ErrUnauthorized + } + parser := jwt.Parser{} + if keyfunc != nil { + token, err := parser.Parse(tokenStr, keyfunc) + if err != nil { + return jwt.Token{}, ErrUnauthorized + } + return *token, nil + } + token, _, err := parser.ParseUnverified(tokenStr, jwt.MapClaims{}) + if err != nil { + return jwt.Token{}, ErrUnauthorized + } + return *token, nil +} + +// WithCallback allows developers to pass their own attributer to the +// authorization service. It gives them the flexibility to add customization to +// the auth process without needing to write a Builder from scratch. +func WithCallback(attr attributer) option { + withCallbackFunc := func(ctx context.Context) ([]*pdp.Attribute, error) { + return attr(ctx) + } + return func(d *defaultBuilder) { + d.getters = append(d.getters, withCallbackFunc) + } +} + +// WithRequest takes metadata from the incoming request and passes it +// to Themis in the authorization request. Specifically, this includes the gRPC +// service name (e.g. AddressBook) and the corresponding function that is +// called by the client (e.g. ListPersons) +func WithRequest() option { + withRequestFunc := func(ctx context.Context) ([]*pdp.Attribute, error) { + stream, ok := transport.StreamFromContext(ctx) + if !ok { + return nil, errors.New("failed getting stream from context") + } + service, method := getRequestDetails(*stream) + service = stripPackageName(service) + attributes := []*pdp.Attribute{ + &pdp.Attribute{"operation", "string", method}, + // lowercase the service to match PARG naming conventions + &pdp.Attribute{"application", "string", strings.ToLower(service)}, + } + return attributes, nil + } + return func(d *defaultBuilder) { + d.getters = append(d.getters, withRequestFunc) + } +} + +// stripPackageName removes the package name prefix from a fully-qualified +// proto service name +func stripPackageName(service string) string { + fields := strings.Split(service, ".") + return fields[len(fields)-1] +} + +func getRequestDetails(stream transport.Stream) (service, method string) { + fullMethodString := stream.Method() + return path.Dir(fullMethodString)[1:], path.Base(fullMethodString) +} + +func combineAttributes(first, second []*pdp.Attribute) []*pdp.Attribute { + for _, attr := range second { + first = append(first, attr) + } + return first +} diff --git a/mw/auth/options_test.go b/mw/auth/options_test.go new file mode 100644 index 00000000..5ef745a0 --- /dev/null +++ b/mw/auth/options_test.go @@ -0,0 +1,182 @@ +package auth + +import ( + "context" + "fmt" + "testing" + + jwt "github.com/dgrijalva/jwt-go" + pdp "github.com/infobloxopen/themis/pdp-service" + "google.golang.org/grpc/metadata" +) + +const ( + TEST_SECRET = "some-secret-123" +) + +func TestWithJWT(t *testing.T) { + var jwtTests = []struct { + token string + expected []*pdp.Attribute + keyfunc jwt.Keyfunc + err error + }{ + // parse and verify a valid token + { + token: makeToken(jwt.MapClaims{ + "username": "john", + "department": "engineering", + }, t), + expected: []*pdp.Attribute{ + &pdp.Attribute{"department", "string", "engineering"}, + &pdp.Attribute{"username", "string", "john"}, + }, + keyfunc: func(token *jwt.Token) (interface{}, error) { + return []byte(TEST_SECRET), nil + }, + err: nil, + }, + // parse and verify an invalid token + { + token: makeToken(jwt.MapClaims{ + "username": "john", + "department": "engineering", + }, t), + expected: []*pdp.Attribute{}, + keyfunc: func(token *jwt.Token) (interface{}, error) { + return []byte("some-other-secret-123"), nil + }, + err: ErrUnauthorized, + }, + // parse a valid token, but do not verify + { + token: makeToken(jwt.MapClaims{}, t), + expected: []*pdp.Attribute{}, + keyfunc: nil, + err: nil, + }, + // parse an invalid token, but do not verify + { + token: "some-nonsense-token", + expected: []*pdp.Attribute{}, + keyfunc: nil, + err: ErrUnauthorized, + }, + // do not include a token in the request context + { + token: "", + expected: []*pdp.Attribute{}, + keyfunc: nil, + err: ErrUnauthorized, + }, + } + for _, test := range jwtTests { + ctx := context.Background() + if test.token != "" { + c, _ := contextWithToken(test.token) + ctx = c + } + builder := NewBuilder(WithJWT(test.keyfunc)) + req, err := builder.build(ctx) + if err != test.err { + t.Errorf("Unexpected error when building request: %v", err) + } + if !hasMatchingAttributes(req.Attributes, test.expected) { + t.Errorf("Invalid request attributes: %v - expected %v", req.GetAttributes(), test.expected) + } + } +} + +func TestWithCallback(t *testing.T) { + var callbackTests = []struct { + callback attributer + expected []*pdp.Attribute + }{ + { + func(ctx context.Context) ([]*pdp.Attribute, error) { + attributes := []*pdp.Attribute{ + &pdp.Attribute{"fruit", "string", "apple"}, + &pdp.Attribute{"vegetable", "string", "carrot"}, + } + return attributes, nil + }, + []*pdp.Attribute{ + {"fruit", "string", "apple"}, + {"vegetable", "string", "carrot"}, + }, + }, + { + func(ctx context.Context) ([]*pdp.Attribute, error) { + return []*pdp.Attribute{}, nil + }, + []*pdp.Attribute{}, + }, + } + for _, test := range callbackTests { + builder := NewBuilder(WithCallback(test.callback)) + req, err := builder.build(context.Background()) + if err != nil { + t.Errorf("Unexpected error when building request: %v", err) + } + if !hasMatchingAttributes(req.Attributes, test.expected) { + t.Errorf("Invalid request attributes: %v - expected %v", req.GetAttributes(), test.expected) + } + } +} + +func TestStripPackageName(t *testing.T) { + var tests = []struct { + fullname string + expected string + }{ + {"ngp.api.toolkit.example.addressbook.AddressBook", "AddressBook"}, + {"AddressBook", "AddressBook"}, + {"", ""}, + } + for _, test := range tests { + name := stripPackageName(test.fullname) + if name != test.expected { + t.Errorf("Unexpected service name: %s - expected %s", name, test.expected) + } + } +} + +// creates a context with a jwt +func contextWithToken(token string) (context.Context, error) { + md := metadata.Pairs( + "authorization", fmt.Sprintf("token %s", token), + ) + return metadata.NewIncomingContext(context.Background(), md), nil +} + +// generates a token string based on the given jwt claims +func makeToken(claims jwt.Claims, t *testing.T) string { + method := jwt.SigningMethodHS256 + token := jwt.NewWithClaims(method, claims) + signingString, err := token.SigningString() + if err != nil { + t.Fatalf("Error when building token: %v", err) + } + signature, err := method.Sign(signingString, []byte(TEST_SECRET)) + if err != nil { + t.Fatalf("Error when building token: %v", err) + } + return fmt.Sprintf("%s.%s", signingString, signature) +} + +// checks if first and second attribute lists contain identical elements +func hasMatchingAttributes(first, second []*pdp.Attribute) bool { + if len(first) != len(second) { + return false + } + for _, attr_first := range first { + var hasAttribute bool + for _, attr_second := range second { + hasAttribute = hasAttribute || attr_first.String() == attr_second.String() + } + if !hasAttribute { + return false + } + } + return true +} diff --git a/mw/auth/tenantid.go b/mw/auth/tenantid.go new file mode 100644 index 00000000..982e3b15 --- /dev/null +++ b/mw/auth/tenantid.go @@ -0,0 +1,40 @@ +package auth + +import ( + "context" + "errors" + "fmt" + + jwt "github.com/dgrijalva/jwt-go" +) + +const ( + + // TODO: Field is tentatively called "TenantID" but will probably need to be + // changed. We don't know what the JWT will look like, so we're giving it our + // best guess for the time being. + TENANT_ID_FIElD = "TenantID" +) + +var ( + errMissingTenantID = errors.New( + fmt.Sprintf("unable to extract %s from token", TENANT_ID_FIElD), + ) + errInvalidAssertion = errors.New("unable to assert value as jwt.MapClaims") +) + +func GetTenantID(ctx context.Context, keyfunc jwt.Keyfunc) (string, error) { + token, err := getToken(ctx, keyfunc) + if err != nil { + return "", err + } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return "", errInvalidAssertion + } + tenantID, ok := claims[TENANT_ID_FIElD] + if !ok { + return "", errMissingTenantID + } + return fmt.Sprint(tenantID), nil +} diff --git a/mw/auth/tenantid_test.go b/mw/auth/tenantid_test.go new file mode 100644 index 00000000..dda332e3 --- /dev/null +++ b/mw/auth/tenantid_test.go @@ -0,0 +1,39 @@ +package auth + +import ( + "testing" + + jwt "github.com/dgrijalva/jwt-go" +) + +func TestGetTenantID(t *testing.T) { + var tenantIDTests = []struct { + claims jwt.Claims + expected string + err error + }{ + { + jwt.MapClaims{ + "TenantID": "tenantid-abc-123", + }, + "tenantid-abc-123", + nil, + }, + { + jwt.MapClaims{}, + "", + errMissingTenantID, + }, + } + for _, test := range tenantIDTests { + token := makeToken(test.claims, t) + ctx, err := contextWithToken(token) + tenantID, err := GetTenantID(ctx, nil) + if err != test.err { + t.Errorf("Invalid error value: %v - expected %v", err, test.err) + } + if tenantID != test.expected { + t.Errorf("Invalid tenant ID: %v - expected %v", tenantID, test.expected) + } + } +} diff --git a/mw/operator.go b/mw/operator.go new file mode 100644 index 00000000..756d2660 --- /dev/null +++ b/mw/operator.go @@ -0,0 +1,173 @@ +package mw + +import ( + "context" + "fmt" + "reflect" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/grpclog" + "google.golang.org/grpc/status" + + "github.com/infobloxopen/atlas-app-toolkit/gw" + "github.com/infobloxopen/atlas-app-toolkit/op" +) + +// WithCollectionOperator returns grpc.UnaryServerInterceptor +// that should be used as a middleware if an user's request message +// defines any of collection operators. +// +// Returned middleware populates collection operators from gRPC metadata if +// they defined in a request message. +func WithCollectionOperator() grpc.UnaryServerInterceptor { + return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (res interface{}, err error) { + // handle panic + defer func() { + if perr := recover(); perr != nil { + err = status.Errorf(codes.Internal, "collection operators interceptor: %s", perr) + grpclog.Errorln(err) + res, err = nil, err + } + }() + + if req == nil { + grpclog.Warningf("collection operator interceptor: empty request %+v", req) + return handler(ctx, req) + } + + // looking for op.Sorting + sorting, err := gw.Sorting(ctx) + if err != nil { + err = status.Errorf(codes.InvalidArgument, "collection operator interceptor: invalid sorting operator - %s", err) + grpclog.Errorln(err) + return nil, err + } + if sorting != nil { + if err := setOp(req, sorting); err != nil { + grpclog.Errorf("collection operator interceptor: failed to set sorting operator - %s", err) + } + } + + // looking for op.FieldSelection + fieldSelection := gw.FieldSelection(ctx) + if fieldSelection != nil { + if err := setOp(req, fieldSelection); err != nil { + grpclog.Errorf("collection operator interceptor: failed to set field selection operator - %s", err) + } + } + + // looking for op.Filtering + filtering, err := gw.Filtering(ctx) + if err != nil { + err = status.Errorf(codes.InvalidArgument, "collection operator interceptor: invalid filtering operator - %s", err) + grpclog.Errorln(err) + return nil, err + } + if filtering != nil { + if err := setOp(req, filtering); err != nil { + grpclog.Errorf("collection operator interceptor: failed to set filtering operator - %s", err) + } + } + + // looking for op.ClientDrivenPagination + pagination, err := gw.Pagination(ctx) + if err != nil { + err = status.Errorf(codes.InvalidArgument, "collection operator interceptor: invalid pagination operator - %s", err) + grpclog.Errorln(err) + return nil, err + } + if pagination != nil { + if err := setOp(req, pagination); err != nil { + grpclog.Errorf("collection operator interceptor: failed to set pagination operator - %s", err) + } + } + + res, err = handler(ctx, req) + if err != nil { + return res, err + } + + // looking for op.PageInfo + page := new(op.PageInfo) + if err := unsetOp(res, page); err != nil { + grpclog.Errorf("collection operator interceptor: failed to set page info - %s", err) + } + + if err := gw.SetPageInfo(ctx, page); err != nil { + grpclog.Errorf("collection operator interceptor: failed to set page info - %s", err) + return nil, err + } + + return + } +} + +func setOp(req, op interface{}) error { + reqval := reflect.ValueOf(req) + + if reqval.Kind() != reflect.Ptr { + return fmt.Errorf("request is not a pointer - %s", reqval.Kind()) + } + + reqval = reqval.Elem() + + if reqval.Kind() != reflect.Struct { + return fmt.Errorf("request value is not a struct - %s", reqval.Kind()) + } + + for i := 0; i < reqval.NumField(); i++ { + f := reqval.FieldByIndex([]int{i}) + + if f.Type() != reflect.TypeOf(op) { + continue + } + + if !f.IsValid() || !f.CanSet() { + return fmt.Errorf("operation field %+v in request %+v is invalid or cannot be set", op, req) + } + + if vop := reflect.ValueOf(op); vop.IsValid() { + f.Set(vop) + } + } + + return nil +} + +func unsetOp(res, op interface{}) error { + resval := reflect.ValueOf(res) + if resval.Kind() != reflect.Ptr { + return fmt.Errorf("response is not a pointer - %s", resval.Kind()) + } + + resval = resval.Elem() + if resval.Kind() != reflect.Struct { + return fmt.Errorf("response value is not a struct - %s", resval.Kind()) + } + + opval := reflect.ValueOf(op) + if opval.Kind() != reflect.Ptr { + return fmt.Errorf("operator is not a pointer - %s", opval.Kind()) + } + + for i := 0; i < resval.NumField(); i++ { + f := resval.FieldByIndex([]int{i}) + + if f.Type() != opval.Type() { + continue + } + + if !f.IsValid() || !f.CanSet() || f.Kind() != reflect.Ptr { + return fmt.Errorf("operation field %T in response %+v is invalid or cannot be set", op, res) + } + + if o := opval.Elem(); o.IsValid() && o.CanSet() && f.Elem().IsValid() { + o.Set(f.Elem()) + } + + f.Set(reflect.Zero(f.Type())) + } + + return nil +} diff --git a/mw/operator_test.go b/mw/operator_test.go new file mode 100644 index 00000000..adee8c5d --- /dev/null +++ b/mw/operator_test.go @@ -0,0 +1,132 @@ +package mw + +import ( + "context" + "net/http" + "testing" + + "google.golang.org/grpc/metadata" + + "github.com/infobloxopen/atlas-app-toolkit/gw" + "github.com/infobloxopen/atlas-app-toolkit/op" +) + +type request struct { + Sorting *op.Sorting + Pagination *op.Pagination +} + +type response struct { + PageInfo *op.PageInfo +} + +func TestWithCollectionOperatorSorting(t *testing.T) { + hreq, err := http.NewRequest(http.MethodGet, "http://app.com?_order_by=name asc, age desc", nil) + if err != nil { + t.Fatalf("failed to build new http request: %s", err) + } + md := gw.MetadataAnnotator(context.Background(), hreq) + + ctx := metadata.NewIncomingContext(context.Background(), md) + req := &request{Sorting: nil} + interceptor := WithCollectionOperator() + + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + msg := req.(*request) + criteria := msg.Sorting.GetCriterias() + if len(criteria) != 2 { + t.Fatalf("invalid number of sort criteria: %d - expected: %d", len(criteria), 2) + } + + if c := criteria[0]; c.Tag != "name" || c.Order != op.SortCriteria_ASC { + t.Errorf("invalid sort criteria: %v - expected: %v", c, op.SortCriteria{"name", op.SortCriteria_ASC}) + } + + if c := criteria[1]; c.Tag != "age" || c.Order != op.SortCriteria_DESC { + t.Errorf("invalid sort criteria: %v - expected: %v", c, op.SortCriteria{"age", op.SortCriteria_DESC}) + } + + return &response{}, nil + } + + _, err = interceptor(ctx, req, nil, handler) + if err != nil { + t.Fatalf("failed to attach sorting to request: %s", err) + } +} + +func TestWithCollectionOperatorPagination(t *testing.T) { + hreq, err := http.NewRequest(http.MethodGet, "http://app.com?_limit=10&_offset=20", nil) + if err != nil { + t.Fatalf("failed to build new http request: %s", err) + } + + md := gw.MetadataAnnotator(context.Background(), hreq) + + ctx := metadata.NewIncomingContext(context.Background(), md) + req := &request{Pagination: nil} + interceptor := WithCollectionOperator() + + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + msg := req.(*request) + page := msg.Pagination + + if page.GetLimit() != 10 { + t.Errorf("invalid pagination limit: %d - expected: 10", page.GetLimit()) + } + if page.GetOffset() != 20 { + t.Errorf("invalid pagination offset: %d - expected: 20", page.GetOffset()) + } + + return &response{}, nil + } + + _, err = interceptor(ctx, req, nil, handler) + if err != nil { + t.Fatalf("failed to attach sorting to request: %s", err) + } +} + +func TestUnsetOp(t *testing.T) { + page := new(op.PageInfo) + res := &response{PageInfo: &op.PageInfo{Offset: 30, Size: 10}} + + if err := unsetOp(res, page); err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if page.GetOffset() != 30 { + t.Errorf("invalid repsponse offset: %d - expected: 30", page.GetOffset()) + } + if page.GetSize() != 10 { + t.Errorf("invalid repsponse size: %d - expected: 10", page.GetSize()) + } + + // nil operator + err := unsetOp(res, nil) + if err == nil { + t.Fatalf("unexpected non error result - expected: %s", "operator is not a pointer - invalid") + } + if err.Error() != "operator is not a pointer - invalid" { + t.Errorf("invalid error: %s - expected: %s", err, "operator is not a pointer - invalid") + } + + // nil response + err = unsetOp(nil, nil) + if err == nil { + t.Fatalf("unexpected non error result - expected: %s", "response is not a pointer - invalid") + } + if err.Error() != "response is not a pointer - invalid" { + t.Errorf("invalid error: %s - expected: %s", err, "response is not a pointer - invalid") + } + + // non struct response + var i int + err = unsetOp(&i, nil) + if err == nil { + t.Fatalf("unexpected non error result - expected: %s", "response value is not a struct - int") + } + if err.Error() != "response value is not a struct - int" { + t.Errorf("invalid error: %s - expected: %s", err, "response value is not a struct - int") + } +} diff --git a/op/collection_operators.pb.go b/op/collection_operators.pb.go new file mode 100644 index 00000000..3500a245 --- /dev/null +++ b/op/collection_operators.pb.go @@ -0,0 +1,1043 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// source: github.com/infobloxopen/atlas-app-toolkit/op/collection_operators.proto + +/* +Package op is a generated protocol buffer package. + +It is generated from these files: + github.com/infobloxopen/atlas-app-toolkit/op/collection_operators.proto + +It has these top-level messages: + SortCriteria + Sorting + FieldSelection + Field + Filtering + LogicalOperator + StringCondition + NumberCondition + NullCondition + Pagination + PageInfo +*/ +package op + +import proto "github.com/golang/protobuf/proto" +import fmt "fmt" +import math "math" + +// Reference imports to suppress errors if they are not otherwise used. +var _ = proto.Marshal +var _ = fmt.Errorf +var _ = math.Inf + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the proto package it is being compiled against. +// A compilation error at this line likely means your copy of the +// proto package needs to be updated. +const _ = proto.ProtoPackageIsVersion2 // please upgrade the proto package + +// Order is a sort order. +type SortCriteria_Order int32 + +const ( + // ascending sort order + SortCriteria_ASC SortCriteria_Order = 0 + // descending sort order + SortCriteria_DESC SortCriteria_Order = 1 +) + +var SortCriteria_Order_name = map[int32]string{ + 0: "ASC", + 1: "DESC", +} +var SortCriteria_Order_value = map[string]int32{ + "ASC": 0, + "DESC": 1, +} + +func (x SortCriteria_Order) String() string { + return proto.EnumName(SortCriteria_Order_name, int32(x)) +} +func (SortCriteria_Order) EnumDescriptor() ([]byte, []int) { return fileDescriptor0, []int{0, 0} } + +type LogicalOperator_Type int32 + +const ( + LogicalOperator_AND LogicalOperator_Type = 0 + LogicalOperator_OR LogicalOperator_Type = 1 +) + +var LogicalOperator_Type_name = map[int32]string{ + 0: "AND", + 1: "OR", +} +var LogicalOperator_Type_value = map[string]int32{ + "AND": 0, + "OR": 1, +} + +func (x LogicalOperator_Type) String() string { + return proto.EnumName(LogicalOperator_Type_name, int32(x)) +} +func (LogicalOperator_Type) EnumDescriptor() ([]byte, []int) { return fileDescriptor0, []int{5, 0} } + +type StringCondition_Type int32 + +const ( + StringCondition_EQ StringCondition_Type = 0 + StringCondition_MATCH StringCondition_Type = 1 +) + +var StringCondition_Type_name = map[int32]string{ + 0: "EQ", + 1: "MATCH", +} +var StringCondition_Type_value = map[string]int32{ + "EQ": 0, + "MATCH": 1, +} + +func (x StringCondition_Type) String() string { + return proto.EnumName(StringCondition_Type_name, int32(x)) +} +func (StringCondition_Type) EnumDescriptor() ([]byte, []int) { return fileDescriptor0, []int{6, 0} } + +type NumberCondition_Type int32 + +const ( + NumberCondition_EQ NumberCondition_Type = 0 + NumberCondition_GT NumberCondition_Type = 1 + NumberCondition_GE NumberCondition_Type = 2 + NumberCondition_LT NumberCondition_Type = 3 + NumberCondition_LE NumberCondition_Type = 4 +) + +var NumberCondition_Type_name = map[int32]string{ + 0: "EQ", + 1: "GT", + 2: "GE", + 3: "LT", + 4: "LE", +} +var NumberCondition_Type_value = map[string]int32{ + "EQ": 0, + "GT": 1, + "GE": 2, + "LT": 3, + "LE": 4, +} + +func (x NumberCondition_Type) String() string { + return proto.EnumName(NumberCondition_Type_name, int32(x)) +} +func (NumberCondition_Type) EnumDescriptor() ([]byte, []int) { return fileDescriptor0, []int{7, 0} } + +// SortCriteria represents sort criteria +type SortCriteria struct { + // Tag is a JSON tag. + Tag string `protobuf:"bytes,1,opt,name=tag" json:"tag,omitempty"` + Order SortCriteria_Order `protobuf:"varint,2,opt,name=order,enum=infoblox.api.SortCriteria_Order" json:"order,omitempty"` +} + +func (m *SortCriteria) Reset() { *m = SortCriteria{} } +func (m *SortCriteria) String() string { return proto.CompactTextString(m) } +func (*SortCriteria) ProtoMessage() {} +func (*SortCriteria) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{0} } + +func (m *SortCriteria) GetTag() string { + if m != nil { + return m.Tag + } + return "" +} + +func (m *SortCriteria) GetOrder() SortCriteria_Order { + if m != nil { + return m.Order + } + return SortCriteria_ASC +} + +// Sorting represents list of sort criterias. +type Sorting struct { + Criterias []*SortCriteria `protobuf:"bytes,1,rep,name=criterias" json:"criterias,omitempty"` +} + +func (m *Sorting) Reset() { *m = Sorting{} } +func (m *Sorting) String() string { return proto.CompactTextString(m) } +func (*Sorting) ProtoMessage() {} +func (*Sorting) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{1} } + +func (m *Sorting) GetCriterias() []*SortCriteria { + if m != nil { + return m.Criterias + } + return nil +} + +// FieldSelection represents a group of fields for some object. +// Main use case for if is to store information about object fields that +// need to be ratained prior to sending object as a response +type FieldSelection struct { + Fields map[string]*Field `protobuf:"bytes,1,rep,name=fields" json:"fields,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` +} + +func (m *FieldSelection) Reset() { *m = FieldSelection{} } +func (m *FieldSelection) String() string { return proto.CompactTextString(m) } +func (*FieldSelection) ProtoMessage() {} +func (*FieldSelection) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{2} } + +func (m *FieldSelection) GetFields() map[string]*Field { + if m != nil { + return m.Fields + } + return nil +} + +// Field represents a single field for an object. +// It contains fields name and also may contain a group of sub-fields for cases +// when a fields represents some structure. +type Field struct { + Name string `protobuf:"bytes,1,opt,name=name" json:"name,omitempty"` + Subs map[string]*Field `protobuf:"bytes,2,rep,name=subs" json:"subs,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` +} + +func (m *Field) Reset() { *m = Field{} } +func (m *Field) String() string { return proto.CompactTextString(m) } +func (*Field) ProtoMessage() {} +func (*Field) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{3} } + +func (m *Field) GetName() string { + if m != nil { + return m.Name + } + return "" +} + +func (m *Field) GetSubs() map[string]*Field { + if m != nil { + return m.Subs + } + return nil +} + +// Filtering represents filtering expression. +// root could be either LogicalOperator or one of the supported conditions. +type Filtering struct { + // Types that are valid to be assigned to Root: + // *Filtering_Operator + // *Filtering_StringCondition + // *Filtering_NumberCondition + // *Filtering_NullCondition + Root isFiltering_Root `protobuf_oneof:"root"` +} + +func (m *Filtering) Reset() { *m = Filtering{} } +func (m *Filtering) String() string { return proto.CompactTextString(m) } +func (*Filtering) ProtoMessage() {} +func (*Filtering) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{4} } + +type isFiltering_Root interface { + isFiltering_Root() +} + +type Filtering_Operator struct { + Operator *LogicalOperator `protobuf:"bytes,1,opt,name=operator,oneof"` +} +type Filtering_StringCondition struct { + StringCondition *StringCondition `protobuf:"bytes,2,opt,name=string_condition,json=stringCondition,oneof"` +} +type Filtering_NumberCondition struct { + NumberCondition *NumberCondition `protobuf:"bytes,3,opt,name=number_condition,json=numberCondition,oneof"` +} +type Filtering_NullCondition struct { + NullCondition *NullCondition `protobuf:"bytes,4,opt,name=null_condition,json=nullCondition,oneof"` +} + +func (*Filtering_Operator) isFiltering_Root() {} +func (*Filtering_StringCondition) isFiltering_Root() {} +func (*Filtering_NumberCondition) isFiltering_Root() {} +func (*Filtering_NullCondition) isFiltering_Root() {} + +func (m *Filtering) GetRoot() isFiltering_Root { + if m != nil { + return m.Root + } + return nil +} + +func (m *Filtering) GetOperator() *LogicalOperator { + if x, ok := m.GetRoot().(*Filtering_Operator); ok { + return x.Operator + } + return nil +} + +func (m *Filtering) GetStringCondition() *StringCondition { + if x, ok := m.GetRoot().(*Filtering_StringCondition); ok { + return x.StringCondition + } + return nil +} + +func (m *Filtering) GetNumberCondition() *NumberCondition { + if x, ok := m.GetRoot().(*Filtering_NumberCondition); ok { + return x.NumberCondition + } + return nil +} + +func (m *Filtering) GetNullCondition() *NullCondition { + if x, ok := m.GetRoot().(*Filtering_NullCondition); ok { + return x.NullCondition + } + return nil +} + +// XXX_OneofFuncs is for the internal use of the proto package. +func (*Filtering) XXX_OneofFuncs() (func(msg proto.Message, b *proto.Buffer) error, func(msg proto.Message, tag, wire int, b *proto.Buffer) (bool, error), func(msg proto.Message) (n int), []interface{}) { + return _Filtering_OneofMarshaler, _Filtering_OneofUnmarshaler, _Filtering_OneofSizer, []interface{}{ + (*Filtering_Operator)(nil), + (*Filtering_StringCondition)(nil), + (*Filtering_NumberCondition)(nil), + (*Filtering_NullCondition)(nil), + } +} + +func _Filtering_OneofMarshaler(msg proto.Message, b *proto.Buffer) error { + m := msg.(*Filtering) + // root + switch x := m.Root.(type) { + case *Filtering_Operator: + b.EncodeVarint(1<<3 | proto.WireBytes) + if err := b.EncodeMessage(x.Operator); err != nil { + return err + } + case *Filtering_StringCondition: + b.EncodeVarint(2<<3 | proto.WireBytes) + if err := b.EncodeMessage(x.StringCondition); err != nil { + return err + } + case *Filtering_NumberCondition: + b.EncodeVarint(3<<3 | proto.WireBytes) + if err := b.EncodeMessage(x.NumberCondition); err != nil { + return err + } + case *Filtering_NullCondition: + b.EncodeVarint(4<<3 | proto.WireBytes) + if err := b.EncodeMessage(x.NullCondition); err != nil { + return err + } + case nil: + default: + return fmt.Errorf("Filtering.Root has unexpected type %T", x) + } + return nil +} + +func _Filtering_OneofUnmarshaler(msg proto.Message, tag, wire int, b *proto.Buffer) (bool, error) { + m := msg.(*Filtering) + switch tag { + case 1: // root.operator + if wire != proto.WireBytes { + return true, proto.ErrInternalBadWireType + } + msg := new(LogicalOperator) + err := b.DecodeMessage(msg) + m.Root = &Filtering_Operator{msg} + return true, err + case 2: // root.string_condition + if wire != proto.WireBytes { + return true, proto.ErrInternalBadWireType + } + msg := new(StringCondition) + err := b.DecodeMessage(msg) + m.Root = &Filtering_StringCondition{msg} + return true, err + case 3: // root.number_condition + if wire != proto.WireBytes { + return true, proto.ErrInternalBadWireType + } + msg := new(NumberCondition) + err := b.DecodeMessage(msg) + m.Root = &Filtering_NumberCondition{msg} + return true, err + case 4: // root.null_condition + if wire != proto.WireBytes { + return true, proto.ErrInternalBadWireType + } + msg := new(NullCondition) + err := b.DecodeMessage(msg) + m.Root = &Filtering_NullCondition{msg} + return true, err + default: + return false, nil + } +} + +func _Filtering_OneofSizer(msg proto.Message) (n int) { + m := msg.(*Filtering) + // root + switch x := m.Root.(type) { + case *Filtering_Operator: + s := proto.Size(x.Operator) + n += proto.SizeVarint(1<<3 | proto.WireBytes) + n += proto.SizeVarint(uint64(s)) + n += s + case *Filtering_StringCondition: + s := proto.Size(x.StringCondition) + n += proto.SizeVarint(2<<3 | proto.WireBytes) + n += proto.SizeVarint(uint64(s)) + n += s + case *Filtering_NumberCondition: + s := proto.Size(x.NumberCondition) + n += proto.SizeVarint(3<<3 | proto.WireBytes) + n += proto.SizeVarint(uint64(s)) + n += s + case *Filtering_NullCondition: + s := proto.Size(x.NullCondition) + n += proto.SizeVarint(4<<3 | proto.WireBytes) + n += proto.SizeVarint(uint64(s)) + n += s + case nil: + default: + panic(fmt.Sprintf("proto: unexpected type %T in oneof", x)) + } + return n +} + +// LogicalOperator represents binary logical operator, either AND or OR depending on type. +// left and right are respectively left and right operands of the operator, could be +// either LogicalOperator or one of the supported conditions. +// is_negative is set to true if the operator is negated. +type LogicalOperator struct { + // Types that are valid to be assigned to Left: + // *LogicalOperator_LeftOperator + // *LogicalOperator_LeftStringCondition + // *LogicalOperator_LeftNumberCondition + // *LogicalOperator_LeftNullCondition + Left isLogicalOperator_Left `protobuf_oneof:"left"` + // Types that are valid to be assigned to Right: + // *LogicalOperator_RightOperator + // *LogicalOperator_RightStringCondition + // *LogicalOperator_RightNumberCondition + // *LogicalOperator_RightNullCondition + Right isLogicalOperator_Right `protobuf_oneof:"right"` + Type LogicalOperator_Type `protobuf:"varint,9,opt,name=type,enum=infoblox.api.LogicalOperator_Type" json:"type,omitempty"` + IsNegative bool `protobuf:"varint,10,opt,name=is_negative,json=isNegative" json:"is_negative,omitempty"` +} + +func (m *LogicalOperator) Reset() { *m = LogicalOperator{} } +func (m *LogicalOperator) String() string { return proto.CompactTextString(m) } +func (*LogicalOperator) ProtoMessage() {} +func (*LogicalOperator) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{5} } + +type isLogicalOperator_Left interface { + isLogicalOperator_Left() +} +type isLogicalOperator_Right interface { + isLogicalOperator_Right() +} + +type LogicalOperator_LeftOperator struct { + LeftOperator *LogicalOperator `protobuf:"bytes,1,opt,name=left_operator,json=leftOperator,oneof"` +} +type LogicalOperator_LeftStringCondition struct { + LeftStringCondition *StringCondition `protobuf:"bytes,2,opt,name=left_string_condition,json=leftStringCondition,oneof"` +} +type LogicalOperator_LeftNumberCondition struct { + LeftNumberCondition *NumberCondition `protobuf:"bytes,3,opt,name=left_number_condition,json=leftNumberCondition,oneof"` +} +type LogicalOperator_LeftNullCondition struct { + LeftNullCondition *NullCondition `protobuf:"bytes,4,opt,name=left_null_condition,json=leftNullCondition,oneof"` +} +type LogicalOperator_RightOperator struct { + RightOperator *LogicalOperator `protobuf:"bytes,5,opt,name=right_operator,json=rightOperator,oneof"` +} +type LogicalOperator_RightStringCondition struct { + RightStringCondition *StringCondition `protobuf:"bytes,6,opt,name=right_string_condition,json=rightStringCondition,oneof"` +} +type LogicalOperator_RightNumberCondition struct { + RightNumberCondition *NumberCondition `protobuf:"bytes,7,opt,name=right_number_condition,json=rightNumberCondition,oneof"` +} +type LogicalOperator_RightNullCondition struct { + RightNullCondition *NullCondition `protobuf:"bytes,8,opt,name=right_null_condition,json=rightNullCondition,oneof"` +} + +func (*LogicalOperator_LeftOperator) isLogicalOperator_Left() {} +func (*LogicalOperator_LeftStringCondition) isLogicalOperator_Left() {} +func (*LogicalOperator_LeftNumberCondition) isLogicalOperator_Left() {} +func (*LogicalOperator_LeftNullCondition) isLogicalOperator_Left() {} +func (*LogicalOperator_RightOperator) isLogicalOperator_Right() {} +func (*LogicalOperator_RightStringCondition) isLogicalOperator_Right() {} +func (*LogicalOperator_RightNumberCondition) isLogicalOperator_Right() {} +func (*LogicalOperator_RightNullCondition) isLogicalOperator_Right() {} + +func (m *LogicalOperator) GetLeft() isLogicalOperator_Left { + if m != nil { + return m.Left + } + return nil +} +func (m *LogicalOperator) GetRight() isLogicalOperator_Right { + if m != nil { + return m.Right + } + return nil +} + +func (m *LogicalOperator) GetLeftOperator() *LogicalOperator { + if x, ok := m.GetLeft().(*LogicalOperator_LeftOperator); ok { + return x.LeftOperator + } + return nil +} + +func (m *LogicalOperator) GetLeftStringCondition() *StringCondition { + if x, ok := m.GetLeft().(*LogicalOperator_LeftStringCondition); ok { + return x.LeftStringCondition + } + return nil +} + +func (m *LogicalOperator) GetLeftNumberCondition() *NumberCondition { + if x, ok := m.GetLeft().(*LogicalOperator_LeftNumberCondition); ok { + return x.LeftNumberCondition + } + return nil +} + +func (m *LogicalOperator) GetLeftNullCondition() *NullCondition { + if x, ok := m.GetLeft().(*LogicalOperator_LeftNullCondition); ok { + return x.LeftNullCondition + } + return nil +} + +func (m *LogicalOperator) GetRightOperator() *LogicalOperator { + if x, ok := m.GetRight().(*LogicalOperator_RightOperator); ok { + return x.RightOperator + } + return nil +} + +func (m *LogicalOperator) GetRightStringCondition() *StringCondition { + if x, ok := m.GetRight().(*LogicalOperator_RightStringCondition); ok { + return x.RightStringCondition + } + return nil +} + +func (m *LogicalOperator) GetRightNumberCondition() *NumberCondition { + if x, ok := m.GetRight().(*LogicalOperator_RightNumberCondition); ok { + return x.RightNumberCondition + } + return nil +} + +func (m *LogicalOperator) GetRightNullCondition() *NullCondition { + if x, ok := m.GetRight().(*LogicalOperator_RightNullCondition); ok { + return x.RightNullCondition + } + return nil +} + +func (m *LogicalOperator) GetType() LogicalOperator_Type { + if m != nil { + return m.Type + } + return LogicalOperator_AND +} + +func (m *LogicalOperator) GetIsNegative() bool { + if m != nil { + return m.IsNegative + } + return false +} + +// XXX_OneofFuncs is for the internal use of the proto package. +func (*LogicalOperator) XXX_OneofFuncs() (func(msg proto.Message, b *proto.Buffer) error, func(msg proto.Message, tag, wire int, b *proto.Buffer) (bool, error), func(msg proto.Message) (n int), []interface{}) { + return _LogicalOperator_OneofMarshaler, _LogicalOperator_OneofUnmarshaler, _LogicalOperator_OneofSizer, []interface{}{ + (*LogicalOperator_LeftOperator)(nil), + (*LogicalOperator_LeftStringCondition)(nil), + (*LogicalOperator_LeftNumberCondition)(nil), + (*LogicalOperator_LeftNullCondition)(nil), + (*LogicalOperator_RightOperator)(nil), + (*LogicalOperator_RightStringCondition)(nil), + (*LogicalOperator_RightNumberCondition)(nil), + (*LogicalOperator_RightNullCondition)(nil), + } +} + +func _LogicalOperator_OneofMarshaler(msg proto.Message, b *proto.Buffer) error { + m := msg.(*LogicalOperator) + // left + switch x := m.Left.(type) { + case *LogicalOperator_LeftOperator: + b.EncodeVarint(1<<3 | proto.WireBytes) + if err := b.EncodeMessage(x.LeftOperator); err != nil { + return err + } + case *LogicalOperator_LeftStringCondition: + b.EncodeVarint(2<<3 | proto.WireBytes) + if err := b.EncodeMessage(x.LeftStringCondition); err != nil { + return err + } + case *LogicalOperator_LeftNumberCondition: + b.EncodeVarint(3<<3 | proto.WireBytes) + if err := b.EncodeMessage(x.LeftNumberCondition); err != nil { + return err + } + case *LogicalOperator_LeftNullCondition: + b.EncodeVarint(4<<3 | proto.WireBytes) + if err := b.EncodeMessage(x.LeftNullCondition); err != nil { + return err + } + case nil: + default: + return fmt.Errorf("LogicalOperator.Left has unexpected type %T", x) + } + // right + switch x := m.Right.(type) { + case *LogicalOperator_RightOperator: + b.EncodeVarint(5<<3 | proto.WireBytes) + if err := b.EncodeMessage(x.RightOperator); err != nil { + return err + } + case *LogicalOperator_RightStringCondition: + b.EncodeVarint(6<<3 | proto.WireBytes) + if err := b.EncodeMessage(x.RightStringCondition); err != nil { + return err + } + case *LogicalOperator_RightNumberCondition: + b.EncodeVarint(7<<3 | proto.WireBytes) + if err := b.EncodeMessage(x.RightNumberCondition); err != nil { + return err + } + case *LogicalOperator_RightNullCondition: + b.EncodeVarint(8<<3 | proto.WireBytes) + if err := b.EncodeMessage(x.RightNullCondition); err != nil { + return err + } + case nil: + default: + return fmt.Errorf("LogicalOperator.Right has unexpected type %T", x) + } + return nil +} + +func _LogicalOperator_OneofUnmarshaler(msg proto.Message, tag, wire int, b *proto.Buffer) (bool, error) { + m := msg.(*LogicalOperator) + switch tag { + case 1: // left.left_operator + if wire != proto.WireBytes { + return true, proto.ErrInternalBadWireType + } + msg := new(LogicalOperator) + err := b.DecodeMessage(msg) + m.Left = &LogicalOperator_LeftOperator{msg} + return true, err + case 2: // left.left_string_condition + if wire != proto.WireBytes { + return true, proto.ErrInternalBadWireType + } + msg := new(StringCondition) + err := b.DecodeMessage(msg) + m.Left = &LogicalOperator_LeftStringCondition{msg} + return true, err + case 3: // left.left_number_condition + if wire != proto.WireBytes { + return true, proto.ErrInternalBadWireType + } + msg := new(NumberCondition) + err := b.DecodeMessage(msg) + m.Left = &LogicalOperator_LeftNumberCondition{msg} + return true, err + case 4: // left.left_null_condition + if wire != proto.WireBytes { + return true, proto.ErrInternalBadWireType + } + msg := new(NullCondition) + err := b.DecodeMessage(msg) + m.Left = &LogicalOperator_LeftNullCondition{msg} + return true, err + case 5: // right.right_operator + if wire != proto.WireBytes { + return true, proto.ErrInternalBadWireType + } + msg := new(LogicalOperator) + err := b.DecodeMessage(msg) + m.Right = &LogicalOperator_RightOperator{msg} + return true, err + case 6: // right.right_string_condition + if wire != proto.WireBytes { + return true, proto.ErrInternalBadWireType + } + msg := new(StringCondition) + err := b.DecodeMessage(msg) + m.Right = &LogicalOperator_RightStringCondition{msg} + return true, err + case 7: // right.right_number_condition + if wire != proto.WireBytes { + return true, proto.ErrInternalBadWireType + } + msg := new(NumberCondition) + err := b.DecodeMessage(msg) + m.Right = &LogicalOperator_RightNumberCondition{msg} + return true, err + case 8: // right.right_null_condition + if wire != proto.WireBytes { + return true, proto.ErrInternalBadWireType + } + msg := new(NullCondition) + err := b.DecodeMessage(msg) + m.Right = &LogicalOperator_RightNullCondition{msg} + return true, err + default: + return false, nil + } +} + +func _LogicalOperator_OneofSizer(msg proto.Message) (n int) { + m := msg.(*LogicalOperator) + // left + switch x := m.Left.(type) { + case *LogicalOperator_LeftOperator: + s := proto.Size(x.LeftOperator) + n += proto.SizeVarint(1<<3 | proto.WireBytes) + n += proto.SizeVarint(uint64(s)) + n += s + case *LogicalOperator_LeftStringCondition: + s := proto.Size(x.LeftStringCondition) + n += proto.SizeVarint(2<<3 | proto.WireBytes) + n += proto.SizeVarint(uint64(s)) + n += s + case *LogicalOperator_LeftNumberCondition: + s := proto.Size(x.LeftNumberCondition) + n += proto.SizeVarint(3<<3 | proto.WireBytes) + n += proto.SizeVarint(uint64(s)) + n += s + case *LogicalOperator_LeftNullCondition: + s := proto.Size(x.LeftNullCondition) + n += proto.SizeVarint(4<<3 | proto.WireBytes) + n += proto.SizeVarint(uint64(s)) + n += s + case nil: + default: + panic(fmt.Sprintf("proto: unexpected type %T in oneof", x)) + } + // right + switch x := m.Right.(type) { + case *LogicalOperator_RightOperator: + s := proto.Size(x.RightOperator) + n += proto.SizeVarint(5<<3 | proto.WireBytes) + n += proto.SizeVarint(uint64(s)) + n += s + case *LogicalOperator_RightStringCondition: + s := proto.Size(x.RightStringCondition) + n += proto.SizeVarint(6<<3 | proto.WireBytes) + n += proto.SizeVarint(uint64(s)) + n += s + case *LogicalOperator_RightNumberCondition: + s := proto.Size(x.RightNumberCondition) + n += proto.SizeVarint(7<<3 | proto.WireBytes) + n += proto.SizeVarint(uint64(s)) + n += s + case *LogicalOperator_RightNullCondition: + s := proto.Size(x.RightNullCondition) + n += proto.SizeVarint(8<<3 | proto.WireBytes) + n += proto.SizeVarint(uint64(s)) + n += s + case nil: + default: + panic(fmt.Sprintf("proto: unexpected type %T in oneof", x)) + } + return n +} + +// StringCondition represents a condition with a string literal, e.g. field == 'string'. +// field_path is a reference to a value of a resource. +// value is the string literal. +// type is a type of the condition. +// is_negative is set to true if the condition is negated. +type StringCondition struct { + FieldPath []string `protobuf:"bytes,1,rep,name=field_path,json=fieldPath" json:"field_path,omitempty"` + Value string `protobuf:"bytes,2,opt,name=value" json:"value,omitempty"` + Type StringCondition_Type `protobuf:"varint,3,opt,name=type,enum=infoblox.api.StringCondition_Type" json:"type,omitempty"` + IsNegative bool `protobuf:"varint,4,opt,name=is_negative,json=isNegative" json:"is_negative,omitempty"` +} + +func (m *StringCondition) Reset() { *m = StringCondition{} } +func (m *StringCondition) String() string { return proto.CompactTextString(m) } +func (*StringCondition) ProtoMessage() {} +func (*StringCondition) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{6} } + +func (m *StringCondition) GetFieldPath() []string { + if m != nil { + return m.FieldPath + } + return nil +} + +func (m *StringCondition) GetValue() string { + if m != nil { + return m.Value + } + return "" +} + +func (m *StringCondition) GetType() StringCondition_Type { + if m != nil { + return m.Type + } + return StringCondition_EQ +} + +func (m *StringCondition) GetIsNegative() bool { + if m != nil { + return m.IsNegative + } + return false +} + +// NumberCondition represents a condition with a number literal, e.g. field > 3. +// field_path is a reference to a value of a resource. +// value is the number literal. +// type is a type of the condition. +// is_negative is set to true if the condition is negated. +type NumberCondition struct { + FieldPath []string `protobuf:"bytes,1,rep,name=field_path,json=fieldPath" json:"field_path,omitempty"` + Value float64 `protobuf:"fixed64,2,opt,name=value" json:"value,omitempty"` + Type NumberCondition_Type `protobuf:"varint,3,opt,name=type,enum=infoblox.api.NumberCondition_Type" json:"type,omitempty"` + IsNegative bool `protobuf:"varint,4,opt,name=is_negative,json=isNegative" json:"is_negative,omitempty"` +} + +func (m *NumberCondition) Reset() { *m = NumberCondition{} } +func (m *NumberCondition) String() string { return proto.CompactTextString(m) } +func (*NumberCondition) ProtoMessage() {} +func (*NumberCondition) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{7} } + +func (m *NumberCondition) GetFieldPath() []string { + if m != nil { + return m.FieldPath + } + return nil +} + +func (m *NumberCondition) GetValue() float64 { + if m != nil { + return m.Value + } + return 0 +} + +func (m *NumberCondition) GetType() NumberCondition_Type { + if m != nil { + return m.Type + } + return NumberCondition_EQ +} + +func (m *NumberCondition) GetIsNegative() bool { + if m != nil { + return m.IsNegative + } + return false +} + +// NullCondition represents a condition with a null literal, e.g. field == null. +// field_path is a reference to a value of a resource. +// is_negative is set to true if the condition is negated. +type NullCondition struct { + FieldPath []string `protobuf:"bytes,1,rep,name=field_path,json=fieldPath" json:"field_path,omitempty"` + IsNegative bool `protobuf:"varint,2,opt,name=is_negative,json=isNegative" json:"is_negative,omitempty"` +} + +func (m *NullCondition) Reset() { *m = NullCondition{} } +func (m *NullCondition) String() string { return proto.CompactTextString(m) } +func (*NullCondition) ProtoMessage() {} +func (*NullCondition) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{8} } + +func (m *NullCondition) GetFieldPath() []string { + if m != nil { + return m.FieldPath + } + return nil +} + +func (m *NullCondition) GetIsNegative() bool { + if m != nil { + return m.IsNegative + } + return false +} + +// Pagination represents both server-driven and client-driven pagination request. +// Server-driven pagination is a model in which the server returns some +// amount of data along with an token indicating there is more data +// and where subsequent queries can get the next page of data. +// Client-driven pagination is a model in which rows are addressable by +// offset and page size (limit). +type Pagination struct { + // The service-defined string used to identify a page of resources. + // A null value indicates the first page. + PageToken string `protobuf:"bytes,1,opt,name=page_token,json=pageToken" json:"page_token,omitempty"` + // The integer index of the offset into a collection of resources. + // If omitted or null the value is assumed to be "0". + Offset int32 `protobuf:"varint,2,opt,name=offset" json:"offset,omitempty"` + // The integer number of resources to be returned in the response. + // The service may impose maximum value. + // If omitted the service may impose a default value. + Limit int32 `protobuf:"varint,3,opt,name=limit" json:"limit,omitempty"` +} + +func (m *Pagination) Reset() { *m = Pagination{} } +func (m *Pagination) String() string { return proto.CompactTextString(m) } +func (*Pagination) ProtoMessage() {} +func (*Pagination) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{9} } + +func (m *Pagination) GetPageToken() string { + if m != nil { + return m.PageToken + } + return "" +} + +func (m *Pagination) GetOffset() int32 { + if m != nil { + return m.Offset + } + return 0 +} + +func (m *Pagination) GetLimit() int32 { + if m != nil { + return m.Limit + } + return 0 +} + +// PageInfo represents both server-driven and client-driven pagination response. +// Server-driven pagination is a model in which the server returns some +// amount of data along with an token indicating there is more data +// and where subsequent queries can get the next page of data. +// Client-driven pagination is a model in which rows are addressable by +// offset and page size (limit). +type PageInfo struct { + // The service response should contain a string to indicate + // the next page of resources. + // A null value indicates no more pages. + PageToken string `protobuf:"bytes,1,opt,name=page_token,json=pageToken" json:"page_token,omitempty"` + // The service may optionally include the total number of resources being paged. + Size int32 `protobuf:"varint,2,opt,name=size" json:"size,omitempty"` + // The service may optionally include the offset of the next page of resources. + // A null value indicates no more pages. + Offset int32 `protobuf:"varint,3,opt,name=offset" json:"offset,omitempty"` +} + +func (m *PageInfo) Reset() { *m = PageInfo{} } +func (m *PageInfo) String() string { return proto.CompactTextString(m) } +func (*PageInfo) ProtoMessage() {} +func (*PageInfo) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{10} } + +func (m *PageInfo) GetPageToken() string { + if m != nil { + return m.PageToken + } + return "" +} + +func (m *PageInfo) GetSize() int32 { + if m != nil { + return m.Size + } + return 0 +} + +func (m *PageInfo) GetOffset() int32 { + if m != nil { + return m.Offset + } + return 0 +} + +func init() { + proto.RegisterType((*SortCriteria)(nil), "infoblox.api.SortCriteria") + proto.RegisterType((*Sorting)(nil), "infoblox.api.Sorting") + proto.RegisterType((*FieldSelection)(nil), "infoblox.api.FieldSelection") + proto.RegisterType((*Field)(nil), "infoblox.api.Field") + proto.RegisterType((*Filtering)(nil), "infoblox.api.Filtering") + proto.RegisterType((*LogicalOperator)(nil), "infoblox.api.LogicalOperator") + proto.RegisterType((*StringCondition)(nil), "infoblox.api.StringCondition") + proto.RegisterType((*NumberCondition)(nil), "infoblox.api.NumberCondition") + proto.RegisterType((*NullCondition)(nil), "infoblox.api.NullCondition") + proto.RegisterType((*Pagination)(nil), "infoblox.api.Pagination") + proto.RegisterType((*PageInfo)(nil), "infoblox.api.PageInfo") + proto.RegisterEnum("infoblox.api.SortCriteria_Order", SortCriteria_Order_name, SortCriteria_Order_value) + proto.RegisterEnum("infoblox.api.LogicalOperator_Type", LogicalOperator_Type_name, LogicalOperator_Type_value) + proto.RegisterEnum("infoblox.api.StringCondition_Type", StringCondition_Type_name, StringCondition_Type_value) + proto.RegisterEnum("infoblox.api.NumberCondition_Type", NumberCondition_Type_name, NumberCondition_Type_value) +} + +func init() { + proto.RegisterFile("github.com/infobloxopen/atlas-app-toolkit/op/collection_operators.proto", fileDescriptor0) +} + +var fileDescriptor0 = []byte{ + // 843 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xa4, 0x96, 0xdd, 0x6e, 0xdb, 0x36, + 0x14, 0x80, 0x4d, 0x59, 0x52, 0xac, 0x93, 0x3f, 0x8d, 0xc9, 0x3a, 0x2f, 0x43, 0x31, 0x43, 0x57, + 0xde, 0x45, 0x65, 0x34, 0x03, 0x8a, 0x62, 0xbd, 0x59, 0xe3, 0x38, 0xcd, 0x86, 0x34, 0xce, 0x68, + 0xf7, 0x62, 0xbb, 0x31, 0x68, 0x87, 0x56, 0x88, 0x28, 0xa4, 0x20, 0xd1, 0xc5, 0xbc, 0x47, 0x19, + 0x76, 0xb5, 0x07, 0xd8, 0x03, 0xec, 0x25, 0xf6, 0x4a, 0x03, 0x69, 0xc9, 0x93, 0xe5, 0xac, 0x89, + 0xdb, 0x2b, 0x89, 0x07, 0xe7, 0x7c, 0x3c, 0xe7, 0x93, 0x4c, 0x0b, 0xde, 0x44, 0x5c, 0xdd, 0xcc, + 0xc6, 0xe1, 0x44, 0xde, 0x75, 0xb8, 0x98, 0xca, 0x71, 0x2c, 0x7f, 0x95, 0x09, 0x13, 0x1d, 0xaa, + 0x62, 0x9a, 0x3d, 0xa3, 0x49, 0xf2, 0x4c, 0x49, 0x19, 0xdf, 0x72, 0xd5, 0x91, 0x49, 0x67, 0x22, + 0xe3, 0x98, 0x4d, 0x14, 0x97, 0x62, 0x24, 0x13, 0x96, 0x52, 0x25, 0xd3, 0x2c, 0x4c, 0x52, 0xa9, + 0x24, 0xde, 0x29, 0xaa, 0x43, 0x9a, 0xf0, 0x40, 0xc1, 0xce, 0x40, 0xa6, 0xaa, 0x9b, 0x72, 0xc5, + 0x52, 0x4e, 0xb1, 0x0f, 0x75, 0x45, 0xa3, 0x26, 0x6a, 0xa1, 0xb6, 0x47, 0xf4, 0x2d, 0x7e, 0x01, + 0x8e, 0x4c, 0xaf, 0x59, 0xda, 0xb4, 0x5a, 0xa8, 0xbd, 0x77, 0xdc, 0x0a, 0xcb, 0xf5, 0x61, 0xb9, + 0x38, 0xec, 0xeb, 0x3c, 0xb2, 0x48, 0x0f, 0x8e, 0xc0, 0x31, 0x6b, 0xbc, 0x05, 0xf5, 0xd7, 0x83, + 0xae, 0x5f, 0xc3, 0x0d, 0xb0, 0x4f, 0x7b, 0x83, 0xae, 0x8f, 0x82, 0x2e, 0x6c, 0xe9, 0x42, 0x2e, + 0x22, 0xfc, 0x12, 0xbc, 0x49, 0x5e, 0x9f, 0x35, 0x51, 0xab, 0xde, 0xde, 0x3e, 0x3e, 0xfa, 0xff, + 0x2d, 0xc8, 0x7f, 0xc9, 0xc1, 0x9f, 0x08, 0xf6, 0xce, 0x38, 0x8b, 0xaf, 0x07, 0x2c, 0x9f, 0x15, + 0x7f, 0x0f, 0xee, 0x54, 0x47, 0x0a, 0x52, 0x7b, 0x95, 0xb4, 0x9a, 0xbd, 0x58, 0x66, 0x3d, 0xa1, + 0xd2, 0x39, 0xc9, 0xeb, 0x8e, 0x2e, 0x61, 0xbb, 0x14, 0xd6, 0x3a, 0x6e, 0xd9, 0xbc, 0xd0, 0x71, + 0xcb, 0xe6, 0xf8, 0x1b, 0x70, 0xde, 0xd3, 0x78, 0xc6, 0x8c, 0x8e, 0xed, 0xe3, 0x83, 0x7b, 0x76, + 0x20, 0x8b, 0x8c, 0xef, 0xac, 0x97, 0x28, 0xf8, 0x03, 0x81, 0x63, 0x82, 0x18, 0x83, 0x2d, 0xe8, + 0x1d, 0xcb, 0x59, 0xe6, 0x1e, 0x3f, 0x07, 0x3b, 0x9b, 0x8d, 0xb3, 0xa6, 0x65, 0xba, 0x7d, 0x7a, + 0x0f, 0x2b, 0x1c, 0xcc, 0xc6, 0x79, 0x8b, 0x26, 0xf5, 0xe8, 0x02, 0xbc, 0x65, 0xe8, 0xd3, 0xdb, + 0xfb, 0xcb, 0x02, 0xef, 0x8c, 0xc7, 0xda, 0xa8, 0x88, 0xf0, 0x2b, 0x68, 0x14, 0x6f, 0x8b, 0x61, + 0xae, 0xb5, 0x74, 0x21, 0x23, 0x3e, 0xa1, 0x71, 0x3f, 0x4f, 0x3a, 0xaf, 0x91, 0x65, 0x01, 0xfe, + 0x11, 0xfc, 0x4c, 0x69, 0xcc, 0x68, 0x22, 0xc5, 0x35, 0xd7, 0x86, 0xf3, 0x26, 0x2a, 0x90, 0x81, + 0xc9, 0xea, 0x16, 0x49, 0xe7, 0x35, 0xb2, 0x9f, 0xad, 0x86, 0x34, 0x4b, 0xcc, 0xee, 0xc6, 0x2c, + 0x2d, 0xb1, 0xea, 0xf7, 0xb1, 0x2e, 0x4d, 0xd6, 0x0a, 0x4b, 0xac, 0x86, 0xf0, 0x29, 0xec, 0x89, + 0x59, 0x1c, 0x97, 0x48, 0xb6, 0x21, 0x7d, 0x55, 0x25, 0xc5, 0x71, 0x99, 0xb3, 0x2b, 0xca, 0x81, + 0x13, 0x17, 0xec, 0x54, 0x4a, 0x15, 0xfc, 0xee, 0xc2, 0x7e, 0xc5, 0x02, 0x3e, 0x85, 0xdd, 0x98, + 0x4d, 0xd5, 0x68, 0x53, 0x77, 0x3b, 0xba, 0x6a, 0x49, 0x19, 0xc0, 0xe7, 0x86, 0xf2, 0xb1, 0x12, + 0x0f, 0x74, 0x75, 0x25, 0xbc, 0x84, 0x7e, 0xac, 0x4d, 0x03, 0xad, 0x84, 0xf1, 0x5b, 0x38, 0xc8, + 0xa1, 0x9b, 0x6b, 0xfd, 0x6c, 0x01, 0x2c, 0x05, 0xf1, 0x19, 0xec, 0xa5, 0x3c, 0xba, 0x29, 0xf9, + 0x73, 0x1e, 0xe3, 0x0f, 0x91, 0x5d, 0x53, 0xb6, 0x14, 0xf8, 0x0e, 0x9e, 0x2c, 0x38, 0x6b, 0x06, + 0xdd, 0xc7, 0x18, 0x44, 0xe4, 0xd0, 0x94, 0x57, 0x15, 0x2e, 0xb1, 0x6b, 0x0e, 0xb7, 0x1e, 0xe3, + 0xb0, 0xc0, 0x56, 0x25, 0xf6, 0xe1, 0xb0, 0xc0, 0xae, 0x58, 0x6c, 0x3c, 0x6c, 0x11, 0x11, 0x9c, + 0x23, 0xcb, 0x1a, 0x5f, 0x80, 0xad, 0xe6, 0x09, 0x6b, 0x7a, 0xe6, 0x98, 0x0e, 0x3e, 0x28, 0x2f, + 0x1c, 0xce, 0x13, 0x46, 0x4c, 0x3e, 0xfe, 0x1a, 0xb6, 0x79, 0x36, 0x12, 0x2c, 0xa2, 0x8a, 0xbf, + 0x67, 0x4d, 0x68, 0xa1, 0x76, 0x83, 0x00, 0xcf, 0x2e, 0xf3, 0x48, 0xf0, 0x05, 0xd8, 0x3a, 0xdd, + 0x9c, 0xe3, 0x97, 0xa7, 0x7e, 0x0d, 0xbb, 0x60, 0xf5, 0x89, 0x8f, 0xf4, 0x6f, 0x42, 0x3f, 0xcd, + 0x93, 0x2d, 0x70, 0x4c, 0x3f, 0xc1, 0xdf, 0x08, 0xf6, 0xab, 0xfa, 0x9e, 0x02, 0x98, 0xa3, 0x75, + 0x94, 0x50, 0x75, 0x63, 0x8e, 0x65, 0x8f, 0x78, 0x26, 0x72, 0x45, 0xd5, 0x0d, 0x3e, 0x2c, 0x9f, + 0x57, 0x5e, 0x7e, 0x34, 0x2d, 0x67, 0xa9, 0xdf, 0x37, 0x4b, 0x65, 0x87, 0x0f, 0xcc, 0x62, 0xaf, + 0xcd, 0xf2, 0x65, 0x3e, 0x8b, 0x0b, 0x56, 0xef, 0x27, 0xbf, 0x86, 0x3d, 0x70, 0xde, 0xbe, 0x1e, + 0x76, 0xcf, 0x7d, 0x14, 0xfc, 0x83, 0x60, 0xbf, 0xfa, 0x90, 0x36, 0x69, 0x1e, 0x3d, 0xaa, 0xf9, + 0xca, 0x0e, 0x1b, 0x35, 0x1f, 0x56, 0x9a, 0x77, 0xc1, 0x7a, 0x33, 0xf4, 0x91, 0xb9, 0xf6, 0x7c, + 0x4b, 0x5f, 0x2f, 0x86, 0x7e, 0xdd, 0x5c, 0x7b, 0xbe, 0x1d, 0xf4, 0x61, 0x77, 0xf5, 0x15, 0x79, + 0x60, 0x9c, 0x4a, 0x03, 0xd6, 0x5a, 0x03, 0x3f, 0x03, 0x5c, 0xd1, 0x88, 0x0b, 0x5a, 0xd0, 0x12, + 0x1a, 0xb1, 0x91, 0x92, 0xb7, 0x4c, 0xe4, 0xff, 0x41, 0x9e, 0x8e, 0x0c, 0x75, 0x00, 0x3f, 0x01, + 0x57, 0x4e, 0xa7, 0x19, 0x53, 0x06, 0xe4, 0x90, 0x7c, 0xa5, 0xa5, 0xc5, 0xfc, 0x8e, 0x2b, 0xe3, + 0xc7, 0x21, 0x8b, 0x45, 0xf0, 0x0e, 0x1a, 0x57, 0x34, 0x62, 0x3f, 0x88, 0xa9, 0x7c, 0x08, 0x8c, + 0xc1, 0xce, 0xf8, 0x6f, 0x2c, 0xc7, 0x9a, 0xfb, 0xd2, 0x66, 0xf5, 0xf2, 0x66, 0x27, 0xcf, 0x7f, + 0xe9, 0x6c, 0xf2, 0xdd, 0xf4, 0x4a, 0x26, 0x63, 0xd7, 0x7c, 0x26, 0x7d, 0xfb, 0x6f, 0x00, 0x00, + 0x00, 0xff, 0xff, 0x9f, 0x77, 0x30, 0xfd, 0x71, 0x09, 0x00, 0x00, +} diff --git a/op/collection_operators.proto b/op/collection_operators.proto new file mode 100644 index 00000000..49073c9e --- /dev/null +++ b/op/collection_operators.proto @@ -0,0 +1,155 @@ +syntax = "proto3"; + +package infoblox.api; + +option go_package = "github.com/infobloxopen/atlas-app-toolkit/op;op"; + +// SortCriteria represents sort criteria +message SortCriteria { + // Tag is a JSON tag. + string tag = 1; + // Order is a sort order. + enum Order { + // ascending sort order + ASC = 0; + // descending sort order + DESC = 1; + } + Order order = 2; +} + +// Sorting represents list of sort criterias. +message Sorting { + repeated SortCriteria criterias = 1; +} + +// FieldSelection represents a group of fields for some object. +// Main use case for if is to store information about object fields that +// need to be ratained prior to sending object as a response +message FieldSelection { + map fields = 1; +} + +// Field represents a single field for an object. +// It contains fields name and also may contain a group of sub-fields for cases +// when a fields represents some structure. +message Field { + string name = 1; + map subs = 2; +} + +// Filtering represents filtering expression. +// root could be either LogicalOperator or one of the supported conditions. +message Filtering { + oneof root { + LogicalOperator operator = 1; + StringCondition string_condition = 2; + NumberCondition number_condition = 3; + NullCondition null_condition = 4; + } +} + +// LogicalOperator represents binary logical operator, either AND or OR depending on type. +// left and right are respectively left and right operands of the operator, could be +// either LogicalOperator or one of the supported conditions. +// is_negative is set to true if the operator is negated. +message LogicalOperator { + oneof left { + LogicalOperator left_operator = 1; + StringCondition left_string_condition = 2; + NumberCondition left_number_condition = 3; + NullCondition left_null_condition = 4; + } + oneof right { + LogicalOperator right_operator = 5; + StringCondition right_string_condition = 6; + NumberCondition right_number_condition = 7; + NullCondition right_null_condition = 8; + } + enum Type { + AND = 0; + OR = 1; + } + Type type = 9; + bool is_negative = 10; +} + +// StringCondition represents a condition with a string literal, e.g. field == 'string'. +// field_path is a reference to a value of a resource. +// value is the string literal. +// type is a type of the condition. +// is_negative is set to true if the condition is negated. +message StringCondition { + repeated string field_path = 1; + string value = 2; + enum Type { + EQ = 0; + MATCH = 1; + } + Type type = 3; + bool is_negative = 4; +} + +// NumberCondition represents a condition with a number literal, e.g. field > 3. +// field_path is a reference to a value of a resource. +// value is the number literal. +// type is a type of the condition. +// is_negative is set to true if the condition is negated. +message NumberCondition { + repeated string field_path = 1; + double value = 2; + enum Type { + EQ = 0; + GT = 1; + GE = 2; + LT = 3; + LE = 4; + } + Type type = 3; + bool is_negative = 4; +} + +// NullCondition represents a condition with a null literal, e.g. field == null. +// field_path is a reference to a value of a resource. +// is_negative is set to true if the condition is negated. +message NullCondition { + repeated string field_path = 1; + bool is_negative = 2; +} + +// Pagination represents both server-driven and client-driven pagination request. +// Server-driven pagination is a model in which the server returns some +// amount of data along with an token indicating there is more data +// and where subsequent queries can get the next page of data. +// Client-driven pagination is a model in which rows are addressable by +// offset and page size (limit). +message Pagination { + // The service-defined string used to identify a page of resources. + // A null value indicates the first page. + string page_token = 1; + // The integer index of the offset into a collection of resources. + // If omitted or null the value is assumed to be "0". + int32 offset = 2; + // The integer number of resources to be returned in the response. + // The service may impose maximum value. + // If omitted the service may impose a default value. + int32 limit = 3; +} + +// PageInfo represents both server-driven and client-driven pagination response. +// Server-driven pagination is a model in which the server returns some +// amount of data along with an token indicating there is more data +// and where subsequent queries can get the next page of data. +// Client-driven pagination is a model in which rows are addressable by +// offset and page size (limit). +message PageInfo { + // The service response should contain a string to indicate + // the next page of resources. + // A null value indicates no more pages. + string page_token = 1; + // The service may optionally include the total number of resources being paged. + int32 size = 2; + // The service may optionally include the offset of the next page of resources. + // A null value indicates no more pages. + int32 offset = 3; +} diff --git a/op/fields.go b/op/fields.go new file mode 100644 index 00000000..3be3f15d --- /dev/null +++ b/op/fields.go @@ -0,0 +1,142 @@ +package op + +import ( + "strings" +) + +const ( + opCommonDelimiter = "," + opCommonInnerDelimiter = "." +) + +//FieldSelectionMap is a convenience type that represents map[string]*Field +//used in FieldSelection and Field structs +type FieldSelectionMap map[string]*Field + +func innerDelimiter(delimiter ...string) string { + split := opCommonInnerDelimiter + if delimiter != nil && len(delimiter) > 0 { + split = delimiter[0] + } + return split +} + +func toParts(input string, delimiter ...string) []string { + split := innerDelimiter(delimiter...) + return strings.Split(input, split) +} + +//ParseFieldSelection transforms a string with comma-separated fields that comes +//from client to FieldSelection struct. For complex fields dot is used as a delimeter by +//default, but it is also possible to specify a different delimiter. +func ParseFieldSelection(input string, delimiter ...string) *FieldSelection { + if len(input) == 0 { + return &FieldSelection{Fields: nil} + } + + fields := strings.Split(input, opCommonDelimiter) + result := &FieldSelection{Fields: make(map[string]*Field, len(fields))} + + for _, field := range fields { + result.Add(field, delimiter...) + } + + return result +} + +//GoString converts FieldSelection to a string representation +//It implements fmt.GoStringer interface and returns dot-notated fields separated by commas +func (f *FieldSelection) GoString() string { + result := make([]string, 0, len(f.Fields)) + for _, field := range f.Fields { + addChildFieldString(&result, "", field) + } + return strings.Join(result, opCommonDelimiter) +} + +func addChildFieldString(result *[]string, parent string, field *Field) { + if field.Subs == nil || len(field.Subs) == 0 { + *result = append(*result, parent+field.Name) + } else { + parent = parent + field.Name + opCommonInnerDelimiter + for _, f := range field.Subs { + addChildFieldString(result, parent, f) + } + } +} + +//Add allows to add new fields to FieldSelection +func (f *FieldSelection) Add(field string, delimiter ...string) { + if len(field) == 0 { + return + } + if f.Fields == nil { + f.Fields = map[string]*Field{} + } + parts := toParts(field, delimiter...) + name := parts[0] + if _, ok := f.Fields[name]; !ok { + f.Fields[name] = &Field{Name: name} + } + + parent := f.Fields[name] + for i := 1; i < len(parts); i++ { + if parent.Subs == nil { + parent.Subs = make(map[string]*Field) + } + name = parts[i] + if _, ok := parent.Subs[name]; !ok { + parent.Subs[name] = &Field{Name: name} + } + parent = parent.Subs[name] + } +} + +//Delete allows to remove fields from FieldSelection +func (f *FieldSelection) Delete(field string, delimiter ...string) bool { + if len(field) == 0 || f.Fields == nil { + return false + } + parts := toParts(field, delimiter...) + name := parts[0] + tmp := f.Fields + + for i := 0; i < len(parts); i++ { + name = parts[i] + if _, ok := tmp[name]; !ok { + return false //such field do not exist if FieldSelection + } + if i < len(parts)-1 { + tmp = tmp[name].Subs + if tmp == nil { + return false //such field do not exist if FieldSelection + } + } + } + delete(tmp, name) + return true +} + +//Get allows to get specified field from FieldSelection +func (f *FieldSelection) Get(field string, delimiter ...string) *Field { + if len(field) == 0 || f.Fields == nil { + return nil + } + parts := toParts(field, delimiter...) + name := parts[0] + tmp := f.Fields + + for i := 0; i < len(parts); i++ { + name = parts[i] + if _, ok := tmp[name]; !ok { + return nil //such field do not exist if FieldSelection + } + if i < len(parts)-1 { + tmp = tmp[name].Subs + if tmp == nil { + return nil //such field do not exist if FieldSelection + } + } + } + return tmp[name] +} diff --git a/op/fields_test.go b/op/fields_test.go new file mode 100644 index 00000000..fab607d0 --- /dev/null +++ b/op/fields_test.go @@ -0,0 +1,166 @@ +package op + +import ( + "reflect" + "strings" + "testing" +) + +func TestParse(t *testing.T) { + validateParse(t, ParseFieldSelection(""), FieldSelection{Fields: nil}) + + expected := FieldSelection{Fields: FieldSelectionMap{"a": &Field{Name: "a"}}} + validateParse(t, ParseFieldSelection("a"), expected) + validateParse(t, ParseFieldSelection("a", "?"), expected) + + expected = FieldSelection{Fields: FieldSelectionMap{"a": &Field{Name: "a", Subs: FieldSelectionMap{"b": &Field{Name: "b"}, "c": &Field{Name: "c"}}}}} + validateParse(t, ParseFieldSelection("a.b,a.c"), expected) + validateParse(t, ParseFieldSelection("a?b,a?c", "?"), expected) + validateParse(t, ParseFieldSelection("a-b,a-c", "-", "?"), expected) + + expected = FieldSelection{Fields: FieldSelectionMap{"a": &Field{Name: "a", Subs: FieldSelectionMap{"b": &Field{Name: "b"}, "c": &Field{Name: "c"}}}, "x": &Field{Name: "x"}}} + validateParse(t, ParseFieldSelection("a.b,a.c,x"), expected) + + expected = FieldSelection{Fields: FieldSelectionMap{"a": &Field{Name: "a", Subs: FieldSelectionMap{"b": &Field{Name: "b", Subs: FieldSelectionMap{"v": &Field{Name: "v"}}}, "c": &Field{Name: "c"}}}, "x": &Field{Name: "x"}}} + validateParse(t, ParseFieldSelection("a.b.v,a.c,x"), expected) + validateParse(t, ParseFieldSelection("a,a.b,a.b.v,a.c,x"), expected) +} + +func validateParse(t *testing.T, result *FieldSelection, expected FieldSelection) { + if !reflect.DeepEqual(result, &expected) { + t.Errorf("Unexpected parse result %v while expecting %v", result, expected) + } +} + +func TestGoString(t *testing.T) { + validateGoString(t, "a,b,c.x,c.y") + validateGoString(t, "a,b,c.x,c.y.z") + validateGoString(t, "q.w,e,a,b,c.x,c.y.z,c.y.r") +} + +func validateGoString(t *testing.T, data string) { + original := map[string]bool{} + for _, x := range strings.Split(data, ",") { + original[x] = true + } + + flds := ParseFieldSelection(data) + fldsStr := flds.GoString() + result := map[string]bool{} + for _, x := range strings.Split(fldsStr, ",") { + result[x] = true + } + + if !reflect.DeepEqual(result, original) { + t.Errorf("Unexpected fields-to-string conversion result for %s", data) + } +} + +func TestAdd(t *testing.T) { + flds := &FieldSelection{} + flds.Add("test") + expected := FieldSelection{Fields: FieldSelectionMap{"test": &Field{Name: "test"}}} + validateParse(t, flds, expected) + + flds = ParseFieldSelection("a.b,x") + + flds.Add("") + flds.Add("x") + expected = FieldSelection{Fields: FieldSelectionMap{"a": &Field{Name: "a", Subs: FieldSelectionMap{"b": &Field{Name: "b"}}}, "x": &Field{Name: "x"}}} + validateParse(t, flds, expected) + + flds.Add("a.c") + expected = FieldSelection{Fields: FieldSelectionMap{"a": &Field{Name: "a", Subs: FieldSelectionMap{"b": &Field{Name: "b"}, "c": &Field{Name: "c"}}}, "x": &Field{Name: "x"}}} + validateParse(t, flds, expected) + + flds.Add("a.b.v") + expected = FieldSelection{Fields: FieldSelectionMap{"a": &Field{Name: "a", Subs: FieldSelectionMap{"b": &Field{Name: "b", Subs: FieldSelectionMap{"v": &Field{Name: "v"}}}, "c": &Field{Name: "c"}}}, "x": &Field{Name: "x"}}} + validateParse(t, flds, expected) + + flds.Add("x.y.z") + expected = FieldSelection{Fields: FieldSelectionMap{"a": &Field{Name: "a", Subs: FieldSelectionMap{"b": &Field{Name: "b", Subs: FieldSelectionMap{"v": &Field{Name: "v"}}}, "c": &Field{Name: "c"}}}, "x": &Field{Name: "x", Subs: FieldSelectionMap{"y": &Field{Name: "y", Subs: FieldSelectionMap{"z": &Field{Name: "z"}}}}}}} + validateParse(t, flds, expected) + + flds.Add("t.i") + expected = FieldSelection{Fields: FieldSelectionMap{"a": &Field{Name: "a", Subs: FieldSelectionMap{"b": &Field{Name: "b", Subs: FieldSelectionMap{"v": &Field{Name: "v"}}}, "c": &Field{Name: "c"}}}, "x": &Field{Name: "x", Subs: FieldSelectionMap{"y": &Field{Name: "y", Subs: FieldSelectionMap{"z": &Field{Name: "z"}}}}}, "t": &Field{Name: "t", Subs: FieldSelectionMap{"i": &Field{Name: "i"}}}}} + validateParse(t, flds, expected) + + flds.Add("k") + expected = FieldSelection{Fields: FieldSelectionMap{"a": &Field{Name: "a", Subs: FieldSelectionMap{"b": &Field{Name: "b", Subs: FieldSelectionMap{"v": &Field{Name: "v"}}}, "c": &Field{Name: "c"}}}, "x": &Field{Name: "x", Subs: FieldSelectionMap{"y": &Field{Name: "y", Subs: FieldSelectionMap{"z": &Field{Name: "z"}}}}}, "t": &Field{Name: "t", Subs: FieldSelectionMap{"i": &Field{Name: "i"}}}, "k": &Field{Name: "k"}}} + validateParse(t, flds, expected) +} + +func TestDelete(t *testing.T) { + flds := ParseFieldSelection("a.b.v,a.c,x.y.z,t.i") + isDel := flds.Delete("q") + if isDel == true { + t.Error("Unexpected delete result") + } + isDel = flds.Delete("") + if isDel == true { + t.Error("Unexpected delete result") + } + isDel = flds.Delete("t.o") + if isDel == true { + t.Error("Unexpected delete result") + } + isDel = flds.Delete("t.i.o") + if isDel == true { + t.Error("Unexpected delete result") + } + + expected := FieldSelection{Fields: FieldSelectionMap{"a": &Field{Name: "a", Subs: FieldSelectionMap{"b": &Field{Name: "b", Subs: FieldSelectionMap{"v": &Field{Name: "v"}}}, "c": &Field{Name: "c"}}}, "x": &Field{Name: "x", Subs: FieldSelectionMap{"y": &Field{Name: "y", Subs: FieldSelectionMap{"z": &Field{Name: "z"}}}}}, "t": &Field{Name: "t", Subs: FieldSelectionMap{"i": &Field{Name: "i"}}}}} + validateParse(t, flds, expected) + + isDel = flds.Delete("t") + if isDel == false { + t.Error("Unexpected delete result") + } + expected = FieldSelection{Fields: FieldSelectionMap{"a": &Field{Name: "a", Subs: FieldSelectionMap{"b": &Field{Name: "b", Subs: FieldSelectionMap{"v": &Field{Name: "v"}}}, "c": &Field{Name: "c"}}}, "x": &Field{Name: "x", Subs: FieldSelectionMap{"y": &Field{Name: "y", Subs: FieldSelectionMap{"z": &Field{Name: "z"}}}}}}} + validateParse(t, flds, expected) + + isDel = flds.Delete("x.y") + if isDel == false { + t.Error("Unexpected delete result") + } + expected = FieldSelection{Fields: FieldSelectionMap{"a": &Field{Name: "a", Subs: FieldSelectionMap{"b": &Field{Name: "b", Subs: FieldSelectionMap{"v": &Field{Name: "v"}}}, "c": &Field{Name: "c"}}}, "x": &Field{Name: "x", Subs: FieldSelectionMap{}}}} + validateParse(t, flds, expected) + + isDel = flds.Delete("a.b.v") + if isDel == false { + t.Error("Unexpected delete result") + } + expected = FieldSelection{Fields: FieldSelectionMap{"a": &Field{Name: "a", Subs: FieldSelectionMap{"b": &Field{Name: "b", Subs: FieldSelectionMap{}}, "c": &Field{Name: "c"}}}, "x": &Field{Name: "x", Subs: FieldSelectionMap{}}}} + validateParse(t, flds, expected) + + isDel = flds.Delete("a") + if isDel == false { + t.Error("Unexpected delete result") + } + expected = FieldSelection{Fields: FieldSelectionMap{"x": &Field{Name: "x", Subs: FieldSelectionMap{}}}} + validateParse(t, flds, expected) +} + +func TestGet(t *testing.T) { + flds := ParseFieldSelection("a.b.v,a.c,x.y.z,t.i") + validateGet(t, flds, "q", nil) + validateGet(t, flds, "", nil) + validateGet(t, flds, "t.o", nil) + validateGet(t, flds, "t.i.o", nil) + + expected := &Field{Name: "i"} + validateGet(t, flds, "t.i", expected) + + expected = &Field{Name: "b", Subs: FieldSelectionMap{"v": &Field{Name: "v"}}} + validateGet(t, flds, "a.b", expected) + + expected = &Field{Name: "x", Subs: FieldSelectionMap{"y": &Field{Name: "y", Subs: FieldSelectionMap{"z": &Field{Name: "z"}}}}} + validateGet(t, flds, "x", expected) +} + +func validateGet(t *testing.T, flds *FieldSelection, field string, expected *Field) { + fld := flds.Get(field) + if !reflect.DeepEqual(fld, expected) { + t.Errorf("Unexpected get result for %s", field) + } +} diff --git a/op/filtering.go b/op/filtering.go new file mode 100644 index 00000000..20fd235e --- /dev/null +++ b/op/filtering.go @@ -0,0 +1,329 @@ +package op + +import ( + "fmt" + "reflect" + "regexp" + "strings" + + "github.com/golang/protobuf/proto" +) + +// Filter is a shortcut to parse a filter string using default FilteringParser implementation +// and call Filter on the returned filtering expression. +func Filter(obj interface{}, filter string) (bool, error) { + f, err := ParseFiltering(filter) + if err != nil { + return false, err + } + return f.Filter(obj) +} + +// FilteringExpression is the interface implemented by types that represent nodes in a filtering expression AST. +type FilteringExpression interface { + Filter(interface{}) (bool, error) +} + +// Matcher is implemented by structs that require custom filtering logic. +type Matcher interface { + Match(*Filtering) (bool, error) +} + +// Filter evaluates underlying filtering expression against obj. +// If obj implements Matcher, call it's custom implementation. +func (m *Filtering) Filter(obj interface{}) (bool, error) { + if m == nil { + return true, nil + } + if matcher, ok := obj.(Matcher); ok { + return matcher.Match(m) + } + r := m.Root + if f, ok := r.(FilteringExpression); ok { + return f.Filter(obj) + } else { + return false, fmt.Errorf("%T type does not implement FilteringExpression", r) + } +} + +// TypeMismatchError representes a type that is required for a value under FieldPath. +type TypeMismatchError struct { + ReqType string + FieldPath []string +} + +func (e *TypeMismatchError) Error() string { + return fmt.Sprintf("%s is not a %s type", strings.Join(e.FieldPath, "."), e.ReqType) +} + +// UnsupportedOperatorError represents an operator that is not supported by a particular field type. +type UnsupportedOperatorError struct { + Type string + Op string +} + +func (e *UnsupportedOperatorError) Error() string { + return fmt.Sprintf("%s is not supported for %s type", e.Op, e.Type) +} + +// Filter evaluates filtering expression against obj. +func (lop *LogicalOperator) Filter(obj interface{}) (bool, error) { + var res bool + var err error + l := lop.Left + if f, ok := l.(FilteringExpression); ok { + res, err = f.Filter(obj) + if err != nil { + return false, err + } + } else { + return false, fmt.Errorf("%T type does not implement FilteringExpression", l) + } + if lop.Type == LogicalOperator_AND && !res { + return negateIfNeeded(lop.IsNegative, false), nil + } else if lop.Type == LogicalOperator_OR && res { + return negateIfNeeded(lop.IsNegative, true), nil + } + r := lop.Right + if f, ok := r.(FilteringExpression); ok { + res, err = f.Filter(obj) + if err != nil { + return false, err + } + } else { + return false, fmt.Errorf("%T type does not implement FilteringExpression", r) + } + return negateIfNeeded(lop.IsNegative, res), nil +} + +// Filter evaluates string condition against obj. +// If obj is a proto message, then 'protobuf' tag is used to map FieldPath to obj's struct fields, +// otherwise 'json' tag is used. +func (c *StringCondition) Filter(obj interface{}) (bool, error) { + fv := fieldByFieldPath(obj, c.FieldPath) + fv = dereferenceValue(fv) + if fv.Kind() != reflect.String { + return false, &TypeMismatchError{"string", c.FieldPath} + } + s := fv.String() + switch c.Type { + case StringCondition_EQ: + return negateIfNeeded(s == c.Value, c.IsNegative), nil + case StringCondition_MATCH: + // add regex caching + matched, err := regexp.MatchString(c.Value, s) + if err != nil { + return false, err + } + return negateIfNeeded(matched, c.IsNegative), nil + default: + return false, &UnsupportedOperatorError{"string", c.Type.String()} + } +} + +// Filter evaluates number condition against obj. +// If obj is a proto message, then 'protobuf' tag is used to map FieldPath to obj's struct fields, +// otherwise 'json' tag is used. +func (c *NumberCondition) Filter(obj interface{}) (bool, error) { + fv := fieldByFieldPath(obj, c.FieldPath) + fv = dereferenceValue(fv) + var f float64 + switch fv.Kind() { + case reflect.Float32, reflect.Float64: + f = fv.Float() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + f = float64(fv.Int()) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + f = float64(fv.Uint()) + default: + return false, &TypeMismatchError{"number", c.FieldPath} + } + switch c.Type { + case NumberCondition_EQ: + return negateIfNeeded(f == c.Value, c.IsNegative), nil + case NumberCondition_GT: + return negateIfNeeded(f > c.Value, c.IsNegative), nil + case NumberCondition_GE: + return negateIfNeeded(f >= c.Value, c.IsNegative), nil + case NumberCondition_LT: + return negateIfNeeded(f < c.Value, c.IsNegative), nil + case NumberCondition_LE: + return negateIfNeeded(f <= c.Value, c.IsNegative), nil + default: + return false, &UnsupportedOperatorError{"number", c.Type.String()} + } +} + +// Filter evaluates null condition against obj. +// If obj is a proto message, then 'protobuf' tag is used to map FieldPath to obj's struct fields, +// otherwise 'json' tag is used. +func (c *NullCondition) Filter(obj interface{}) (bool, error) { + fv := fieldByFieldPath(obj, c.FieldPath) + if fv.Kind() != reflect.Ptr { + return false, &TypeMismatchError{"nullable", c.FieldPath} + } + return negateIfNeeded(fv.IsNil(), c.IsNegative), nil +} + +func fieldByFieldPath(obj interface{}, fieldPath []string) reflect.Value { + switch obj.(type) { + case proto.Message: + return fieldByProtoPath(obj, fieldPath) + default: + return fieldByJSONPath(obj, fieldPath) + } +} + +func fieldByProtoPath(obj interface{}, protoPath []string) reflect.Value { + v := dereferenceValue(reflect.ValueOf(obj)) + props := proto.GetProperties(v.Type()) + for _, p := range props.Prop { + if p.OrigName == protoPath[0] { + return v.FieldByName(p.Name) + } + if p.JSONName == protoPath[0] { + return v.FieldByName(p.Name) + } + } + return reflect.Value{} +} + +func fieldByJSONPath(obj interface{}, jsonPath []string) reflect.Value { + v := dereferenceValue(reflect.ValueOf(obj)) + t := v.Type() + for i := 0; i < t.NumField(); i++ { + sf := t.Field(i) + if getJSONName(sf) == jsonPath[0] { + return v.Field(i) + } + } + return reflect.Value{} +} + +func getJSONName(sf reflect.StructField) string { + if jsonTag, ok := sf.Tag.Lookup("json"); ok { + return strings.Split(jsonTag, ",")[0] + } + return sf.Name +} + +func dereferenceValue(value reflect.Value) reflect.Value { + kind := value.Kind() + for kind == reflect.Ptr || kind == reflect.Interface { + value = value.Elem() + kind = value.Kind() + } + return value +} + +func negateIfNeeded(neg bool, value bool) bool { + if neg { + return !value + } + return value +} + +func (m *Filtering_Operator) Filter(obj interface{}) (bool, error) { + return m.Operator.Filter(obj) +} + +func (m *Filtering_StringCondition) Filter(obj interface{}) (bool, error) { + return m.StringCondition.Filter(obj) +} + +func (m *Filtering_NumberCondition) Filter(obj interface{}) (bool, error) { + return m.NumberCondition.Filter(obj) +} + +func (m *Filtering_NullCondition) Filter(obj interface{}) (bool, error) { + return m.NullCondition.Filter(obj) +} + +func (m *LogicalOperator_LeftOperator) Filter(obj interface{}) (bool, error) { + return m.LeftOperator.Filter(obj) +} + +func (m *LogicalOperator_LeftStringCondition) Filter(obj interface{}) (bool, error) { + return m.LeftStringCondition.Filter(obj) +} + +func (m *LogicalOperator_LeftNumberCondition) Filter(obj interface{}) (bool, error) { + return m.LeftNumberCondition.Filter(obj) +} + +func (m *LogicalOperator_LeftNullCondition) Filter(obj interface{}) (bool, error) { + return m.LeftNullCondition.Filter(obj) +} + +func (m *LogicalOperator_RightOperator) Filter(obj interface{}) (bool, error) { + return m.RightOperator.Filter(obj) +} + +func (m *LogicalOperator_RightStringCondition) Filter(obj interface{}) (bool, error) { + return m.RightStringCondition.Filter(obj) +} + +func (m *LogicalOperator_RightNumberCondition) Filter(obj interface{}) (bool, error) { + return m.RightNumberCondition.Filter(obj) +} + +func (m *LogicalOperator_RightNullCondition) Filter(obj interface{}) (bool, error) { + return m.RightNullCondition.Filter(obj) +} + +// SetRoot automatically wraps r into appropriate oneof structure and sets it to Root. +func (m *Filtering) SetRoot(r interface{}) error { + switch x := r.(type) { + case *LogicalOperator: + m.Root = &Filtering_Operator{x} + case *StringCondition: + m.Root = &Filtering_StringCondition{x} + case *NumberCondition: + m.Root = &Filtering_NumberCondition{x} + case *NullCondition: + m.Root = &Filtering_NullCondition{x} + case nil: + m.Root = nil + default: + return fmt.Errorf("Filtering.Root cannot be assigned to type %T", x) + } + return nil +} + +// SetLeft automatically wraps l into appropriate oneof structure and sets it to Root. +func (m *LogicalOperator) SetLeft(l interface{}) error { + switch x := l.(type) { + case *LogicalOperator: + m.Left = &LogicalOperator_LeftOperator{x} + case *StringCondition: + m.Left = &LogicalOperator_LeftStringCondition{x} + case *NumberCondition: + m.Left = &LogicalOperator_LeftNumberCondition{x} + case *NullCondition: + m.Left = &LogicalOperator_LeftNullCondition{x} + case nil: + m.Left = nil + default: + return fmt.Errorf("Filtering.Left cannot be assigned to type %T", x) + } + return nil +} + +// SetRight automatically wraps r into appropriate oneof structure and sets it to Root. +func (m *LogicalOperator) SetRight(r interface{}) error { + switch x := r.(type) { + case *LogicalOperator: + m.Right = &LogicalOperator_RightOperator{x} + case *StringCondition: + m.Right = &LogicalOperator_RightStringCondition{x} + case *NumberCondition: + m.Right = &LogicalOperator_RightNumberCondition{x} + case *NullCondition: + m.Right = &LogicalOperator_RightNullCondition{x} + case nil: + m.Right = nil + default: + return fmt.Errorf("Filtering.Right cannot be assigned to type %T", x) + } + return nil +} diff --git a/op/filtering_lexer.go b/op/filtering_lexer.go new file mode 100644 index 00000000..045dede0 --- /dev/null +++ b/op/filtering_lexer.go @@ -0,0 +1,374 @@ +package op + +import ( + "fmt" + "strconv" + "unicode" +) + +// FilteringLexer is impemented by lexical analyzers that are used by filtering expression parsers. +type FilteringLexer interface { + NextToken() (Token, error) +} + +// NewFilteringLexer returns a default FilteringLexer implementation. +// text is a filtering expression to analyze. +func NewFilteringLexer(text string) FilteringLexer { + var runes []rune + for _, r := range text { + runes = append(runes, r) + } + if len(runes) > 0 { + return &filteringLexer{runes, 0, runes[0], false} + } + return &filteringLexer{runes, 0, 0, true} +} + +// UnexpectedSymbolError describes symbol S in position Pos that was not appropriate according to REST API Syntax Specification. +type UnexpectedSymbolError struct { + S rune + Pos int +} + +func (e *UnexpectedSymbolError) Error() string { + return fmt.Sprintf("Unexpected symbol %c in %d position", e.S, e.Pos) +} + +// Token is impelemented by all supported tokens in a filtering expression. +type Token interface { + Token() +} + +// TokenBase is used as a base type for all types which are tokens. +type TokenBase struct{} + +// Token distinguishes tokens from other types. +func (t TokenBase) Token() {} + +// LparenToken represents left parenthesis. +type LparenToken struct { + TokenBase +} + +func (t LparenToken) String() string { + return "(" +} + +// RparenToken represents right parenthesis. +type RparenToken struct { + TokenBase +} + +func (t RparenToken) String() string { + return ")" +} + +// NumberToken represents a number literal. +// Value is a value of the literal. +type NumberToken struct { + TokenBase + Value float64 +} + +func (t NumberToken) String() string { + return fmt.Sprint(t.Value) +} + +// StringToken represents a string literal. +// Value is a value of the literal. +type StringToken struct { + TokenBase + Value string +} + +func (t StringToken) String() string { + return fmt.Sprint(t.Value) +} + +// FieldToken represents a reference to a value of a resource. +// Value is a value of the reference. +type FieldToken struct { + TokenBase + Value string +} + +func (t FieldToken) String() string { + return fmt.Sprint(t.Value) +} + +// AndToken represents logical and. +type AndToken struct { + TokenBase +} + +func (t AndToken) String() string { + return "and" +} + +// OrToken represents logical or. +type OrToken struct { + TokenBase +} + +func (t OrToken) String() string { + return "or" +} + +// NotToken represents logical not. +type NotToken struct { + TokenBase +} + +func (t NotToken) String() string { + return "not" +} + +// EqToken represents equals operator. +type EqToken struct { + TokenBase +} + +func (t EqToken) String() string { + return "==" +} + +// NeToken represents not equals operator. +type NeToken struct { + TokenBase +} + +func (t NeToken) String() string { + return "!=" +} + +// MatchToken represents regular expression match. +type MatchToken struct { + TokenBase +} + +func (t MatchToken) String() string { + return "~" +} + +// NmatchToken represents negation of regular expression match. +type NmatchToken struct { + TokenBase +} + +func (t NmatchToken) String() string { + return "!~" +} + +// GtToken represents greater than operator. +type GtToken struct { + TokenBase +} + +func (t GtToken) String() string { + return ">" +} + +// GeToken represents greater than or equals operator. +type GeToken struct { + TokenBase +} + +func (t GeToken) String() string { + return ">=" +} + +// LtToken represents less than operator. +type LtToken struct { + TokenBase +} + +func (t LtToken) String() string { + return "<" +} + +// LeToken represents less than or equals operator. +type LeToken struct { + TokenBase +} + +func (t LeToken) String() string { + return "<=" +} + +// NullToken represents null literal. +type NullToken struct { + TokenBase +} + +func (t NullToken) String() string { + return "null" +} + +// EOFToken represents end of an expression. +type EOFToken struct { + TokenBase +} + +func (t EOFToken) String() string { + return "EOF" +} + +type filteringLexer struct { + text []rune + pos int + curChar rune + eof bool +} + +func (lexer *filteringLexer) advance() { + lexer.pos++ + if lexer.pos < len(lexer.text) { + lexer.curChar = lexer.text[lexer.pos] + } else { + lexer.eof = true + lexer.curChar = 0 + } +} + +func (lexer *filteringLexer) number() (Token, error) { + number := string(lexer.curChar) + metDot := false + lexer.advance() + for !lexer.eof { + if unicode.IsDigit(lexer.curChar) { + number += string(lexer.curChar) + } else if !metDot && lexer.curChar == '.' { + number += string(lexer.curChar) + metDot = true + } else { + break + } + lexer.advance() + } + parsed, err := strconv.ParseFloat(number, 64) + if err != nil { + return nil, err + } + return NumberToken{Value: parsed}, nil +} + +func (lexer *filteringLexer) string() (Token, error) { + // Add quote escaping support + term := lexer.curChar + s := "" + lexer.advance() + for lexer.curChar != term { + if lexer.eof { + return nil, &UnexpectedSymbolError{lexer.curChar, lexer.pos} + } + s += string(lexer.curChar) + lexer.advance() + } + lexer.advance() + return StringToken{Value: s}, nil +} + +func (lexer *filteringLexer) fieldOrReserved() (Token, error) { + s := string(lexer.curChar) + lexer.advance() + for !lexer.eof { + if unicode.IsDigit(lexer.curChar) || + unicode.IsLetter(lexer.curChar) || + lexer.curChar == '.' || + lexer.curChar == '-' || + lexer.curChar == '_' { + s += string(lexer.curChar) + } else { + break + } + lexer.advance() + } + switch s { + case "and": + return AndToken{}, nil + case "or": + return OrToken{}, nil + case "not": + return NotToken{}, nil + case "null": + return NullToken{}, nil + case "eq": + return EqToken{}, nil + case "ne": + return NeToken{}, nil + case "gt": + return GtToken{}, nil + case "ge": + return GeToken{}, nil + case "lt": + return LtToken{}, nil + case "le": + return LeToken{}, nil + case "match": + return MatchToken{}, nil + case "nomatch": + return NmatchToken{}, nil + default: + return FieldToken{Value: s}, nil + } +} + +// NextToken returns the next token from the expression. +func (lexer *filteringLexer) NextToken() (Token, error) { + for !lexer.eof { + switch { + case unicode.IsSpace(lexer.curChar): + lexer.advance() + case lexer.curChar == '(': + lexer.advance() + return LparenToken{}, nil + case lexer.curChar == ')': + lexer.advance() + return RparenToken{}, nil + case lexer.curChar == '~': + lexer.advance() + return MatchToken{}, nil + case lexer.curChar == '=': + lexer.advance() + if lexer.curChar == '=' { + lexer.advance() + return EqToken{}, nil + } + return nil, &UnexpectedSymbolError{lexer.curChar, lexer.pos} + case lexer.curChar == '!': + lexer.advance() + if lexer.curChar == '=' { + lexer.advance() + return NeToken{}, nil + } else if lexer.curChar == '~' { + lexer.advance() + return NmatchToken{}, nil + } else { + return nil, &UnexpectedSymbolError{lexer.curChar, lexer.pos} + } + case lexer.curChar == '>': + lexer.advance() + if lexer.curChar == '=' { + lexer.advance() + return GeToken{}, nil + } + return GtToken{}, nil + case lexer.curChar == '<': + lexer.advance() + if lexer.curChar == '=' { + lexer.advance() + return LeToken{}, nil + } + return LtToken{}, nil + case lexer.curChar == '\'' || lexer.curChar == '"': + return lexer.string() + case unicode.IsDigit(lexer.curChar): + return lexer.number() + case unicode.IsLetter(lexer.curChar): + return lexer.fieldOrReserved() + default: + return nil, &UnexpectedSymbolError{lexer.curChar, lexer.pos} + } + } + return EOFToken{}, nil +} diff --git a/op/filtering_lexer_test.go b/op/filtering_lexer_test.go new file mode 100644 index 00000000..475e876b --- /dev/null +++ b/op/filtering_lexer_test.go @@ -0,0 +1,65 @@ +package op + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFilteringLexer(t *testing.T) { + + lexer := NewFilteringLexer(`()14 13.23 'abc'"bcd" field1 and or not == eq ne != match ~ nomatch !~ gt > ge >= lt < le <= null`) + tests := []Token{ + LparenToken{}, + RparenToken{}, + NumberToken{Value: 14}, + NumberToken{Value: 13.23}, + StringToken{Value: "abc"}, + StringToken{Value: "bcd"}, + FieldToken{Value: "field1"}, + AndToken{}, + OrToken{}, + NotToken{}, + EqToken{}, + EqToken{}, + NeToken{}, + NeToken{}, + MatchToken{}, + MatchToken{}, + NmatchToken{}, + NmatchToken{}, + GtToken{}, + GtToken{}, + GeToken{}, + GeToken{}, + LtToken{}, + LtToken{}, + LeToken{}, + LeToken{}, + NullToken{}, + EOFToken{}, + } + + for _, test := range tests { + token, err := lexer.NextToken() + assert.Equal(t, test, token) + assert.Nil(t, err) + } +} + +func TestFilteringLexerNegative(t *testing.T) { + tests := []string{ + "=!", + "!!", + "%", + "'string", + } + + for _, test := range tests { + lexer := NewFilteringLexer(test) + token, err := lexer.NextToken() + assert.Nil(t, token) + assert.IsType(t, &UnexpectedSymbolError{}, err) + } + +} diff --git a/op/filtering_parser.go b/op/filtering_parser.go new file mode 100644 index 00000000..2cfe27e2 --- /dev/null +++ b/op/filtering_parser.go @@ -0,0 +1,386 @@ +package op + +import ( + "fmt" + "strings" +) + +// ParseFiltering is a shortcut to parse a filtering expression using default FilteringParser implementation +func ParseFiltering(text string) (*Filtering, error) { + return (&filteringParser{}).Parse(text) +} + +// FilteringParser is implemented by parsers of a filtering expression that conforms to REST API Syntax Specification. +type FilteringParser interface { + Parse(string) (*Filtering, error) +} + +// NewFilteringParser returns a default FilteringParser implementation. +func NewFilteringParser() FilteringParser { + return &filteringParser{} +} + +// UnexpectedTokenError describes a token that was not appropriate according to REST API Syntax Specification. +type UnexpectedTokenError struct { + T Token +} + +func (e *UnexpectedTokenError) Error() string { + return fmt.Sprintf("Unexpected token %s", e.T) +} + +// parser implements recursive descent parser of a filtering expression that conforms to REST API Syntax Specification. +// Some insights into recursive descent: https://en.wikipedia.org/wiki/Recursive_descent_parser . +type filteringParser struct { + lexer FilteringLexer + curToken Token +} + +// Parse builds an AST from an expression in text according to the following grammar: +// expr : term (OR term)* +// term : factor (AND factor)* +// factor : ?NOT (LPAREN expr RPAREN | condition) +// condition : FIELD ((== | !=) (STRING | NUMBER | NULL) | (~ | !~) STRING | (> | >= | < | <=) NUMBER). +func (p *filteringParser) Parse(text string) (*Filtering, error) { + p.lexer = NewFilteringLexer(text) + token, err := p.lexer.NextToken() + if err != nil { + return nil, err + } + if _, ok := token.(EOFToken); ok { + return nil, nil + } + p.curToken = token + var expr FilteringExpression + expr, err = p.expr() + if err != nil { + return nil, err + } + switch p.curToken.(type) { + case EOFToken: + f := &Filtering{} + err := f.SetRoot(expr) + if err != nil { + return nil, err + } + return f, nil + default: + return nil, &UnexpectedTokenError{p.curToken} + } +} + +func (p *filteringParser) negateNode(node FilteringExpression) { + switch v := node.(type) { + case *LogicalOperator: + v.IsNegative = !v.IsNegative + case *StringCondition: + v.IsNegative = !v.IsNegative + case *NumberCondition: + v.IsNegative = !v.IsNegative + case *NullCondition: + v.IsNegative = !v.IsNegative + } +} + +func (p *filteringParser) eatToken() error { + token, err := p.lexer.NextToken() + if err != nil { + return err + } + p.curToken = token + return nil +} + +func (p *filteringParser) expr() (FilteringExpression, error) { + node, err := p.term() + if err != nil { + return nil, err + } + _, isOr := p.curToken.(OrToken) + for isOr { + if err := p.eatToken(); err != nil { + return nil, err + } + right, err := p.term() + if err != nil { + return nil, err + } + newNode := &LogicalOperator{Type: LogicalOperator_OR} + err = newNode.SetLeft(node) + if err != nil { + return nil, err + } + err = newNode.SetRight(right) + if err != nil { + return nil, err + } + node = newNode + _, isOr = p.curToken.(OrToken) + } + return node, nil +} + +func (p *filteringParser) term() (FilteringExpression, error) { + node, err := p.factor() + if err != nil { + return nil, err + } + _, isAnd := p.curToken.(AndToken) + for isAnd { + if err := p.eatToken(); err != nil { + return nil, err + } + right, err := p.factor() + if err != nil { + return nil, err + } + newNode := &LogicalOperator{Type: LogicalOperator_AND} + err = newNode.SetLeft(node) + if err != nil { + return nil, err + } + err = newNode.SetRight(right) + if err != nil { + return nil, err + } + node = newNode + _, isAnd = p.curToken.(AndToken) + } + return node, nil +} + +func (p *filteringParser) factor() (FilteringExpression, error) { + isNot := false + switch p.curToken.(type) { + case NotToken: + isNot = true + if err := p.eatToken(); err != nil { + return nil, err + } + } + switch p.curToken.(type) { + case LparenToken: + if err := p.eatToken(); err != nil { + return nil, err + } + node, err := p.expr() + if err != nil { + return nil, err + } + switch p.curToken.(type) { + case RparenToken: + if err := p.eatToken(); err != nil { + return nil, err + } + if isNot { + p.negateNode(node) + } + return node, nil + default: + return nil, &UnexpectedTokenError{p.curToken} + } + default: + node, err := p.condition() + if err != nil { + return nil, err + } + if isNot { + p.negateNode(node) + } + return node, nil + } +} + +func (p *filteringParser) condition() (FilteringExpression, error) { + field, ok := p.curToken.(FieldToken) + if !ok { + return nil, &UnexpectedTokenError{p.curToken} + } + if err := p.eatToken(); err != nil { + return nil, err + } + switch p.curToken.(type) { + case EqToken: + if err := p.eatToken(); err != nil { + return nil, err + } + switch token := p.curToken.(type) { + case StringToken: + if err := p.eatToken(); err != nil { + return nil, err + } + return &StringCondition{ + FieldPath: strings.Split(field.Value, "."), + Value: token.Value, + Type: StringCondition_EQ, + IsNegative: false, + }, nil + case NumberToken: + if err := p.eatToken(); err != nil { + return nil, err + } + return &NumberCondition{ + FieldPath: strings.Split(field.Value, "."), + Value: token.Value, + Type: NumberCondition_EQ, + IsNegative: false, + }, nil + case NullToken: + if err := p.eatToken(); err != nil { + return nil, err + } + return &NullCondition{ + FieldPath: strings.Split(field.Value, "."), + IsNegative: false, + }, nil + default: + return nil, &UnexpectedTokenError{p.curToken} + } + case NeToken: + if err := p.eatToken(); err != nil { + return nil, err + } + switch token := p.curToken.(type) { + case StringToken: + if err := p.eatToken(); err != nil { + return nil, err + } + return &StringCondition{ + FieldPath: strings.Split(field.Value, "."), + Value: token.Value, + Type: StringCondition_EQ, + IsNegative: true, + }, nil + case NumberToken: + if err := p.eatToken(); err != nil { + return nil, err + } + return &NumberCondition{ + FieldPath: strings.Split(field.Value, "."), + Value: token.Value, + Type: NumberCondition_EQ, + IsNegative: true, + }, nil + case NullToken: + if err := p.eatToken(); err != nil { + return nil, err + } + return &NullCondition{ + FieldPath: strings.Split(field.Value, "."), + IsNegative: true, + }, nil + default: + return nil, &UnexpectedTokenError{p.curToken} + } + case MatchToken: + if err := p.eatToken(); err != nil { + return nil, err + } + switch token := p.curToken.(type) { + case StringToken: + if err := p.eatToken(); err != nil { + return nil, err + } + return &StringCondition{ + FieldPath: strings.Split(field.Value, "."), + Value: token.Value, + Type: StringCondition_MATCH, + IsNegative: false, + }, nil + default: + return nil, &UnexpectedTokenError{p.curToken} + } + case NmatchToken: + if err := p.eatToken(); err != nil { + return nil, err + } + switch token := p.curToken.(type) { + case StringToken: + if err := p.eatToken(); err != nil { + return nil, err + } + return &StringCondition{ + FieldPath: strings.Split(field.Value, "."), + Value: token.Value, + Type: StringCondition_MATCH, + IsNegative: true, + }, nil + default: + return nil, &UnexpectedTokenError{p.curToken} + } + case GtToken: + if err := p.eatToken(); err != nil { + return nil, err + } + switch token := p.curToken.(type) { + case NumberToken: + if err := p.eatToken(); err != nil { + return nil, err + } + return &NumberCondition{ + FieldPath: strings.Split(field.Value, "."), + Value: token.Value, + Type: NumberCondition_GT, + IsNegative: false, + }, nil + default: + return nil, &UnexpectedTokenError{p.curToken} + } + case GeToken: + if err := p.eatToken(); err != nil { + return nil, err + } + switch token := p.curToken.(type) { + case NumberToken: + if err := p.eatToken(); err != nil { + return nil, err + } + return &NumberCondition{ + FieldPath: strings.Split(field.Value, "."), + Value: token.Value, + Type: NumberCondition_GE, + IsNegative: false, + }, nil + default: + return nil, &UnexpectedTokenError{p.curToken} + } + case LtToken: + if err := p.eatToken(); err != nil { + return nil, err + } + switch token := p.curToken.(type) { + case NumberToken: + if err := p.eatToken(); err != nil { + return nil, err + } + return &NumberCondition{ + FieldPath: strings.Split(field.Value, "."), + Value: token.Value, + Type: NumberCondition_LT, + IsNegative: false, + }, nil + default: + return nil, &UnexpectedTokenError{p.curToken} + } + case LeToken: + if err := p.eatToken(); err != nil { + return nil, err + } + switch token := p.curToken.(type) { + case NumberToken: + if err := p.eatToken(); err != nil { + return nil, err + } + return &NumberCondition{ + FieldPath: strings.Split(field.Value, "."), + Value: token.Value, + Type: NumberCondition_LE, + IsNegative: false, + }, nil + default: + return nil, &UnexpectedTokenError{p.curToken} + } + default: + return nil, &UnexpectedTokenError{p.curToken} + } +} diff --git a/op/filtering_parser_test.go b/op/filtering_parser_test.go new file mode 100644 index 00000000..3a7fc9b2 --- /dev/null +++ b/op/filtering_parser_test.go @@ -0,0 +1,365 @@ +package op + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFilteringParser(t *testing.T) { + + tests := []struct { + text string + exp *Filtering + }{ + { + text: "not(not(not field1 == 'abc' or not field2 == 'bcd') and (field3 != 'cde'))", + exp: &Filtering{ + &Filtering_Operator{ + &LogicalOperator{ + Left: &LogicalOperator_LeftOperator{ + &LogicalOperator{ + Left: &LogicalOperator_LeftStringCondition{ + &StringCondition{ + FieldPath: []string{"field1"}, + Value: "abc", + Type: StringCondition_EQ, + IsNegative: true, + }, + }, + Right: &LogicalOperator_RightStringCondition{ + &StringCondition{ + FieldPath: []string{"field2"}, + Value: "bcd", + Type: StringCondition_EQ, + IsNegative: true, + }, + }, + Type: LogicalOperator_OR, + IsNegative: true, + }, + }, + Right: &LogicalOperator_RightStringCondition{ + &StringCondition{ + FieldPath: []string{"field3"}, + Value: "cde", + Type: StringCondition_EQ, + IsNegative: true, + }, + }, + Type: LogicalOperator_AND, + IsNegative: true, + }, + }, + }, + }, + { + text: "field1 == 'abc' or field2 == 'cde' and not field3 == 'cdf'", + exp: &Filtering{ + &Filtering_Operator{ + &LogicalOperator{ + Left: &LogicalOperator_LeftStringCondition{ + &StringCondition{ + FieldPath: []string{"field1"}, + Value: "abc", + Type: StringCondition_EQ, + IsNegative: false, + }, + }, + Right: &LogicalOperator_RightOperator{ + &LogicalOperator{ + Left: &LogicalOperator_LeftStringCondition{ + &StringCondition{ + FieldPath: []string{"field2"}, + Value: "cde", + Type: StringCondition_EQ, + IsNegative: false, + }, + }, + Right: &LogicalOperator_RightStringCondition{ + &StringCondition{ + FieldPath: []string{"field3"}, + Value: "cdf", + Type: StringCondition_EQ, + IsNegative: true, + }, + }, + Type: LogicalOperator_AND, + IsNegative: false, + }, + }, + Type: LogicalOperator_OR, + IsNegative: false, + }, + }, + }, + }, + { + text: "(field1 == 'abc' or field2 == 'cde') and (field3 == 'fbg' or field4 == 'zux')", + exp: &Filtering{ + &Filtering_Operator{ + &LogicalOperator{ + Left: &LogicalOperator_LeftOperator{ + &LogicalOperator{ + Left: &LogicalOperator_LeftStringCondition{ + &StringCondition{ + FieldPath: []string{"field1"}, + Value: "abc", + Type: StringCondition_EQ, + IsNegative: false, + }, + }, + Right: &LogicalOperator_RightStringCondition{ + &StringCondition{ + FieldPath: []string{"field2"}, + Value: "cde", + Type: StringCondition_EQ, + IsNegative: false, + }, + }, + Type: LogicalOperator_OR, + IsNegative: false, + }, + }, + Right: &LogicalOperator_RightOperator{ + &LogicalOperator{ + Left: &LogicalOperator_LeftStringCondition{ + &StringCondition{ + FieldPath: []string{"field3"}, + Value: "fbg", + Type: StringCondition_EQ, + IsNegative: false, + }, + }, + Right: &LogicalOperator_RightStringCondition{ + &StringCondition{ + FieldPath: []string{"field4"}, + Value: "zux", + Type: StringCondition_EQ, + IsNegative: false, + }, + }, + Type: LogicalOperator_OR, + IsNegative: false, + }, + }, + Type: LogicalOperator_AND, + IsNegative: false, + }, + }, + }, + }, + { + text: "field == 'abc'", + exp: &Filtering{ + &Filtering_StringCondition{ + &StringCondition{ + FieldPath: []string{"field"}, + Value: "abc", + Type: StringCondition_EQ, + IsNegative: false, + }, + }, + }, + }, + { + text: "field != \"abc cde\"", + exp: &Filtering{ + &Filtering_StringCondition{ + &StringCondition{ + FieldPath: []string{"field"}, + Value: "abc cde", + Type: StringCondition_EQ, + IsNegative: true, + }, + }, + }, + }, + { + text: "field == 123", + exp: &Filtering{ + &Filtering_NumberCondition{ + &NumberCondition{ + FieldPath: []string{"field"}, + Value: 123, + Type: NumberCondition_EQ, + IsNegative: false, + }, + }, + }, + }, + { + text: "field != 0.2343", + exp: &Filtering{ + &Filtering_NumberCondition{ + &NumberCondition{ + FieldPath: []string{"field"}, + Value: 0.2343, + Type: NumberCondition_EQ, + IsNegative: true, + }, + }, + }, + }, + { + text: "field == null", + exp: &Filtering{ + &Filtering_NullCondition{ + &NullCondition{ + FieldPath: []string{"field"}, + IsNegative: false, + }, + }, + }, + }, + { + text: "field != null", + exp: &Filtering{ + &Filtering_NullCondition{ + &NullCondition{ + FieldPath: []string{"field"}, + IsNegative: true, + }, + }, + }, + }, + { + text: "not field != null", + exp: &Filtering{ + &Filtering_NullCondition{ + &NullCondition{ + FieldPath: []string{"field"}, + IsNegative: false, + }, + }, + }, + }, + { + text: "field ~ 'regex'", + exp: &Filtering{ + &Filtering_StringCondition{ + &StringCondition{ + FieldPath: []string{"field"}, + Value: "regex", + Type: StringCondition_MATCH, + IsNegative: false, + }, + }, + }, + }, + { + text: "field !~ 'regex'", + exp: &Filtering{ + &Filtering_StringCondition{ + &StringCondition{ + FieldPath: []string{"field"}, + Value: "regex", + Type: StringCondition_MATCH, + IsNegative: true, + }, + }, + }, + }, + { + text: "field < 123", + exp: &Filtering{ + &Filtering_NumberCondition{ + &NumberCondition{ + FieldPath: []string{"field"}, + Value: 123, + Type: NumberCondition_LT, + IsNegative: false, + }, + }, + }, + }, + { + text: "not field <= 123", + exp: &Filtering{ + &Filtering_NumberCondition{ + &NumberCondition{ + FieldPath: []string{"field"}, + Value: 123, + Type: NumberCondition_LE, + IsNegative: true, + }, + }, + }, + }, + { + text: "field > 123", + exp: &Filtering{ + &Filtering_NumberCondition{ + &NumberCondition{ + FieldPath: []string{"field"}, + Value: 123, + Type: NumberCondition_GT, + IsNegative: false, + }, + }, + }, + }, + { + text: "field >= 123", + exp: &Filtering{ + &Filtering_NumberCondition{ + &NumberCondition{ + FieldPath: []string{"field"}, + Value: 123, + Type: NumberCondition_GE, + IsNegative: false, + }, + }, + }, + }, + { + text: "", + exp: nil, + }, + } + + p := NewFilteringParser() + for _, test := range tests { + result, err := p.Parse(test.text) + assert.Equal(t, test.exp, result) + assert.Nil(t, err) + } +} + +func TestFilteringParserNegative(t *testing.T) { + p := NewFilteringParser() + + tests := []string{ + "(field1 == 'abc'", + "((field1 == 'abc)'", + "field1 == 'abc')", + "null == field1", + "field1 == field2", + "field1 != field1", + "field1 ~ 123", + "field1 !~ 123", + "field1 > 'abc'", + "field1 >= 'bcd'", + "field1 < or", + "field1 <= null", + "field1 or field2", + } + + for _, test := range tests { + token, err := p.Parse(test) + assert.Nil(t, token) + assert.IsType(t, &UnexpectedTokenError{}, err) + } + + tests = []string{ + "field1 == 234.23.23", + "field1 == 'abc", + "field1 =! 'cdf'", + } + + for _, test := range tests { + token, err := p.Parse(test) + assert.Nil(t, token) + assert.IsType(t, &UnexpectedSymbolError{}, err) + } +} diff --git a/op/filtering_test.go b/op/filtering_test.go new file mode 100644 index 00000000..21c1e05f --- /dev/null +++ b/op/filtering_test.go @@ -0,0 +1,212 @@ +package op + +import ( + "regexp/syntax" + "testing" + + "github.com/golang/protobuf/proto" + "github.com/stretchr/testify/assert" +) + +type TestObject struct { + Str string `json:"str"` + Float float64 `json:"float"` + Uint uint `json:"uint"` + Ptr *struct{} +} + +type TestProtoMessage struct { + Str string `protobuf:"bytes,1,opt,name=str"` + Int int32 `protobuf:"varint,2,opt,name=int"` + Nested *NestedMessage `protobuf:"bytes,3,opt,name=nested,json=nestedJSON"` +} + +func (m *TestProtoMessage) Reset() { *m = TestProtoMessage{} } +func (m *TestProtoMessage) String() string { return proto.CompactTextString(m) } +func (*TestProtoMessage) ProtoMessage() {} + +type NestedMessage struct { + Str string `protobuf:"bytes,1,opt,name=str"` +} + +func (m *NestedMessage) Reset() { *m = NestedMessage{} } +func (m *NestedMessage) String() string { return proto.CompactTextString(m) } +func (*NestedMessage) ProtoMessage() {} + +func TestFiltering(t *testing.T) { + + tests := []struct { + obj interface{} + filter string + res bool + }{ + { + obj: &TestObject{Str: "111", Float: 11.11, Uint: 11}, + filter: "str == '111' and float == 11.11 and uint == 11 and Ptr == null", + res: true, + }, + { + obj: &TestProtoMessage{Str: "111", Int: 111}, + filter: "str == '111' and int == 111 and nestedJSON == null", + res: true, + }, + { + obj: &TestProtoMessage{Str: "111", Int: 111}, + filter: "str == '111' and int == 111 and nestedJSON != null", + res: false, + }, + { + obj: &TestProtoMessage{Str: "111", Int: 111}, + filter: "str == '222' and int == 111 and nestedJSON == null", + res: false, + }, + { + obj: &TestProtoMessage{Str: "111", Int: 111}, + filter: "str == '111' or int == 222 or nestedJSON != null", + res: true, + }, + { + obj: &TestProtoMessage{Str: "111", Int: 111}, + filter: "str == '222' or not int == 222", + res: true, + }, + { + obj: &TestProtoMessage{Str: "111"}, + filter: "str == '111'", + res: true, + }, + { + obj: &TestProtoMessage{Str: "111"}, + filter: "not str == '111'", + res: false, + }, + { + obj: &TestProtoMessage{Str: "111"}, + filter: "str == '222'", + res: false, + }, + { + obj: &TestProtoMessage{Str: "111"}, + filter: "str ~ '1*'", + res: true, + }, + { + obj: &TestProtoMessage{Str: "111"}, + filter: "str !~ '1112?'", + res: false, + }, + { + obj: &TestProtoMessage{Str: "111"}, + filter: "str ~ '[23]1*'", + res: false, + }, + { + obj: &TestProtoMessage{Int: 111}, + filter: "int == 111", + res: true, + }, + { + obj: &TestProtoMessage{Int: 111}, + filter: "not int == 111", + res: false, + }, + { + obj: &TestProtoMessage{Int: 111}, + filter: "int == 222", + res: false, + }, + { + obj: &TestProtoMessage{Int: 111}, + filter: "int > 110", + res: true, + }, + { + obj: &TestProtoMessage{Int: 111}, + filter: "int >= 111", + res: true, + }, + { + obj: &TestProtoMessage{Int: 111}, + filter: "int < 112", + res: true, + }, + { + obj: &TestProtoMessage{Int: 111}, + filter: "int <= 111", + res: true, + }, + { + obj: &TestProtoMessage{}, + filter: "nestedJSON == null", + res: true, + }, + { + obj: &TestProtoMessage{}, + filter: "not nestedJSON == null", + res: false, + }, + { + obj: &TestProtoMessage{Nested: &NestedMessage{}}, + filter: "nestedJSON == null", + res: false, + }, + { + obj: &TestProtoMessage{}, + filter: "", + res: true, + }, + } + + for _, test := range tests { + res, err := Filter(test.obj, test.filter) + assert.Equal(t, res, test.res) + assert.Nil(t, err) + } +} + +func TestFilteringNegative(t *testing.T) { + + tests := []struct { + obj interface{} + filter string + err error + }{ + { + obj: &TestObject{Str: "111"}, + filter: "str == 111", + err: &TypeMismatchError{}, + }, + { + obj: &TestObject{Float: 11.11}, + filter: "float == '11.11'", + err: &TypeMismatchError{}, + }, + { + obj: &TestObject{}, + filter: "float == null", + err: &TypeMismatchError{}, + }, + { + obj: &TestObject{}, + filter: "missingField == 11.11", + err: &TypeMismatchError{}, + }, + { + obj: &TestProtoMessage{}, + filter: "missingField == 11.11", + err: &TypeMismatchError{}, + }, + { + obj: &TestObject{Str: "111"}, + filter: "str ~ '11[1'", + err: &syntax.Error{}, + }, + } + + for _, test := range tests { + res, err := Filter(test.obj, test.filter) + assert.False(t, res) + assert.IsType(t, test.err, err) + } + +} diff --git a/op/gorm/collection_operators.go b/op/gorm/collection_operators.go new file mode 100644 index 00000000..1f89fbbe --- /dev/null +++ b/op/gorm/collection_operators.go @@ -0,0 +1,90 @@ +package gorm + +import ( + "context" + "strings" + + "github.com/jinzhu/gorm" + + "github.com/infobloxopen/atlas-app-toolkit/gw" + "github.com/infobloxopen/atlas-app-toolkit/op" +) + +// ApplyCollectionOperators applies collections operators taken from context ctx to gorm instance db. +func ApplyCollectionOperators(db *gorm.DB, ctx context.Context) (*gorm.DB, error) { + f, err := gw.Filtering(ctx) + if err != nil { + return nil, err + } + db, err = ApplyFiltering(db, f) + if err != nil { + return nil, err + } + + var s *op.Sorting + s, err = gw.Sorting(ctx) + if err != nil { + return nil, err + } + db = ApplySorting(db, s) + + var p *op.Pagination + p, err = gw.Pagination(ctx) + if err != nil { + return nil, err + } + db = ApplyPagination(db, p) + + fs := gw.FieldSelection(ctx) + if err != nil { + return nil, err + } + db = ApplyFieldSelection(db, fs) + + return db, nil +} + +// ApplyFiltering applies filtering operator f to gorm instance db. +func ApplyFiltering(db *gorm.DB, f *op.Filtering) (*gorm.DB, error) { + str, args, err := FilteringToGorm(f) + if err != nil { + return nil, err + } + if str != "" { + return db.Where(str, args...), nil + } + return db, nil +} + +// ApplySorting applies sorting operator s to gorm instance db. +func ApplySorting(db *gorm.DB, s *op.Sorting) *gorm.DB { + var crs []string + for _, cr := range s.GetCriterias() { + if cr.IsDesc() { + crs = append(crs, cr.GetTag()+" desc") + } else { + crs = append(crs, cr.GetTag()) + } + } + if len(crs) > 0 { + return db.Order(strings.Join(crs, ",")) + } + return db +} + +// ApplyPagination applies pagination operator p to gorm instance db. +func ApplyPagination(db *gorm.DB, p *op.Pagination) *gorm.DB { + return db.Offset(p.GetOffset()).Limit(p.DefaultLimit()) +} + +// ApplyFieldSelection applies field selection operator fs to gorm instance db. +func ApplyFieldSelection(db *gorm.DB, fs *op.FieldSelection) *gorm.DB { + var fields []string + for _, f := range fs.GetFields() { + fields = append(fields, f.GetName()) + } + if len(fields) > 0 { + return db.Select(fields) + } + return db +} diff --git a/op/gorm/collection_operators_test.go b/op/gorm/collection_operators_test.go new file mode 100644 index 00000000..96ba63ce --- /dev/null +++ b/op/gorm/collection_operators_test.go @@ -0,0 +1,63 @@ +package gorm + +import ( + "context" + "fmt" + "net/http" + "regexp" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/jinzhu/gorm" + "google.golang.org/grpc/metadata" + + "github.com/infobloxopen/atlas-app-toolkit/gw" +) + +type Person struct { + ID int64 + Name string + Age int +} + +func fixedFullRe(s string) string { + return fmt.Sprintf("^%s$", regexp.QuoteMeta(s)) +} + +func setUp(t *testing.T) (*gorm.DB, sqlmock.Sqlmock) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + var gormDB *gorm.DB + gormDB, err = gorm.Open("postgres", db) + if err != nil { + t.Fatal(err) + } + + return gormDB, mock +} + +func TestApplyCollectionOperators(t *testing.T) { + gormDB, mock := setUp(t) + + req, err := http.NewRequest("GET", "http://test.com?_fields=name&_filter=age<=25&_order_by=age desc&_limit=2&_offset=1", nil) + if err != nil { + t.Fatal(err) + } + mock.ExpectQuery(fixedFullRe("SELECT name FROM \"people\" WHERE ((age <= $1)) ORDER BY age desc LIMIT 2 OFFSET 1")).WithArgs(25.0) + + md := gw.MetadataAnnotator(nil, req) + ctx := metadata.NewIncomingContext(context.Background(), md) + gormDB, err = ApplyCollectionOperators(gormDB, ctx) + if err != nil { + t.Fatal(err) + } + + var actual []Person + gormDB.Find(&actual) + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("There were unfulfilled expectations: %s", err) + } +} diff --git a/op/gorm/filtering.go b/op/gorm/filtering.go new file mode 100644 index 00000000..0ffe780c --- /dev/null +++ b/op/gorm/filtering.go @@ -0,0 +1,139 @@ +package gorm + +import ( + "fmt" + "strings" + + "github.com/infobloxopen/atlas-app-toolkit/op" +) + +// FilterStringToGorm is a shortcut to parse a filter string using default FilteringParser implementation +// and call FilteringToGorm on the returned filtering expression. +func FilterStringToGorm(filter string) (string, []interface{}, error) { + f, err := op.ParseFiltering(filter) + if err != nil { + return "", nil, err + } + return FilteringToGorm(f) +} + +// FilteringToGorm returns GORM Plain SQL representation of the filtering expression. +func FilteringToGorm(m *op.Filtering) (string, []interface{}, error) { + if m == nil { + return "", nil, nil + } + + switch r := m.Root.(type) { + case *op.Filtering_Operator: + return LogicalOperatorToGorm(r.Operator) + case *op.Filtering_StringCondition: + return StringConditionToGorm(r.StringCondition) + case *op.Filtering_NumberCondition: + return NumberConditionToGorm(r.NumberCondition) + case *op.Filtering_NullCondition: + return NullConditionToGorm(r.NullCondition) + default: + return "", nil, fmt.Errorf("%T type is not supported in Filtering", r) + } +} + +// LogicalOperatorToGorm returns GORM Plain SQL representation of the logical operator. +func LogicalOperatorToGorm(lop *op.LogicalOperator) (string, []interface{}, error) { + var lres string + var largs []interface{} + var err error + switch l := lop.Left.(type) { + case *op.LogicalOperator_LeftOperator: + lres, largs, err = LogicalOperatorToGorm(l.LeftOperator) + case *op.LogicalOperator_LeftStringCondition: + lres, largs, err = StringConditionToGorm(l.LeftStringCondition) + case *op.LogicalOperator_LeftNumberCondition: + lres, largs, err = NumberConditionToGorm(l.LeftNumberCondition) + case *op.LogicalOperator_LeftNullCondition: + lres, largs, err = NullConditionToGorm(l.LeftNullCondition) + default: + return "", nil, fmt.Errorf("%T type is not supported in Filtering", l) + } + if err != nil { + return "", nil, err + } + + var rres string + var rargs []interface{} + switch r := lop.Right.(type) { + case *op.LogicalOperator_RightOperator: + rres, rargs, err = LogicalOperatorToGorm(r.RightOperator) + case *op.LogicalOperator_RightStringCondition: + rres, rargs, err = StringConditionToGorm(r.RightStringCondition) + case *op.LogicalOperator_RightNumberCondition: + rres, rargs, err = NumberConditionToGorm(r.RightNumberCondition) + case *op.LogicalOperator_RightNullCondition: + rres, rargs, err = NullConditionToGorm(r.RightNullCondition) + default: + return "", nil, fmt.Errorf("%T type is not supported in Filtering", r) + } + if err != nil { + return "", nil, err + } + + var o string + switch lop.Type { + case op.LogicalOperator_AND: + o = "AND" + case op.LogicalOperator_OR: + o = "OR" + } + var neg string + if lop.IsNegative { + neg = "NOT" + } + return fmt.Sprintf("%s(%s %s %s)", neg, lres, o, rres), append(largs, rargs...), nil +} + +// StringConditionToGorm returns GORM Plain SQL representation of the string condition. +func StringConditionToGorm(c *op.StringCondition) (string, []interface{}, error) { + var o string + switch c.Type { + case op.StringCondition_EQ: + o = "=" + case op.StringCondition_MATCH: + o = "~" + } + var neg string + if c.IsNegative { + neg = "NOT" + } + return fmt.Sprintf("%s(%s %s ?)", neg, strings.Join(c.FieldPath, "."), o), []interface{}{c.Value}, nil +} + +// NumberConditionToGorm returns GORM Plain SQL representation of the number condition. +func NumberConditionToGorm(c *op.NumberCondition) (string, []interface{}, error) { + var o string + switch c.Type { + case op.NumberCondition_EQ: + o = "=" + case op.NumberCondition_GT: + o = ">" + case op.NumberCondition_GE: + o = ">=" + case op.NumberCondition_LT: + o = "<" + case op.NumberCondition_LE: + o = "<=" + } + var neg string + if c.IsNegative { + neg = "NOT" + } + return fmt.Sprintf("%s(%s %s ?)", neg, strings.Join(c.FieldPath, "."), o), []interface{}{c.Value}, nil +} + +// NullConditionToGorm returns GORM Plain SQL representation of the null condition. +func NullConditionToGorm(c *op.NullCondition) (string, []interface{}, error) { + o := "IS NULL" + var neg string + if c.IsNegative { + neg = "NOT" + } + return fmt.Sprintf("%s(%s %s)", neg, strings.Join(c.FieldPath, "."), o), nil, nil +} diff --git a/op/gorm/filtering_test.go b/op/gorm/filtering_test.go new file mode 100644 index 00000000..797d5b9d --- /dev/null +++ b/op/gorm/filtering_test.go @@ -0,0 +1,129 @@ +package gorm + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/infobloxopen/atlas-app-toolkit/op" +) + +func TestGormFiltering(t *testing.T) { + + tests := []struct { + rest string + gorm string + args []interface{} + err error + }{ + { + "not(field1 == 'value1' or field2 == 'value2' and field3 != 'value3')", + "NOT((field1 = ?) OR ((field2 = ?) AND NOT(field3 = ?)))", + []interface{}{"value1", "value2", "value3"}, + nil, + }, + { + "field1 ~ 'regex'", + "(field1 ~ ?)", + []interface{}{"regex"}, + nil, + }, + { + "field1 !~ 'regex'", + "NOT(field1 ~ ?)", + []interface{}{"regex"}, + nil, + }, + { + "field1 == 22", + "(field1 = ?)", + []interface{}{22.0}, + nil, + }, + { + "not field1 == 22", + "NOT(field1 = ?)", + []interface{}{22.0}, + nil, + }, + { + "field1 > 22", + "(field1 > ?)", + []interface{}{22.0}, + nil, + }, + { + "not field1 > 22", + "NOT(field1 > ?)", + []interface{}{22.0}, + nil, + }, + { + "field1 >= 22", + "(field1 >= ?)", + []interface{}{22.0}, + nil, + }, + { + "not field1 >= 22", + "NOT(field1 >= ?)", + []interface{}{22.0}, + nil, + }, + { + "field1 < 22", + "(field1 < ?)", + []interface{}{22.0}, + nil, + }, + { + "not field1 < 22", + "NOT(field1 < ?)", + []interface{}{22.0}, + nil, + }, + { + "field1 <= 22", + "(field1 <= ?)", + []interface{}{22.0}, + nil, + }, + { + "not field1 <= 22", + "NOT(field1 <= ?)", + []interface{}{22.0}, + nil, + }, + { + "field1 == null", + "(field1 IS NULL)", + nil, + nil, + }, + { + "field1 != null", + "NOT(field1 IS NULL)", + nil, + nil, + }, + { + "", + "", + nil, + nil, + }, + { + "field1 === null", + "", + nil, + &op.UnexpectedSymbolError{}, + }, + } + + for _, test := range tests { + gorm, args, err := FilterStringToGorm(test.rest) + assert.Equal(t, test.gorm, gorm) + assert.Equal(t, test.args, args) + assert.IsType(t, test.err, err) + } +} diff --git a/op/pagination.go b/op/pagination.go new file mode 100644 index 00000000..fbcb9745 --- /dev/null +++ b/op/pagination.go @@ -0,0 +1,85 @@ +package op + +import ( + "fmt" + "strconv" +) + +const ( + // Default pagination limit + DefaultLimit = 1000 + + lastOffset = int32(1 << 30) +) + +// Pagination parses string representation of pagination limit, offset. +// Returns error if limit or offset has invalid syntax or out of range. +func ParsePagination(limit, offset, ptoken string) (*Pagination, error) { + p := new(Pagination) + + if limit != "" { + if u, err := strconv.ParseInt(limit, 10, 32); err != nil { + return nil, fmt.Errorf("pagination: limit - %s", err.(*strconv.NumError).Err) + } else if u < 0 { + return nil, fmt.Errorf("pagination: limit - negative value") + } else { + p.Limit = int32(u) + } + } + + if offset == "null" { + p.Offset = 0 + } else if offset != "" { + if u, err := strconv.ParseInt(offset, 10, 32); err != nil { + return nil, fmt.Errorf("pagination: offset - %s", err.(*strconv.NumError).Err) + } else if u < 0 { + return nil, fmt.Errorf("pagination: offset - negative value") + } else { + p.Offset = int32(u) + } + } + + if ptoken != "" { + p.PageToken = ptoken + } + + return p, nil +} + +// FirstPage returns true if requested first page +func (p *Pagination) FirstPage() bool { + if p.GetPageToken() == "null" || p.GetOffset() == 0 { + return true + } + return false +} + +// DefaultLimit returns DefaultLimit if limit was not specified otherwise +// returns either requested or specified one. +func (p *Pagination) DefaultLimit(dl ...int) int { + if l := p.GetLimit(); l != 0 { + return int(l) + } + if len(dl) > 0 && dl[0] > 0 { + return dl[0] + } + return DefaultLimit +} + +// SetLastToken sets page info to indicate no more pages are available +func (p *PageInfo) SetLastToken() { + p.PageToken = "null" +} + +// SetLastOffset sets page info to indicate no more pages are available +func (p *PageInfo) SetLastOffset() { + p.Offset = lastOffset +} + +// NoMore reports whether page info indicates no more pages are available +func (p *PageInfo) NoMore() bool { + if p.GetOffset() == lastOffset || p.GetPageToken() == "null" { + return true + } + return false +} diff --git a/op/pagination_test.go b/op/pagination_test.go new file mode 100644 index 00000000..89886ca6 --- /dev/null +++ b/op/pagination_test.go @@ -0,0 +1,108 @@ +package op + +import ( + "testing" +) + +func TestParsePagination(t *testing.T) { + // invalid limit + _, err := ParsePagination("1s", "0", "ptoken") + if err == nil { + t.Error("unexpected nil error - expected: pagination: limit - invalid syntax") + } + if err.Error() != "pagination: limit - invalid syntax" { + t.Errorf("invalid error: %s - expected: pagination: limit - invalid syntax", err) + } + + // negative limit + _, err = ParsePagination("-1", "0", "ptoken") + if err == nil { + t.Error("unexpected nil error - expected: pagination: limit - negative value") + } + if err.Error() != "pagination: limit - negative value" { + t.Errorf("invalid error: %s - expected: pagination: limit - negative value", err) + } + + // invalid offset + _, err = ParsePagination("0", "0w", "ptoken") + if err == nil { + t.Error("unexpected nil error - expected: pagination: offset - invalid syntax") + } + if err.Error() != "pagination: offset - invalid syntax" { + t.Errorf("invalid error: %s - expected: pagination: offset - invalid syntax", err) + } + + // negative offset + _, err = ParsePagination("0", "-1", "ptoken") + if err == nil { + t.Error("unexpected nil error - expected: pagination: offset - negative value") + } + if err.Error() != "pagination: offset - negative value" { + t.Errorf("invalid error: %s - expected: pagination: offset - negative value", err) + } + + // null offset + p, err := ParsePagination("0", "null", "ptoken") + if err != nil { + t.Error("unexpected error: %s", err) + } + if p.GetOffset() != 0 { + t.Errorf("invalid offset: %d - expected: 0", p.GetOffset()) + } + + // first page + p, err = ParsePagination("0", "0", "ptoken") + if err != nil { + t.Errorf("unexpected error: %s", err) + } + if !p.FirstPage() { + t.Errorf("invalid value of first page: %v - expected: true", p.FirstPage()) + } + p, err = ParsePagination("0", "100", "null") + if err != nil { + t.Errorf("unexpected error: %s", err) + } + if !p.FirstPage() { + t.Errorf("invalid value of first page: %v - expected: true", p.FirstPage()) + } + + // default limit + if p.DefaultLimit(1000) != 1000 { + t.Errorf("invalid default limit: %d - expected: 1000", p.DefaultLimit(1000)) + } + + // valid pagination + p, err = ParsePagination("1000", "100", "ptoken") + if err != nil { + t.Errorf("unexpected error: %s", err) + } + if p.GetLimit() != 1000 { + t.Errorf("invalid limit: %d - expected: 1000", p.GetLimit()) + } + if p.GetOffset() != 100 { + t.Errorf("invalid offset: %d - expected: 100", p.GetOffset()) + } + if p.GetPageToken() != "ptoken" { + t.Errorf("invalid page token: %d - expected: ptoken", p.GetPageToken()) + } +} + +func TestPageInfo(t *testing.T) { + p := new(PageInfo) + if p.NoMore() { + t.Errorf("invalid value of NoMore: %v - expected: false", p.NoMore()) + } + p.SetLastOffset() + if !p.NoMore() { + t.Errorf("invalid value of NoMore: %v - expected: true", p.NoMore()) + } + + p = new(PageInfo) + if p.NoMore() { + t.Errorf("invalid value of NoMore: %v - expected: false", p.NoMore()) + } + p.SetLastToken() + if !p.NoMore() { + t.Errorf("invalid value of NoMore: %v - expected: true", p.NoMore()) + } +} diff --git a/op/sorting.go b/op/sorting.go new file mode 100644 index 00000000..c981c012 --- /dev/null +++ b/op/sorting.go @@ -0,0 +1,67 @@ +package op + +import ( + "fmt" + "strings" +) + +// IsAsc returns true if sort criteria has ascending sort order, otherwise false. +func (c SortCriteria) IsAsc() bool { + return c.Order == SortCriteria_ASC +} + +// IsDesc returns true if sort criteria has descending sort order, otherwise false. +func (c SortCriteria) IsDesc() bool { + return c.Order == SortCriteria_DESC +} + +// GoString implements fmt.GoStringer interface +// return string representation of a sort criteria in next form: +// " (ASC|DESC)". +func (c SortCriteria) GoString() string { + return fmt.Sprintf("%s %s", c.Tag, c.Order) +} + +// ParseSorting parses raw string that represent sort criteria into a Sorting +// data structure. +// Provided string is supposed to be in accordance with the sorting collection +// operator from REST API Syntax. +// See: https://github.com/infobloxopen/atlas-app-toolkit#sorting +func ParseSorting(s string) (*Sorting, error) { + var sorting Sorting + + for _, craw := range strings.Split(s, ",") { + v := strings.Fields(craw) + + var c SortCriteria + switch len(v) { + case 1: + c.Tag, c.Order = v[0], SortCriteria_ASC + case 2: + if o, ok := SortCriteria_Order_value[strings.ToUpper(v[1])]; !ok { + return nil, fmt.Errorf("invalid sort order - %q in %q", v[1], craw) + } else { + c.Tag, c.Order = v[0], SortCriteria_Order(o) + } + default: + return nil, fmt.Errorf("invalid sort criteria: %s", craw) + } + + sorting.Criterias = append(sorting.Criterias, &c) + } + + return &sorting, nil +} + +// GoString implements fmt.GoStringer interface +// Returns string representation of sorting in next form: +// " (ASC|DESC) [, (ASC|DESC)]" +func (s Sorting) GoString() string { + var l []string + + for _, c := range s.GetCriterias() { + l = append(l, c.GoString()) + } + + return strings.Join(l, ", ") +} diff --git a/op/sorting_test.go b/op/sorting_test.go new file mode 100644 index 00000000..02ba0510 --- /dev/null +++ b/op/sorting_test.go @@ -0,0 +1,61 @@ +package op + +import ( + "testing" +) + +func TestSortCriteria(t *testing.T) { + c := SortCriteria{"name", SortCriteria_ASC} + if !c.IsAsc() { + t.Errorf("invalid sort order: IsAsc = %v - expected: %v", c.IsAsc(), true) + } + if c.GoString() != "name ASC" { + t.Errorf("invalid string representation: %v - expected: %s", c, "name ASC") + } + + c = SortCriteria{"age", SortCriteria_DESC} + if !c.IsDesc() { + t.Errorf("invalid sort order: IsDesc = %v - expected: %v", c.IsDesc(), true) + } + if c.GoString() != "age DESC" { + t.Errorf("invalid string representation: %v - expected: %s", c, "age DESC") + } +} + +func TestParseSorting(t *testing.T) { + s, err := ParseSorting("name") + if err != nil { + t.Fatalf("failed to parse sort parameters: %s", err) + } + if len(s.GetCriterias()) != 1 { + t.Fatalf("invalid number of sort criterias: %d - expected: %d", len(s.GetCriterias()), 1) + } + if c := s.GetCriterias()[0]; !c.IsAsc() || c.Tag != "name" { + t.Errorf("invalid sort criteria: %v - expected: %v", c, SortCriteria{"name", SortCriteria_ASC}) + } + + s, err = ParseSorting("name desc, age") + if err != nil { + t.Fatalf("failed to parse sort parameters: %s", err) + } + if len(s.GetCriterias()) != 2 { + t.Fatalf("invalid number of sort criterias: %d - expected: %d", len(s.GetCriterias()), 2) + } + if c := s.GetCriterias()[0]; !c.IsDesc() || c.Tag != "name" { + t.Errorf("invalid sort criteria: %v - expected: %v", c, SortCriteria{"name", SortCriteria_DESC}) + } + if c := s.GetCriterias()[1]; !c.IsAsc() || c.Tag != "age" { + t.Errorf("invalid sort criteria: %v - expected: %v", c, SortCriteria{"age", SortCriteria_ASC}) + } + if s.GoString() != "name DESC, age ASC" { + t.Errorf("invalid sorting: %v - expected: %s", s, "name DESC, age ASC") + } + + _, err = ParseSorting("name dask") + if err == nil { + t.Fatal("expected error - got nil") + } + if err.Error() != "invalid sort order - \"dask\" in \"name dask\"" { + t.Errorf("invalid error message: %s - expected: %s", err, "invalid sort order - \"dask\" in \"name dask\"") + } +} diff --git a/pb/README.md b/pb/README.md new file mode 100644 index 00000000..7b6baf16 --- /dev/null +++ b/pb/README.md @@ -0,0 +1,31 @@ +# Object Data Transfer Tool + +The `pb/converter.go` contains a tool to transfer data from service/API (pb generated) +objects to ORM compatible objects, because PB generated objects do not follow +golint case requirements (i.e. `Ip` or `Id` instead of `IP` and `ID`), and have special +handling for nullable values (Well-Known-Types wrappers). + +The generated nature also prevents any custom ORM or SQL level tags from being added to the +Protocol Buffer objects. + +This tool uses reflection to transfer between fields of the same name, +case insensitively, and for the WKTs from `wrappers.StringValue` to `*string`, and +`wrappers.UInt32Value` to `*uint32`. + +More complicated PB behavior, such as oneof and maps is **not currently convertible**. + +Note that this requires the definition of an ORM compatible object from +the Protocol Buffer generated objects with the proper types and foreign keys defined +for child objects. + +Additional fields desired in the DB, but not exposed in the API, +potentially such as create/update times, and any necessary SQL or ORM tags +can be defined in this ORMified object. + +``` +package pb // import "github.com/infobloxopen/atlas-app-toolkit/pb" + +func Convert(source interface{}, dest interface{}) error + Convert Copies data between fields at ORM and service levels. Works under + the assumption that any WKT fields in proto map to * fields at ORM. +``` diff --git a/pb/converter.go b/pb/converter.go new file mode 100644 index 00000000..1d324753 --- /dev/null +++ b/pb/converter.go @@ -0,0 +1,157 @@ +package pb + +import ( + "fmt" + "github.com/golang/protobuf/ptypes/wrappers" + "reflect" + "strings" +) + +// Strips a pointer, or pointer(-to-pointer)^* to base object if needed +func indirect(reflectValue reflect.Value) reflect.Value { + for reflectValue.Kind() == reflect.Ptr { + reflectValue = reflectValue.Elem() + } + return reflectValue +} + +// Strips a pointer, slice, pointer-to-slice, slice-of-pointers, +// pointer-to-slice-of-pointers-to-pointers, etc... to base type if needed +func indirectType(reflectType reflect.Type) reflect.Type { + for reflectType.Kind() == reflect.Ptr || reflectType.Kind() == reflect.Slice { + reflectType = reflectType.Elem() + } + return reflectType +} + +//----- Types, and zero values to avoid having to recreate them every time +var pStringValueType = reflect.TypeOf(&wrappers.StringValue{}) +var pStringValueZeroValue = reflect.Zero(pStringValueType) + +var pUInt32ValueType = reflect.TypeOf(&wrappers.UInt32Value{}) +var pUInt32ValueZeroValue = reflect.Zero(pUInt32ValueType) + +var exString = "" +var pStringType = reflect.TypeOf(&exString) +var pStringZeroValue = reflect.Zero(pStringType) + +var exUInt32 = uint32(0) +var pUInt32Type = reflect.TypeOf(&exUInt32) +var pUInt32ZeroValue = reflect.Zero(pUInt32Type) + +type typeToType struct { + from reflect.Type + to reflect.Type +} + +// Cache for different conversions, dest fields in order, -1 if no dest +var fieldMapsByType = make(map[typeToType][]int) + +// Convert Copies data between fields at ORM and service levels. +// Works under the assumption that any WKT fields in proto map to * fields at ORM. +func Convert(source interface{}, dest interface{}) error { + // If dest object is unaddressable, that won't work. Unfortunately, a code + // error that will only be caught at runtime + toObject := indirect(reflect.ValueOf(dest)) + if toObject.CanAddr() == false { + return fmt.Errorf("Dest, type %s, is unaddressable", reflect.TypeOf(dest)) + } + if indirectType(reflect.TypeOf(source)).Kind() != reflect.Struct { + return fmt.Errorf("Cannot convert a non-struct") + } + destType := toObject.Type() + fromObject := indirect(reflect.ValueOf(source)) + fromType := fromObject.Type() + + // Check for mapping, populate mapping if not already present + fieldMap, exists := fieldMapsByType[typeToType{fromType, destType}] + if !exists { + for i := 0; i < fromType.NumField(); i++ { + found := false + for j := 0; j < destType.NumField(); j++ { + if strings.EqualFold(fromType.Field(i).Name, destType.Field(j).Name) { + found = true + fieldMap = append(fieldMap, j) + break + } + } + // Store -1 if no dest corresponds to field + if !found { + fieldMap = append(fieldMap, -1) + } + } + fieldMapsByType[typeToType{fromType, destType}] = fieldMap + } + + for i := 0; i < fromType.NumField(); i++ { + if fieldMap[i] == -1 { + continue + } + to := toObject.Field(fieldMap[i]) + if to.IsValid() { + fromFieldDesc := fromType.Field(i) + fromData := fromObject.Field(i) + + switch fromFieldDesc.Type { + case to.Type(): // Matching type + to.Set(fromData) + case pStringValueType: // WKT *StringValue{} --> *string + if fromData.IsNil() || !fromData.IsValid() { + to.Set(pStringZeroValue) + } else { + value := fromData.Elem().Field(0).String() + to.Set(reflect.ValueOf(&value)) + } + case pStringType: // *string --> WKT *StringValue{} + if fromData.IsNil() || !fromData.IsValid() { + to.Set(pStringValueZeroValue) + } else { + strValue := fromData.Elem().String() + to.Set(reflect.ValueOf(&wrappers.StringValue{strValue})) + } + case pUInt32ValueType: // WKT *UInt32Value{} --> *uint32 + if fromData.IsNil() || !fromData.IsValid() { + to.Set(pUInt32ZeroValue) + } else { + value := uint32(fromData.Elem().Field(0).Uint()) + to.Set(reflect.ValueOf(&value)) + } + case pUInt32Type: // *uint32 --> WKT *UInt32Value{} + if fromData.IsNil() || !fromData.IsValid() { + to.Set(pUInt32ValueZeroValue) + } else { + intValue := uint32(fromData.Elem().Uint()) + to.Set(reflect.ValueOf(&wrappers.UInt32Value{intValue})) + } + //Additional WKTs to be used should be included here + default: + kind := fromFieldDesc.Type.Kind() + if kind == reflect.Slice && + indirectType(fromFieldDesc.Type).Kind() == reflect.Struct && + indirectType(to.Type()).Kind() == reflect.Struct { // Copy slice one at a time + + len := fromData.Len() + to.Set(reflect.MakeSlice(to.Type(), len, len)) + for k := 0; k < len; k++ { + dest := to.Index(k) + if dest.Kind() == reflect.Ptr { + dest.Set(reflect.New(indirectType(dest.Type())).Elem().Addr()) + } + err := Convert(fromData.Index(k).Interface(), dest.Addr().Interface()) + if err != nil { + fmt.Printf("%s", err.Error()) + } + } + } else if kind == reflect.Struct && !fromData.IsNil() { // A nested struct + err := Convert(fromData.Interface(), to.Addr().Interface()) + if err != nil { + fmt.Printf("%s", err.Error()) + } + } else if kind == reflect.Int32 && to.Type().Kind() == reflect.Int32 { // Probably an enum + to.Set(reflect.ValueOf(int32(fromData.Int()))) + } + } + } + } + return nil +} diff --git a/pb/converter_test.go b/pb/converter_test.go new file mode 100644 index 00000000..ce4301b3 --- /dev/null +++ b/pb/converter_test.go @@ -0,0 +1,173 @@ +package pb + +import ( + "fmt" + "github.com/golang/protobuf/ptypes/wrappers" + "reflect" + "testing" +) + +var testObjectWBR = WBRRsrc{ + OphId: "abc123", + Name: "wbr1", + SerialNumber: "idk", + Location: "tacoma", + BgpPassword: &wrappers.StringValue{"p@$$word"}, + BgpAsn: &wrappers.UInt32Value{12345}, + AggregateRoute: &wrappers.StringValue{"127.0.0.1/32"}, + Network: &wrappers.StringValue{"10.0.0.0/24"}, + Description: "nothing useful", + Interfaces: []*InterfaceRsrc{ + { + Name: "eth0", + IpAddress: "10.0.0.2", + Type: "link-layer", + // GreKey: + // LlInterfaceName: + // Ttl: + }, + { + Name: "tunl_gre0", + IpAddress: "10.0.0.3", + Type: "tunnel", + GreKey: &wrappers.StringValue{"1"}, + LlInterfaceName: &wrappers.StringValue{"eth0"}, + Ttl: &wrappers.UInt32Value{255}, + }, + }, +} +var result *WBR + +func BenchmarkServiceObjectToGORM(b *testing.B) { + var x *WBR + for n := 0; n < b.N; n++ { + x = &WBR{} + Convert(testObjectWBR, x) + } + result = x +} + +var resultBloat *WBRWithBloat + +// A straight iterative approach takes longer when there are case mismatched +// fields later in the struct +func BenchmarkBloatedServiceObjectToGORM(b *testing.B) { + var x *WBRWithBloat + for n := 0; n < b.N; n++ { + x = &WBRWithBloat{} + Convert(testObjectWBR, x) + } + resultBloat = x +} + +func BenchmarkWBRRsrcToGORM(b *testing.B) { + var x *WBR + for n := 0; n < b.N; n++ { + x = WBRRsrcToGORM(testObjectWBR) + } + result = x +} + +var resultI *Interface + +func BenchmarkServiceObjectToGORMInterface(b *testing.B) { + var x *Interface + for n := 0; n < b.N; n++ { + x = &Interface{} + Convert(testObjectWBR.Interfaces[1], x) + } + resultI = x +} + +func BenchmarkWBRRsrcToGORMInterface(b *testing.B) { + var x *Interface + for n := 0; n < b.N; n++ { + x = InterfaceRsrcToGORM(*testObjectWBR.Interfaces[1]) + } + resultI = x +} + +type mockEnum int32 + +type demoPB struct { + Num uint32 + Str string + MaybeNum *wrappers.UInt32Value + MaybeStr *wrappers.StringValue + TestEnum mockEnum + Casecheck string + ExtraneousPBField string +} + +type demoGORM struct { + Num uint32 + Str string + MaybeNum *uint32 + MaybeStr *string + TestEnum int32 + CaseCheck string + ExtraneousGormField string +} + +func pInt(num uint32) *uint32 { + x := num + return &x +} +func pString(str string) *string { + x := str + return &x +} + +func TestConvertServiceGORMObjects(t *testing.T) { + testCases := map[*demoPB]demoGORM{ + &demoPB{ + Num: 1, + Str: "nothing", + TestEnum: 0, + }: demoGORM{ + Num: 1, + Str: "nothing", + MaybeNum: nil, + MaybeStr: nil, + TestEnum: 0, + }, + + &demoPB{ + Num: 3, + MaybeNum: &wrappers.UInt32Value{Value: 5}, + MaybeStr: &wrappers.StringValue{Value: "Message"}, + TestEnum: 2, + }: demoGORM{ + Num: 3, + Str: "", + MaybeNum: pInt(5), + MaybeStr: pString("Message"), + TestEnum: 2, + }, + + &demoPB{ + Casecheck: "arbitrary", + }: demoGORM{ + Num: 0, + CaseCheck: "arbitrary", + }, + } + for input, goal := range testCases { + to := &demoGORM{} + err := Convert(input, to) + if err != nil { + t.Log(fmt.Sprintf("Conversion from %+v to %+v failed", *input, *to)) + t.Fail() + } + if !reflect.DeepEqual(*to, goal) { + t.Log(fmt.Sprintf("Expected %+v, got %+v", goal, *to)) + t.Fail() + } + } + // Should fail + err := Convert(&demoPB{}, demoGORM{}) + if err == nil { + t.Log("Should throw error copying to non-pointer object") + t.Fail() + } +} diff --git a/pb/converter_test_objects.go b/pb/converter_test_objects.go new file mode 100644 index 00000000..aec7aab3 --- /dev/null +++ b/pb/converter_test_objects.go @@ -0,0 +1,148 @@ +package pb + +import google_protobuf1 "github.com/golang/protobuf/ptypes/wrappers" +import "time" + +// InterfaceRsrc pulled from a protobuf generated file +type InterfaceRsrc struct { + Name string `protobuf:"bytes,1,opt,name=name" json:"name,omitempty"` + IpAddress string `protobuf:"bytes,2,opt,name=ip_address,json=ipAddress" json:"ip_address,omitempty"` + Type string `protobuf:"bytes,3,opt,name=type" json:"type,omitempty"` + GreKey *google_protobuf1.StringValue `protobuf:"bytes,4,opt,name=gre_key,json=greKey" json:"gre_key,omitempty"` + LlInterfaceName *google_protobuf1.StringValue `protobuf:"bytes,5,opt,name=ll_interface_name,json=llInterfaceName" json:"ll_interface_name,omitempty"` + Ttl *google_protobuf1.UInt32Value `protobuf:"bytes,6,opt,name=ttl" json:"ttl,omitempty"` +} + +// WBRRsrc pulled from a protobuf generated file +type WBRRsrc struct { + Id uint32 `protobuf:"varint,11,opt,name=id" json:"id,omitempty"` + OphId string `protobuf:"bytes,1,opt,name=oph_id,json=ophId" json:"oph_id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name" json:"name,omitempty"` + SerialNumber string `protobuf:"bytes,3,opt,name=serial_number,json=serialNumber" json:"serial_number,omitempty"` + Location string `protobuf:"bytes,4,opt,name=location" json:"location,omitempty"` + BgpPassword *google_protobuf1.StringValue `protobuf:"bytes,5,opt,name=bgp_password,json=bgpPassword" json:"bgp_password,omitempty"` + BgpAsn *google_protobuf1.UInt32Value `protobuf:"bytes,6,opt,name=bgp_asn,json=bgpAsn" json:"bgp_asn,omitempty"` + AggregateRoute *google_protobuf1.StringValue `protobuf:"bytes,7,opt,name=aggregate_route,json=aggregateRoute" json:"aggregate_route,omitempty"` + Network *google_protobuf1.StringValue `protobuf:"bytes,8,opt,name=network" json:"network,omitempty"` + Description string `protobuf:"bytes,9,opt,name=description" json:"description,omitempty"` + Interfaces []*InterfaceRsrc `protobuf:"bytes,10,rep,name=interfaces" json:"interfaces,omitempty"` +} + +// Interface is the gorm compatible version of InterfaceRsrc from above +type Interface struct { + ID uint + CreatedAt time.Time + UpdatedAt time.Time + + WBRID uint + Name string + IPAddress string + Type string + GreKey *string + LlInterfaceName *string + TTL *uint32 +} + +// WBR is the gorm compatible version of WBRRsrc from above +type WBR struct { + ID uint32 + CreatedAt time.Time + UpdatedAt time.Time + + OphID string + Name string + SerialNumber string + Location string + BgpPassword *string + BgpAsn *uint32 + AggregateRoute *string + Network *string + Description string + Interfaces []Interface +} + +// WBRWithBloat also has a number of unused fields before the last case mismatch +type WBRWithBloat struct { + ID uint32 + CreatedAt time.Time + UpdatedAt time.Time + + Name string + SerialNumber string + Location string + BgpPassword *string + BgpAsn *uint32 + AggregateRoute *string + Network *string + Description string + Interfaces []Interface + + Bloat1 string + Bloat2 string + Bloat3 string + Bloat4 string + Bloat5 string + Bloat6 string + Bloat7 string + Bloat8 string + Bloat9 string + Bloat10 string + Bloat11 string + Bloat12 string + + OphID string +} + +func unwrapInt(num *google_protobuf1.UInt32Value) *uint32 { + if num != nil { + value := num.Value + return &value + } + return nil +} + +func unwrapString(str *google_protobuf1.StringValue) *string { + if str != nil { + value := str.Value + return &value + } + return nil +} + +// InterfaceRsrcToGORM ... +func InterfaceRsrcToGORM(iface InterfaceRsrc) *Interface { + ifaceGORM := Interface{ + // WBRRsrcID int + // FK Field handled by GORM + Name: iface.Name, + IPAddress: iface.IpAddress, + Type: iface.Type, + GreKey: unwrapString(iface.GreKey), + LlInterfaceName: unwrapString(iface.LlInterfaceName), + TTL: unwrapInt(iface.Ttl), + } + // Other validation that could be necessary goes in here + + return &ifaceGORM +} + +// WBRRsrcToGORM ... +func WBRRsrcToGORM(wbr WBRRsrc) *WBR { + wbrGORM := WBR{ + + OphID: wbr.OphId, + Name: wbr.Name, + SerialNumber: wbr.SerialNumber, + Location: wbr.Location, + BgpPassword: unwrapString(wbr.BgpPassword), + BgpAsn: unwrapInt(wbr.BgpAsn), + AggregateRoute: unwrapString(wbr.AggregateRoute), + Network: unwrapString(wbr.Network), + Description: wbr.Description, + // Interfaces []*InterfaceRsrc + } + for _, iface := range wbr.Interfaces { + wbrGORM.Interfaces = append(wbrGORM.Interfaces, *InterfaceRsrcToGORM(*iface)) + } + return &wbrGORM +} diff --git a/rpc/errdetails/error_details.go b/rpc/errdetails/error_details.go new file mode 100644 index 00000000..ecfb46d2 --- /dev/null +++ b/rpc/errdetails/error_details.go @@ -0,0 +1,67 @@ +package errdetails + +import ( + "encoding/json" + "fmt" + "strings" + + "google.golang.org/genproto/googleapis/rpc/code" + "google.golang.org/grpc/codes" +) + +// New returns a TargetInfo representing c, target and msg. +// Converts provided Code to int32 +func New(c codes.Code, target string, msg string) *TargetInfo { + return &TargetInfo{Code: int32(c), Message: msg, Target: target} +} + +// NewfTargetInfo returns NewTargetInfo(c, fmt.Sprintf(format, a...)). +func Newf(c codes.Code, target string, format string, a ...interface{}) *TargetInfo { + return New(c, target, fmt.Sprintf(format, a...)) +} + +// MarshalJSON implements json.Marshaler. +// TargetInfo.Code field is marshaled into string with corresponding value, +// see [google.rpc.Code][google.rpc.Code], if code is the codes.Unimplemented +// it is marshaled as "NOT_IMPLEMENTED" string. +func (ti *TargetInfo) MarshalJSON() ([]byte, error) { + v := make(map[string]string, 3) + if m := ti.GetMessage(); m != "" { + v["message"] = ti.Message + } + if t := ti.GetTarget(); t != "" { + v["target"] = ti.Target + } + if ti.GetCode() == int32(codes.Unimplemented) { + v["code"] = "NOT_IMPLEMENTED" + } else { + v["code"] = code.Code(ti.GetCode()).String() + } + + return json.Marshal(&v) +} + +// UnmarshalJSON implements json.Unmarshaler. +// If "code" is not provided in JSON data or is null, +// the TargetInfo.Code will be set to 0 (OK) +func (ti *TargetInfo) UnmarshalJSON(data []byte) error { + v := make(map[string]string, 3) + if err := json.Unmarshal(data, &v); err != nil { + return err + } + + ti.Message = v["message"] + ti.Target = v["target"] + + // if not provided or null + if s, ok := v["code"]; !ok || s == "" { + ti.Code = int32(code.Code_OK) + } else if c, ok := code.Code_value[strings.ToUpper(s)]; ok { + ti.Code = c + } else if strings.ToUpper(s) == "NOT_IMPLEMENTED" { + ti.Code = int32(code.Code_UNIMPLEMENTED) + } else { + ti.Code = int32(code.Code_UNKNOWN) + } + return nil +} diff --git a/rpc/errdetails/error_details.pb.go b/rpc/errdetails/error_details.pb.go new file mode 100644 index 00000000..9f53992b --- /dev/null +++ b/rpc/errdetails/error_details.pb.go @@ -0,0 +1,90 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// source: github.com/infobloxopen/atlas-app-toolkit/rpc/errdetails/error_details.proto + +/* +Package errdetails is a generated protocol buffer package. + +It is generated from these files: + github.com/infobloxopen/atlas-app-toolkit/rpc/errdetails/error_details.proto + +It has these top-level messages: + TargetInfo +*/ +package errdetails + +import proto "github.com/golang/protobuf/proto" +import fmt "fmt" +import math "math" + +// Reference imports to suppress errors if they are not otherwise used. +var _ = proto.Marshal +var _ = fmt.Errorf +var _ = math.Inf + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the proto package it is being compiled against. +// A compilation error at this line likely means your copy of the +// proto package needs to be updated. +const _ = proto.ProtoPackageIsVersion2 // please upgrade the proto package + +// TargetInfo is a default representation of error details that conforms +// REST API Syntax Specification +type TargetInfo struct { + // The status code is an enumerated error code, + // which should be an enum value of [google.rpc.Code][google.rpc.Code] + Code int32 `protobuf:"varint,1,opt,name=code" json:"code,omitempty"` + // The message is a human-readable non-localized message + Message string `protobuf:"bytes,2,opt,name=message" json:"message,omitempty"` + // The target is a resource name + Target string `protobuf:"bytes,3,opt,name=target" json:"target,omitempty"` +} + +func (m *TargetInfo) Reset() { *m = TargetInfo{} } +func (m *TargetInfo) String() string { return proto.CompactTextString(m) } +func (*TargetInfo) ProtoMessage() {} +func (*TargetInfo) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{0} } + +func (m *TargetInfo) GetCode() int32 { + if m != nil { + return m.Code + } + return 0 +} + +func (m *TargetInfo) GetMessage() string { + if m != nil { + return m.Message + } + return "" +} + +func (m *TargetInfo) GetTarget() string { + if m != nil { + return m.Target + } + return "" +} + +func init() { + proto.RegisterType((*TargetInfo)(nil), "infoblox.rpc.TargetInfo") +} + +func init() { + proto.RegisterFile("github.com/infobloxopen/atlas-app-toolkit/rpc/errdetails/error_details.proto", fileDescriptor0) +} + +var fileDescriptor0 = []byte{ + // 180 bytes of a gzipped FileDescriptorProto + 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x9c, 0x8e, 0xb1, 0xcb, 0xc2, 0x30, + 0x10, 0x47, 0xe9, 0xf7, 0x69, 0xc5, 0xe0, 0x94, 0x41, 0x32, 0x16, 0xa7, 0x2e, 0x6d, 0x06, 0x47, + 0x37, 0xc5, 0x41, 0x70, 0x2a, 0x4e, 0x2e, 0x92, 0xa6, 0x69, 0x0d, 0xa6, 0xbd, 0x70, 0x39, 0xc1, + 0x3f, 0x5f, 0x0c, 0x2d, 0xee, 0x6e, 0xef, 0x71, 0xdc, 0x8f, 0xc7, 0xce, 0x9d, 0xa5, 0xfb, 0xb3, + 0x2e, 0x35, 0xf4, 0xd2, 0x0e, 0x2d, 0xd4, 0x0e, 0x5e, 0xe0, 0xcd, 0x20, 0x15, 0x39, 0x15, 0x0a, + 0xe5, 0x7d, 0x41, 0x00, 0xee, 0x61, 0x49, 0xa2, 0xd7, 0xd2, 0x20, 0x36, 0x86, 0x94, 0x75, 0xe1, + 0x83, 0x80, 0xb7, 0xd1, 0x4a, 0x8f, 0x40, 0xc0, 0x57, 0xd3, 0x44, 0x89, 0x5e, 0x6f, 0x2a, 0xc6, + 0x2e, 0x0a, 0x3b, 0x43, 0xa7, 0xa1, 0x05, 0xce, 0xd9, 0x4c, 0x43, 0x63, 0x44, 0x92, 0x25, 0xf9, + 0xbc, 0x8a, 0xcc, 0x05, 0x5b, 0xf4, 0x26, 0x04, 0xd5, 0x19, 0xf1, 0x97, 0x25, 0xf9, 0xb2, 0x9a, + 0x94, 0xaf, 0x59, 0x4a, 0xf1, 0x57, 0xfc, 0xc7, 0xc3, 0x68, 0xfb, 0xe3, 0xf5, 0xf0, 0x6b, 0xf1, + 0xee, 0x8b, 0x75, 0x1a, 0x7b, 0xb7, 0xef, 0x00, 0x00, 0x00, 0xff, 0xff, 0x5c, 0x45, 0x80, 0x3c, + 0xff, 0x00, 0x00, 0x00, +} diff --git a/rpc/errdetails/error_details.proto b/rpc/errdetails/error_details.proto new file mode 100644 index 00000000..2b3e5d81 --- /dev/null +++ b/rpc/errdetails/error_details.proto @@ -0,0 +1,17 @@ +syntax = "proto3"; + +package infoblox.rpc; + +option go_package = "github.com/infobloxopen/atlas-app-toolkit/rpc/errdetails;errdetails"; + +// TargetInfo is a default representation of error details that conforms +// REST API Syntax Specification +message TargetInfo { + // The status code is an enumerated error code, + // which should be an enum value of [google.rpc.Code][google.rpc.Code] + int32 code = 1; + // The message is a human-readable non-localized message + string message = 2; + // The target is a resource name + string target = 3; +} diff --git a/rpc/errdetails/error_details_test.go b/rpc/errdetails/error_details_test.go new file mode 100644 index 00000000..37863b36 --- /dev/null +++ b/rpc/errdetails/error_details_test.go @@ -0,0 +1,132 @@ +package errdetails + +import ( + "encoding/json" + "testing" + + "google.golang.org/grpc/codes" +) + +func TestOkCode(t *testing.T) { + ti := New(codes.OK, "", "") + + if ti.GetCode() != int32(codes.OK) { + t.Error("code is not OK") + } + + // test MarshalJSON + data, err := json.Marshal(ti) + if err != nil { + t.Error(err) + } + if string(data) != `{"code":"OK"}` { + t.Errorf("invalid code: %s", data) + } + + // test UnmarshalJSON + var tu TargetInfo + if err := json.Unmarshal(data, &tu); err != nil { + t.Error(err) + } + + if ti.GetCode() != tu.GetCode() { + t.Errorf("invalid code", tu.GetCode()) + } + + // code is not set + ti.Reset() + if err := json.Unmarshal([]byte(`{}`), ti); err != nil { + t.Error(err) + } + if ti.GetCode() != int32(codes.OK) { + t.Errorf("invalid code: %s", ti.GetCode()) + } + + // code is null + ti.Reset() + if err := json.Unmarshal([]byte(`{"code": null}`), ti); err != nil { + t.Error(err) + } + if ti.GetCode() != int32(codes.OK) { + t.Errorf("invalid code: %s", ti.GetCode()) + } + + // code is empty string + ti.Reset() + if err := json.Unmarshal([]byte(`{"code": ""}`), ti); err != nil { + t.Error(err) + } + if ti.GetCode() != int32(codes.OK) { + t.Errorf("invalid code: %s", ti.GetCode()) + } +} + +func TestUnimplementedCode(t *testing.T) { + ti := New(codes.Unimplemented, "", "") + + if ti.GetCode() != int32(codes.Unimplemented) { + t.Error("code is not Unimplemented") + } + + // test MarshalJSON + data, err := json.Marshal(ti) + if err != nil { + t.Error(err) + } + if string(data) != `{"code":"NOT_IMPLEMENTED"}` { + t.Errorf("invalid code: %s", data) + } + + // test UnmarshalJSON + var tu TargetInfo + if err := json.Unmarshal(data, &tu); err != nil { + t.Error(err) + } + + if ti.GetCode() != tu.GetCode() { + t.Errorf("invalid code", tu.GetCode()) + } + + ti.Reset() + if err := json.Unmarshal([]byte(`{"code": "NOT_IMPLEMENTED"}`), ti); err != nil { + t.Error(err) + } + if ti.GetCode() != int32(codes.Unimplemented) { + t.Errorf("invalid code: %s", ti.GetCode()) + } +} + +func TestUnknownCode(t *testing.T) { + ti := New(codes.Unknown, "", "") + + if ti.GetCode() != int32(codes.Unknown) { + t.Error("code is not Unimplemented") + } + + // test MarshalJSON + data, err := json.Marshal(ti) + if err != nil { + t.Error(err) + } + if string(data) != `{"code":"UNKNOWN"}` { + t.Errorf("invalid code: %s", data) + } + + // test UnmarshalJSON + var tu TargetInfo + if err := json.Unmarshal(data, &tu); err != nil { + t.Error(err) + } + + if ti.GetCode() != tu.GetCode() { + t.Errorf("invalid code", tu.GetCode()) + } + + ti.Reset() + if err := json.Unmarshal([]byte(`{"code": "NEW_CODE"}`), ti); err != nil { + t.Error(err) + } + if ti.GetCode() != int32(codes.Unknown) { + t.Errorf("invalid code: %s", ti.GetCode()) + } +}