diff --git a/cloudsmith_cli/core/api/metadata.py b/cloudsmith_cli/core/api/metadata.py index 4f1595b9..5091d6f1 100644 --- a/cloudsmith_cli/core/api/metadata.py +++ b/cloudsmith_cli/core/api/metadata.py @@ -165,9 +165,9 @@ def list_metadata( response = _request( client, "GET", + "metadata", "packages", package_slug_perm, - "metadata", query_params=api_kwargs or None, ) @@ -183,9 +183,9 @@ def get_metadata(package_slug_perm: str, metadata_slug_perm: str): response = _request( client, "GET", + "metadata", "packages", package_slug_perm, - "metadata", metadata_slug_perm, ) return _response_json(response) @@ -206,7 +206,7 @@ def create_metadata( "source_identity": source_identity, } response = _request( - client, "POST", "packages", package_slug_perm, "metadata", body=body + client, "POST", "metadata", "packages", package_slug_perm, body=body ) return _response_json(response) @@ -238,9 +238,9 @@ def update_metadata( response = _request( client, "PATCH", + "metadata", "packages", package_slug_perm, - "metadata", metadata_slug_perm, body=body, ) @@ -253,8 +253,20 @@ def delete_metadata(package_slug_perm: str, metadata_slug_perm: str): _request( client, "DELETE", + "metadata", "packages", package_slug_perm, - "metadata", metadata_slug_perm, ) + + +def validate_metadata(*, content: Any, content_type: str): + """Validate a metadata payload against its content type schema. + + Hits POST /v2/metadata/validate/ which checks shape and schema without + persisting. Server returns 200 on success and 422 on validation failure. + """ + client = get_metadata_api() + body = {"content": content, "content_type": content_type} + _request(client, "POST", "metadata", "validate", body=body) + return True diff --git a/cloudsmith_cli/core/tests/test_metadata.py b/cloudsmith_cli/core/tests/test_metadata.py index b0c10a65..dee1971d 100644 --- a/cloudsmith_cli/core/tests/test_metadata.py +++ b/cloudsmith_cli/core/tests/test_metadata.py @@ -15,8 +15,9 @@ API_HOST = "https://api.cloudsmith.io" PKG = "pkg-slug" META = "meta-slug" -LIST_URL = f"{API_HOST}/v2/packages/{PKG}/metadata/" -DETAIL_URL = f"{API_HOST}/v2/packages/{PKG}/metadata/{META}/" +LIST_URL = f"{API_HOST}/v2/metadata/packages/{PKG}/" +DETAIL_URL = f"{API_HOST}/v2/metadata/packages/{PKG}/{META}/" +VALIDATE_URL = f"{API_HOST}/v2/metadata/validate/" @pytest.fixture(autouse=True) @@ -440,6 +441,131 @@ def test_422_raises(self): assert exc_info.value.fields == {"non_field_errors": ["Metadata is read-only."]} +class TestValidateMetadata: + @httpretty.activate(allow_net_connect=False) + def test_success_returns_true(self): + httpretty.register_uri(httpretty.POST, VALIDATE_URL, status=200) + + assert ( + metadata.validate_metadata( + content={"foo": "bar"}, content_type="application/json" + ) + is True + ) + + sent = _last_request() + assert sent.method == "POST" + assert json.loads(sent.body) == { + "content": {"foo": "bar"}, + "content_type": "application/json", + } + assert sent.headers.get("X-Api-Key") == "test-api-key" + + @httpretty.activate(allow_net_connect=False) + def test_422_on_non_dict_content(self): + body = { + "code": "invalid", + "detail": "Invalid input.", + "fields": {"content": ["Content must be a JSON object."]}, + } + httpretty.register_uri( + httpretty.POST, + VALIDATE_URL, + body=json.dumps(body), + status=422, + content_type="application/json", + ) + + with pytest.raises(ApiException) as exc_info: + metadata.validate_metadata( + content="not-an-object", content_type="application/json" + ) + + assert exc_info.value.status == 422 + assert exc_info.value.detail == "Invalid input." + assert exc_info.value.fields == {"content": ["Content must be a JSON object."]} + + @httpretty.activate(allow_net_connect=False) + def test_422_on_failing_schema(self): + body = { + "code": "invalid", + "detail": "Invalid input.", + "fields": { + "content": [ + "Content does not conform to the schema for content type" + " 'application/vnd.jfrog.buildinfo+json'." + ] + }, + } + httpretty.register_uri( + httpretty.POST, + VALIDATE_URL, + body=json.dumps(body), + status=422, + content_type="application/json", + ) + + with pytest.raises(ApiException) as exc_info: + metadata.validate_metadata( + content={"bad": "payload"}, + content_type="application/vnd.jfrog.buildinfo+json", + ) + + assert exc_info.value.status == 422 + assert exc_info.value.detail == "Invalid input." + assert "content" in exc_info.value.fields + + @httpretty.activate(allow_net_connect=False) + def test_422_on_non_customer_writable_content_type(self): + body = { + "code": "invalid", + "detail": "Invalid input.", + "fields": { + "content_type": [ + "Content type 'application/vnd.cloudsmith.system+json'" + " is not customer-writable." + ] + }, + } + httpretty.register_uri( + httpretty.POST, + VALIDATE_URL, + body=json.dumps(body), + status=422, + content_type="application/json", + ) + + with pytest.raises(ApiException) as exc_info: + metadata.validate_metadata( + content={"foo": "bar"}, + content_type="application/vnd.cloudsmith.system+json", + ) + + assert exc_info.value.status == 422 + assert exc_info.value.detail == "Invalid input." + assert "content_type" in exc_info.value.fields + + @httpretty.activate(allow_net_connect=False) + def test_401_when_unauthenticated(self): + httpretty.register_uri( + httpretty.POST, + VALIDATE_URL, + body=json.dumps( + {"detail": "Authentication credentials were not provided."} + ), + status=401, + content_type="application/json", + ) + + with pytest.raises(ApiException) as exc_info: + metadata.validate_metadata( + content={"foo": "bar"}, content_type="application/json" + ) + + assert exc_info.value.status == 401 + assert exc_info.value.detail == "Authentication credentials were not provided." + + class TestAuthHeaders: @staticmethod def _override_config(monkeypatch, *, api_key=None, headers=None):