Skip to content

Commit 48c6062

Browse files
committed
feat(api): unify API naming to snake_case with CamelCaseJSONField
Add CamelCaseJSONField serializer field that converts between snake_case (API layer) and camelCase (DB/K8s layer). Applied to RouteSerializer.rules, ConfigSerializer.healthcheck/lifecycle, AddonInstanceSerializer.parameters, AddonClassSerializer.plans, VolumeSerializer.parameters, and new PodDetailSerializer/DeploymentDetailSerializer for describe endpoints. - Remove id from Gateway/Route serializers, uuid from AddonInstance - Add PodDetailSerializer and DeploymentDetailSerializer with CamelCaseJSONField - Unify ServiceSerializer (list output + input validation in one class) - Change AddonClassSerializer from BaseSerializer to Serializer with source - describe_pod output keys changed to snake_case (last_state, restart_count) - CLI output now uses snake_case: backend_refs, liveness_probe, post_start - DB/K8s storage unchanged (camelCase), Python code unchanged - Update AGENTS.md with CamelCaseJSONField documentation and conventions
1 parent 13c7938 commit 48c6062

8 files changed

Lines changed: 174 additions & 57 deletions

File tree

AGENTS.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Drycc Controller — Agent Instructions
22

3+
> **When you change code patterns, conventions, or architecture, update this file
4+
> in the same commit.** AGENTS.md is the single source of truth for project
5+
> conventions — if it's stale, the next agent will follow wrong patterns.
6+
37
## Project Structure
48

59
```
@@ -201,6 +205,52 @@ class MyModelSerializer(serializers.ModelSerializer):
201205
read_only_fields = ['uuid', 'created', 'updated']
202206
```
203207

208+
### Naming Convention: snake_case API + CamelCaseJSONField
209+
210+
The API uses **snake_case** for all field names. K8s data stored in JSONFields
211+
uses camelCase internally (matching K8s API conventions), but is exposed as
212+
snake_case in API responses.
213+
214+
**`CamelCaseJSONField`** (in `api/serializers/__init__.py`, extends `JSONFieldSerializer`):
215+
- **Input** (`to_internal_value`): converts snake_case keys to camelCase before
216+
validation and DB storage. Both snake_case and camelCase input are accepted.
217+
- **Output** (`to_representation`): converts camelCase keys to snake_case for API
218+
responses.
219+
- **DB storage**: camelCase (Python code and K8s API calls use camelCase unchanged).
220+
- Conversion functions (`camelize`, `underscoreize`, etc.) live in `api/utils.py`.
221+
222+
Use `CamelCaseJSONField` for any JSONField that contains K8s camelCase data:
223+
224+
```python
225+
class RouteSerializer(serializers.ModelSerializer):
226+
rules = CamelCaseJSONField(convert_to_str=False, required=True)
227+
parent_refs = serializers.JSONField(required=True) # single-word keys, no conversion needed
228+
```
229+
230+
For serializers that read from K8s CRD/API (not Django models), use `Serializer`
231+
with `source` + `CamelCaseJSONField`:
232+
233+
```python
234+
class AddonClassSerializer(serializers.Serializer):
235+
name = serializers.CharField(source='metadata.name', required=False)
236+
plans = CamelCaseJSONField(source='spec.plans', required=False)
237+
```
238+
239+
**When to use CamelCaseJSONField** (K8s data with camelCase keys inside):
240+
- `Route.rules` - K8s Gateway API (`backendRefs`, `requestRedirect`, etc.)
241+
- `Config.healthcheck` - K8s probes (`livenessProbe`, `httpGet`, etc.)
242+
- `Config.lifecycle` - K8s lifecycle (`postStart`, `preStop`, etc.)
243+
- `AddonInstance.parameters` - user/Helm parameters (may have camelCase keys)
244+
- `PodDetailSerializer.state`/`last_state` - K8s container state
245+
- `DeploymentDetailSerializer.liveness_probe`/`readiness_probe`/`volume_mounts`
246+
247+
**When NOT to use CamelCaseJSONField** (single-word keys or non-K8s data):
248+
- `Gateway.ports` - keys are `port`, `protocol` (single words)
249+
- `Route.parent_refs` - keys are `name`, `port` (single words)
250+
- `Config.values` - keys are `name`, `value`, `ptype` (single words)
251+
- `Config.tags` - K8s labels (dots/hyphens, not camelCase)
252+
- `AppSettings.autoscale` - already snake_case (`cpu_percent`), HPA code converts
253+
204254
## URL Conventions
205255

