diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml index a0dd7f9cb2..fb8842251a 100644 --- a/.github/workflows/api-tests.yml +++ b/.github/workflows/api-tests.yml @@ -32,6 +32,7 @@ env: THINGS_URL: http://localhost:9000 INVITATIONS_URL: http://localhost:9020 AUTH_URL: http://localhost:8189 + BOOTSTRAP_URL: http://localhost:9013 jobs: api-test: @@ -50,7 +51,7 @@ jobs: run: make all -j $(nproc) && make dockers_dev -j $(nproc) - name: Start containers - run: make run up args="-d" && sleep 10 + run: make run up args="-d" && make run_addons up args="-d" - name: Set access token run: | @@ -159,6 +160,16 @@ jobs: report: false args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --contrib-openapi-formats-uuid --hypothesis-suppress-health-check=filter_too_much --stateful=links' + - name: Run Bootstrap API tests + if: steps.changes.outputs.bootstrap == 'true' + uses: schemathesis/action@v1 + with: + schema: api/openapi/bootstrap.yml + base-url: ${{ env.BOOTSTRAP_URL }} + checks: all + report: false + args: '--header "Authorization: Bearer ${{ env.USER_TOKEN }}" --contrib-openapi-formats-uuid --hypothesis-suppress-health-check=filter_too_much --stateful=links' + - name: Stop containers if: always() run: make run down args="-v" diff --git a/Makefile b/Makefile index 1773c04f94..ee33df020a 100644 --- a/Makefile +++ b/Makefile @@ -154,6 +154,7 @@ test_api_users: TEST_API_URL := http://localhost:9002 test_api_things: TEST_API_URL := http://localhost:9000 test_api_invitations: TEST_API_URL := http://localhost:9020 test_api_auth: TEST_API_URL := http://localhost:8189 +test_api_bootstrap: TEST_API_URL := http://localhost:9013 $(TEST_API): $(call test_api_service,$(@),$(TEST_API_URL)) diff --git a/api/openapi/bootstrap.yml b/api/openapi/bootstrap.yml index 6318f0dc54..804c2d262c 100644 --- a/api/openapi/bootstrap.yml +++ b/api/openapi/bootstrap.yml @@ -25,10 +25,11 @@ tags: externalDocs: description: Find out more about Configs url: https://docs.magistrala.abstractmachines.fr/ - + paths: /things/configs: post: + operationId: createConfig summary: Adds new config description: | Adds new config to the list of config owned by user identified using @@ -38,17 +39,28 @@ paths: requestBody: $ref: "#/components/requestBodies/ConfigCreateReq" responses: - '201': + "201": $ref: "#/components/responses/ConfigCreateRes" - '400': + "400": description: Failed due to malformed JSON. - '401': + "401": description: Missing or invalid access token provided. - '415': + "403": + description: Failed to perform authorization over the entity. + "404": + description: A non-existent entity request. + "409": + description: Failed due to using an existing identity. + "415": description: Missing or invalid content type. - '500': + "422": + description: Database can't process request. + "500": $ref: "#/components/responses/ServiceError" + "503": + description: Failed to receive response from the things service. get: + operationId: getConfigs summary: Retrieves managed configs description: | Retrieves a list of managed configs. Due to performance concerns, data @@ -63,31 +75,37 @@ paths: - $ref: "#/components/parameters/State" - $ref: "#/components/parameters/Name" responses: - '200': + "200": $ref: "#/components/responses/ConfigListRes" - '400': + "400": description: Failed due to malformed query parameters. - '401': + "401": description: Missing or invalid access token provided. - '500': + "422": + description: Database can't process request. + "500": $ref: "#/components/responses/ServiceError" /things/configs/{configId}: get: + operationId: getConfig summary: Retrieves config info (with channels). tags: - configs parameters: - $ref: "#/components/parameters/ConfigId" responses: - '200': + "200": $ref: "#/components/responses/ConfigRes" - '401': + "401": description: Missing or invalid access token provided. - '404': + "404": description: Config does not exist. - '500': + "422": + description: Database can't process request. + "500": $ref: "#/components/responses/ServiceError" put: + operationId: updateConfig summary: Updates config info description: | Update is performed by replacing the current resource data with values @@ -98,21 +116,24 @@ paths: parameters: - $ref: "#/components/parameters/ConfigId" requestBody: - $ref: "#/components/requestBodies/ConfigUpdateReq" + $ref: "#/components/requestBodies/ConfigUpdateReq" responses: - '200': + "200": description: Config updated. - '400': + "400": description: Failed due to malformed JSON. - '401': + "401": description: Missing or invalid access token provided. - '404': + "404": description: Config does not exist. - '415': + "415": description: Missing or invalid content type. - '500': + "422": + description: Database can't process request. + "500": $ref: "#/components/responses/ServiceError" delete: + operationId: removeConfig summary: Removes a Config description: | Removes a Config. In case of successful removal the service will ensure @@ -122,16 +143,19 @@ paths: parameters: - $ref: "#/components/parameters/ConfigId" responses: - '204': + "204": description: Config removed. - '400': + "400": description: Failed due to malformed config ID. - '401': + "401": description: Missing or invalid access token provided. - '500': + "422": + description: Database can't process request. + "500": $ref: "#/components/responses/ServiceError" /things/configs/certs/{configId}: patch: + operationId: updateConfigCerts summary: Updates certs description: | Update is performed by replacing the current certificate data with values @@ -143,21 +167,24 @@ paths: requestBody: $ref: "#/components/requestBodies/ConfigCertUpdateReq" responses: - '200': + "200": description: Config updated. $ref: "#/components/responses/ConfigUpdateCertsRes" - '400': + "400": description: Failed due to malformed JSON. - '401': + "401": description: Missing or invalid access token provided. - '404': + "404": description: Config does not exist. - '415': + "415": description: Missing or invalid content type. - '500': + "422": + description: Database can't process request. + "500": $ref: "#/components/responses/ServiceError" /things/configs/connections/{configId}: put: + operationId: updateConfigConnections summary: Updates channels the thing is connected to description: | Update connections performs update of the channel list corresponding @@ -169,20 +196,23 @@ paths: requestBody: $ref: "#/components/requestBodies/ConfigConnUpdateReq" responses: - '200': + "200": description: Config updated. - '400': + "400": description: Failed due to malformed JSON. - '401': + "401": description: Missing or invalid access token provided. - '404': + "404": description: Config does not exist. - '415': + "415": description: Missing or invalid content type. - '500': + "422": + description: Database can't process request. + "500": $ref: "#/components/responses/ServiceError" /things/bootstrap/{externalId}: get: + operationId: getBootstrapConfig summary: Retrieves configuration. description: | Retrieves a configuration with given external ID and external key. @@ -193,18 +223,21 @@ paths: parameters: - $ref: "#/components/parameters/ExternalId" responses: - '200': + "200": $ref: "#/components/responses/BootstrapConfigRes" - '400': + "400": description: Failed due to malformed JSON. - '401': + "401": description: Missing or invalid external key provided. - '404': + "404": description: Failed to retrieve corresponding config. - '500': + "422": + description: Database can't process request. + "500": $ref: "#/components/responses/ServiceError" /things/bootstrap/secure/{externalId}: get: + operationId: getSecureBootstrapConfig summary: Retrieves configuration. description: | Retrieves a configuration with given external ID and encrypted external key. @@ -215,15 +248,22 @@ paths: parameters: - $ref: "#/components/parameters/ExternalId" responses: - '200': + "200": $ref: "#/components/responses/BootstrapConfigRes" - '404': + "400": + description: Failed due to malformed JSON. + "401": + description: Missing or invalid access token provided. + "404": description: | Failed to retrieve corresponding config. - '500': + "422": + description: Database can't process request. + "500": $ref: "#/components/responses/ServiceError" /things/state/{configId}: put: + operationId: updateConfigState summary: Updates Config state. description: | Updating state represents enabling/disabling Config, i.e. connecting @@ -233,15 +273,21 @@ paths: parameters: - $ref: "#/components/parameters/ConfigId" requestBody: - $ref: '#/components/requestBodies/ConfigStateUpdateReq' + $ref: "#/components/requestBodies/ConfigStateUpdateReq" responses: - '204': + "204": description: Config removed. - '400': + "400": description: Failed due to malformed config's ID. - '401': + "401": description: Missing or invalid access token provided. - '500': + "404": + description: A non-existent entity request. + "415": + description: Missing or invalid content type. + "422": + description: Database can't process request. + "500": $ref: "#/components/responses/ServiceError" /health: get: @@ -249,9 +295,9 @@ paths: tags: - health responses: - '200': + "200": $ref: "#/components/responses/HealthRes" - '500': + "500": $ref: "#/components/responses/ServiceError" components: @@ -453,12 +499,14 @@ components: description: External key. thing_id: type: string + format: uuid description: ID of the corresponding Magistrala Thing. channels: type: array minItems: 0 items: type: string + format: uuid content: type: string name: @@ -468,7 +516,7 @@ components: description: Thing Certificate. client_key: type: string - description: Thing Private Key. + description: Thing Private Key. ca_cert: type: string required: @@ -513,6 +561,7 @@ components: minItems: 0 items: type: string + format: uuid ConfigStateUpdateReq: description: Update the state of the Config. content: @@ -525,14 +574,14 @@ components: responses: ConfigCreateRes: - description: Config registered. - headers: - Location: - content: - text/plain: - schema: - type: string - description: Created configuration's relative URL (i.e. /things/configs/{configId}). + description: Config registered. + headers: + Location: + content: + text/plain: + schema: + type: string + description: Created configuration's relative URL (i.e. /things/configs/{configId}). ConfigListRes: description: Data retrieved. Configs from this list don't contain channels. content: @@ -545,10 +594,31 @@ components: application/json: schema: $ref: "#/components/schemas/Config" + links: + update: + operationId: updateConfig + parameters: + configId: $response.body#/id + updateCerts: + operationId: updateConfigCerts + parameters: + configId: $response.body#/id + updateConnections: + operationId: updateConfigConnections + parameters: + configId: $response.body#/id + updateState: + operationId: updateConfigState + parameters: + configId: $response.body#/id + delete: + operationId: removeConfig + parameters: + configId: $response.body#/id BootstrapConfigRes: description: | - Data retrieved. If secure, a response is encrypted using - the secret key, so the response is in the binary form. + Data retrieved. If secure, a response is encrypted using + the secret key, so the response is in the binary form. content: application/json: schema: @@ -558,7 +628,7 @@ components: HealthRes: description: Service Health Check. content: - application/json: + application/health+json: schema: $ref: "./schemas/HealthInfo.yml" ConfigUpdateCertsRes: diff --git a/bootstrap/api/endpoint_test.go b/bootstrap/api/endpoint_test.go index 7203f4c0e9..881eb1f2f5 100644 --- a/bootstrap/api/endpoint_test.go +++ b/bootstrap/api/endpoint_test.go @@ -1162,7 +1162,7 @@ func TestBootstrap(t *testing.T) { desc: "bootstrap a Thing with an empty key", externalID: c.ExternalID, externalKey: "", - status: http.StatusUnauthorized, + status: http.StatusBadRequest, res: missingKeyRes, secure: false, err: errors.Wrap(bootstrap.ErrBootstrap, svcerr.ErrAuthentication), diff --git a/bootstrap/api/transport.go b/bootstrap/api/transport.go index 572a45126a..a8346162c8 100644 --- a/bootstrap/api/transport.go +++ b/bootstrap/api/transport.go @@ -13,9 +13,9 @@ import ( "github.com/absmach/magistrala" "github.com/absmach/magistrala/bootstrap" + "github.com/absmach/magistrala/internal/api" "github.com/absmach/magistrala/internal/apiutil" "github.com/absmach/magistrala/pkg/errors" - svcerr "github.com/absmach/magistrala/pkg/errors/service" "github.com/go-chi/chi/v5" kithttp "github.com/go-kit/kit/transport/http" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -41,7 +41,7 @@ var ( // MakeHandler returns a HTTP handler for API endpoints. func MakeHandler(svc bootstrap.Service, reader bootstrap.ConfigReader, logger *slog.Logger, instanceID string) http.Handler { opts := []kithttp.ServerOption{ - kithttp.ServerErrorEncoder(apiutil.LoggingErrorEncoder(logger, encodeError)), + kithttp.ServerErrorEncoder(apiutil.LoggingErrorEncoder(logger, api.EncodeError)), } r := chi.NewRouter() @@ -51,43 +51,43 @@ func MakeHandler(svc bootstrap.Service, reader bootstrap.ConfigReader, logger *s r.Post("/", otelhttp.NewHandler(kithttp.NewServer( addEndpoint(svc), decodeAddRequest, - encodeResponse, + api.EncodeResponse, opts...), "add").ServeHTTP) r.Get("/", otelhttp.NewHandler(kithttp.NewServer( listEndpoint(svc), decodeListRequest, - encodeResponse, + api.EncodeResponse, opts...), "list").ServeHTTP) r.Get("/{configID}", otelhttp.NewHandler(kithttp.NewServer( viewEndpoint(svc), decodeEntityRequest, - encodeResponse, + api.EncodeResponse, opts...), "view").ServeHTTP) r.Put("/{configID}", otelhttp.NewHandler(kithttp.NewServer( updateEndpoint(svc), decodeUpdateRequest, - encodeResponse, + api.EncodeResponse, opts...), "update").ServeHTTP) r.Delete("/{configID}", otelhttp.NewHandler(kithttp.NewServer( removeEndpoint(svc), decodeEntityRequest, - encodeResponse, + api.EncodeResponse, opts...), "remove").ServeHTTP) r.Patch("/certs/{certID}", otelhttp.NewHandler(kithttp.NewServer( updateCertEndpoint(svc), decodeUpdateCertRequest, - encodeResponse, + api.EncodeResponse, opts...), "update_cert").ServeHTTP) r.Put("/connections/{connID}", otelhttp.NewHandler(kithttp.NewServer( updateConnEndpoint(svc), decodeUpdateConnRequest, - encodeResponse, + api.EncodeResponse, opts...), "update_connections").ServeHTTP) }) @@ -95,12 +95,12 @@ func MakeHandler(svc bootstrap.Service, reader bootstrap.ConfigReader, logger *s r.Get("/", otelhttp.NewHandler(kithttp.NewServer( bootstrapEndpoint(svc, reader, false), decodeBootstrapRequest, - encodeResponse, + api.EncodeResponse, opts...), "bootstrap").ServeHTTP) r.Get("/{externalID}", otelhttp.NewHandler(kithttp.NewServer( bootstrapEndpoint(svc, reader, false), decodeBootstrapRequest, - encodeResponse, + api.EncodeResponse, opts...), "bootstrap").ServeHTTP) r.Get("/secure/{externalID}", otelhttp.NewHandler(kithttp.NewServer( bootstrapEndpoint(svc, reader, true), @@ -112,7 +112,7 @@ func MakeHandler(svc bootstrap.Service, reader bootstrap.ConfigReader, logger *s r.Put("/state/{thingID}", otelhttp.NewHandler(kithttp.NewServer( stateEndpoint(svc), decodeStateRequest, - encodeResponse, + api.EncodeResponse, opts...), "update_state").ServeHTTP) }) r.Get("/health", magistrala.Health("bootstrap", instanceID)) @@ -242,23 +242,6 @@ func decodeEntityRequest(_ context.Context, r *http.Request) (interface{}, error return req, nil } -func encodeResponse(_ context.Context, w http.ResponseWriter, response interface{}) error { - w.Header().Set("Content-Type", contentType) - if ar, ok := response.(magistrala.Response); ok { - for k, v := range ar.Headers() { - w.Header().Set(k, v) - } - - w.WriteHeader(ar.Code()) - - if ar.Empty() { - return nil - } - } - - return json.NewEncoder(w).Encode(response) -} - func encodeSecureRes(_ context.Context, w http.ResponseWriter, response interface{}) error { w.Header().Set("Content-Type", byteContentType) w.WriteHeader(http.StatusOK) @@ -270,57 +253,6 @@ func encodeSecureRes(_ context.Context, w http.ResponseWriter, response interfac return nil } -func encodeError(_ context.Context, err error, w http.ResponseWriter) { - var wrapper error - if errors.Contains(err, apiutil.ErrValidation) { - wrapper, err = errors.Unwrap(err) - } - - switch { - case errors.Contains(err, svcerr.ErrAuthentication), - errors.Contains(err, apiutil.ErrBearerToken), - errors.Contains(err, apiutil.ErrBearerKey): - w.WriteHeader(http.StatusUnauthorized) - case errors.Contains(err, apiutil.ErrUnsupportedContentType): - w.WriteHeader(http.StatusUnsupportedMediaType) - case errors.Contains(err, apiutil.ErrInvalidQueryParams), - errors.Contains(err, svcerr.ErrMalformedEntity), - errors.Contains(err, apiutil.ErrMissingID), - errors.Contains(err, apiutil.ErrBootstrapState), - errors.Contains(err, apiutil.ErrLimitSize): - w.WriteHeader(http.StatusBadRequest) - case errors.Contains(err, svcerr.ErrNotFound): - w.WriteHeader(http.StatusNotFound) - case errors.Contains(err, bootstrap.ErrExternalKey), - errors.Contains(err, bootstrap.ErrExternalKeySecure), - errors.Contains(err, svcerr.ErrAuthorization): - w.WriteHeader(http.StatusForbidden) - case errors.Contains(err, bootstrap.ErrThings): - w.WriteHeader(http.StatusServiceUnavailable) - case errors.Contains(err, svcerr.ErrConflict): - w.WriteHeader(http.StatusConflict) - case errors.Contains(err, svcerr.ErrCreateEntity), - errors.Contains(err, svcerr.ErrUpdateEntity), - errors.Contains(err, svcerr.ErrViewEntity), - errors.Contains(err, svcerr.ErrRemoveEntity): - w.WriteHeader(http.StatusInternalServerError) - - default: - w.WriteHeader(http.StatusInternalServerError) - } - - if wrapper != nil { - err = errors.Wrap(wrapper, err) - } - - if errorVal, ok := err.(errors.Error); ok { - w.Header().Set("Content-Type", contentType) - if err := json.NewEncoder(w).Encode(errorVal); err != nil { - w.WriteHeader(http.StatusInternalServerError) - } - } -} - func parseFilter(values url.Values) bootstrap.Filter { ret := bootstrap.Filter{ FullMatch: make(map[string]string), diff --git a/internal/api/common.go b/internal/api/common.go index bad612c4be..0e290e378c 100644 --- a/internal/api/common.go +++ b/internal/api/common.go @@ -9,6 +9,7 @@ import ( "net/http" "github.com/absmach/magistrala" + "github.com/absmach/magistrala/bootstrap" "github.com/absmach/magistrala/internal/apiutil" mgclients "github.com/absmach/magistrala/pkg/clients" "github.com/absmach/magistrala/pkg/errors" @@ -129,10 +130,11 @@ func EncodeError(_ context.Context, err error, w http.ResponseWriter) { errors.Contains(err, apiutil.ErrMissingRelation), errors.Contains(err, apiutil.ErrPasswordFormat), errors.Contains(err, apiutil.ErrInvalidLevel), - errors.Contains(err, apiutil.ErrInvalidQueryParams), errors.Contains(err, apiutil.ErrMalformedPolicy), errors.Contains(err, apiutil.ErrInvalidAPIKey), - errors.Contains(err, apiutil.ErrMissingName): + errors.Contains(err, apiutil.ErrMissingName), + errors.Contains(err, apiutil.ErrBootstrapState), + errors.Contains(err, apiutil.ErrInvalidQueryParams): w.WriteHeader(http.StatusBadRequest) case errors.Contains(err, svcerr.ErrAuthentication), errors.Contains(err, svcerr.ErrLogin), @@ -144,7 +146,9 @@ func EncodeError(_ context.Context, err error, w http.ResponseWriter) { errors.Contains(err, errors.ErrStatusAlreadyAssigned): w.WriteHeader(http.StatusConflict) case errors.Contains(err, svcerr.ErrAuthorization), - errors.Contains(err, svcerr.ErrDomainAuthorization): + errors.Contains(err, svcerr.ErrDomainAuthorization), + errors.Contains(err, bootstrap.ErrExternalKey), + errors.Contains(err, bootstrap.ErrExternalKeySecure): w.WriteHeader(http.StatusForbidden) case errors.Contains(err, apiutil.ErrUnsupportedContentType): w.WriteHeader(http.StatusUnsupportedMediaType) @@ -156,6 +160,8 @@ func EncodeError(_ context.Context, err error, w http.ResponseWriter) { errors.Contains(err, svcerr.ErrDeletePolicies), errors.Contains(err, svcerr.ErrRemoveEntity): w.WriteHeader(http.StatusUnprocessableEntity) + case errors.Contains(err, bootstrap.ErrThings): + w.WriteHeader(http.StatusServiceUnavailable) default: w.WriteHeader(http.StatusInternalServerError) }