Skip to content

Commit 5bf6fab

Browse files
committed
feat(gateway): unify gateway/route API fields and add JSON schema validation
- Add LazySchemaValidator to validate JSON fields at model level - Add schema validators for Gateway.ports, Route.rules and Route.parent_refs - Change GatewaySerializer from Serializer to ModelSerializer - Add validate_parent_refs and validate methods to RouteSerializer - Add to_internal_value/to_representation for backend_refs <-> backendRefs conversion - Change Route.save() to raise ValidationError instead of Http404 - Add Django ValidationError handling in custom_exception_handler - Add GRPCRoute support to route kind validation - Add gateway.py and route.py schema definitions - Update tests to use API calls for consistent serializer behavior
1 parent 705abde commit 5bf6fab

8 files changed

Lines changed: 616 additions & 380 deletions

File tree

rootfs/api/exceptions.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from django.core.exceptions import ValidationError
12
from django.http import Http404
23
import logging
34
from rest_framework.exceptions import APIException, status
@@ -36,20 +37,25 @@ def custom_exception_handler(exc, context):
3637
if isinstance(exc, Http404):
3738
set_rollback()
3839
return Response(str(exc), status=status.HTTP_404_NOT_FOUND)
40+
# Convert Django ValidationError to DRF 400 response
41+
if isinstance(exc, ValidationError):
42+
set_rollback()
43+
if hasattr(exc, 'message_dict'):
44+
return Response(exc.message_dict, status=status.HTTP_400_BAD_REQUEST)
45+
elif hasattr(exc, 'messages'):
46+
return Response({'non_field_errors': exc.messages}, status=status.HTTP_400_BAD_REQUEST)
47+
return Response({'non_field_errors': [str(exc)]}, status=status.HTTP_400_BAD_REQUEST)
3948
# Call REST framework's default exception handler after specific 404 handling,
4049
# to get the standard error response.
4150
response = exception_handler(exc, context)
42-
# No response means DRF couldn't handle it
43-
# Output a generic 500 in a JSON format
51+
# No response means DRF couldn't handle it, output a generic 500 in a JSON format
4452
if response is None:
4553
import traceback
4654
traceback.print_exc()
4755
logging.exception('Uncaught Exception', exc_info=exc)
4856
set_rollback()
4957
return Response({'detail': 'Server Error'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
50-
5158
# log a few different types of exception instead of using APIException
5259
if isinstance(exc, (DryccException, ServiceUnavailable, HealthcheckException)):
5360
logging.exception(exc.__cause__, exc_info=exc)
54-
5561
return response

rootfs/api/models/gateway.py

Lines changed: 29 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
import threading
44
from django.db import models
55
from django.conf import settings
6-
from django.http import Http404
6+
from django.core.exceptions import ValidationError
77

88
from api.tasks import send_app_log
9-
from api.utils import validate_label
9+
from api.utils import validate_json, validate_label
1010
from api.exceptions import ServiceUnavailable
1111
from scheduler import KubeException
1212

@@ -19,10 +19,25 @@
1919
HOSTNAME_PROTOCOLS = TLS_PROTOCOLS + ("HTTP", )
2020

2121

22+
class LazySchemaValidator:
23+
"""Defers schema import until validation time to avoid circular imports."""
24+
25+
def __init__(self, module_path, attr_name):
26+
self.module_path = module_path
27+
self.attr_name = attr_name
28+
29+
def __call__(self, value):
30+
mod = __import__(self.module_path, fromlist=[self.attr_name])
31+
validate_json(value, schema=getattr(mod, self.attr_name))
32+
33+
2234
class Gateway(AuditedModel):
2335
app = models.ForeignKey('App', on_delete=models.CASCADE)
2436
name = models.CharField(max_length=63, db_index=True, validators=[validate_label])
25-
ports = models.JSONField(default=list)
37+
ports = models.JSONField(
38+
default=list,
39+
validators=[LazySchemaValidator("api.serializers.schemas.gateway", "SCHEMA")],
40+
)
2641

2742
def log(self, message, level=logging.INFO):
2843
"""Logs a message in the context of this service.
@@ -281,9 +296,15 @@ class Route(AuditedModel):
281296
kind = models.CharField(max_length=15, choices=[
282297
(key, '/'.join(value)) for key, value in PROTOCOLS_CHOICES.items()])
283298
name = models.CharField(max_length=63, db_index=True)
284-
rules = models.JSONField(default=list)
299+
rules = models.JSONField(
300+
default=list,
301+
validators=[LazySchemaValidator("api.serializers.schemas.rules", "SCHEMA")],
302+
)
285303
routable = models.BooleanField(default=True)
286-
parent_refs = models.JSONField(default=list)
304+
parent_refs = models.JSONField(
305+
default=list,
306+
validators=[LazySchemaValidator("api.serializers.schemas.route", "PARENT_REFS_SCHEMA")],
307+
)
287308

288309
@property
289310
def services(self):
@@ -318,23 +339,6 @@ def cleaned_rules(self):
318339
rules.append(rule)
319340
return rules
320341

321-
def check_rules(self, rules):
322-
for rule in rules:
323-
for backend_ref in rule["backendRefs"]:
324-
kind = backend_ref.get("kind", "Service")
325-
if kind != "Service":
326-
return False, {"detail": "BackendRef only supports service kind"}
327-
has_service = False
328-
for service in self.services:
329-
ports = [item["port"] for item in service.ports]
330-
if backend_ref["port"] in ports and backend_ref["name"] == service.name:
331-
has_service = True
332-
break
333-
if not has_service:
334-
msg = f"service {backend_ref['name']}:{backend_ref['port']} does not exist"
335-
return False, {"detail": msg}
336-
return True, ""
337-
338342
def log(self, message, level=logging.INFO):
339343
"""Logs a message in the context of this service.
340344
@@ -395,12 +399,11 @@ def detach(self, gateway_name, port):
395399

396400
def save(self, *args, **kwargs):
397401
self.change_default_tls()
398-
cleaned_rules = self.cleaned_rules
399-
if not cleaned_rules:
402+
if not self.cleaned_rules:
400403
msg = f"route {self.name} no available backend"
401404
self.log(msg, level=logging.ERROR)
402-
raise Http404(msg)
403-
self.rules = cleaned_rules
405+
raise ValidationError(msg)
406+
self.rules = self.cleaned_rules
404407
super().save(*args, **kwargs)
405408
self.refresh_to_k8s()
406409

rootfs/api/serializers/__init__.py

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
from .schemas.autoscale import SCHEMA as AUTOSCALE_SCHEMA
2727
from .schemas.healthcheck import SCHEMA as HEALTHCHECK_SCHEMA
2828
from .schemas.dryccfile import (SCHEMA as DRYCCFILE_SCHEMA, PROCTYPE_REGEX, CONFIGKEY_REGEX)
29+
from .schemas.gateway import PORT_SCHEMA as GATEWAY_PORT_SCHEMA
30+
from .schemas.route import PARENT_REF_SCHEMA as ROUTE_PARENT_REF_SCHEMA
2931

3032

3133
User = get_user_model()
@@ -36,7 +38,7 @@
3638
GATEWAY_PROTOCOL_MATCH = re.compile(r'^(HTTP|HTTPS|TCP|TLS|UDP)$')
3739
GATEWAY_PROTOCOL_MISMATCH_MSG = (
3840
"the gateway protocol only supports: %s" % GATEWAY_PROTOCOL_MATCH.pattern)
39-
ROUTE_PROTOCOL_MATCH = re.compile(r'^(HTTPRoute|TCPRoute|UDPRoute|TLSRoute)$')
41+
ROUTE_PROTOCOL_MATCH = re.compile(r'^(HTTPRoute|TCPRoute|UDPRoute|GRPCRoute|TLSRoute)$')
4042
ROUTE_PROTOCOL_MISMATCH_MSG = (
4143
"the route kind only supports: %s" % ROUTE_PROTOCOL_MATCH.pattern)
4244
PROCTYPE_MATCH = re.compile(PROCTYPE_REGEX)
@@ -735,16 +737,15 @@ def validate(self, attrs):
735737
return attrs
736738

737739

738-
class GatewaySerializer(serializers.Serializer):
740+
class GatewaySerializer(serializers.ModelSerializer):
739741
app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.App.objects.all())
740742
name = serializers.CharField(max_length=63, required=True)
741-
listeners = serializers.JSONField(required=False)
743+
ports = serializers.JSONField(required=True)
742744
addresses = serializers.JSONField(read_only=True)
743745

744-
def to_representation(self, instance):
745-
representation = super().to_representation(instance)
746-
representation['addresses'] = instance.addresses
747-
return representation
746+
class Meta:
747+
model = models.gateway.Gateway
748+
fields = '__all__'
748749

749750
validate_name = staticmethod(validate_name)
750751

@@ -757,13 +758,35 @@ def validate_protocol(value):
757758
validate_port = staticmethod(validate_port)
758759
validate_ptype = staticmethod(validate_ptype)
759760

761+
@staticmethod
762+
def validate_ports(value):
763+
if not isinstance(value, list):
764+
raise serializers.ValidationError("ports must be a list")
765+
for item in value:
766+
validate_json(item, GATEWAY_PORT_SCHEMA, serializers.ValidationError)
767+
return value
768+
769+
def validate(self, attrs):
770+
attrs = super().validate(attrs)
771+
gateway = self.instance or models.gateway.Gateway(**attrs)
772+
new_ports = []
773+
for item in attrs.get("ports", gateway.ports):
774+
port, protocol = item["port"], item["protocol"]
775+
gateway.ports = new_ports
776+
if not gateway._check_port(port, protocol):
777+
raise serializers.ValidationError({"detail": "port is occupied"})
778+
new_ports.append({"port": port, "protocol": protocol})
779+
attrs["ports"] = new_ports
780+
return attrs
781+
760782

761783
class RouteSerializer(serializers.ModelSerializer):
762784
app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.App.objects.all())
763785
kind = serializers.CharField(max_length=15, required=True)
764786
name = serializers.CharField(max_length=63, required=True)
765-
rules = serializers.JSONField(required=False)
766-
parent_refs = serializers.JSONField(required=False)
787+
rules = serializers.JSONField(required=True)
788+
routable = serializers.BooleanField(read_only=True)
789+
parent_refs = serializers.JSONField(required=True)
767790