206256
URLs use `re_path` in `api/urls.py`. Workspace-managed resources are **flat** at top-level:

rootfs/api/models/app.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -694,9 +694,9 @@ def get_command_and_args(pod, container_name):
694694
"command": command,
695695
"args": args,
696696
"state": status.get("state", {}),
697-
"lastState": status.get("lastState", {}),
697+
"last_state": status.get("lastState", {}),
698698
"ready": status.get("ready", False),
699-
"restartCount": status.get("restartCount", 0),
699+
"restart_count": status.get("restartCount", 0),
700700
"status": pod["status"].get("phase", ""),
701701
"reason": pod["status"].get("reason", ""),
702702
"message": pod["status"].get("message", ""),

rootfs/api/serializers/__init__.py

Lines changed: 95 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,53 @@ def to_representation(self, obj):
131131
return obj
132132

133133

134+
class CamelCaseJSONField(JSONFieldSerializer):
135+
"""JSONField that stores camelCase data and accepts snake_case input.
136+
137+
- Input (API -> DB): snake_case keys -> camelCase (before validation)
138+
- Output (DB -> API): camelCase keys -> snake_case
139+
- Storage: camelCase (Python code & K8s API unchanged)
140+
"""
141+
142+
@staticmethod
143+
def camel_to_snake(s):
144+
"""camelCase -> snake_case"""
145+
s1 = re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', s)
146+
return re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', s1).lower()
147+
148+
@staticmethod
149+
def snake_to_camel(s):
150+
"""snake_case -> camelCase"""
151+
parts = s.split('_')
152+
return parts[0] + ''.join(x.title() for x in parts[1:])
153+
154+
@classmethod
155+
def underscoreize(cls, data):
156+
"""Recursively convert dict keys: camelCase -> snake_case"""
157+
if isinstance(data, dict):
158+
return {cls.camel_to_snake(k): cls.underscoreize(v) for k, v in data.items()}
159+
if isinstance(data, list):
160+
return [cls.underscoreize(i) for i in data]
161+
return data
162+
163+
@classmethod
164+
def camelize(cls, data):
165+
"""Recursively convert dict keys: snake_case -> camelCase"""
166+
if isinstance(data, dict):
167+
return {cls.snake_to_camel(k): cls.camelize(v) for k, v in data.items()}
168+
if isinstance(data, list):
169+
return [cls.camelize(i) for i in data]
170+
return data
171+
172+
def to_internal_value(self, data):
173+
val = super().to_internal_value(data)
174+
return self.camelize(val)
175+
176+
def to_representation(self, obj):
177+
val = super().to_representation(obj)
178+
return self.underscoreize(val)
179+
180+
134181
class AuthSerializer(serializers.Serializer):
135182
username = serializers.CharField(
136183
label=gettext_lazy("Username"),
@@ -283,10 +330,10 @@ class ConfigSerializer(serializers.ModelSerializer):
283330
app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.App.objects.all())
284331
values = JSONFieldSerializer(required=False, binary=True)
285332
limits = JSONFieldSerializer(required=False, binary=True)
286-
lifecycle = JSONFieldSerializer(convert_to_str=False, required=False, binary=True)
333+
lifecycle = CamelCaseJSONField(convert_to_str=False, required=False, binary=True)
287334
tags = JSONFieldSerializer(required=False, binary=True)
288335
registry = JSONFieldSerializer(required=False, binary=True)
289-
healthcheck = JSONFieldSerializer(convert_to_str=False, required=False, binary=True)
336+
healthcheck = CamelCaseJSONField(convert_to_str=False, required=False, binary=True)
290337
routable = serializers.BooleanField(required=False)
291338
termination_grace_period = JSONFieldSerializer(required=False, binary=True)
292339

