From 0c1b21283d152ab811b1a10805b525c9f95790c4 Mon Sep 17 00:00:00 2001 From: Juan Hernandez Date: Fri, 15 Dec 2023 17:07:53 +0100 Subject: [PATCH] MGMT-16112: Add OpenAPI handler This patch adds a new `/o2ims-infrastructureInventory/v1/openapi` endpoint that returns the OpenAPI specification of the service. Currently this specification only covers the metadata and the deployment manager endpoints. Other endpoints will be added later. Related: https://issues.redhat.com/browse/MGMT-16112 Signed-off-by: Juan Hernandez --- Makefile | 5 + go.mod | 2 +- hack/install_test_deps.sh | 9 + internal/cmd/server/start_metadata_server.go | 16 + internal/openapi/handler.go | 103 ++++++ internal/openapi/handler_test.go | 104 ++++++ internal/openapi/lint.yaml | 20 ++ internal/openapi/spec.yaml | 344 +++++++++++++++++++ internal/openapi/suite_test.go | 40 +++ 9 files changed, 642 insertions(+), 1 deletion(-) create mode 100644 internal/openapi/handler.go create mode 100644 internal/openapi/handler_test.go create mode 100644 internal/openapi/lint.yaml create mode 100644 internal/openapi/spec.yaml create mode 100644 internal/openapi/suite_test.go diff --git a/Makefile b/Makefile index e9727302..d3614657 100644 --- a/Makefile +++ b/Makefile @@ -51,6 +51,11 @@ lint: golangci-lint --version golangci-lint run --verbose --print-resources-usage --modules-download-mode=vendor --timeout=5m0s +.PHONY: lint-openapi +lint-openapi: + @echo "Run lint OpenAPI" + spectral lint --ruleset internal/openapi/lint.yaml internal/openapi/spec.yaml + .PHONY: deps-update deps-update: @echo "Update dependencies" diff --git a/go.mod b/go.mod index a2109077..595cbf29 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/json-iterator/go v1.1.12 github.com/onsi/ginkgo/v2 v2.13.0 github.com/onsi/gomega v1.29.0 + github.com/thoas/go-funk v0.9.3 go.uber.org/mock v0.3.0 k8s.io/api v0.28.4 k8s.io/apimachinery v0.28.4 @@ -39,7 +40,6 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/thoas/go-funk v0.9.3 // indirect golang.org/x/exp v0.0.0-20230118134722-a68e582fa157 // indirect golang.org/x/oauth2 v0.8.0 // indirect golang.org/x/term v0.14.0 // indirect diff --git a/hack/install_test_deps.sh b/hack/install_test_deps.sh index 1a19746b..4623a5c0 100755 --- a/hack/install_test_deps.sh +++ b/hack/install_test_deps.sh @@ -14,3 +14,12 @@ if ! [ -x "$(command -v golangci-lint)" ]; then rm tarball fi + +if ! [ -x "$(command -v spectral)" ]; then + echo "Downloading spectral" + + curl -Lo spectral https://github.com/stoplightio/spectral/releases/download/v6.11.0/spectral-linux-x64 + echo 0e151d3dc5729750805428f79a152fa01dd4c203f1d9685ef19f4fd4696fcd5f spectral | sha256sum -c + chmod +x spectral + mv spectral $(go env GOPATH)/bin +fi diff --git a/internal/cmd/server/start_metadata_server.go b/internal/cmd/server/start_metadata_server.go index 7d166653..fa39c444 100644 --- a/internal/cmd/server/start_metadata_server.go +++ b/internal/cmd/server/start_metadata_server.go @@ -24,6 +24,7 @@ import ( "github.com/openshift-kni/oran-o2ims/internal" "github.com/openshift-kni/oran-o2ims/internal/exit" "github.com/openshift-kni/oran-o2ims/internal/network" + "github.com/openshift-kni/oran-o2ims/internal/openapi" "github.com/openshift-kni/oran-o2ims/internal/service" ) @@ -99,6 +100,21 @@ func (c *MetadataServerCommand) run(cmd *cobra.Command, argv []string) error { service.SendError(w, http.StatusMethodNotAllowed, "Method not allowed") }) + // Create the handler that serves the OpenAPI metadata: + openapiHandler, err := openapi.NewHandler(). + SetLogger(logger). + Build() + if err != nil { + logger.Error( + "Failed to create OpenAPI handler", + slog.String("error", err.Error()), + ) + return exit.Error(1) + } + router.PathPrefix( + "/o2ims-infrastructureInventory/v1/openapi", + ).Handler(openapiHandler) + // Create the handler that servers the information about the versions of the API: versionsHandler, err := service.NewVersionsHandler(). SetLogger(logger). diff --git a/internal/openapi/handler.go b/internal/openapi/handler.go new file mode 100644 index 00000000..e4cdddf0 --- /dev/null +++ b/internal/openapi/handler.go @@ -0,0 +1,103 @@ +/* +Copyright 2023 Red Hat Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in +compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is +distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the License for the specific language governing permissions and limitations under the +License. +*/ + +package openapi + +import ( + "embed" + "encoding/json" + "errors" + "log/slog" + "net/http" + + "gopkg.in/yaml.v3" +) + +//go:embed spec.yaml +var dataFS embed.FS + +// HandlerBuilder contains the data and logic needed to create a new handler for the OpenAPI +// metadata. Don't create instances of this type directly, use the NewHandler function instead. +type HandlerBuilder struct { + logger *slog.Logger +} + +// Handler knows how to respond to requests for the OpenAPI metadata. Don't create instances of +// this type directly, use the NewHandler function instead. +type Handler struct { + logger *slog.Logger + spec []byte +} + +// NewHandler creates a builder that can then be used to configure and create a handler for the +// OpenAPI metadata. +func NewHandler() *HandlerBuilder { + return &HandlerBuilder{} +} + +// SetLogger sets the logger that the handler will use to write to the log. This is mandatory. +func (b *HandlerBuilder) SetLogger(value *slog.Logger) *HandlerBuilder { + b.logger = value + return b +} + +// Build uses the data stored in the builder to create and configure a new handler. +func (b *HandlerBuilder) Build() (result *Handler, err error) { + // Check parameters: + if b.logger == nil { + err = errors.New("logger is mandatory") + return + } + + // Load the specification: + spec, err := b.loadSpec() + if err != nil { + return + } + + // Create and populate the object: + result = &Handler{ + logger: b.logger, + spec: spec, + } + return +} + +func (b *HandlerBuilder) loadSpec() (result []byte, err error) { + // Read the main file: + data, err := dataFS.ReadFile("spec.yaml") + if err != nil { + return + } + var spec any + err = yaml.Unmarshal(data, &spec) + if err != nil { + return + } + result, err = json.Marshal(spec) + return +} + +// ServeHTTP is the implementation of the object HTTP handler interface. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write(h.spec) + if err != nil { + h.logger.Error( + "Failed to send data", + slog.String("error", err.Error()), + ) + } +} diff --git a/internal/openapi/handler_test.go b/internal/openapi/handler_test.go new file mode 100644 index 00000000..73205bea --- /dev/null +++ b/internal/openapi/handler_test.go @@ -0,0 +1,104 @@ +/* +Copyright (c) 2023 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in +compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is +distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the License for the specific language governing permissions and limitations under the +License. +*/ + +package openapi + +import ( + "encoding/json" + "mime" + "net/http" + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2/dsl/core" + . "github.com/onsi/ginkgo/v2/dsl/decorators" + . "github.com/onsi/gomega" + + . "github.com/openshift-kni/oran-o2ims/internal/testing" +) + +var _ = Describe("Handler", func() { + It("Returns a valid JSON content type and document", func() { + // Create the handler: + handler, err := NewHandler(). + SetLogger(logger). + Build() + Expect(err).ToNot(HaveOccurred()) + Expect(handler).ToNot(BeNil()) + + // Send the request: + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/", nil) + handler.ServeHTTP(recorder, request) + + // Verify the content type: + Expect(recorder.Code).To(Equal(http.StatusOK)) + contentType := recorder.Header().Get("Content-Type") + Expect(contentType).ToNot(BeEmpty()) + mediaType, _, err := mime.ParseMediaType(contentType) + Expect(err).ToNot(HaveOccurred()) + Expect(mediaType).To(Equal("application/json")) + + // Verify the content format: + var spec any + err = json.Unmarshal(recorder.Body.Bytes(), &spec) + Expect(err).ToNot(HaveOccurred()) + }) + + Describe("Content", Ordered, func() { + var spec any + + BeforeAll(func() { + // Create the handler: + handler, err := NewHandler(). + SetLogger(logger). + Build() + Expect(err).ToNot(HaveOccurred()) + Expect(handler).ToNot(BeNil()) + + // Send the request: + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/", nil) + handler.ServeHTTP(recorder, request) + + // Parse the response: + Expect(recorder.Code).To(Equal(http.StatusOK)) + err = json.Unmarshal(recorder.Body.Bytes(), &spec) + Expect(err).ToNot(HaveOccurred()) + }) + + It("Contains the basic fields", func() { + Expect(spec).To(MatchJQ(`.openapi`, "3.0.0")) + Expect(spec).To(MatchJQ(`.info.title`, "O2 IMS")) + Expect(spec).To(MatchJQ(`.info.version`, "1.0.0")) + }) + + It("Contains at least one path", func() { + Expect(spec).To(MatchJQ(`(.paths | length) > 0`, true)) + }) + + It("Contains at least one schema", func() { + Expect(spec).To(MatchJQ(`(.components.schemas | length) > 0`, true)) + }) + + It("All paths start with the expected prefix", func() { + Expect(spec).To(MatchJQ(`[.paths | keys[] | select(startswith("/o2ims-infrastructureInventory/") | not)] | length`, 0)) + }) + + It("Contains the expected schemas", func() { + Expect(spec).To(MatchJQ(`any(.components.schemas | keys[]; . == "APIVersion")`, true)) + Expect(spec).To(MatchJQ(`any(.components.schemas | keys[]; . == "APIVersions")`, true)) + Expect(spec).To(MatchJQ(`any(.components.schemas | keys[]; . == "DeploymentManager")`, true)) + }) + }) +}) diff --git a/internal/openapi/lint.yaml b/internal/openapi/lint.yaml new file mode 100644 index 00000000..6a53c310 --- /dev/null +++ b/internal/openapi/lint.yaml @@ -0,0 +1,20 @@ +# +# Copyright (c) 2023 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in +# compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. See the License for the specific language governing permissions and limitations under the +# License. +# + +# This file contains the `spectral` configuration used to check the `spec.yaml` file. + +extends: [[spectral:oas, all]] + +rules: + info-contact: off \ No newline at end of file diff --git a/internal/openapi/spec.yaml b/internal/openapi/spec.yaml new file mode 100644 index 00000000..aaf3147d --- /dev/null +++ b/internal/openapi/spec.yaml @@ -0,0 +1,344 @@ +# +# Copyright (c) 2023 Red Hat, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in +# compliance with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License is +# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. See the License for the specific language governing permissions and limitations under the +# License. +# + +openapi: "3.0.0" + +info: + title: O2 IMS + version: 1.0.0 + description: | + O-RAN O2 IMS + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + +servers: +- url: http://localhost:8000 + +tags: +- name: deploymentManagers + description: | + Information about deployment managers. +- name: metadata + description: | + Service metadata, including versions and O-Cloud information. + +paths: + + /o2ims-infrastructureInventory/api_versions: + get: + operationId: getAllVersions + summary: Get API versions + description: | + Returns the complete list of API versions implemented by the service. + parameters: + - $ref: "#/components/parameters/excludeFields" + - $ref: "#/components/parameters/fields" + tags: + - metadata + responses: + '200': + description: | + Successfully obtaiend the complete list of versions. + content: + application/json: + schema: + $ref: "#/components/schemas/APIVersions" + + /o2ims-infrastructureInventory/v1: + get: + operationId: getCloudInfo + summary: Get O-Cloud info + description: | + Returns the details of the O-Cloud instance. + parameters: + - $ref: "#/components/parameters/excludeFields" + - $ref: "#/components/parameters/fields" + tags: + - metadata + responses: + '200': + description: | + Successfully obtained the details of the O-Cloud instance. + content: + application/json: + schema: + $ref: "#/components/schemas/OCloudInfo" + + /o2ims-infrastructureInventory/{version}/api_versions: + get: + operationId: getMinorVersions + summary: Get minor API versions + description: | + Returns the list of minor API versions implemented for this major version of the API. + parameters: + - $ref: "#/components/parameters/excludeFields" + - $ref: "#/components/parameters/fields" + - $ref: "#/components/parameters/version" + tags: + - metadata + responses: + '200': + description: | + Success + content: + application/json: + schema: + $ref: "#/components/schemas/APIVersions" + + /o2ims-infrastructureInventory/{version}/deploymentManagers: + get: + operationId: getDeploymentManagers + summary: Get deployment managers + description: | + Returns the list of deployment managers. + parameters: + - $ref: "#/components/parameters/excludeFields" + - $ref: "#/components/parameters/fields" + - $ref: "#/components/parameters/filter" + - $ref: "#/components/parameters/version" + tags: + - deploymentManagers + responses: + '200': + description: | + Successfully obtained the list of deployment managers. + content: + application/json: + schema: + $ref: "#/components/schemas/DeploymentManagers" + + /o2ims-infrastructureInventory/{version}/deploymentManagers/{deploymentManagerId}: + get: + operationId: getDeploymentManager + summary: Get deployment manager + description: | + Returns the details of a deployment manager. + parameters: + - $ref: "#/components/parameters/deploymentManagerId" + - $ref: "#/components/parameters/excludeFields" + - $ref: "#/components/parameters/fields" + - $ref: "#/components/parameters/version" + tags: + - deploymentManagers + responses: + '200': + description: | + Successfully obtained the details of the deployment manager. + content: + application/json: + schema: + $ref: "#/components/schemas/DeploymentManagers" + +components: + + parameters: + + version: + name: version + description: | + Major version number of the API, with a `v` prefix, for example `v1`. + in: path + required: true + schema: + type: string + example: v1 + + deploymentManagerId: + name: deploymentManagerId + description: | + Unique identifier of a deployment manager. + in: path + required: true + schema: + type: string + example: 65221564-8b05-416a-bfc3-c250bc64e1aa + + fields: + name: fields + description: | + Comma separated list of field references to include in the result. + + Each field reference is a field name, or a sequence of field names separated by slashes. For + example, to get the `name` field and the `country` subfield of the `extensions` field: + + ``` + fields=name,extensions/country + ``` + + When this parameter isn't used all the fields will be returned. + in: query + required: false + schema: + type: string + example: "name,extensions/country" + + excludeFields: + name: exclude_fields + description: | + Comma separated list of field references to exclude from the result. + + Each field reference is a field name, or a sequence of field names separated by slashes. For + example, to exclude the `country` subfield of the `extensions` field: + + ``` + exclude_fields=extensions/country + ``` + + When this parameter isn't used no field will be excluded. + + Fields in this list will be excluded even if they are explicitly included using the + `fields` parameter. + in: query + required: false + schema: + type: string + example: "name,extensions/country" + + filter: + name: filter + description: | + Search criteria. + + Contains one or more search criteria, separated by semicolons. Each search criteria is a + tuple containing an operator, a field reference and one or more values. The operator can + be any of the following strings: + + | Operator | Meaning | + |----------|-------------------------------------------------------------| + | `cont` | Matches if the field contains the value | + | `eq` | Matches if the field is equal to the value | + | `gt` | Matches if the field is greater than the value | + | `gte` | Matches if the field is greater than or equal to the value | + | `in` | Matches if the field is one of the values | + | `lt` | Matches if the field is less than the value | + | `lte` | Matches if the field is less than or equal to the the value | + | `ncont` | Matches if the field does not contain the value | + | `neq` | Matches if the field is not equal to the value | + | `nin` | Matches if the field is not one of the values | + + The field reference is the name of one of the fields of the object, or a sequence of + name of fields separated by slashes. For example, to use the `country` sub-field inside + the `extensions` field: + + ``` + filter=(eq,extensions/country,EQ) + ``` + + The values are the arguments of the operator. For example, the `eq` operator compares + checks if the value of the field is equal to the value. + + The `in` and `nin` operators support multiple values. For example, to check if the `country` + sub-field inside the `extensions` field is either `ES` or `US: + + ``` + filter=(in,extensions/country,ES,US) + ``` + + When values contain commas, slashes or spaces they need to be surrounded by single quotes. + For examplle, to check if the `name` field is the string `my cluster`: + + ``` + filter=(eq,name,'my cluster') + ``` + + When multiple criteria separated by semicolons are used, all of them must match for the + complete condition to match. For example, the following will check if the `name` is + `my cluster` *and* the `country` extension is `ES`: + + ``` + filter=(eq,name,'my cluster');(eq,extensions/country,ES) + ``` + + When this parameter isn't used all the results will be returned. + in: query + required: false + schema: + type: string + example: "(eq,name,my cluster)" + + schemas: + + APIVersion: + description: | + Information about a version of the API. + type: object + properties: + version: + type: string + example: "1.0.0" + + APIVersions: + description: | + Information about a list of versions of the API. + type: object + properties: + uriPrefix: + type: string + example: "/o2ims-infrastructureInventory/v1" + apiVersions: + type: array + items: + $ref: "#/components/schemas/APIVersion" + example: + - version: "1.0.0" + + DeploymentManager: + description: | + Information about a deployment manager. + type: object + properties: + deploymentManagerId: + type: string + example: "65221564-8b05-416a-bfc3-c250bc64e1aa" + description: + type: string + example: "My cluster" + oCloudId: + type: string + example: "262c8f17-52b5-4614-9e56-812ae21fa8a7" + serviceUri: + type: string + example: "https://my.cluster:6443" + + DeploymentManagers: + description: | + List of deployment managers. + type: array + items: + $ref: "#/components/schemas/DeploymentManager" + + OCloudInfo: + type: object + properties: + oCloudId: + type: string + example: "262c8f17-52b5-4614-9e56-812ae21fa8a7" + globalCloudId: + type: string + example: "8c1c151d-2899-4c96-b76f-4fe92064b57b" + name: + type: string + example: "my-cloud" + description: + type: string + example: "My cloud" + serviceUri: + type: string + example: "http://localhost:8000" + extensions: + type: object + example: + hub: "hub0" + country: "ES" diff --git a/internal/openapi/suite_test.go b/internal/openapi/suite_test.go new file mode 100644 index 00000000..70394585 --- /dev/null +++ b/internal/openapi/suite_test.go @@ -0,0 +1,40 @@ +/* +Copyright (c) 2023 Red Hat, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in +compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is +distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied. See the License for the specific language governing permissions and limitations under the +License. +*/ + +package openapi + +import ( + "log/slog" + "testing" + + . "github.com/onsi/ginkgo/v2/dsl/core" + . "github.com/onsi/gomega" +) + +func TestOpenAPI(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "OpenAPI") +} + +var logger *slog.Logger + +var _ = BeforeSuite(func() { + // Create a logger that writes to the Ginkgo writer, so that the log messages will be + // attached to the output of the right test: + options := &slog.HandlerOptions{ + Level: slog.LevelDebug, + } + handler := slog.NewJSONHandler(GinkgoWriter, options) + logger = slog.New(handler) +})