768791
class Meta:
769792
"""Metadata options for a :class:`RouteSerializer`."""
@@ -778,14 +801,35 @@ def validate_kind(value):
778801
raise serializers.ValidationError(ROUTE_PROTOCOL_MISMATCH_MSG)
779802
return value
780803

781-
def validate_rules(self, value):
782-
kind = getattr(self, "initial_data", {}).get("kind", None)
804+
def validate_rules(self, value, kind=None):
805+
if kind is None:
806+
kind = getattr(self, "initial_data", {}).get("kind", None)
783807
if kind:
784-
schema = getattr(rules, f"{kind.replace("Route", "")}_RULES_SCHEMA", "SCHEMA")
808+
schema = getattr(rules, f"{kind.replace('Route', '')}_RULES_SCHEMA", rules.SCHEMA)
785809
else:
786810
schema = rules.SCHEMA
787811
return validate_json(value, schema, serializers.ValidationError)
788812

813+
@staticmethod
814+
def validate_parent_refs(value):
815+
if not isinstance(value, list):
816+
raise serializers.ValidationError("parent_refs must be a list")
817+
for item in value:
818+
validate_json(item, ROUTE_PARENT_REF_SCHEMA, serializers.ValidationError)
819+
return value
820+
821+
def validate(self, attrs):
822+
attrs = super().validate(attrs)
823+
self.validate_rules(attrs.get("rules"), kind=attrs.get("kind"))
824+
route = self.instance or models.gateway.Route(**attrs)
825+
if self.instance and self.instance.kind != attrs.get("kind"):
826+
raise serializers.ValidationError({"detail": "route kind cannot be changed"})
827+
for parent_ref in attrs.get("parent_refs", route.parent_refs):
828+
ok, msg = route._check_parent(parent_ref["name"], parent_ref["port"])
829+
if not ok:
830+
raise serializers.ValidationError(msg)
831+
return attrs
832+
789833