@@ -509,12 +556,15 @@ class ServiceSerializer(serializers.ModelSerializer):
509556
protocol = serializers.CharField(required=True)
510557
target_port = serializers.IntegerField(default=DEFAULT_CONTAINER_PORT)
511558
ptype = serializers.CharField(required=True)
559+
name = serializers.ReadOnlyField()
560+
domain = serializers.ReadOnlyField()
561+
ports = CamelCaseJSONField()
512562

513563
class Meta:
514564
"""Metadata options for a :class:`ServiceSerializer`."""
515565
model = models.service.Service
516-
fields = ['created', 'updated', 'app', 'ptype']
517-
read_only_fields = ['uuid']
566+
fields = ['created', 'updated', 'app', 'ptype', 'name', 'domain', 'ports']
567+
read_only_fields = ['created', 'updated', 'name', 'domain', 'ports']
518568

519569
@staticmethod
520570
def validate_protocol(value):
@@ -566,6 +616,34 @@ def to_representation(self, obj):
566616
return obj
567617

568618

619+
class PodDetailSerializer(serializers.Serializer):
620+
"""Serialize pod detail data from Kubernetes API."""
621+
container = serializers.CharField()
622+
image = serializers.CharField()
623+
command = serializers.JSONField()
624+
args = serializers.JSONField()
625+
state = CamelCaseJSONField()
626+
last_state = CamelCaseJSONField()
627+
ready = serializers.BooleanField()
628+
restart_count = serializers.IntegerField()
629+
status = serializers.CharField()
630+
reason = serializers.CharField()
631+
message = serializers.CharField()
632+
633+
634+
class DeploymentDetailSerializer(serializers.Serializer):
635+
"""Serialize deployment detail data from Kubernetes API."""
636+
container = serializers.CharField()
637+
image = serializers.CharField()
638+
command = serializers.JSONField()
639+
args = serializers.JSONField()
640+
liveness_probe = CamelCaseJSONField()
641+
readiness_probe = CamelCaseJSONField()
642+
limits = serializers.JSONField()
643+
volume_mounts = CamelCaseJSONField()
644+
node_selector = serializers.JSONField()
645+
646+
569647
class EventSerializer(serializers.BaseSerializer):
570648
reason = serializers.CharField()
571649
message = serializers.CharField()
@@ -626,7 +704,7 @@ class VolumeSerializer(serializers.ModelSerializer):
626704

627705
app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.App.objects.all())
628706
path = JSONFieldSerializer(required=False, binary=True)
629-
parameters = serializers.JSONField(required=False)
707+
parameters = CamelCaseJSONField(required=False)
630708

631709
class Meta:
632710
"""Metadata options for a :class:`AppVolumeSerializer`."""
@@ -717,7 +795,7 @@ class GatewaySerializer(serializers.ModelSerializer):
717795

718796
class Meta:
719797
model = models.gateway.Gateway
720-
fields = '__all__'
798+
fields = ['app', 'name', 'ports', 'addresses', 'created', 'updated']
721799

722800
validate_name = staticmethod(validate_name)
723801

@@ -756,14 +834,14 @@ class RouteSerializer(serializers.ModelSerializer):
756834
app = serializers.SlugRelatedField(slug_field='id', queryset=models.app.App.objects.all())
757835
kind = serializers.CharField(max_length=15, required=True)
758836
name = serializers.CharField(max_length=63, required=True)
759-
rules = serializers.JSONField(required=True)
837+
rules = CamelCaseJSONField(convert_to_str=False, required=True)
760838
routable = serializers.BooleanField(read_only=True)
761839
parent_refs = serializers.JSONField(required=True)
762840

763841
class Meta:
764842
"""Metadata options for a :class:`RouteSerializer`."""
765843
model = models.gateway.Route
766-
fields = '__all__'
844+
fields = ['app', 'kind', 'name', 'rules', 'routable', 'parent_refs', 'created', 'updated']
767845

768846
validate_name = staticmethod(validate_name)
769847

@@ -843,35 +921,24 @@ class Meta:
843921
read_only_fields = ['id', 'token', 'created']
844922

845923

