Merge "Add JSON schema to limits"

This commit is contained in:
Zuul 2024-11-25 11:03:02 +00:00 committed by Gerrit Code Review
commit 516a341b90
5 changed files with 428 additions and 177 deletions

View File

@ -17,10 +17,10 @@ import http.client
import flask
import flask_restful
from keystone.api import validation
from keystone.common import json_home
from keystone.common import provider_api
from keystone.common import rbac_enforcer
from keystone.common import validation
from keystone import exception
from keystone.limit import schema
from keystone.server import flask as ks_flask
@ -58,7 +58,13 @@ class LimitsResource(ks_flask.ResourceBase):
api='unified_limit_api', method='get_limit'
)
def _list_limits(self):
@validation.request_query_schema(schema.limits_index_request_query)
@validation.response_body_schema(schema.limits_index_response_body)
def get(self):
"""List limits.
GET /v3/limits
"""
filters = [
'service_id',
'region_id',
@ -95,25 +101,17 @@ class LimitsResource(ks_flask.ResourceBase):
return self.wrap_collection(filtered_refs, hints=hints)
def _get_limit(self, limit_id):
ENFORCER.enforce_call(
action='identity:get_limit',
build_target=_build_limit_enforcement_target,
)
ref = PROVIDERS.unified_limit_api.get_limit(limit_id)
return self.wrap_member(ref)
def get(self, limit_id=None):
if limit_id is not None:
return self._get_limit(limit_id)
return self._list_limits()
@validation.request_body_schema(schema.limits_create_request_body)
@validation.response_body_schema(schema.limits_create_response_body)
def post(self):
"""Create new limits.
POST /v3/limits
"""
ENFORCER.enforce_call(action='identity:create_limits')
limits_b = (flask.request.get_json(silent=True, force=True) or {}).get(
'limits', {}
)
validation.lazy_validate(schema.limit_create, limits_b)
limits = [
self._assign_unique_id(self._normalize_dict(limit))
for limit in limits_b
@ -123,17 +121,51 @@ class LimitsResource(ks_flask.ResourceBase):
refs.pop('links')
return refs, http.client.CREATED
class LimitResource(ks_flask.ResourceBase):
collection_key = 'limits'
member_key = 'limit'
json_home_resource_status = json_home.Status.EXPERIMENTAL
get_member_from_driver = PROVIDERS.deferred_provider_lookup(
api='unified_limit_api', method='get_limit'
)
@validation.request_body_schema(None)
@validation.response_body_schema(schema.limit_show_response_body)
def get(self, limit_id):
"""Retrieve an existing limit.
GET /v3/limits/{limit_id}
"""
ENFORCER.enforce_call(
action='identity:get_limit',
build_target=_build_limit_enforcement_target,
)
ref = PROVIDERS.unified_limit_api.get_limit(limit_id)
return self.wrap_member(ref)
@validation.request_body_schema(schema.limit_update_request_body)
@validation.response_body_schema(schema.limit_show_response_body)
def patch(self, limit_id):
"""Update an existing limit.
PATCH /v3/limits/{limit_id}
"""
ENFORCER.enforce_call(action='identity:update_limit')
limit = (flask.request.get_json(silent=True, force=True) or {}).get(
'limit', {}
)
validation.lazy_validate(schema.limit_update, limit)
self._require_matching_id(limit)
ref = PROVIDERS.unified_limit_api.update_limit(limit_id, limit)
return self.wrap_member(ref)
@validation.request_body_schema(None)
@validation.response_body_schema(None)
def delete(self, limit_id):
"""Delete a limit.
DELETE /v3/limits/{limit_id}
"""
ENFORCER.enforce_call(action='identity:delete_limit')
return (
PROVIDERS.unified_limit_api.delete_limit(limit_id),
@ -142,7 +174,13 @@ class LimitsResource(ks_flask.ResourceBase):
class LimitModelResource(flask_restful.Resource):
@validation.request_body_schema(None)
@validation.response_body_schema(schema.limit_model_show_response_body)
def get(self):
"""Retrieve enforcement model.
GET /v3/limits/model
"""
ENFORCER.enforce_call(action='identity:get_limit_model')
model = PROVIDERS.unified_limit_api.get_model()
return {'model': model}
@ -151,15 +189,32 @@ class LimitModelResource(flask_restful.Resource):
class LimitsAPI(ks_flask.APIBase):
_name = 'limits'
_import_name = __name__
resources = [LimitsResource]
resource_mapping = [
ks_flask.construct_resource_map(
resource=LimitsResource,
url='/limits',
resource_kwargs={},
rel="limits",
path_vars=None,
status=json_home.Status.EXPERIMENTAL,
),
ks_flask.construct_resource_map(
resource=LimitResource,
url='/limits/<string:limit_id>',
resource_kwargs={},
rel="limit",
path_vars={
'limit_id': json_home.build_v3_parameter_relation("limit_id")
},
status=json_home.Status.EXPERIMENTAL,
),
ks_flask.construct_resource_map(
resource=LimitModelResource,
resource_kwargs={},
url='/limits/model',
rel='limit_model',
status=json_home.Status.EXPERIMENTAL,
)
),
]

View File

@ -21,6 +21,7 @@ name: dict[str, Any] = {
"minLength": 1,
"maxLength": 255,
"pattern": r"[\S]+",
"description": "The resource name.",
}
boolean = {
@ -28,11 +29,36 @@ boolean = {
"enum": [True, "True", "TRUE", "true", False, "False", "FALSE", "false"],
}
description: dict[str, Any] = {
"type": "string",
"description": "The resource description.",
}
domain_id: dict[str, str] = {"type": "string"}
domain_id: dict[str, Any] = {
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": r"^[a-zA-Z0-9-]+$",
"description": "The ID of the domain.",
}
project_id: dict[str, Any] = {
"type": "string",
"minLength": 1,
"maxLength": 64,
"pattern": r"^[a-zA-Z0-9-]+$",
"description": "The ID of the project.",
}
parent_id: dict[str, str] = {"type": "string", "format": "uuid"}
region_id: dict[str, Any] = {
"type": ["string", "null"],
"minLength": 1,
"maxLength": 255,
"description": "The ID of the region.",
}
_tag_name_property = {
"type": "string",
"minLength": 1,
@ -41,7 +67,7 @@ _tag_name_property = {
# guidelines as set by the API-WG, which matches anything that
# does not contain a '/' or ','.
# https://specs.openstack.org/openstack/api-wg/guidelines/tags.html
"pattern": "^[^,/]*$",
"pattern": r"^[^,/]*$",
}
tags: dict[str, Any] = {

View File

@ -17,6 +17,7 @@ from typing import Any
# Common schema for resource `link` attribute
links: dict[str, Any] = {
"type": "object",
"description": "Links for the collection of resources.",
"properties": {
"next": {"type": ["string", "null"], "format": "uri"},
"previous": {"type": ["string", "null"], "format": "uri"},
@ -24,13 +25,16 @@ links: dict[str, Any] = {
},
"required": ["self"],
"additionalProperties": False,
"readOnly": True,
}
# Resource `links` attribute schema
resource_links: dict[str, Any] = {
"type": "object",
"description": "The link to the resource in question.",
"properties": {"self": {"type": "string", "format": "uri"}},
"additionalProperties": False,
"readOnly": True,
}
truncated: dict[str, Any] = {

View File

@ -12,11 +12,15 @@
# License for the specific language governing permissions and limitations
# under the License.
from typing import Any
from keystone.api.validation import parameter_types
from keystone.api.validation import response_types
from keystone.common import validation
from keystone.common.validation import parameter_types
from keystone.common.validation import parameter_types as old_parameter_types
_registered_limit_properties = {
'service_id': parameter_types.id_string,
'service_id': old_parameter_types.id_string,
'region_id': {'type': ['null', 'string']},
'resource_name': {'type': 'string', 'minLength': 1, 'maxLength': 255},
'default_limit': {
@ -24,7 +28,7 @@ _registered_limit_properties = {
'minimum': -1,
'maximum': 0x7FFFFFFF, # The maximum value a signed INT may have
},
'description': validation.nullable(parameter_types.description),
'description': validation.nullable(old_parameter_types.description),
}
_registered_limit_create = {
@ -45,71 +49,214 @@ registered_limit_update = {
'additionalProperties': False,
}
_project_limit_create_properties = {
'project_id': parameter_types.id_string,
'service_id': parameter_types.id_string,
'region_id': {'type': 'string'},
'resource_name': {'type': 'string', 'minLength': 1, 'maxLength': 255},
'resource_limit': {
'type': 'integer',
'minimum': -1,
'maximum': 0x7FFFFFFF, # The maximum value a signed INT may have
},
'description': validation.nullable(parameter_types.description),
_limit_integer_type = {
"type": "integer",
"minimum": -1,
"maximum": 0x7FFFFFFF, # The maximum value a signed INT may have
}
_domain_limit_create_properties = {
'domain_id': parameter_types.id_string,
'service_id': parameter_types.id_string,
'region_id': {'type': 'string'},
'resource_name': {'type': 'string', 'minLength': 1, 'maxLength': 255},
'resource_limit': {
'type': 'integer',
'minimum': -1,
'maximum': 0x7FFFFFFF, # The maximum value a signed INT may have
# Individual properties of the `Limit`
_limit_properties = {
"resource_name": parameter_types.name,
"region_id": {
"description": (
"The ID of the region that contains the service endpoint."
),
**parameter_types.region_id,
},
'description': validation.nullable(parameter_types.description),
"service_id": {
"type": "string",
"format": "uuid",
"description": "The UUID of the service to which the limit belongs.",
},
"resource_limit": {
"description": "The override limit.",
**_limit_integer_type,
},
"description": validation.nullable(parameter_types.description),
}
# Common schema of `Limit` resource
limit_schema: dict[str, Any] = {
"type": "object",
"description": "A limit object.",
"properties": {
"id": {
"type": "string",
"format": "uuid",
"description": "The limit ID.",
"readOnly": True,
},
"project_id": validation.nullable(parameter_types.project_id),
"domain_id": validation.nullable(parameter_types.domain_id),
"links": response_types.resource_links,
**_limit_properties,
},
"additionalProperties": False,
}
# Response body of API operations returning a single limit
# `GET /limits/{limit_id}` and `PATCH /limits/{limit_id}`
limit_show_response_body: dict[str, Any] = {
"type": "object",
"properties": {"limit": limit_schema},
"additionalProperties": False,
}
# Query parameters of the `GET /limits` API operation
# returning a list of limits
limits_index_request_query: dict[str, Any] = {
"type": "object",
"properties": {
"service_id": {
"type": "string",
"format": "uuid",
"description": "Filters the response by a service ID.",
},
"region_id": {
"description": "Filters the response by a region ID.",
**parameter_types.region_id,
},
"resource_name": {
"description": (
"Filters the response by a specified resource name."
),
**parameter_types.name,
},
"project_id": {
"description": "Filters the response by a project ID.",
**parameter_types.project_id,
},
"domain_id": {
"description": "Filters the response by a domain ID.",
**parameter_types.domain_id,
},
},
"additionalProperties": False,
}
# Response body of the `GET /limits` API operation
# returning a list of limits
limits_index_response_body: dict[str, Any] = {
"type": "object",
"properties": {
"links": response_types.links,
"limits": {
"type": "array",
"items": limit_schema,
"description": "A list of limit objects.",
},
"truncated": response_types.truncated,
},
"additionalProperties": False,
}
# Response body of the `GET /limits/model` API operation
# returning an enforcement model
limit_model_show_response_body: dict[str, Any] = {
"type": "object",
"properties": {
"model": {
"type": "object",
"description": (
"A model object describing the configured enforcement model "
"used by the deployment."
),
"properties": {
"description": {
"type": "string",
"description": (
"A short description of the enforcement model used."
),
},
"name": {
**parameter_types.name,
"description": "The name of the enforcement model.",
},
},
},
"additionalProperties": False,
},
"additionalProperties": False,
}
# Individual properties for creating a new limit
_limit_create = {
'type': 'object',
'oneOf': [
"type": "object",
"properties": {
"project_id": validation.nullable(parameter_types.project_id),
"domain_id": validation.nullable(parameter_types.domain_id),
**_limit_properties,
},
"required": ["service_id", "resource_name", "resource_limit"],
"oneOf": [
{
'properties': _project_limit_create_properties,
'required': [
'project_id',
'service_id',
'resource_name',
'resource_limit',
],
'additionalProperties': False,
"required": [
"service_id",
"resource_name",
"resource_limit",
"domain_id",
]
},
{
'properties': _domain_limit_create_properties,
'required': [
'domain_id',
'service_id',
'resource_name',
'resource_limit',
],
'additionalProperties': False,
"required": [
"service_id",
"resource_name",
"resource_limit",
"project_id",
]
},
],
"additionalProperties": False,
}
limit_create = {'type': 'array', 'items': _limit_create, 'minItems': 1}
_limit_update_properties = {
'resource_limit': {
'type': 'integer',
'minimum': -1,
'maximum': 0x7FFFFFFF, # The maximum value a signed INT may have
# Request body of the `POST /limits` API operation
limits_create_request_body = {
"type": "object",
"properties": {
"limits": {
"type": "array",
"items": _limit_create,
"minItems": 1,
"description": "A list of limit objects.",
}
},
'description': validation.nullable(parameter_types.description),
"required": ["limits"],
"additionalProperties": False,
}
limit_update = {
'type': 'object',
'properties': _limit_update_properties,
'additionalProperties': False,
# Response body of the `POST /limits` API operation
limits_create_response_body: dict[str, Any] = {
"type": "object",
"properties": {
"limits": {
"type": "array",
"items": limit_schema,
"description": "A list of limit objects.",
}
},
"additionalProperties": False,
}
# Request body of the `PATCH /limits/{limit_id}` operation
limit_update_request_body = {
"type": "object",
"properties": {
"limit": {
"type": "object",
"description": "Updates to make to a limit.",
"properties": {
"resource_limit": {
"description": "The override limit.",
**_limit_integer_type,
},
"description": validation.nullable(
parameter_types.description
),
},
"additionalProperties": False,
}
},
"additionalProperties": False,
"required": ["limit"],
}

View File

@ -3060,8 +3060,8 @@ class LimitValidationTestCase(unit.BaseTestCase):
create_registered_limits = limit_schema.registered_limit_create
update_registered_limits = limit_schema.registered_limit_update
create_limits = limit_schema.limit_create
update_limits = limit_schema.limit_update
create_limits = limit_schema.limits_create_request_body
update_limits = limit_schema.limit_update_request_body
self.create_registered_limits_validator = validators.SchemaValidator(
create_registered_limits
@ -3222,55 +3222,60 @@ class LimitValidationTestCase(unit.BaseTestCase):
)
def test_validate_project_limit_create_request_succeeds(self):
request_to_validate = [
{
'project_id': uuid.uuid4().hex,
'service_id': uuid.uuid4().hex,
'region_id': 'RegionOne',
'resource_name': 'volume',
'resource_limit': 10,
'description': 'test description',
}
]
request_to_validate = {
"limits": [
{
'project_id': uuid.uuid4().hex,
'service_id': uuid.uuid4().hex,
'region_id': 'RegionOne',
'resource_name': 'volume',
'resource_limit': 10,
'description': 'test description',
}
]
}
self.create_limits_validator.validate(request_to_validate)
def test_validate_domain_limit_create_request_succeeds(self):
request_to_validate = [
{
'domain_id': uuid.uuid4().hex,
'service_id': uuid.uuid4().hex,
'region_id': 'RegionOne',
'resource_name': 'volume',
'resource_limit': 10,
'description': 'test description',
}
]
request_to_validate = {
"limits": [
{
'domain_id': uuid.uuid4().hex,
'service_id': uuid.uuid4().hex,
'region_id': 'RegionOne',
'resource_name': 'volume',
'resource_limit': 10,
'description': 'test description',
}
]
}
self.create_limits_validator.validate(request_to_validate)
def test_validate_limit_create_request_without_optional(self):
request_to_validate = [
{
'project_id': uuid.uuid4().hex,
'service_id': uuid.uuid4().hex,
'resource_name': 'volume',
'resource_limit': 10,
}
]
request_to_validate = {
"limits": [
{
'project_id': uuid.uuid4().hex,
'service_id': uuid.uuid4().hex,
'resource_name': 'volume',
'resource_limit': 10,
}
]
}
self.create_limits_validator.validate(request_to_validate)
def test_validate_limit_update_request_succeeds(self):
request_to_validate = {
'resource_limit': 10,
'description': 'test description',
"limit": {'resource_limit': 10, 'description': 'test description'}
}
self.update_limits_validator.validate(request_to_validate)
def test_validate_limit_update_request_without_optional(self):
request_to_validate = {'resource_limit': 10}
request_to_validate = {"limit": {'resource_limit': 10}}
self.update_limits_validator.validate(request_to_validate)
def test_validate_limit_request_with_no_parameters(self):
request_to_validate = []
request_to_validate = {"limits": []}
# At least one property should be given.
self.assertRaises(
exception.SchemaValidationError,
@ -3292,17 +3297,19 @@ class LimitValidationTestCase(unit.BaseTestCase):
{'description': 123},
]
for invalid_attribute in _INVALID_FORMATS:
request_to_validate = [
{
'project_id': uuid.uuid4().hex,
'service_id': uuid.uuid4().hex,
'region_id': 'RegionOne',
'resource_name': 'volume',
'resource_limit': 10,
'description': 'test description',
}
]
request_to_validate[0].update(invalid_attribute)
request_to_validate = {
"limits": [
{
'project_id': uuid.uuid4().hex,
'service_id': uuid.uuid4().hex,
'region_id': 'RegionOne',
'resource_name': 'volume',
'resource_limit': 10,
'description': 'test description',
}
]
}
request_to_validate['limits'][0].update(invalid_attribute)
self.assertRaises(
exception.SchemaValidationError,
@ -3311,16 +3318,18 @@ class LimitValidationTestCase(unit.BaseTestCase):
)
def test_validate_limit_create_request_with_invalid_domain(self):
request_to_validate = [
{
'domain_id': 'fake_id',
'service_id': uuid.uuid4().hex,
'region_id': 'RegionOne',
'resource_name': 'volume',
'resource_limit': 10,
'description': 'test description',
}
]
request_to_validate = {
"limits": [
{
'domain_id': 'fake_id',
'service_id': uuid.uuid4().hex,
'region_id': 'RegionOne',
'resource_name': 'volume',
'resource_limit': 10,
'description': 'test description',
}
]
}
self.assertRaises(
exception.SchemaValidationError,
self.create_limits_validator.validate,
@ -3338,8 +3347,8 @@ class LimitValidationTestCase(unit.BaseTestCase):
{'description': 123},
]
for invalid_desc in _INVALID_FORMATS:
request_to_validate = [
{
request_to_validate = {
"limit": {
'project_id': uuid.uuid4().hex,
'service_id': uuid.uuid4().hex,
'region_id': 'RegionOne',
@ -3347,8 +3356,8 @@ class LimitValidationTestCase(unit.BaseTestCase):
'resource_limit': 10,
'description': 'test description',
}
]
request_to_validate[0].update(invalid_desc)
}
request_to_validate['limit'].update(invalid_desc)
self.assertRaises(
exception.SchemaValidationError,
@ -3357,15 +3366,17 @@ class LimitValidationTestCase(unit.BaseTestCase):
)
def test_validate_limit_create_request_with_addition_input_fails(self):
request_to_validate = [
{
'service_id': uuid.uuid4().hex,
'region_id': 'RegionOne',
'resource_name': 'volume',
'resource_limit': 10,
'more_key': 'more_value',
}
]
request_to_validate = {
"limits": [
{
'service_id': uuid.uuid4().hex,
'region_id': 'RegionOne',
'resource_name': 'volume',
'resource_limit': 10,
'more_key': 'more_value',
}
]
}
self.assertRaises(
exception.SchemaValidationError,
self.create_limits_validator.validate,
@ -3374,9 +3385,11 @@ class LimitValidationTestCase(unit.BaseTestCase):
def test_validate_limit_update_request_with_addition_input_fails(self):
request_to_validate = {
'id': uuid.uuid4().hex,
'resource_limit': 10,
'more_key': 'more_value',
"limit": {
'id': uuid.uuid4().hex,
'resource_limit': 10,
'more_key': 'more_value',
}
}
self.assertRaises(
exception.SchemaValidationError,
@ -3393,16 +3406,18 @@ class LimitValidationTestCase(unit.BaseTestCase):
'resource_name',
'resource_limit',
]:
request_to_validate = [
{
'project_id': uuid.uuid4().hex,
'service_id': uuid.uuid4().hex,
'region_id': 'RegionOne',
'resource_name': 'volume',
'resource_limit': 10,
}
]
request_to_validate[0].pop(key)
request_to_validate = {
"limits": [
{
'project_id': uuid.uuid4().hex,
'service_id': uuid.uuid4().hex,
'region_id': 'RegionOne',
'resource_name': 'volume',
'resource_limit': 10,
}
]
}
request_to_validate['limits'][0].pop(key)
self.assertRaises(
exception.SchemaValidationError,
self.create_limits_validator.validate,
@ -3416,16 +3431,18 @@ class LimitValidationTestCase(unit.BaseTestCase):
'resource_name',
'resource_limit',
]:
request_to_validate = [
{
'domain_id': uuid.uuid4().hex,
'service_id': uuid.uuid4().hex,
'region_id': 'RegionOne',
'resource_name': 'volume',
'resource_limit': 10,
}
]
request_to_validate[0].pop(key)
request_to_validate = {
"limits": [
{
'domain_id': uuid.uuid4().hex,
'service_id': uuid.uuid4().hex,
'region_id': 'RegionOne',
'resource_name': 'volume',
'resource_limit': 10,
}
]
}
request_to_validate['limits'][0].pop(key)
self.assertRaises(
exception.SchemaValidationError,
self.create_limits_validator.validate,
@ -3433,17 +3450,19 @@ class LimitValidationTestCase(unit.BaseTestCase):
)
def test_validate_limit_create_request_with_both_project_and_domain(self):
request_to_validate = [
{
'project_id': uuid.uuid4().hex,
'domain_id': uuid.uuid4().hex,
'service_id': uuid.uuid4().hex,
'region_id': 'RegionOne',
'resource_name': 'volume',
'resource_limit': 10,
'description': 'test description',
}
]
request_to_validate = {
"limits": [
{
'project_id': uuid.uuid4().hex,
'domain_id': uuid.uuid4().hex,
'service_id': uuid.uuid4().hex,
'region_id': 'RegionOne',
'resource_name': 'volume',
'resource_limit': 10,
'description': 'test description',
}
]
}
self.assertRaises(
exception.SchemaValidationError,
self.create_limits_validator.validate,