790834
class WorkspaceSerializer(serializers.ModelSerializer):
791835
"""Serialize Workspace model."""
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
PORT_SCHEMA = {
2+
"type": "object",
3+
"additionalProperties": False,
4+
"required": ["port", "protocol"],
5+
"properties": {
6+
"port": {
7+
"type": "integer",
8+
"minimum": 1,
9+
"maximum": 65535,
10+
},
11+
"protocol": {
12+
"type": "string",
13+
"enum": ["HTTP", "HTTPS", "TCP", "UDP", "TLS"],
14+
},
15+
},
16+
}
17+
18+
19+
SCHEMA = {
20+
"$schema": "http://json-schema.org/schema#",
21+
"type": "array",
22+
"items": PORT_SCHEMA,
23+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
PARENT_REF_SCHEMA = {
2+
"type": "object",
3+
"additionalProperties": False,
4+
"required": ["name", "port"],
5+
"properties": {
6+
"name": {
7+
"type": "string",
8+
},
9+
"port": {
10+
"type": "integer",
11+
"minimum": 1,
12+
"maximum": 65535,
13+
},
14+
},
15+
}
16+
17+
18+
PARENT_REFS_SCHEMA = {
19+
"$schema": "http://json-schema.org/schema#",
20+
"type": "array",
21+
"items": PARENT_REF_SCHEMA,
22+
}

0 commit comments

Comments
 (0)