846-
class AddonClassSerializer(serializers.BaseSerializer):
924+
class AddonClassSerializer(serializers.Serializer):
847925
"""Serialize AddonClass data from Kubernetes CRD."""
848-
849-
name = serializers.CharField(required=False)
850-
description = serializers.CharField(required=False)
851-
kind = serializers.CharField(required=False)
852-
storage_model = serializers.CharField(required=False)
853-
plans = serializers.ListField(required=False)
854-
855-
def to_representation(self, instance):
856-
spec = instance.get('spec', {})
857-
target_resource = spec.get('targetResource', {})
858-
return {
859-
'name': instance.get('metadata', {}).get('name', ''),
860-
'description': spec.get('description', ''),
861-
'kind': target_resource.get('kind', ''),
862-
'storage_model': spec.get('storageModel', ''),
863-
'plans': spec.get('plans', []),
864-
}
926+
name = serializers.CharField(source='metadata.name', required=False)
927+
description = serializers.CharField(source='spec.description', required=False)
928+
kind = serializers.CharField(source='spec.targetResource.kind', required=False)
929+
storage_model = serializers.CharField(source='spec.storageModel', required=False)
930+
plans = CamelCaseJSONField(source='spec.plans', required=False)
865931

866932

867933
class AddonInstanceSerializer(serializers.ModelSerializer):
868934
"""Serialize AddonInstance model."""
869935
app = serializers.ReadOnlyField(source='app.id')
870936
name = serializers.CharField(max_length=63, required=False)
871937
kind = serializers.CharField(max_length=63, required=False)
938+
parameters = CamelCaseJSONField(convert_to_str=False, default=dict, required=False)
872939

