diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e623b4d..b474161 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,36 +4,48 @@ on: pull_request: branches: [ "main" ] +env: + GO_VERSION: '1.23' + RUNNING_IN_CI: true + jobs: - build: - strategy: - matrix: - arch: [amd64, arm64] - os: [ubuntu-latest] - runs-on: ${{ matrix.os }} + build-and-test: + name: Build and Test + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: - go-version: '1.22' + go-version: ${{ env.GO_VERSION }} cache: true - - name: Install dependencies + - name: Cache tools + uses: actions/cache@v4 + with: + path: | + bin/ + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}-tools-${{ hashFiles('Makefile') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Install tools run: | - go mod download - go mod verify + echo "::group::Installing development tools" + make tools + echo "::endgroup::" - - name: Build + - name: Build and test run: | - GOARCH=${{ matrix.arch }} make build + echo "::group::Building project" + make build + echo "::endgroup::" - - name: Run tests - run: make test - - - name: Upload artifacts - uses: actions/upload-artifact@v3 - with: - name: manager-${{ matrix.arch }} - path: bin/manager-${{ matrix.arch }} + echo "::group::Running tests" + make test + echo "::endgroup::" diff --git a/Makefile b/Makefile index 7e65181..ee393bc 100644 --- a/Makefile +++ b/Makefile @@ -1,22 +1,25 @@ -# Common variables and settings -ARCH ?= $(shell go env GOARCH) +# ==================== +# Base Settings +# ==================== + +SHELL := /usr/bin/env bash +.SHELLFLAGS := -euo pipefail -c + +# Directory settings +WORKSPACE_DIR := $(CURDIR) +LOCALBIN := $(WORKSPACE_DIR)/bin + +# Build settings GOOS ?= linux -PLATFORMS ?= linux/arm64,linux/amd64 +GOARCH ?= $(shell go env GOARCH) +CGO_ENABLED ?= 0 +COMMON_LDFLAGS := -s -w -# Version information -GIT_VERSION ?= $(shell git describe --tags --always) -FILE_VERSION ?= $(shell awk -F= '/^operator=/ {print $$2}' versions.txt) -# Use git version if available, otherwise fall back to versions.txt -VERSION ?= $(if $(shell git describe --tags --exact-match 2>/dev/null),$(GIT_VERSION),$(FILE_VERSION)) -VERSION_DATE ?= $(shell date -u +'%Y-%m-%dT%H:%M:%SZ') -VERSION_PKG ?= github.com/hellices/openapi-aggregator-operator/pkg/version - -# LDFLAGS for the build -COMMON_LDFLAGS ?= -s -w -OPERATOR_LDFLAGS ?= -X ${VERSION_PKG}.version=${VERSION} -X ${VERSION_PKG}.buildDate=${VERSION_DATE} -ifneq ($(origin CHANNELS), undefined) -BUNDLE_CHANNELS := --channels=$(CHANNELS) -endif +# Version settings +VERSION ?= $(shell git describe --tags --exact-match 2>/dev/null || git describe --tags --always) +VERSION_DATE := $(shell date -u +'%Y-%m-%dT%H:%M:%SZ') +VERSION_PKG := github.com/hellices/openapi-aggregator-operator/pkg/version +OPERATOR_LDFLAGS := -X $(VERSION_PKG).version=$(VERSION) -X $(VERSION_PKG).buildDate=$(VERSION_DATE) # DEFAULT_CHANNEL defines the default channel used in the bundle. # Add a new line here if you would like to change its default config. (E.g DEFAULT_CHANNEL = "stable") @@ -71,8 +74,6 @@ GOBIN=$(shell go env GOBIN) endif LOCALBIN ?= $(shell pwd)/bin -$(LOCALBIN): - mkdir -p $(LOCALBIN) # CONTAINER_TOOL defines the container tool to be used for building images. # Be aware that the target commands are only tested with Docker which is @@ -134,14 +135,16 @@ fmt: ## Run go fmt against code. vet: ## Run go vet against code. go vet ./... -.PHONY: test -test: manifests generate fmt vet envtest ## Run tests. - KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $$(go list ./... | grep -v /e2e) -coverprofile cover.out +.PHONY: test test-e2e test-coverage +test: manifests generate fmt vet envtest ## Run unit tests + KUBEBUILDER_ASSETS="$$($(ENVTEST) use $(ENVTEST_K8S_VERSION) -p path)" \ + go test $(shell go list ./... | grep -v /test/e2e) -v -race -coverprofile=cover.out -covermode=atomic + +test-e2e: docker-build ## Run e2e tests + go test ./test/e2e/... -v -ginkgo.v -# Utilize Kind or modify the e2e tests to load the image locally, enabling compatibility with other vendors. -.PHONY: test-e2e # Run the e2e tests against a Kind k8s instance that is spun up. -test-e2e: - go test ./test/e2e/ -v -ginkgo.v +test-coverage: test ## Generate test coverage report + go tool cover -html=cover.out -o coverage.html .PHONY: lint lint: golangci-lint ## Run golangci-lint linter @@ -153,15 +156,32 @@ lint-fix: golangci-lint ## Run golangci-lint linter and perform fixes ##@ Build -.PHONY: build -build: manifests generate fmt vet ## Build manager binary. - CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=amd64 go build -ldflags "${COMMON_LDFLAGS} ${OPERATOR_LDFLAGS}" -o bin/manager_amd64 cmd/main.go - CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=arm64 go build -ldflags "${COMMON_LDFLAGS} ${OPERATOR_LDFLAGS}" -o bin/manager_arm64 cmd/main.go - ln -sf manager_$(ARCH) bin/manager +.PHONY: build build-only build-all +build: manifests generate fmt vet ## Build manager binary for current architecture + @$(MAKE) build-only + +build-only: ## Build manager binary without preprocessing + @mkdir -p $(LOCALBIN) + @echo "Building manager for $(GOARCH)..." + @CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) go build \ + -ldflags "$(COMMON_LDFLAGS) $(OPERATOR_LDFLAGS)" \ + -o $(LOCALBIN)/manager_$(GOARCH) cmd/main.go + @cd $(LOCALBIN) && ln -sf manager_$(GOARCH) manager + +BUILD_PLATFORMS := amd64 arm64 +build-all: manifests generate fmt vet ## Build manager binaries for all architectures + @mkdir -p $(LOCALBIN) + @for arch in $(BUILD_PLATFORMS); do \ + echo "Building manager for $$arch..." ;\ + GOOS=$(GOOS) GOARCH=$$arch CGO_ENABLED=0 go build \ + -ldflags "$(COMMON_LDFLAGS) $(OPERATOR_LDFLAGS)" \ + -o $(LOCALBIN)/manager_$$arch cmd/main.go ;\ + done + @cd $(LOCALBIN) && ln -sf manager_$$(go env GOARCH) manager .PHONY: run run: manifests generate fmt vet ## Run a controller from your host. - go run ./cmd/main.go + DEV_MODE=true go run ./cmd/main.go # If you wish to build the manager image targeting other platforms you can use the --platform flag. # (i.e. docker build --platform linux/arm64). However, you must enable docker buildKit for it. @@ -241,56 +261,96 @@ undeploy: kustomize ## Undeploy controller from the K8s cluster specified in ~/. ## Location to install dependencies to LOCALBIN ?= $(shell pwd)/bin -$(LOCALBIN): + +## Create bin directory if it doesn't exist +bin_dir: ## Create bin directory mkdir -p $(LOCALBIN) -## Tool Binaries -KUBECTL ?= kubectl -KUSTOMIZE ?= $(LOCALBIN)/kustomize -CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen -ENVTEST ?= $(LOCALBIN)/setup-envtest -GOLANGCI_LINT = $(LOCALBIN)/golangci-lint - -## Tool Versions -KUSTOMIZE_VERSION ?= v5.4.3 -CONTROLLER_TOOLS_VERSION ?= v0.16.1 -ENVTEST_VERSION ?= release-0.19 -GOLANGCI_LINT_VERSION ?= v1.59.1 - -.PHONY: kustomize -kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary. -$(KUSTOMIZE): $(LOCALBIN) - $(call go-install-tool,$(KUSTOMIZE),sigs.k8s.io/kustomize/kustomize/v5,$(KUSTOMIZE_VERSION)) - -.PHONY: controller-gen -controller-gen: $(CONTROLLER_GEN) ## Download controller-gen locally if necessary. -$(CONTROLLER_GEN): $(LOCALBIN) - $(call go-install-tool,$(CONTROLLER_GEN),sigs.k8s.io/controller-tools/cmd/controller-gen,$(CONTROLLER_TOOLS_VERSION)) - -.PHONY: envtest -envtest: $(ENVTEST) ## Download setup-envtest locally if necessary. -$(ENVTEST): $(LOCALBIN) - $(call go-install-tool,$(ENVTEST),sigs.k8s.io/controller-runtime/tools/setup-envtest,$(ENVTEST_VERSION)) - -.PHONY: golangci-lint -golangci-lint: $(GOLANGCI_LINT) ## Download golangci-lint locally if necessary. -$(GOLANGCI_LINT): $(LOCALBIN) - $(call go-install-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/cmd/golangci-lint,$(GOLANGCI_LINT_VERSION)) - -# go-install-tool will 'go install' any package with custom target and name of binary, if it doesn't exist -# $1 - target path with name of binary -# $2 - package url which can be installed -# $3 - specific version of package +## Tool Versions and Configurations +TOOLS := kustomize controller-gen setup-envtest golangci-lint operator-sdk +TOOL_VERSIONS := \ + KUSTOMIZE=v5.4.3 \ + CONTROLLER_GEN=v0.16.1 \ + ENVTEST=release-0.19 \ + GOLANGCI_LINT=v1.59.1 \ + OPERATOR_SDK=v1.39.2 + +# Build Architecture Settings +BUILD_ARCH ?= $(shell go env GOARCH) +ifeq ($(RUNNING_IN_CI),true) + # Force AMD64 in CI environment + BUILD_ARCH := amd64 +endif + +# Tool Paths and URLs +KUBECTL := kubectl +KUSTOMIZE := $(LOCALBIN)/kustomize +CONTROLLER_GEN := $(LOCALBIN)/controller-gen +ENVTEST := $(LOCALBIN)/setup-envtest +GOLANGCI_LINT := $(LOCALBIN)/golangci-lint +OPERATOR_SDK := $(LOCALBIN)/operator-sdk + +# Tool URLs +KUSTOMIZE_PKG := sigs.k8s.io/kustomize/kustomize/v5 +CONTROLLER_GEN_PKG := sigs.k8s.io/controller-tools/cmd/controller-gen +ENVTEST_PKG := sigs.k8s.io/controller-runtime/tools/setup-envtest +GOLANGCI_LINT_PKG := github.com/golangci/golangci-lint/cmd/golangci-lint + +.PHONY: tools tools-verify kustomize controller-gen envtest golangci-lint +tools: bin_dir ## Download and install all tools + @echo "Installing tools for $(BUILD_ARCH)..." + @$(MAKE) kustomize controller-gen envtest golangci-lint + @echo "All tools installed successfully!" + +tools-verify: ## Verify all required tools are installed + @echo "Verifying tools..." + @for tool in $(TOOLS); do \ + if [ ! -f "$(LOCALBIN)/$$tool" ]; then \ + echo "โŒ Missing tool: $$tool" ;\ + exit 1 ;\ + fi ;\ + done + @echo "โœ“ All tools are installed" + +kustomize: bin_dir ## Install kustomize + $(call go-install-tool,$(KUSTOMIZE),$(KUSTOMIZE_PKG),v5.4.3) + +controller-gen: bin_dir ## Install controller-gen + $(call go-install-tool,$(CONTROLLER_GEN),$(CONTROLLER_GEN_PKG),v0.16.1) + +envtest: bin_dir ## Install envtest + $(call go-install-tool,$(ENVTEST),$(ENVTEST_PKG),release-0.19) + +golangci-lint: bin_dir ## Install golangci-lint + $(call go-install-tool,$(GOLANGCI_LINT),$(GOLANGCI_LINT_PKG),v1.59.1) + +# Install Go tools +# params: binary-path package-url version define go-install-tool -@[ -f "$(1)-$(3)-$$(go env GOARCH)" ] || { \ -set -e; \ -package=$(2)@$(3) ;\ -echo "Downloading and building $${package} for $$(go env GOARCH)" ;\ -rm -f $(1) || true ;\ -GOARCH=$$(go env GOARCH) GOBIN=$(LOCALBIN) go install $${package} ;\ -mv $(1) $(1)-$(3)-$$(go env GOARCH) ;\ -} ;\ -ln -sf $(1)-$(3)-$$(go env GOARCH) $(1) +@{ \ + if [ -f "$(1)" ]; then \ + echo "Tool already installed: $(1)" ;\ + exit 0 ;\ + fi ;\ + set -e ;\ + echo "Installing $(2)@$(3) for $(BUILD_ARCH)..." ;\ + TEMP_DIR=$$(mktemp -d) ;\ + cd $$TEMP_DIR ;\ + GO111MODULE=on go mod init tmp ;\ + GO111MODULE=on go get $(2)@$(3) ;\ + BASE_NAME=$$(basename $(1)) ;\ + BINARY_NAME="$$BASE_NAME-$(3)-$(BUILD_ARCH)" ;\ + echo "Building $$BINARY_NAME..." ;\ + CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(BUILD_ARCH) go build -o "$$BINARY_NAME" $(2) ;\ + mkdir -p $(LOCALBIN) ;\ + mv "$$BINARY_NAME" "$(LOCALBIN)/" ;\ + cd $(LOCALBIN) ;\ + rm -f "$$BASE_NAME" ;\ + ln -sf "$$BINARY_NAME" "$$BASE_NAME" ;\ + cd $(WORKSPACE_DIR) ;\ + rm -rf $$TEMP_DIR ;\ + echo "โœ“ Installed $$BASE_NAME for $(BUILD_ARCH)" ;\ +} endef .PHONY: operator-sdk @@ -369,8 +429,11 @@ catalog-push: ## Push a catalog image. ##@ Development tools .PHONY: install-tools -install-tools: ## Install development tools - $(LOCALBIN)/controller-gen --version || $(MAKE) controller-gen - $(LOCALBIN)/kustomize --version || $(MAKE) kustomize - $(LOCALBIN)/envtest --version || GOBIN=$(LOCALBIN) go install sigs.k8s.io/controller-runtime/tools/setup-envtest@latest - setup-envtest use --bin-dir $(LOCALBIN) 1.24.2 +install-tools: bin_dir ## Install all development tools + $(MAKE) controller-gen + $(MAKE) kustomize + $(MAKE) envtest + $(MAKE) golangci-lint + $(MAKE) operator-sdk + @echo "Installing envtest assets..." + $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) diff --git a/README.md b/README.md index cf77c2a..4c3e563 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,6 @@ [![Go Report Card](https://goreportcard.com/badge/github.com/hellices/openapi-aggregator-operator)](https://goreportcard.com/report/github.com/hellices/openapi-aggregator-operator) [![GitHub License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE) [![Go Version](https://img.shields.io/github/go-mod/go-version/hellices/openapi-aggregator-operator)](go.mod) -[![Docker Pulls](https://img.shields.io/docker/pulls/hellices/openapi-aggregator-operator)](https://hub.docker.com/r/hellices/openapi-aggregator-operator) -[![Release](https://img.shields.io/github/v/release/hellices/openapi-aggregator-operator)](https://github.com/hellices/openapi-aggregator-operator/releases) Kubernetes operator that discovers and aggregates OpenAPI/Swagger specifications from services running in your cluster. It provides a unified Swagger UI interface to browse and test all your APIs in one place. @@ -62,6 +60,55 @@ Then open http://localhost:9090 in your browser. - ๐Ÿ“ **Service Information**: Displays service metadata including namespace and resource type - โšก **Zero-config Services**: Works with any service that exposes an OpenAPI/Swagger specification +### 5. Ingress/Route Integration + +You can expose the Swagger UI through Ingress or OpenShift Route. + +#### Using Kubernetes Ingress + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: swagger-ui + annotations: + nginx.ingress.kubernetes.io/rewrite-target: /$2 +spec: + rules: + - host: api.example.com + http: + paths: + - path: /swagger-ui(/|$)(.*) + pathType: Prefix + backend: + service: + name: openapi-aggregator-openapi-aggregator-swagger-ui + port: + number: 9090 +``` + +And set the environment variable in the deployment: +```yaml +env: +- name: SWAGGER_BASE_PATH + value: /swagger-ui +``` + +#### Using OpenShift Route + +```yaml +apiVersion: route.openshift.io/v1 +kind: Route +metadata: + name: swagger-ui +spec: + to: + kind: Service + name: openapi-aggregator-openapi-aggregator-swagger-ui + port: + targetPort: swagger-ui +``` + ## Project Architecture ### Components @@ -122,8 +169,18 @@ make deploy For installation via Operator Lifecycle Manager, see detailed instructions in [OLM Installation Guide](docs/olm-install.md). -### Development Commands +### Development Environment Setup +1. Deploy the test service: +```bash +# First, deploy the test service that provides OpenAPI specs +kubectl apply -f config/samples/test-service.yaml + +# Port forward the test service to localhost:8080 +kubectl port-forward svc/test-service 8080:8080 +``` + +2. Run the operator in development mode: ```bash # Run locally make run @@ -138,6 +195,8 @@ make docker-build docker-push make manifests ``` +Note: When running the operator in development mode with `make run`, ensure that the test service is running and port-forwarded to localhost:8080. This is required for the operator to properly fetch and display the OpenAPI specifications in the Swagger UI. + ### Version Management - Version is managed in `versions.txt` diff --git a/api/v1alpha1/openapiaggregator_types.go b/api/v1alpha1/openapiaggregator_types.go index 5ba2d03..31c1f54 100644 --- a/api/v1alpha1/openapiaggregator_types.go +++ b/api/v1alpha1/openapiaggregator_types.go @@ -44,6 +44,10 @@ type OpenAPIAggregatorSpec struct { // PortAnnotation is the annotation key for OpenAPI port // +kubebuilder:default="openapi.aggregator.io/port" PortAnnotation string `json:"portAnnotation,omitempty"` + + // AllowedMethodsAnnotation is the annotation key for allowed HTTP methods in Swagger UI + // +kubebuilder:default="openapi.aggregator.io/allowed-methods" + AllowedMethodsAnnotation string `json:"allowedMethodsAnnotation,omitempty"` } // OpenAPIAggregatorStatus defines the observed state of OpenAPIAggregator @@ -83,6 +87,9 @@ type APIInfo struct { // Annotations stores relevant annotations from the resource Annotations map[string]string `json:"annotations,omitempty"` + + // AllowedMethods stores the allowed HTTP methods for Swagger UI + AllowedMethods []string `json:"allowedMethods,omitempty"` } //+kubebuilder:object:root=true diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 6477834..551819c 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -34,6 +34,11 @@ func (in *APIInfo) DeepCopyInto(out *APIInfo) { (*out)[key] = val } } + if in.AllowedMethods != nil { + in, out := &in.AllowedMethods, &out.AllowedMethods + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new APIInfo. diff --git a/bin/controller-gen b/bin/controller-gen index 67c736b..c02c099 120000 --- a/bin/controller-gen +++ b/bin/controller-gen @@ -1 +1 @@ -/home/inhwanhwang/golang/openapi-aggregator-operator/bin/controller-gen-v0.16.1-arm64 \ No newline at end of file +controller-gen-v0.16.1-arm64 \ No newline at end of file diff --git a/config/crd/bases/observability.aggregator.io_openapiaggregators.yaml b/config/crd/bases/observability.aggregator.io_openapiaggregators.yaml index 4549c41..803eca4 100644 --- a/config/crd/bases/observability.aggregator.io_openapiaggregators.yaml +++ b/config/crd/bases/observability.aggregator.io_openapiaggregators.yaml @@ -39,6 +39,11 @@ spec: spec: description: OpenAPIAggregatorSpec defines the desired state of OpenAPIAggregator properties: + allowedMethodsAnnotation: + default: openapi.aggregator.io/allowed-methods + description: AllowedMethodsAnnotation is the annotation key for allowed + HTTP methods in Swagger UI + type: string defaultPath: default: /v2/api-docs description: DefaultPath is the default path for OpenAPI documentation @@ -77,6 +82,12 @@ spec: description: APIInfo contains information about a collected OpenAPI spec properties: + allowedMethods: + description: AllowedMethods stores the allowed HTTP methods + for Swagger UI + items: + type: string + type: array annotations: additionalProperties: type: string diff --git a/config/samples/observability_v1alpha1_openapiaggregator.yaml b/config/samples/observability_v1alpha1_openapiaggregator.yaml index 9a9c08c..a322a1d 100644 --- a/config/samples/observability_v1alpha1_openapiaggregator.yaml +++ b/config/samples/observability_v1alpha1_openapiaggregator.yaml @@ -8,3 +8,4 @@ spec: swaggerAnnotation: "openapi.aggregator.io/swagger" pathAnnotation: "openapi.aggregator.io/path" portAnnotation: "openapi.aggregator.io/port" + allowedMethodsAnnotation: "openapi.aggregator.io/allowed-methods" diff --git a/config/samples/test-service.yaml b/config/samples/test-service.yaml index 46971e5..73e8d52 100644 --- a/config/samples/test-service.yaml +++ b/config/samples/test-service.yaml @@ -8,6 +8,7 @@ metadata: openapi.aggregator.io/swagger: "true" openapi.aggregator.io/path: "/api/swagger.json" # ์˜ต์…˜ openapi.aggregator.io/port: "8080" # ์˜ต์…˜ + openapi.aggregator.io/allowed-methods: "get" # get๋งŒ ํ—ˆ์šฉ, 'get,post'์ฒ˜๋Ÿผ ์‰ผํ‘œ๋กœ ๊ตฌ๋ถ„ํ•˜์—ฌ ์—ฌ๋Ÿฌ ๋ฉ”์„œ๋“œ ํ—ˆ์šฉ ๊ฐ€๋Šฅ spec: ports: - port: 8080 diff --git a/go.mod b/go.mod index ab7fa34..02941a3 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,6 @@ toolchain go1.23.9 require ( github.com/onsi/ginkgo/v2 v2.19.0 github.com/onsi/gomega v1.33.1 - github.com/stretchr/testify v1.9.0 k8s.io/api v0.31.0 k8s.io/apimachinery v0.31.0 k8s.io/client-go v0.31.0 @@ -53,7 +52,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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.19.1 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // indirect diff --git a/internal/controller/openapiaggregator_controller.go b/internal/controller/openapiaggregator_controller.go index 704e31e..5aa395d 100644 --- a/internal/controller/openapiaggregator_controller.go +++ b/internal/controller/openapiaggregator_controller.go @@ -19,12 +19,14 @@ package controller import ( "context" "fmt" - "net/http" + "strings" "time" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/retry" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/handler" @@ -49,9 +51,7 @@ type OpenAPIAggregatorReconciler struct { // Reconcile handles the reconciliation loop for OpenAPIAggregator resources func (r *OpenAPIAggregatorReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - logger := log.FromContext(ctx) - - logger.Info("Starting reconciliation", "namespace", req.Namespace, "name", req.Name) + logger := log.FromContext(ctx).V(1) // ๊ธฐ๋ณธ ๋กœ๊ทธ ๋ ˆ๋ฒจ์„ 1๋กœ ์„ค์ • // Fetch the OpenAPIAggregator instance instance := &observabilityv1alpha1.OpenAPIAggregator{} @@ -66,39 +66,43 @@ func (r *OpenAPIAggregatorReconciler) Reconcile(ctx context.Context, req ctrl.Re // List all services based on label selector var services corev1.ServiceList labelSelector := client.MatchingLabels(instance.Spec.LabelSelector) - logger.Info("Listing services", "labelSelector", instance.Spec.LabelSelector) if err := r.List(ctx, &services, labelSelector); err != nil { logger.Error(err, "Failed to list services") return ctrl.Result{}, err } - logger.Info("Found services", "count", len(services.Items)) // Process each service and collect OpenAPI specs var collectedAPIs []observabilityv1alpha1.APIInfo for _, service := range services.Items { - logger.Info("Processing service", "name", service.Name, "namespace", service.Namespace) - if apiInfo := r.processService(ctx, service, instance); apiInfo != nil { - logger.Info("Successfully collected API info", "service", service.Name, "url", apiInfo.URL) + logger.V(1).Info("Collected API info", "service", service.Name, "url", apiInfo.URL) collectedAPIs = append(collectedAPIs, *apiInfo) - } else { - logger.Info("Service skipped", "name", service.Name, "namespace", service.Namespace) } } - // Update status - instance.Status.CollectedAPIs = collectedAPIs - if err := r.Status().Update(ctx, instance); err != nil { - logger.Error(err, "Failed to update OpenAPIAggregator status") - return ctrl.Result{}, err + // Update status with retry on conflict + retryErr := retry.RetryOnConflict(retry.DefaultRetry, func() error { + // Get the latest version + latest := &observabilityv1alpha1.OpenAPIAggregator{} + if err := r.Get(ctx, req.NamespacedName, latest); err != nil { + return err + } + + // Update status + latest.Status.CollectedAPIs = collectedAPIs + return r.Status().Update(ctx, latest) + }) + + if retryErr != nil { + logger.Error(retryErr, "Failed to update OpenAPIAggregator status") + return ctrl.Result{}, retryErr } // Update Swagger UI with collected specs - logger.Info("Updating Swagger UI specs", "count", len(collectedAPIs)) r.swaggerServer.UpdateSpecs(collectedAPIs) - logger.Info("Reconciliation completed", "collectedAPIs", len(collectedAPIs)) + logger.V(1).Info("Reconciliation completed", "collectedAPIs", len(collectedAPIs)) // Requeue after 10 seconds return ctrl.Result{RequeueAfter: time.Second * 10}, nil @@ -110,7 +114,7 @@ func (r *OpenAPIAggregatorReconciler) processService(ctx context.Context, svc co // Check if the service has the required swagger annotation if svc.Annotations[instance.Spec.SwaggerAnnotation] != "true" { - logger.Info("Skipping service - missing swagger annotation", + logger.V(2).Info("Skipping service - missing swagger annotation", "service", svc.Name, "namespace", svc.Namespace, "requiredAnnotation", instance.Spec.SwaggerAnnotation) @@ -120,71 +124,85 @@ func (r *OpenAPIAggregatorReconciler) processService(ctx context.Context, svc co // Get path and port from annotations or defaults path := svc.Annotations[instance.Spec.PathAnnotation] if path == "" { - logger.Info("Using default path", "service", svc.Name, "defaultPath", instance.Spec.DefaultPath) path = instance.Spec.DefaultPath } port := svc.Annotations[instance.Spec.PortAnnotation] if port == "" { - logger.Info("Using default port", "service", svc.Name, "defaultPort", instance.Spec.DefaultPort) port = instance.Spec.DefaultPort } + // Process allowed methods + allowedMethods := make([]string, 0) + methodsStr := svc.Annotations[instance.Spec.AllowedMethodsAnnotation] + + if methodsStr != "" { + // Split the string by comma and trim spaces + for _, method := range strings.Split(methodsStr, ",") { + method = strings.ToLower(strings.TrimSpace(method)) + // Validate method + switch method { + case "get", "put", "post", "delete", "options", "head", "patch", "trace": + allowedMethods = append(allowedMethods, method) + } + } + } + // Create API info apiInfo := &observabilityv1alpha1.APIInfo{ - Name: svc.Name, - ResourceName: svc.Name, - ResourceType: "Service", - Namespace: svc.Namespace, - Path: path, - Port: port, - // URL: fmt.Sprintf("http://%s.%s.svc.cluster.local:%s%s", svc.Name, svc.Namespace, port, path), - URL: fmt.Sprintf("http://%s:%s%s", svc.Spec.ClusterIP, port, path), - LastUpdated: time.Now().Format(time.RFC3339), - Annotations: svc.Annotations, + Name: svc.Name, + ResourceName: svc.Name, + ResourceType: "Service", + Namespace: svc.Namespace, + Path: path, + Port: port, + URL: fmt.Sprintf("http://%s.%s.svc.cluster.local:%s%s", svc.Name, svc.Namespace, port, path), + LastUpdated: time.Now().Format(time.RFC3339), + Annotations: svc.Annotations, + AllowedMethods: allowedMethods, } - // Check if the OpenAPI endpoint is accessible - + // Enable health check to validate accessibility // TODO: Uncomment this line for production use // r.checkAPIHealth(ctx, apiInfo) return apiInfo } -// checkAPIHealth verifies if the OpenAPI endpoint is accessible -func (r *OpenAPIAggregatorReconciler) checkAPIHealth(ctx context.Context, apiInfo *observabilityv1alpha1.APIInfo) { - logger := log.FromContext(ctx).V(1) - - logger.Info("Checking API health", "name", apiInfo.Name, "url", apiInfo.URL) - - client := &http.Client{Timeout: 5 * time.Second} - resp, err := client.Get(apiInfo.URL) - if err != nil { - logger.Info("API health check failed", "name", apiInfo.Name, "error", err) - apiInfo.Error = fmt.Sprintf("Failed to access OpenAPI endpoint: %v", err) - return - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - logger.Info("API health check failed", - "name", apiInfo.Name, - "statusCode", resp.StatusCode, - "headers", resp.Header) - apiInfo.Error = fmt.Sprintf("OpenAPI endpoint returned non-200 status: %d", resp.StatusCode) - return - } - - logger.Info("API health check successful", "name", apiInfo.Name) - apiInfo.Error = "" -} +// // checkAPIHealth verifies if the OpenAPI endpoint is accessible +// func (r *OpenAPIAggregatorReconciler) checkAPIHealth(ctx context.Context, apiInfo *observabilityv1alpha1.APIInfo) { +// logger := log.FromContext(ctx).V(1) + +// client := &http.Client{Timeout: 5 * time.Second} +// resp, err := client.Get(apiInfo.URL) +// if err != nil { +// logger.V(1).Info("API health check failed", "name", apiInfo.Name, "error", err) +// apiInfo.Error = fmt.Sprintf("Failed to access OpenAPI endpoint: %v", err) +// return +// } +// defer func() { +// if err := resp.Body.Close(); err != nil { +// logger.Error(err, "Failed to close response body") +// } +// }() + +// if resp.StatusCode != http.StatusOK { +// logger.V(1).Info("API health check failed", +// "name", apiInfo.Name, +// "statusCode", resp.StatusCode) +// apiInfo.Error = fmt.Sprintf("OpenAPI endpoint returned non-200 status: %d", resp.StatusCode) +// return +// } + +// logger.V(2).Info("API health check successful", "name", apiInfo.Name) +// apiInfo.Error = "" +// } // SetupWithManager sets up the controller with the Manager. func (r *OpenAPIAggregatorReconciler) SetupWithManager(mgr ctrl.Manager) error { // Initialize Swagger UI server - r.swaggerServer = swagger.NewServer() - if !r.TestMode { + if r.swaggerServer == nil && !r.TestMode { + r.swaggerServer = swagger.NewServer() go func() { log.Log.Info("Starting Swagger UI server on HTTP port 9090") if err := r.swaggerServer.Start(9090); err != nil { @@ -195,6 +213,21 @@ func (r *OpenAPIAggregatorReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&observabilityv1alpha1.OpenAPIAggregator{}). - Watches(&corev1.Service{}, &handler.EnqueueRequestForObject{}). + Watches( + &corev1.Service{}, + handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []ctrl.Request { + svc := obj.(*corev1.Service) + // ์„œ๋น„์Šค์— swagger ๊ด€๋ จ ์–ด๋…ธํ…Œ์ด์…˜์ด ์žˆ๋Š” ๊ฒฝ์šฐ์—๋งŒ ๋ฆฌ์ปจ์‹ค๋ ˆ์ด์…˜ ํŠธ๋ฆฌ๊ฑฐ + if val, ok := svc.Annotations["openapi.aggregator.io/swagger"]; ok && val == "true" { + return []ctrl.Request{ + {NamespacedName: types.NamespacedName{ + Name: "openapi-aggregator", + Namespace: svc.Namespace, + }}, + } + } + return nil + }), + ). Complete(r) } diff --git a/pkg/swagger/server.go b/pkg/swagger/server.go index 9b64b2e..893018b 100644 --- a/pkg/swagger/server.go +++ b/pkg/swagger/server.go @@ -1,3 +1,4 @@ +// Package swagger provides a Swagger UI server for displaying OpenAPI specifications package swagger import ( @@ -5,7 +6,9 @@ import ( "encoding/json" "fmt" "io" + "log" "net/http" + "os" "strings" "sync" @@ -15,24 +18,32 @@ import ( //go:embed swagger-ui/* var swaggerUI embed.FS +// APIMetadata represents metadata about an OpenAPI specification type APIMetadata struct { - Name string - URL string - Title string - Version string - Description string + Name string `json:"name"` // API name + URL string `json:"url"` // URL to fetch the OpenAPI spec + Title string `json:"title"` // Display title + Version string `json:"version"` // API version + Description string `json:"description"` // API description + ResourceType string `json:"resourceType"` // Type of resource (e.g., Service, Deployment) + ResourceName string `json:"resourceName"` // Name of the Kubernetes resource + Namespace string `json:"namespace"` // Kubernetes namespace + LastUpdated string `json:"lastUpdated"` // Last update timestamp + AllowedMethods []string `json:"allowedMethods"` // Allowed HTTP methods for Swagger UI } // Server serves the Swagger UI and aggregated OpenAPI specs type Server struct { - specs map[string]APIMetadata - specsMux sync.RWMutex + specs map[string]APIMetadata // Map of API name to metadata + specsMux sync.RWMutex // Mutex for thread-safe access to specs + basePath string // Base path for the server (for Ingress/Route support) } // NewServer creates a new Swagger UI server func NewServer() *Server { return &Server{ - specs: make(map[string]APIMetadata), + specs: make(map[string]APIMetadata), + basePath: os.Getenv("SWAGGER_BASE_PATH"), } } @@ -41,68 +52,54 @@ func (s *Server) UpdateSpecs(apis []observabilityv1alpha1.APIInfo) { s.specsMux.Lock() defer s.specsMux.Unlock() - fmt.Printf("Updating specs with %d APIs\n", len(apis)) - newSpecs := make(map[string]APIMetadata) for _, api := range apis { - fmt.Printf("Processing API %s (URL: %s, Error: %s)\n", api.Name, api.URL, api.Error) + // Skip APIs with errors if api.Error != "" { continue } - // Store only metadata metadata := APIMetadata{ - Name: api.Name, - URL: api.URL, - Title: api.Name, - Description: fmt.Sprintf("API from %s/%s", api.Namespace, api.ResourceName), + Name: api.Name, + URL: api.URL, + Title: api.Name, + Description: fmt.Sprintf("API from %s/%s", api.Namespace, api.ResourceName), + ResourceType: api.ResourceType, + ResourceName: api.ResourceName, + Namespace: api.Namespace, + LastUpdated: api.LastUpdated, + AllowedMethods: api.AllowedMethods, } newSpecs[api.Name] = metadata - fmt.Printf("Added metadata for %s\n", api.Name) } - fmt.Printf("Total APIs processed: %d\n", len(newSpecs)) s.specs = newSpecs } -// fetchSpec fetches the OpenAPI spec from a service URL -func (s *Server) fetchSpec(url string) (map[string]interface{}, error) { - fmt.Printf("Fetching OpenAPI spec from URL: %s\n", url) - - resp, err := http.Get(url) - if err != nil { - return nil, fmt.Errorf("failed to fetch spec: %v", err) - } - defer func() { - if err := resp.Body.Close(); err != nil { - fmt.Printf("Error closing response body: %v\n", err) - } - }() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("service returned status code: %d", resp.StatusCode) +// stripBasePath removes the base path prefix from the request path +func (s *Server) stripBasePath(path string) string { + if s.basePath != "" && strings.HasPrefix(path, s.basePath) { + return strings.TrimPrefix(path, s.basePath) } - - var spec map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&spec); err != nil { - return nil, fmt.Errorf("failed to decode spec: %v", err) - } - - fmt.Printf("Successfully fetched and decoded OpenAPI spec from %s\n", url) - return spec, nil + return path } // serveIndex serves the Swagger UI index page func (s *Server) serveIndex(w http.ResponseWriter, r *http.Request) { indexContent, err := swaggerUI.ReadFile("swagger-ui/index.html") if err != nil { - fmt.Printf("Failed to read index.html: %v\n", err) http.Error(w, "Failed to read index.html", http.StatusInternalServerError) return } + + // Add base path meta tag + htmlContent := string(indexContent) + metaTag := fmt.Sprintf(``, s.basePath) + htmlContent = strings.Replace(htmlContent, "", metaTag+"", 1) + w.Header().Set("Content-Type", "text/html") - if _, err := w.Write(indexContent); err != nil { - fmt.Printf("Error writing index content: %v\n", err) + if _, err := w.Write([]byte(htmlContent)); err != nil { + http.Error(w, "Failed to write response", http.StatusInternalServerError) } } @@ -112,7 +109,9 @@ func (s *Server) serveSpecs(w http.ResponseWriter, r *http.Request) { defer s.specsMux.RUnlock() w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(s.specs) + if err := json.NewEncoder(w).Encode(s.specs); err != nil { + http.Error(w, "Failed to encode specs", http.StatusInternalServerError) + } } // serveIndividualSpec serves individual OpenAPI spec by fetching it in real-time @@ -129,15 +128,23 @@ func (s *Server) serveIndividualSpec(w http.ResponseWriter, r *http.Request) { } // Fetch the spec in real-time - resp, err := http.Get(metadata.URL) - // for test - // fmt.Printf("Metadata for %s: %+v, exists: %v\n", apiName, metadata, exists) - // resp, err := http.Get("https://petstore.swagger.io/v2/swagger.json") + url := metadata.URL + if os.Getenv("DEV_MODE") == "true" { + // In development mode, rewrite any cluster URLs to localhost:8080 + if strings.Contains(url, ".svc.cluster.local:8080") { + url = "http://localhost:8080" + strings.Split(url, ".svc.cluster.local:8080")[1] + } + } + resp, err := http.Get(url) if err != nil { http.Error(w, fmt.Sprintf("Failed to fetch spec: %v", err), http.StatusInternalServerError) return } - defer resp.Body.Close() + defer func() { + if err := resp.Body.Close(); err != nil { + log.Printf("Failed to close response body: %v", err) + } + }() if resp.StatusCode != http.StatusOK { http.Error(w, fmt.Sprintf("Failed to fetch spec, status: %d", resp.StatusCode), resp.StatusCode) @@ -145,48 +152,58 @@ func (s *Server) serveIndividualSpec(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - io.Copy(w, resp.Body) + if _, err := io.Copy(w, resp.Body); err != nil { + http.Error(w, "Failed to copy response", http.StatusInternalServerError) + } } // serveStaticFiles serves embedded static files func (s *Server) serveStaticFiles(w http.ResponseWriter, r *http.Request) { - // For other paths, try to serve from embedded files - // First try assets subdirectory for static files - var content []byte - var err error + path := s.stripBasePath(r.URL.Path) - content, err = swaggerUI.ReadFile("swagger-ui/assets" + r.URL.Path) + // First try assets subdirectory for static files + content, err := swaggerUI.ReadFile("swagger-ui/assets" + path) if err != nil { // If not found in assets, try the root swagger-ui directory - content, err = swaggerUI.ReadFile("swagger-ui" + r.URL.Path) + content, err = swaggerUI.ReadFile("swagger-ui" + path) if err != nil { http.NotFound(w, r) return } } - // Set content type based on file extension - switch { - case strings.HasSuffix(r.URL.Path, ".css"): - w.Header().Set("Content-Type", "text/css") - case strings.HasSuffix(r.URL.Path, ".js"): - w.Header().Set("Content-Type", "application/javascript") - case strings.HasSuffix(r.URL.Path, ".png"): - w.Header().Set("Content-Type", "image/png") - case strings.HasSuffix(r.URL.Path, ".html"): - w.Header().Set("Content-Type", "text/html") - } + // Set appropriate content type based on file extension + contentType := s.getContentType(path) + w.Header().Set("Content-Type", contentType) + w.Header().Set("Cache-Control", "public, max-age=3600") // Add caching for static files if _, err := w.Write(content); err != nil { - fmt.Printf("Error writing content: %v\n", err) + http.Error(w, "Failed to write response", http.StatusInternalServerError) + } +} + +// getContentType determines the content type based on file extension +func (s *Server) getContentType(path string) string { + switch { + case strings.HasSuffix(path, ".css"): + return "text/css" + case strings.HasSuffix(path, ".js"): + return "application/javascript" + case strings.HasSuffix(path, ".png"): + return "image/png" + case strings.HasSuffix(path, ".html"): + return "text/html" + default: + return "application/octet-stream" } } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { - // Set common headers + // Set common headers with more permissive CORS w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + w.Header().Set("Access-Control-Expose-Headers", "Content-Length") // Handle OPTIONS requests for CORS if r.Method == "OPTIONS" { @@ -194,19 +211,24 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + // Strip base path if configured + path := s.stripBasePath(r.URL.Path) + // Route to appropriate handler switch { - case r.URL.Path == "/" || r.URL.Path == "/index.html": + case path == "/" || path == "/index.html": s.serveIndex(w, r) - case r.URL.Path == "/swagger-specs": + case path == "/swagger-specs": s.serveSpecs(w, r) - case strings.HasPrefix(r.URL.Path, "/swagger-specs/"): + case strings.HasPrefix(path, "/api/"): s.serveIndividualSpec(w, r) default: s.serveStaticFiles(w, r) } } +//TODO: swagger์—์„œ ๋ณด๋‚ด๋Š” ์š”์ฒญ์„ ๋ชจ๋‘ server.go๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธํ•˜๋Š” ๊ธฐ๋Šฅ ์ถ”๊ฐ€. ์„œ๋ฒ„๋Š” ์ด๊ฑธ ๋ฐ›์•„์„œ proxy ์š”์ฒญ์„ ๋ณด๋‚ด๋Š” ๊ธฐ๋Šฅ์„ ๊ตฌํ˜„ํ•ด์•ผ ํ•จ. + // Start starts the Swagger UI server func (s *Server) Start(port int) error { // Use the embedded file system instead of serving from disk diff --git a/pkg/swagger/swagger-ui/assets/custom.css b/pkg/swagger/swagger-ui/assets/custom.css new file mode 100644 index 0000000..40c40e3 --- /dev/null +++ b/pkg/swagger/swagger-ui/assets/custom.css @@ -0,0 +1,163 @@ +/* Base styles */ +body { + margin: 0; + padding: 0; + background: #fafafa; +} + +#swagger-ui { + max-width: 1460px; + margin: 0 auto; + padding: 20px; + background: white; + border-radius: 4px; + box-shadow: 0 1px 3px rgba(0,0,0,0.12); +} + +/* Top bar */ +.swagger-ui .topbar { + background: #1b1b1b; + padding: 10px 0; +} + +/* API Selector */ +.api-selector-wrapper { + background: #1b1b1b; + padding: 30px 0 10px; + margin-bottom: 30px; +} + +.api-selector { + max-width: 1460px; + margin: 0 auto; + padding: 0 20px; +} + +.api-selector-title { + color: #fff; + font-size: 24px; + font-weight: bold; + margin-bottom: 20px; + font-family: sans-serif; +} + +.api-selector-subtitle { + color: rgba(255,255,255,0.7); + font-size: 14px; + margin-bottom: 20px; + font-family: sans-serif; +} + +/* Selector Container */ +.selector-container { + display: flex; + gap: 20px; + margin-bottom: 15px; +} + +.selector-group { + flex: 1; +} + +.selector-group label { + display: block; + color: rgba(255,255,255,0.7); + margin-bottom: 5px; + font-size: 12px; + font-family: sans-serif; +} + +/* Input Select Container */ +.api-selector .select-container { + position: relative; + display: flex; + align-items: center; +} + +.api-selector .select-container input { + width: 100%; + padding: 10px 32px 10px 12px; + border: 1px solid #50555e; + border-radius: 4px; + background: #1b1b1b; + color: #fff; + font-family: sans-serif; + font-size: 14px; + appearance: none !important; + -webkit-appearance: none !important; + -moz-appearance: none !important; + background-image: none !important; +} + +.api-selector .select-container::after { + content: ''; + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 6px solid #fff; + pointer-events: none; + z-index: 1; +} + +/* Hide default datalist dropdown indicator */ +input::-webkit-calendar-picker-indicator { + display: none !important; + -webkit-appearance: none; + opacity: 0; +} + +input::-webkit-list-button { + display: none !important; +} + +.api-selector .select-container input:hover { + border-color: #89bf04; + background-color: #222; +} + +.api-selector .select-container input:focus { + outline: none; + border-color: #89bf04; + box-shadow: 0 0 0 1px #89bf04; +} + +/* Select Element */ +.api-selector select:hover { + border-color: #89bf04; + background-color: #222; +} + +.api-selector select:focus { + outline: none; + border-color: #89bf04; + box-shadow: 0 0 0 1px #89bf04; +} + +.api-selector select option { + background: #1b1b1b; + color: #fff; + padding: 8px; +} + +/* API Info */ +.api-info { + margin-top: 10px; + color: rgba(255,255,255,0.7); + font-family: sans-serif; + font-size: 13px; + line-height: 1.5; +} + +.api-info div { + margin: 4px 0; +} + +/* Swagger UI Info */ +.swagger-ui .info { + margin: 20px 0; +} \ No newline at end of file diff --git a/pkg/swagger/swagger-ui/index.html b/pkg/swagger/swagger-ui/index.html index 5f17474..43d912a 100644 --- a/pkg/swagger/swagger-ui/index.html +++ b/pkg/swagger/swagger-ui/index.html @@ -5,102 +5,33 @@ OpenAPI Aggregator + -

OpenAPI Specifications

-
Select an API service to view its documentation
- +
Select a namespace and service to view its documentation
+
+
+ +
+ + + +
+
+
+ +
+ + + +
+
+
@@ -108,153 +39,348 @@

OpenAPI Specifications