873940
class Meta:
874941
model = models.addon.AddonInstance
875-
fields = ['uuid', 'name', 'app', 'plan', 'kind',
942+
fields = ['name', 'app', 'plan', 'kind',
876943
'multiplier', 'parameters', 'created', 'updated']
877-
read_only_fields = ['uuid', 'multiplier', 'created', 'updated']
944+
read_only_fields = ['multiplier', 'created', 'updated']

rootfs/api/tests/test_addon.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -249,8 +249,8 @@ def test_list_addonclasses(self, mock_gs):
249249
for plan in response.data['results'][0]['plans']:
250250
self.assertNotIn('defaults', plan)
251251
self.assertNotIn('overrides', plan)
252-
self.assertIn('allowCreate', plan)
253-
self.assertIn('allowUpdate', plan)
252+
self.assertIn('allow_create', plan)
253+
self.assertIn('allow_update', plan)
254254

255255
@mock.patch('api.views.addon.get_scheduler')
256256
def test_retrieve_addonclass(self, mock_gs):
@@ -281,7 +281,7 @@ def test_addonclass_visible_paths_fallback(self, mock_gs):
281281
for plan in response.data['plans']:
282282
self.assertNotIn('defaults', plan)
283283
self.assertNotIn('overrides', plan)
284-
self.assertIn('allowCreate', plan)
284+
self.assertIn('allow_create', plan)
285285

286286
@mock.patch('api.views.addon.get_scheduler')
287287
def test_addonclass_visible_paths_nested_jsonpath(self, mock_gs):
@@ -299,11 +299,11 @@ def test_addonclass_visible_paths_nested_jsonpath(self, mock_gs):
299299
self.assertEqual(response.status_code, 200, response.data)
300300
plan = response.data['plans'][0]
301301
self.assertEqual(plan['name'], 'micro')
302-
self.assertEqual(plan['defaults'], {'imagePullPolicy': 'IfNotPresent'})
302+
self.assertEqual(plan['defaults'], {'image_pull_policy': 'IfNotPresent'})
303303
self.assertNotIn('replicas', plan.get('defaults', {}))
304304
self.assertNotIn('persistence', plan.get('defaults', {}))
305305
self.assertNotIn('overrides', plan)
306-
self.assertNotIn('allowCreate', plan)
306+
self.assertNotIn('allow_create', plan)
307307

308308
@mock.patch('api.views.addon.get_scheduler')
309309
def test_retrieve_nonexistent_addonclass(self, mock_gs):

rootfs/api/tests/test_gateway.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -667,7 +667,7 @@ def test_route_rules_get_returns_backendRefs_for_api(self):
667667
'/v2/apps/{}/routes/{}/'.format(app_id, route_name),
668668
)
669669
expect = [{
670-
'backendRefs': [{
670+
'backend_refs': [{
671671
'kind': 'Service',
672672
'name': '%s-%s' % (app_id, ptype),
673673
'port': 5000,
@@ -689,7 +689,7 @@ def test_route_rules_set_accepts_backendRefs_for_storage(self):
689689
app_id = self.create_app()
690690
ptype, _, route_name = self.create_route(app_id)
691691
expect = [{
692-
"backendRefs": [{
692+
"backend_refs": [{
693693
"kind": "Service",
694694
"name": "%s-%s" % (app_id, ptype),
695695
"port": 5000,
@@ -763,7 +763,7 @@ def test_route_serializer_accepts_backendRefs_input(self):
763763
self.assertEqual(
764764
response.data["rules"],
765765
[{
766-
"backendRefs": [{
766+
"backend_refs": [{
767767
"kind": "Service",
768768
"name": f"{app_id}-{ptype}",
769769
"port": port,

rootfs/api/tests/test_healthchecks.py

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -30,31 +30,31 @@ def test_config_healthchecks(self, mock_requests):
3030
"""
3131
app_id = self.create_app()
3232
readiness_probe = {
33-
'healthcheck': {'web': {'readinessProbe': {'httpGet': {'port': 5000}}}}
33+
'healthcheck': {'web': {'readiness_probe': {'http_get': {'port': 5000}}}}
3434
}
3535
response = self.client.post(
3636
f'/v2/apps/{app_id}/config',
3737
readiness_probe)
3838
self.assertEqual(response.status_code, 201, response.data)
39-
self.assertIn('readinessProbe', response.data['healthcheck']['web'])
39+
self.assertIn('readiness_probe', response.data['healthcheck']['web'])
4040
self.assertEqual(response.data['healthcheck'], readiness_probe['healthcheck'])
4141

42-
liveness_probe = {'healthcheck': {'web': {'livenessProbe':
43-
{'httpGet': {'port': 5000},
44-
'successThreshold': 1}}}}
42+
liveness_probe = {'healthcheck': {'web': {'liveness_probe':
43+
{'http_get': {'port': 5000},
44+
'success_threshold': 1}}}}
4545
response = self.client.post(
4646
f'/v2/apps/{app_id}/config',
4747
liveness_probe)
4848
self.assertEqual(response.status_code, 201, response.data)
49-
self.assertIn('livenessProbe', response.data['healthcheck']['web'])
49+
self.assertIn('liveness_probe', response.data['healthcheck']['web'])
5050
self.assertEqual(
51-
response.data['healthcheck']['web']['livenessProbe'],
52-
liveness_probe['healthcheck']['web']['livenessProbe'])
51+
response.data['healthcheck']['web']['liveness_probe'],
52+
liveness_probe['healthcheck']['web']['liveness_probe'])
5353
# check that the readiness probe is still there too!
54-
self.assertIn('readinessProbe', response.data['healthcheck']['web'])
54+
self.assertIn('readiness_probe', response.data['healthcheck']['web'])
5555
self.assertEqual(
56-
response.data['healthcheck']['web']['readinessProbe'],
57-
readiness_probe['healthcheck']['web']['readinessProbe'])
56+
response.data['healthcheck']['web']['readiness_probe'],
57+
readiness_probe['healthcheck']['web']['readiness_probe'])
5858

5959
# check that config fails if trying to unset non-existing healthcheck
6060
response = self.client.post(
@@ -65,10 +65,10 @@ def test_config_healthchecks(self, mock_requests):
6565
# remove a probeType
6666
response = self.client.post(
6767
f'/v2/apps/{app_id}/config',
68-
{'healthcheck': {'web': {'livenessProbe': None}}})
68+
{'healthcheck': {'web': {'liveness_probe': None}}})
6969
self.assertEqual(response.status_code, 201, response.data)
70-
self.assertNotIn('livenessProbe', response.data['healthcheck']['web'])
71-
self.assertIn('readinessProbe', response.data['healthcheck']['web'])
70+
self.assertNotIn('liveness_probe', response.data['healthcheck']['web'])
71+
self.assertIn('readiness_probe', response.data['healthcheck']['web'])
7272

7373
# check that config fails if trying to unset non-existing probeType
7474
response = self.client.post(

0 commit comments

Comments
 (0)